close the jellyfin ping-pong via mqtt webhook subscriber
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m5s
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m5s
after ffmpeg finishes we used to block the queue on a jellyfin refresh
+ re-analyze round-trip. now we just kick jellyfin and return. a new
mqtt subscriber listens for library events from jellyfin's webhook
plugin and re-runs upsertJellyfinItem — flipping plans back to pending
when the on-disk streams still don't match, otherwise confirming done.
- execute.ts: hand-off is fire-and-forget; no more sync re-analyze
- rescan.ts: upsertJellyfinItem takes source: 'scan' | 'webhook'.
webhook-sourced rescans can reopen terminal 'done' plans when
is_noop flips back to 0; scan-sourced rescans still treat done as
terminal (keeps the dup-job fix from a06ab34 intact).
- mqtt.ts: long-lived client, auto-reconnect, status feed for UI badge
- webhook.ts: pure processWebhookEvent(db, deps) handler + 5s dedupe
map to kill jellyfin's burst re-fires during library scans
- settings: /api/settings/mqtt{,/status,/test} + /api/settings/
jellyfin/webhook-plugin (checks if the plugin is installed)
- ui: new Settings section with broker form, test button, copy-paste
setup panel for the Jellyfin plugin template. MQTT status badge on
the scan page.
This commit is contained in:
93
bun.lock
93
bun.lock
@@ -10,6 +10,7 @@
|
||||
"@tanstack/react-router": "^1.163.3",
|
||||
"clsx": "^2.1.1",
|
||||
"hono": "^4",
|
||||
"mqtt": "^5.15.1",
|
||||
"react": "19",
|
||||
"react-aria-components": "^1.16.0",
|
||||
"react-dom": "19",
|
||||
@@ -66,6 +67,8 @@
|
||||
|
||||
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
@@ -530,10 +533,16 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/readable-stream": ["@types/readable-stream@4.0.23", "", { "dependencies": { "@types/node": "*" } }, "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig=="],
|
||||
|
||||
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@4.2.3", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2", "@swc/core": "^1.15.11" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-QIluDil2prhY1gdA3GGwxZzTAmLdi8cQ2CcuMW4PB/Wu4e/1pzqrwhYWVd09LInCRlDUidQjd0B70QWbjWtLxA=="],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@@ -550,16 +559,26 @@
|
||||
|
||||
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
|
||||
|
||||
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"broker-factory": ["broker-factory@3.1.14", "", { "dependencies": { "@babel/runtime": "^7.29.2", "fast-unique-numbers": "^9.0.27", "tslib": "^2.8.1", "worker-factory": "^7.0.49" } }, "sha512-L45k5HMbPIrMid0nTOZ/UPXG/c0aRuQKVrSDFIb1zOkvfiyHgYmIjc3cSiN1KwQIvRDOtKE0tfb3I9EZ3CmpQQ=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
@@ -580,6 +599,10 @@
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"commist": ["commist@3.2.0", "", {}, "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="],
|
||||
|
||||
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
|
||||
|
||||
"concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
@@ -610,6 +633,12 @@
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||
|
||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||
|
||||
"fast-unique-numbers": ["fast-unique-numbers@9.0.27", "", { "dependencies": { "@babel/runtime": "^7.29.2", "tslib": "^2.8.1" } }, "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
@@ -628,10 +657,18 @@
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
|
||||
|
||||
"hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
@@ -646,6 +683,8 @@
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-sdsl": ["js-sdsl@4.3.0", "", {}, "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
@@ -676,10 +715,16 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"mqtt": ["mqtt@5.15.1", "", { "dependencies": { "@types/readable-stream": "^4.0.21", "@types/ws": "^8.18.1", "commist": "^3.2.0", "concat-stream": "^2.0.0", "debug": "^4.4.1", "help-me": "^5.0.0", "lru-cache": "^10.4.3", "minimist": "^1.2.8", "mqtt-packet": "^9.0.2", "number-allocator": "^1.0.14", "readable-stream": "^4.7.0", "rfdc": "^1.4.1", "socks": "^2.8.6", "split2": "^4.2.0", "worker-timers": "^8.0.23", "ws": "^8.18.3" }, "bin": { "mqtt_pub": "build/bin/pub.js", "mqtt_sub": "build/bin/sub.js", "mqtt": "build/bin/mqtt.js" } }, "sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA=="],
|
||||
|
||||
"mqtt-packet": ["mqtt-packet@9.0.2", "", { "dependencies": { "bl": "^6.0.8", "debug": "^4.3.4", "process-nextick-args": "^2.0.1" } }, "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nan": ["nan@2.25.0", "", {}, "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g=="],
|
||||
@@ -690,6 +735,8 @@
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"number-allocator": ["number-allocator@1.0.14", "", { "dependencies": { "debug": "^4.3.1", "js-sdsl": "4.3.0" } }, "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
@@ -700,6 +747,10 @@
|
||||
|
||||
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||
|
||||
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-aria": ["react-aria@3.47.0", "", { "dependencies": { "@internationalized/string": "^3.2.7", "@react-aria/breadcrumbs": "^3.5.32", "@react-aria/button": "^3.14.5", "@react-aria/calendar": "^3.9.5", "@react-aria/checkbox": "^3.16.5", "@react-aria/color": "^3.1.5", "@react-aria/combobox": "^3.15.0", "@react-aria/datepicker": "^3.16.1", "@react-aria/dialog": "^3.5.34", "@react-aria/disclosure": "^3.1.3", "@react-aria/dnd": "^3.11.6", "@react-aria/focus": "^3.21.5", "@react-aria/gridlist": "^3.14.4", "@react-aria/i18n": "^3.12.16", "@react-aria/interactions": "^3.27.1", "@react-aria/label": "^3.7.25", "@react-aria/landmark": "^3.0.10", "@react-aria/link": "^3.8.9", "@react-aria/listbox": "^3.15.3", "@react-aria/menu": "^3.21.0", "@react-aria/meter": "^3.4.30", "@react-aria/numberfield": "^3.12.5", "@react-aria/overlays": "^3.31.2", "@react-aria/progress": "^3.4.30", "@react-aria/radio": "^3.12.5", "@react-aria/searchfield": "^3.8.12", "@react-aria/select": "^3.17.3", "@react-aria/selection": "^3.27.2", "@react-aria/separator": "^3.4.16", "@react-aria/slider": "^3.8.5", "@react-aria/ssr": "^3.9.10", "@react-aria/switch": "^3.7.11", "@react-aria/table": "^3.17.11", "@react-aria/tabs": "^3.11.1", "@react-aria/tag": "^3.8.1", "@react-aria/textfield": "^3.18.5", "@react-aria/toast": "^3.0.11", "@react-aria/tooltip": "^3.9.2", "@react-aria/tree": "^3.1.7", "@react-aria/utils": "^3.33.1", "@react-aria/visually-hidden": "^3.8.31", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-nvahimIqdByl/PXk/xPkG30LPRzcin+/Uk0uFfwbbKRRFC9aa22a6BRULZLqVHwa9GaNyKe6CDUxO1Dde4v0kA=="],
|
||||
@@ -710,6 +761,8 @@
|
||||
|
||||
"react-stately": ["react-stately@3.45.0", "", { "dependencies": { "@react-stately/calendar": "^3.9.3", "@react-stately/checkbox": "^3.7.5", "@react-stately/collections": "^3.12.10", "@react-stately/color": "^3.9.5", "@react-stately/combobox": "^3.13.0", "@react-stately/data": "^3.15.2", "@react-stately/datepicker": "^3.16.1", "@react-stately/disclosure": "^3.0.11", "@react-stately/dnd": "^3.7.4", "@react-stately/form": "^3.2.4", "@react-stately/list": "^3.13.4", "@react-stately/menu": "^3.9.11", "@react-stately/numberfield": "^3.11.0", "@react-stately/overlays": "^3.6.23", "@react-stately/radio": "^3.11.5", "@react-stately/searchfield": "^3.5.19", "@react-stately/select": "^3.9.2", "@react-stately/selection": "^3.20.9", "@react-stately/slider": "^3.7.5", "@react-stately/table": "^3.15.4", "@react-stately/tabs": "^3.8.9", "@react-stately/toast": "^3.1.3", "@react-stately/toggle": "^3.9.5", "@react-stately/tooltip": "^3.5.11", "@react-stately/tree": "^3.9.6", "@react-types/shared": "^3.33.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-G3bYr0BIiookpt4H05VeZUuVS/FslQAj2TeT8vDfCiL314Y+LtPXIPe/a3eamCA0wljy7z1EDYKV50Qbz7pcJg=="],
|
||||
|
||||
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
||||
|
||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
|
||||
@@ -718,10 +771,14 @@
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||
|
||||
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
@@ -734,14 +791,22 @@
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||
|
||||
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
|
||||
|
||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
@@ -768,6 +833,8 @@
|
||||
|
||||
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||
|
||||
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
|
||||
|
||||
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||
@@ -776,12 +843,24 @@
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"worker-factory": ["worker-factory@7.0.49", "", { "dependencies": { "@babel/runtime": "^7.29.2", "fast-unique-numbers": "^9.0.27", "tslib": "^2.8.1" } }, "sha512-lW7tpgy6aUv2dFsQhv1yv+XFzdkCf/leoKRTGMPVK5/die6RrUjqgJHJf556qO+ZfytNG6wPXc17E8zzsOLUDw=="],
|
||||
|
||||
"worker-timers": ["worker-timers@8.0.31", "", { "dependencies": { "@babel/runtime": "^7.29.2", "tslib": "^2.8.1", "worker-timers-broker": "^8.0.16", "worker-timers-worker": "^9.0.14" } }, "sha512-ngkq5S6JuZyztom8tDgBzorLo9byhBMko/sXfgiUD945AuzKGg1GCgDMCC3NaYkicLpGKXutONM36wEX8UbBCA=="],
|
||||
|
||||
"worker-timers-broker": ["worker-timers-broker@8.0.16", "", { "dependencies": { "@babel/runtime": "^7.29.2", "broker-factory": "^3.1.14", "fast-unique-numbers": "^9.0.27", "tslib": "^2.8.1", "worker-timers-worker": "^9.0.14" } }, "sha512-JyP3AvUGyPGbBGW7XiUewm2+0pN/aYo1QpVf5kdXAfkDZcN3p7NbWrG6XnyDEpDIvfHk/+LCnOW/NsuiU9riYA=="],
|
||||
|
||||
"worker-timers-worker": ["worker-timers-worker@9.0.14", "", { "dependencies": { "@babel/runtime": "^7.29.2", "tslib": "^2.8.1", "worker-factory": "^7.0.49" } }, "sha512-/qF06C60sXmSLfUl7WglvrDIbspmPOM8UrG63Dnn4bi2x4/DfqHS/+dxF5B+MdHnYO5tVuZYLHdAodrKdabTIg=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
@@ -794,6 +873,8 @@
|
||||
|
||||
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
@@ -816,16 +897,26 @@
|
||||
|
||||
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@types/readable-stream/@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
|
||||
|
||||
"@types/ws/@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
|
||||
|
||||
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"@types/readable-stream/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "netfelix-audio-fix",
|
||||
"version": "2026.04.14.1",
|
||||
"version": "2026.04.14.2",
|
||||
"scripts": {
|
||||
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
|
||||
"dev:client": "vite",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@tanstack/react-router": "^1.163.3",
|
||||
"clsx": "^2.1.1",
|
||||
"hono": "^4",
|
||||
"mqtt": "^5.15.1",
|
||||
"react": "19",
|
||||
"react-aria-components": "^1.16.0",
|
||||
"react-dom": "19",
|
||||
|
||||
@@ -4,9 +4,7 @@ import { stream } from "hono/streaming";
|
||||
import { getAllConfig, getDb } from "../db/index";
|
||||
import { log, error as logError, warn } from "../lib/log";
|
||||
import { predictExtractedFiles } from "../services/ffmpeg";
|
||||
import { getItem, refreshItem } from "../services/jellyfin";
|
||||
import { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "../services/radarr";
|
||||
import { upsertJellyfinItem } from "../services/rescan";
|
||||
import { refreshItem } from "../services/jellyfin";
|
||||
import {
|
||||
getScheduleConfig,
|
||||
isInProcessWindow,
|
||||
@@ -15,28 +13,15 @@ import {
|
||||
sleepBetweenJobs,
|
||||
waitForProcessWindow,
|
||||
} from "../services/scheduler";
|
||||
import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "../services/sonarr";
|
||||
import { verifyDesiredState } from "../services/verify";
|
||||
import type { Job, MediaItem, MediaStream } from "../types";
|
||||
|
||||
function parseLanguageList(raw: string | null | undefined, fallback: string[]): string[] {
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a job finishes successfully, ask Jellyfin to re-scan the file,
|
||||
* fetch the fresh item, and upsert it — including running analyzeItem so the
|
||||
* review plan reflects whether the file is now fully conformant. If is_noop
|
||||
* is true on the refreshed streams, the plan lands in `done`; otherwise it
|
||||
* flips back to `pending` so the user sees what still needs attention.
|
||||
* Fire-and-forget hand-off to Jellyfin after a successful job: ask Jellyfin
|
||||
* to re-scan the file and return immediately. The MQTT webhook subscriber
|
||||
* closes the loop once Jellyfin finishes its rescan and publishes an event.
|
||||
*/
|
||||
async function refreshItemFromJellyfin(itemId: number): Promise<void> {
|
||||
async function handOffToJellyfin(itemId: number): Promise<void> {
|
||||
const db = getDb();
|
||||
const row = db.prepare("SELECT jellyfin_id FROM media_items WHERE id = ?").get(itemId) as
|
||||
| { jellyfin_id: string }
|
||||
@@ -52,34 +37,6 @@ async function refreshItemFromJellyfin(itemId: number): Promise<void> {
|
||||
} catch (err) {
|
||||
warn(`Jellyfin refresh for item ${itemId} failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
const fresh = await getItem(jellyfinCfg, row.jellyfin_id);
|
||||
if (!fresh) {
|
||||
warn(`Jellyfin returned no item for ${row.jellyfin_id} after refresh`);
|
||||
return;
|
||||
}
|
||||
|
||||
const radarrCfg = { url: cfg.radarr_url, apiKey: cfg.radarr_api_key };
|
||||
const sonarrCfg = { url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key };
|
||||
const radarrEnabled = cfg.radarr_enabled === "1" && radarrUsable(radarrCfg);
|
||||
const sonarrEnabled = cfg.sonarr_enabled === "1" && sonarrUsable(sonarrCfg);
|
||||
const [radarrLibrary, sonarrLibrary] = await Promise.all([
|
||||
radarrEnabled ? loadRadarrLibrary(radarrCfg) : Promise.resolve(null),
|
||||
sonarrEnabled ? loadSonarrLibrary(sonarrCfg) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
await upsertJellyfinItem(
|
||||
db,
|
||||
fresh,
|
||||
{
|
||||
audioLanguages: parseLanguageList(cfg.audio_languages, []),
|
||||
radarr: radarrEnabled ? radarrCfg : null,
|
||||
sonarr: sonarrEnabled ? sonarrCfg : null,
|
||||
radarrLibrary,
|
||||
sonarrLibrary,
|
||||
},
|
||||
{ executed: true },
|
||||
);
|
||||
}
|
||||
|
||||
const app = new Hono();
|
||||
@@ -531,15 +488,14 @@ async function runJob(job: Job): Promise<void> {
|
||||
log(`Job ${job.id} completed successfully`);
|
||||
emitJobUpdate(job.id, "done", fullOutput);
|
||||
|
||||
// Ask Jellyfin to rescan the file and pull the fresh metadata so our DB
|
||||
// reflects what actually ended up on disk. If the refreshed streams still
|
||||
// don't satisfy is_noop (e.g. a codec didn't transcode as planned), the
|
||||
// plan flips back to 'pending' in the same upsert and the UI shows it.
|
||||
try {
|
||||
await refreshItemFromJellyfin(job.item_id);
|
||||
} catch (refreshErr) {
|
||||
warn(`Post-job refresh for item ${job.item_id} failed: ${String(refreshErr)}`);
|
||||
}
|
||||
// Fire-and-forget: tell Jellyfin to rescan the file. The MQTT subscriber
|
||||
// will pick up Jellyfin's resulting Library event and re-analyze the
|
||||
// item — flipping the plan back to 'pending' if the on-disk streams
|
||||
// don't actually match the plan. We don't await that; the job queue
|
||||
// moves on.
|
||||
handOffToJellyfin(job.item_id).catch((err) =>
|
||||
warn(`Jellyfin hand-off for item ${job.item_id} failed: ${String(err)}`),
|
||||
);
|
||||
} catch (err) {
|
||||
logError(`Job ${job.id} failed:`, err);
|
||||
const fullOutput = `${outputLines.join("\n")}\n${String(err)}`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { getAllConfig, getDb, getEnvLockedKeys, reseedDefaults, setConfig } from "../db/index";
|
||||
import { getAllConfig, getConfig, getDb, getEnvLockedKeys, reseedDefaults, setConfig } from "../db/index";
|
||||
import { getUsers, testConnection as testJellyfin } from "../services/jellyfin";
|
||||
import { getMqttStatus, startMqttClient, testMqttConnection } from "../services/mqtt";
|
||||
import { testConnection as testRadarr } from "../services/radarr";
|
||||
import { getScheduleConfig, type ScheduleConfig, updateScheduleConfig } from "../services/scheduler";
|
||||
import { testConnection as testSonarr } from "../services/sonarr";
|
||||
@@ -103,6 +104,63 @@ app.patch("/schedule", async (c) => {
|
||||
return c.json(getScheduleConfig());
|
||||
});
|
||||
|
||||
// ─── MQTT ────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.post("/mqtt", async (c) => {
|
||||
const body = await c.req.json<{ url?: string; topic?: string; username?: string; password?: string }>();
|
||||
const url = (body.url ?? "").trim();
|
||||
const topic = (body.topic ?? "jellyfin/events").trim();
|
||||
const username = (body.username ?? "").trim();
|
||||
const password = body.password ?? "";
|
||||
|
||||
setConfig("mqtt_url", url);
|
||||
setConfig("mqtt_topic", topic || "jellyfin/events");
|
||||
setConfig("mqtt_username", username);
|
||||
// Only overwrite password when a non-empty value is sent, so the UI can
|
||||
// leave the field blank to indicate "keep the existing one".
|
||||
if (password) setConfig("mqtt_password", password);
|
||||
|
||||
// Reconnect with the new config. Best-effort; failures surface in status.
|
||||
startMqttClient().catch(() => {});
|
||||
|
||||
return c.json({ ok: true, saved: true });
|
||||
});
|
||||
|
||||
app.get("/mqtt/status", (c) => {
|
||||
return c.json(getMqttStatus());
|
||||
});
|
||||
|
||||
app.post("/mqtt/test", async (c) => {
|
||||
const body = await c.req.json<{ url?: string; topic?: string; username?: string; password?: string }>();
|
||||
const url = (body.url ?? "").trim();
|
||||
if (!url) return c.json({ ok: false, error: "Broker URL required" }, 400);
|
||||
const topic = (body.topic ?? "jellyfin/events").trim() || "jellyfin/events";
|
||||
const password = body.password || getConfig("mqtt_password") || "";
|
||||
|
||||
const result = await testMqttConnection({ url, topic, username: (body.username ?? "").trim(), password }, 15_000);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns whether Jellyfin has the Webhook plugin installed. The Settings
|
||||
* panel uses this to decide between "setup steps" vs "install this plugin".
|
||||
*/
|
||||
app.get("/jellyfin/webhook-plugin", async (c) => {
|
||||
const url = getConfig("jellyfin_url");
|
||||
const apiKey = getConfig("jellyfin_api_key");
|
||||
if (!url || !apiKey) return c.json({ ok: false, error: "Jellyfin not configured" }, 400);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${url}/Plugins`, { headers: { "X-Emby-Token": apiKey } });
|
||||
if (!res.ok) return c.json({ ok: false, error: `HTTP ${res.status}` }, 502);
|
||||
const plugins = (await res.json()) as { Name?: string; Id?: string; Version?: string }[];
|
||||
const hit = plugins.find((p) => typeof p.Name === "string" && p.Name.toLowerCase().includes("webhook"));
|
||||
return c.json({ ok: true, installed: !!hit, plugin: hit ?? null });
|
||||
} catch (err) {
|
||||
return c.json({ ok: false, error: String(err) }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/clear-scan", (c) => {
|
||||
const db = getDb();
|
||||
// Delete children first to avoid slow cascade deletes
|
||||
|
||||
@@ -22,6 +22,10 @@ const ENV_MAP: Record<string, string> = {
|
||||
sonarr_api_key: "SONARR_API_KEY",
|
||||
sonarr_enabled: "SONARR_ENABLED",
|
||||
audio_languages: "AUDIO_LANGUAGES",
|
||||
mqtt_url: "MQTT_URL",
|
||||
mqtt_topic: "MQTT_TOPIC",
|
||||
mqtt_username: "MQTT_USERNAME",
|
||||
mqtt_password: "MQTT_PASSWORD",
|
||||
};
|
||||
|
||||
/** Read a config key from environment variables (returns null if not set). */
|
||||
|
||||
@@ -143,4 +143,9 @@ export const DEFAULT_CONFIG: Record<string, string> = {
|
||||
process_schedule_enabled: "0",
|
||||
process_schedule_start: "01:00",
|
||||
process_schedule_end: "07:00",
|
||||
|
||||
mqtt_url: "",
|
||||
mqtt_topic: "jellyfin/events",
|
||||
mqtt_username: "",
|
||||
mqtt_password: "",
|
||||
};
|
||||
|
||||
@@ -9,7 +9,8 @@ import scanRoutes from "./api/scan";
|
||||
import settingsRoutes from "./api/settings";
|
||||
import subtitlesRoutes from "./api/subtitles";
|
||||
import { getDb } from "./db/index";
|
||||
import { log } from "./lib/log";
|
||||
import { log, error as logError } from "./lib/log";
|
||||
import { startMqttClient } from "./services/mqtt";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -68,6 +69,8 @@ log(`netfelix-audio-fix v${pkg.version} starting on http://localhost:${port}`);
|
||||
|
||||
getDb();
|
||||
|
||||
startMqttClient().catch((err) => logError("MQTT bootstrap failed:", err));
|
||||
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
|
||||
182
server/services/__tests__/webhook.test.ts
Normal file
182
server/services/__tests__/webhook.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { SCHEMA } from "../../db/schema";
|
||||
import type { JellyfinItem } from "../../types";
|
||||
import type { JellyfinConfig } from "../jellyfin";
|
||||
import type { RescanConfig } from "../rescan";
|
||||
import { _resetDedupe, processWebhookEvent } from "../webhook";
|
||||
|
||||
function makeDb(): Database {
|
||||
const db = new Database(":memory:");
|
||||
for (const stmt of SCHEMA.split(";")) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) db.run(trimmed);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
const JF: JellyfinConfig = { url: "http://jf", apiKey: "k" };
|
||||
const RESCAN_CFG: RescanConfig = {
|
||||
audioLanguages: [],
|
||||
radarr: null,
|
||||
sonarr: null,
|
||||
radarrLibrary: null,
|
||||
sonarrLibrary: null,
|
||||
};
|
||||
|
||||
function fakeItem(over: Partial<JellyfinItem> = {}): JellyfinItem {
|
||||
return {
|
||||
Id: "jf-1",
|
||||
Type: "Movie",
|
||||
Name: "Test Movie",
|
||||
Path: "/movies/Test.mkv",
|
||||
Container: "mkv",
|
||||
MediaStreams: [
|
||||
{ Type: "Video", Index: 0, Codec: "h264" },
|
||||
{ Type: "Audio", Index: 1, Codec: "aac", Language: "eng", IsDefault: true },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("processWebhookEvent — acceptance", () => {
|
||||
beforeEach(() => _resetDedupe());
|
||||
afterEach(() => _resetDedupe());
|
||||
|
||||
test("rejects unknown events", async () => {
|
||||
const db = makeDb();
|
||||
const res = await processWebhookEvent(
|
||||
{ event: "PlaybackStart", itemId: "jf-1", itemType: "Movie" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("event");
|
||||
});
|
||||
|
||||
test("rejects non-Movie/Episode types", async () => {
|
||||
const db = makeDb();
|
||||
const res = await processWebhookEvent(
|
||||
{ event: "ItemUpdated", itemId: "jf-1", itemType: "Trailer" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem({ Type: "Trailer" }) },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("itemType");
|
||||
});
|
||||
|
||||
test("rejects missing itemId", async () => {
|
||||
const db = makeDb();
|
||||
const res = await processWebhookEvent(
|
||||
{ event: "ItemUpdated", itemType: "Movie" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => fakeItem() },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("itemId");
|
||||
});
|
||||
|
||||
test("dedupes bursts within 5s and accepts again after", async () => {
|
||||
const db = makeDb();
|
||||
let fakeNow = 1_000_000;
|
||||
const getItemFn = async () => fakeItem();
|
||||
const payload = { event: "ItemUpdated", itemId: "jf-1", itemType: "Movie" };
|
||||
|
||||
const first = await processWebhookEvent(payload, {
|
||||
db,
|
||||
jellyfin: JF,
|
||||
rescanCfg: RESCAN_CFG,
|
||||
getItemFn,
|
||||
now: () => fakeNow,
|
||||
});
|
||||
expect(first.accepted).toBe(true);
|
||||
|
||||
fakeNow += 1000;
|
||||
const second = await processWebhookEvent(payload, {
|
||||
db,
|
||||
jellyfin: JF,
|
||||
rescanCfg: RESCAN_CFG,
|
||||
getItemFn,
|
||||
now: () => fakeNow,
|
||||
});
|
||||
expect(second.accepted).toBe(false);
|
||||
expect(second.reason).toBe("deduped");
|
||||
|
||||
fakeNow += 5001;
|
||||
const third = await processWebhookEvent(payload, {
|
||||
db,
|
||||
jellyfin: JF,
|
||||
rescanCfg: RESCAN_CFG,
|
||||
getItemFn,
|
||||
now: () => fakeNow,
|
||||
});
|
||||
expect(third.accepted).toBe(true);
|
||||
});
|
||||
|
||||
test("drops when Jellyfin returns no item", async () => {
|
||||
const db = makeDb();
|
||||
const res = await processWebhookEvent(
|
||||
{ event: "ItemUpdated", itemId: "jf-missing", itemType: "Movie" },
|
||||
{ db, jellyfin: JF, rescanCfg: RESCAN_CFG, getItemFn: async () => null },
|
||||
);
|
||||
expect(res.accepted).toBe(false);
|
||||
expect(res.reason).toContain("no item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processWebhookEvent — done-status override", () => {
|
||||
beforeEach(() => _resetDedupe());
|
||||
|
||||
async function runWebhook(db: Database, item: JellyfinItem, cfg: RescanConfig = RESCAN_CFG) {
|
||||
return processWebhookEvent(
|
||||
{ event: "ItemUpdated", itemId: item.Id, itemType: item.Type as "Movie" | "Episode" },
|
||||
{ db, jellyfin: JF, rescanCfg: cfg, getItemFn: async () => item },
|
||||
);
|
||||
}
|
||||
|
||||
function planStatusFor(db: Database, jellyfinId: string): string {
|
||||
return (
|
||||
db
|
||||
.prepare("SELECT rp.status FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE mi.jellyfin_id = ?")
|
||||
.get(jellyfinId) as { status: string }
|
||||
).status;
|
||||
}
|
||||
|
||||
test("a webhook that analyzes to is_noop=1 leaves a done plan as done", async () => {
|
||||
const db = makeDb();
|
||||
const fresh = fakeItem();
|
||||
await runWebhook(db, fresh);
|
||||
|
||||
db
|
||||
.prepare(
|
||||
"UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)",
|
||||
)
|
||||
.run(fresh.Id);
|
||||
|
||||
_resetDedupe();
|
||||
await runWebhook(db, fresh);
|
||||
expect(planStatusFor(db, fresh.Id)).toBe("done");
|
||||
});
|
||||
|
||||
test("a webhook that analyzes to is_noop=0 flips a done plan back to pending", async () => {
|
||||
const db = makeDb();
|
||||
// audio_languages=['deu'] means a file with english OG + french extra
|
||||
// audio should remove french → is_noop=0.
|
||||
const cfg: RescanConfig = { ...RESCAN_CFG, audioLanguages: ["deu"] };
|
||||
const fresh = fakeItem({
|
||||
MediaStreams: [
|
||||
{ Type: "Video", Index: 0, Codec: "h264" },
|
||||
{ Type: "Audio", Index: 1, Codec: "aac", Language: "eng", IsDefault: true },
|
||||
{ Type: "Audio", Index: 2, Codec: "aac", Language: "fra" },
|
||||
],
|
||||
});
|
||||
|
||||
await runWebhook(db, fresh, cfg);
|
||||
db
|
||||
.prepare(
|
||||
"UPDATE review_plans SET status = 'done' WHERE item_id = (SELECT id FROM media_items WHERE jellyfin_id = ?)",
|
||||
)
|
||||
.run(fresh.Id);
|
||||
|
||||
_resetDedupe();
|
||||
await runWebhook(db, fresh, cfg);
|
||||
expect(planStatusFor(db, fresh.Id)).toBe("pending");
|
||||
});
|
||||
});
|
||||
159
server/services/mqtt.ts
Normal file
159
server/services/mqtt.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import mqtt, { type MqttClient } from "mqtt";
|
||||
import { getConfig } from "../db/index";
|
||||
import { log, error as logError, warn } from "../lib/log";
|
||||
import { handleWebhookMessage } from "./webhook";
|
||||
|
||||
export type MqttStatus = "connected" | "disconnected" | "error" | "not_configured";
|
||||
|
||||
interface MqttConfig {
|
||||
url: string;
|
||||
topic: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
let client: MqttClient | null = null;
|
||||
let currentStatus: MqttStatus = "not_configured";
|
||||
let currentError: string | null = null;
|
||||
const statusListeners = new Set<(status: MqttStatus, error: string | null) => void>();
|
||||
|
||||
export function getMqttStatus(): { status: MqttStatus; error: string | null } {
|
||||
return { status: currentStatus, error: currentError };
|
||||
}
|
||||
|
||||
export function onMqttStatus(fn: (status: MqttStatus, error: string | null) => void): () => void {
|
||||
statusListeners.add(fn);
|
||||
return () => {
|
||||
statusListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
function setStatus(next: MqttStatus, err: string | null = null): void {
|
||||
currentStatus = next;
|
||||
currentError = err;
|
||||
for (const l of statusListeners) l(next, err);
|
||||
}
|
||||
|
||||
function readConfig(): MqttConfig | null {
|
||||
const url = getConfig("mqtt_url") ?? "";
|
||||
if (!url) return null;
|
||||
return {
|
||||
url,
|
||||
topic: getConfig("mqtt_topic") ?? "jellyfin/events",
|
||||
username: getConfig("mqtt_username") ?? "",
|
||||
password: getConfig("mqtt_password") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the configured MQTT broker and subscribe to the webhook topic.
|
||||
* Safe to call repeatedly: an existing client is torn down first. When no
|
||||
* broker is configured, status is set to 'not_configured' and the call is
|
||||
* a no-op.
|
||||
*/
|
||||
export async function startMqttClient(): Promise<void> {
|
||||
await stopMqttClient();
|
||||
const cfg = readConfig();
|
||||
if (!cfg) {
|
||||
setStatus("not_configured");
|
||||
return;
|
||||
}
|
||||
|
||||
log(`MQTT: connecting to ${cfg.url} (topic=${cfg.topic})`);
|
||||
const c = mqtt.connect(cfg.url, {
|
||||
username: cfg.username || undefined,
|
||||
password: cfg.password || undefined,
|
||||
reconnectPeriod: 5000,
|
||||
connectTimeout: 15_000,
|
||||
clientId: `netfelix-audio-fix-${Math.random().toString(16).slice(2, 10)}`,
|
||||
});
|
||||
client = c;
|
||||
|
||||
c.on("connect", () => {
|
||||
c.subscribe(cfg.topic, { qos: 0 }, (err) => {
|
||||
if (err) {
|
||||
logError(`MQTT subscribe to ${cfg.topic} failed:`, err);
|
||||
setStatus("error", String(err));
|
||||
return;
|
||||
}
|
||||
log(`MQTT: connected, subscribed to ${cfg.topic}`);
|
||||
setStatus("connected");
|
||||
});
|
||||
});
|
||||
|
||||
c.on("reconnect", () => {
|
||||
setStatus("disconnected", "reconnecting");
|
||||
});
|
||||
|
||||
c.on("close", () => {
|
||||
setStatus("disconnected", null);
|
||||
});
|
||||
|
||||
c.on("error", (err) => {
|
||||
warn(`MQTT error: ${String(err)}`);
|
||||
setStatus("error", String(err));
|
||||
});
|
||||
|
||||
c.on("message", (_topic, payload) => {
|
||||
const text = payload.toString("utf8");
|
||||
// Best-effort: the handler owns its own error handling. Don't let a
|
||||
// single malformed message tear the subscriber down.
|
||||
handleWebhookMessage(text).catch((err) => logError("webhook handler threw:", err));
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopMqttClient(): Promise<void> {
|
||||
if (!client) return;
|
||||
const c = client;
|
||||
client = null;
|
||||
await new Promise<void>((resolve) => {
|
||||
c.end(false, {}, () => resolve());
|
||||
});
|
||||
setStatus("not_configured");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a candidate MQTT configuration without touching the running client.
|
||||
* Connects, subscribes to `<topic>/#`, waits up to `timeoutMs` for any
|
||||
* message, then disconnects. Returns whether the connection succeeded and
|
||||
* whether any traffic arrived.
|
||||
*/
|
||||
export async function testMqttConnection(
|
||||
cfg: MqttConfig,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<{ connected: boolean; receivedMessage: boolean; error?: string; samplePayload?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const c = mqtt.connect(cfg.url, {
|
||||
username: cfg.username || undefined,
|
||||
password: cfg.password || undefined,
|
||||
reconnectPeriod: 0,
|
||||
connectTimeout: 10_000,
|
||||
clientId: `netfelix-audio-fix-test-${Math.random().toString(16).slice(2, 10)}`,
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const done = (result: { connected: boolean; receivedMessage: boolean; error?: string; samplePayload?: string }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
c.end(true);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
c.on("connect", () => {
|
||||
c.subscribe(`${cfg.topic}`, { qos: 0 }, (err) => {
|
||||
if (err) {
|
||||
done({ connected: true, receivedMessage: false, error: String(err) });
|
||||
}
|
||||
});
|
||||
setTimeout(() => done({ connected: true, receivedMessage: false }), timeoutMs);
|
||||
});
|
||||
|
||||
c.on("message", (_topic, payload) => {
|
||||
done({ connected: true, receivedMessage: true, samplePayload: payload.toString("utf8").slice(0, 200) });
|
||||
});
|
||||
|
||||
c.on("error", (err) => {
|
||||
done({ connected: false, receivedMessage: false, error: String(err) });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -38,8 +38,9 @@ export async function upsertJellyfinItem(
|
||||
db: Database,
|
||||
jellyfinItem: JellyfinItem,
|
||||
cfg: RescanConfig,
|
||||
opts: { executed?: boolean } = {},
|
||||
opts: { executed?: boolean; source?: "scan" | "webhook" } = {},
|
||||
): Promise<RescanResult> {
|
||||
const source = opts.source ?? "scan";
|
||||
if (!jellyfinItem.Name || !jellyfinItem.Path) {
|
||||
throw new Error(`Jellyfin item ${jellyfinItem.Id} missing Name or Path`);
|
||||
}
|
||||
@@ -219,14 +220,24 @@ export async function upsertJellyfinItem(
|
||||
{ audioLanguages: cfg.audioLanguages },
|
||||
);
|
||||
|
||||
// Status transition rules:
|
||||
// is_noop=1 → done (all sources)
|
||||
// done + is_noop=0 + source='webhook' → pending (Jellyfin says the
|
||||
// on-disk file doesn't match the plan, re-open for review)
|
||||
// done + is_noop=0 + source='scan' → done (safety net: a plain
|
||||
// rescan must not reopen plans and risk duplicate jobs; see the
|
||||
// commit that made done terminal)
|
||||
// error → pending (retry loop)
|
||||
// else keep current status
|
||||
db
|
||||
.prepare(`
|
||||
INSERT INTO review_plans (item_id, status, is_noop, confidence, apple_compat, job_type, notes)
|
||||
VALUES (?, 'pending', ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(item_id) DO UPDATE SET
|
||||
status = CASE
|
||||
WHEN review_plans.status = 'done' THEN 'done'
|
||||
WHEN excluded.is_noop = 1 THEN 'done'
|
||||
WHEN review_plans.status = 'done' AND ? = 'webhook' THEN 'pending'
|
||||
WHEN review_plans.status = 'done' THEN 'done'
|
||||
WHEN review_plans.status = 'error' THEN 'pending'
|
||||
ELSE review_plans.status
|
||||
END,
|
||||
@@ -243,6 +254,7 @@ export async function upsertJellyfinItem(
|
||||
analysis.apple_compat,
|
||||
analysis.job_type,
|
||||
analysis.notes.length > 0 ? analysis.notes.join("\n") : null,
|
||||
source,
|
||||
);
|
||||
|
||||
const planRow = db.prepare("SELECT id FROM review_plans WHERE item_id = ?").get(itemId) as { id: number };
|
||||
|
||||
141
server/services/webhook.ts
Normal file
141
server/services/webhook.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { Database } from "bun:sqlite";
|
||||
import { getAllConfig, getDb } from "../db/index";
|
||||
import { log, warn } from "../lib/log";
|
||||
import { getItem, type JellyfinConfig } from "./jellyfin";
|
||||
import { loadLibrary as loadRadarrLibrary, isUsable as radarrUsable } from "./radarr";
|
||||
import { type RescanConfig, type RescanResult, upsertJellyfinItem } from "./rescan";
|
||||
import { loadLibrary as loadSonarrLibrary, isUsable as sonarrUsable } from "./sonarr";
|
||||
|
||||
/**
|
||||
* Events we care about. Jellyfin's Webhook plugin emits many event types;
|
||||
* Library.ItemAdded and Library.ItemUpdated are the only ones that signal
|
||||
* an on-disk file mutation. We ignore user-data changes, playback, etc.
|
||||
*/
|
||||
const ACCEPTED_EVENTS = new Set(["ItemAdded", "ItemUpdated", "Library.ItemAdded", "Library.ItemUpdated"]);
|
||||
const ACCEPTED_TYPES = new Set(["Movie", "Episode"]);
|
||||
|
||||
/** 5-second dedupe window: Jellyfin fires ItemUpdated multiple times per rescan. */
|
||||
const DEDUPE_WINDOW_MS = 5000;
|
||||
const dedupe = new Map<string, number>();
|
||||
|
||||
function parseLanguageList(raw: string | null | undefined, fallback: string[]): string[] {
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebhookPayload {
|
||||
event?: string;
|
||||
itemId?: string;
|
||||
itemType?: string;
|
||||
}
|
||||
|
||||
export interface WebhookHandlerDeps {
|
||||
db: Database;
|
||||
jellyfin: JellyfinConfig;
|
||||
rescanCfg: RescanConfig;
|
||||
getItemFn?: typeof getItem;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface WebhookResult {
|
||||
accepted: boolean;
|
||||
reason?: string;
|
||||
result?: RescanResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an incoming webhook payload and, if it describes a relevant Jellyfin
|
||||
* library event for a Movie/Episode, re-analyze the item and let rescan's
|
||||
* webhook-override flip stale 'done' plans back to 'pending'.
|
||||
*
|
||||
* Errors from Jellyfin are logged and swallowed: one bad message must not
|
||||
* take down the MQTT subscriber.
|
||||
*/
|
||||
export async function processWebhookEvent(payload: WebhookPayload, deps: WebhookHandlerDeps): Promise<WebhookResult> {
|
||||
const { db, jellyfin, rescanCfg, getItemFn = getItem, now = Date.now } = deps;
|
||||
|
||||
if (!payload.event || !ACCEPTED_EVENTS.has(payload.event)) {
|
||||
return { accepted: false, reason: `event '${payload.event}' not accepted` };
|
||||
}
|
||||
if (!payload.itemType || !ACCEPTED_TYPES.has(payload.itemType)) {
|
||||
return { accepted: false, reason: `itemType '${payload.itemType}' not accepted` };
|
||||
}
|
||||
if (!payload.itemId) {
|
||||
return { accepted: false, reason: "missing itemId" };
|
||||
}
|
||||
|
||||
// Debounce: drop bursts within the window, always evict stale entries.
|
||||
const ts = now();
|
||||
for (const [id, seen] of dedupe) {
|
||||
if (ts - seen > DEDUPE_WINDOW_MS) dedupe.delete(id);
|
||||
}
|
||||
const last = dedupe.get(payload.itemId);
|
||||
if (last != null && ts - last <= DEDUPE_WINDOW_MS) {
|
||||
return { accepted: false, reason: "deduped" };
|
||||
}
|
||||
dedupe.set(payload.itemId, ts);
|
||||
|
||||
const fresh = await getItemFn(jellyfin, payload.itemId);
|
||||
if (!fresh) {
|
||||
warn(`Webhook: Jellyfin returned no item for ${payload.itemId}`);
|
||||
return { accepted: false, reason: "jellyfin returned no item" };
|
||||
}
|
||||
|
||||
const result = await upsertJellyfinItem(db, fresh, rescanCfg, { source: "webhook" });
|
||||
log(`Webhook: reanalyzed ${payload.itemType} ${payload.itemId} is_noop=${result.isNoop}`);
|
||||
return { accepted: true, result };
|
||||
}
|
||||
|
||||
/**
|
||||
* MQTT-facing adapter: parses the raw payload text, pulls config, calls
|
||||
* processWebhookEvent. Exposed so server/services/mqtt.ts can stay purely
|
||||
* about transport, and tests can drive the logic without spinning up MQTT.
|
||||
*/
|
||||
export async function handleWebhookMessage(rawPayload: string): Promise<WebhookResult> {
|
||||
let payload: WebhookPayload;
|
||||
try {
|
||||
payload = JSON.parse(rawPayload);
|
||||
} catch (err) {
|
||||
warn(`Webhook: malformed JSON payload: ${String(err)}`);
|
||||
return { accepted: false, reason: "malformed JSON" };
|
||||
}
|
||||
|
||||
const cfg = getAllConfig();
|
||||
const jellyfin: JellyfinConfig = {
|
||||
url: cfg.jellyfin_url,
|
||||
apiKey: cfg.jellyfin_api_key,
|
||||
userId: cfg.jellyfin_user_id,
|
||||
};
|
||||
if (!jellyfin.url || !jellyfin.apiKey) {
|
||||
return { accepted: false, reason: "jellyfin not configured" };
|
||||
}
|
||||
|
||||
const radarrCfg = { url: cfg.radarr_url, apiKey: cfg.radarr_api_key };
|
||||
const sonarrCfg = { url: cfg.sonarr_url, apiKey: cfg.sonarr_api_key };
|
||||
const radarrEnabled = cfg.radarr_enabled === "1" && radarrUsable(radarrCfg);
|
||||
const sonarrEnabled = cfg.sonarr_enabled === "1" && sonarrUsable(sonarrCfg);
|
||||
const [radarrLibrary, sonarrLibrary] = await Promise.all([
|
||||
radarrEnabled ? loadRadarrLibrary(radarrCfg) : Promise.resolve(null),
|
||||
sonarrEnabled ? loadSonarrLibrary(sonarrCfg) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const rescanCfg: RescanConfig = {
|
||||
audioLanguages: parseLanguageList(cfg.audio_languages, []),
|
||||
radarr: radarrEnabled ? radarrCfg : null,
|
||||
sonarr: sonarrEnabled ? sonarrCfg : null,
|
||||
radarrLibrary,
|
||||
sonarrLibrary,
|
||||
};
|
||||
|
||||
return processWebhookEvent(payload, { db: getDb(), jellyfin, rescanCfg });
|
||||
}
|
||||
|
||||
/** Exposed for tests. */
|
||||
export function _resetDedupe(): void {
|
||||
dedupe.clear();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MqttBadge } from "~/shared/components/MqttBadge";
|
||||
import { Alert } from "~/shared/components/ui/alert";
|
||||
import { Badge } from "~/shared/components/ui/badge";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
@@ -259,7 +260,10 @@ export function ScanPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold mb-4">Scan</h1>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-bold m-0">Scan</h1>
|
||||
<MqttBadge />
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-2.5 mb-5">
|
||||
|
||||
243
src/features/settings/MqttSection.tsx
Normal file
243
src/features/settings/MqttSection.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/shared/components/ui/button";
|
||||
import { Input } from "~/shared/components/ui/input";
|
||||
import { api } from "~/shared/lib/api";
|
||||
|
||||
interface MqttStatus {
|
||||
status: "connected" | "disconnected" | "error" | "not_configured";
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
connected: boolean;
|
||||
receivedMessage: boolean;
|
||||
error?: string;
|
||||
samplePayload?: string;
|
||||
}
|
||||
|
||||
interface WebhookPluginInfo {
|
||||
ok: boolean;
|
||||
installed?: boolean;
|
||||
plugin?: { Name?: string; Version?: string } | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const HANDLEBARS_TEMPLATE = `{
|
||||
"event": "{{NotificationType}}",
|
||||
"itemId": "{{ItemId}}",
|
||||
"itemType": "{{ItemType}}"
|
||||
}`;
|
||||
|
||||
function CopyableValue({ label, value, mono = true }: { label: string; value: string; mono?: boolean }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
return (
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<div className="text-xs text-gray-500 w-24 flex-shrink-0 pt-1.5">{label}</div>
|
||||
<pre className={`flex-1 text-xs bg-gray-50 border rounded px-2 py-1.5 overflow-x-auto ${mono ? "font-mono" : ""}`}>
|
||||
{value}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="text-xs px-2 py-1 rounded border border-gray-300 text-gray-600 hover:bg-gray-100 flex-shrink-0"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MqttSection({ cfg, locked }: { cfg: Record<string, string>; locked: Set<string> }) {
|
||||
const [url, setUrl] = useState(cfg.mqtt_url ?? "");
|
||||
const [topic, setTopic] = useState(cfg.mqtt_topic || "jellyfin/events");
|
||||
const [username, setUsername] = useState(cfg.mqtt_username ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedMsg, setSavedMsg] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [status, setStatus] = useState<MqttStatus>({ status: "not_configured", error: null });
|
||||
const [plugin, setPlugin] = useState<WebhookPluginInfo | null>(null);
|
||||
|
||||
const allLocked =
|
||||
locked.has("mqtt_url") && locked.has("mqtt_topic") && locked.has("mqtt_username") && locked.has("mqtt_password");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const s = await api.get<MqttStatus>("/api/settings/mqtt/status");
|
||||
if (!cancelled) setStatus(s);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<WebhookPluginInfo>("/api/settings/jellyfin/webhook-plugin")
|
||||
.then(setPlugin)
|
||||
.catch((err) => setPlugin({ ok: false, error: String(err) }));
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setSavedMsg("");
|
||||
try {
|
||||
await api.post("/api/settings/mqtt", { url, topic, username, password });
|
||||
setSavedMsg(password ? "Saved." : "Saved (password unchanged).");
|
||||
setPassword("");
|
||||
setTimeout(() => setSavedMsg(""), 2500);
|
||||
} catch (e) {
|
||||
setSavedMsg(`Failed: ${String(e)}`);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const runTest = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const r = await api.post<TestResult>("/api/settings/mqtt/test", { url, topic, username, password });
|
||||
setTestResult(r);
|
||||
} catch (e) {
|
||||
setTestResult({ connected: false, receivedMessage: false, error: String(e) });
|
||||
}
|
||||
setTesting(false);
|
||||
};
|
||||
|
||||
const statusColor =
|
||||
status.status === "connected"
|
||||
? "text-green-700 bg-green-50 border-green-300"
|
||||
: status.status === "error"
|
||||
? "text-red-700 bg-red-50 border-red-300"
|
||||
: status.status === "disconnected"
|
||||
? "text-amber-700 bg-amber-50 border-amber-300"
|
||||
: "text-gray-600 bg-gray-50 border-gray-300";
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-semibold text-sm">
|
||||
Jellyfin webhook <span className="text-gray-400 font-normal">(MQTT)</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded border ${statusColor}`}>MQTT: {status.status}</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm mb-3 mt-0">
|
||||
Close the loop: once Jellyfin finishes its post-ffmpeg rescan, it publishes an event to your MQTT broker. We
|
||||
re-analyze the item, confirming it as done or flipping it back to pending if something didn't stick.
|
||||
</p>
|
||||
|
||||
<label className="block text-sm text-gray-700 mb-1">
|
||||
Broker URL
|
||||
<Input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="mqtt://192.168.1.10:1883"
|
||||
disabled={locked.has("mqtt_url")}
|
||||
className="mt-0.5 max-w-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700 mb-1 mt-3">
|
||||
Topic
|
||||
<Input
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder="jellyfin/events"
|
||||
disabled={locked.has("mqtt_topic")}
|
||||
className="mt-0.5 max-w-xs"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3 mt-3 max-w-md">
|
||||
<label className="block text-sm text-gray-700">
|
||||
Username
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="(optional)"
|
||||
disabled={locked.has("mqtt_username")}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-gray-700">
|
||||
Password
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={cfg.mqtt_password ? "(unchanged)" : "(optional)"}
|
||||
disabled={locked.has("mqtt_password")}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<Button onClick={save} disabled={saving || allLocked}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={runTest} disabled={testing || !url}>
|
||||
{testing ? "Testing…" : "Test connection"}
|
||||
</Button>
|
||||
{savedMsg && <span className="text-sm text-green-700">✓ {savedMsg}</span>}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="mt-3 text-sm">
|
||||
{testResult.connected && testResult.receivedMessage && (
|
||||
<div className="text-green-700">
|
||||
✓ Connected and received a message.
|
||||
{testResult.samplePayload && (
|
||||
<pre className="mt-1 text-xs bg-gray-50 border rounded px-2 py-1 font-mono">{testResult.samplePayload}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{testResult.connected && !testResult.receivedMessage && !testResult.error && (
|
||||
<div className="text-amber-700">
|
||||
⚠ Connected, but no traffic in the timeout window. Trigger a library scan in Jellyfin, then retry.
|
||||
</div>
|
||||
)}
|
||||
{testResult.error && <div className="text-red-700">✗ {testResult.error}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugin + setup instructions */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="text-sm font-medium mb-2">Jellyfin Webhook plugin</div>
|
||||
{plugin?.ok === false && <p className="text-xs text-red-600">Couldn't reach Jellyfin: {plugin.error}</p>}
|
||||
{plugin?.ok && !plugin.installed && (
|
||||
<p className="text-xs text-amber-700">
|
||||
⚠ The Webhook plugin is not installed on Jellyfin. Install it from{" "}
|
||||
<span className="font-mono">Jellyfin → Dashboard → Plugins → Catalog → Webhook</span>, restart Jellyfin, then
|
||||
configure an MQTT destination with the values below.
|
||||
</p>
|
||||
)}
|
||||
{plugin?.ok && plugin.installed && (
|
||||
<p className="text-xs text-green-700 mb-2">
|
||||
✓ Plugin detected{plugin.plugin?.Version ? ` (v${plugin.plugin.Version})` : ""}. Add an MQTT destination with:
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<CopyableValue label="Broker URL" value={url || "mqtt://broker:1883"} />
|
||||
<CopyableValue label="Topic" value={topic || "jellyfin/events"} />
|
||||
<CopyableValue label="Events" value="Item Added, Item Updated" mono={false} />
|
||||
<CopyableValue label="Item types" value="Movie, Episode" mono={false} />
|
||||
<CopyableValue label="Template" value={HANDLEBARS_TEMPLATE} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Select } from "~/shared/components/ui/select";
|
||||
import { TimeInput } from "~/shared/components/ui/time-input";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { LANG_NAMES } from "~/shared/lib/lang";
|
||||
import { MqttSection } from "./MqttSection";
|
||||
|
||||
interface ScheduleWindow {
|
||||
enabled: boolean;
|
||||
@@ -457,6 +458,9 @@ export function SettingsPage() {
|
||||
onSave={saveSonarr}
|
||||
/>
|
||||
|
||||
{/* MQTT (Jellyfin webhook) */}
|
||||
<MqttSection cfg={cfg} locked={locked} />
|
||||
|
||||
{/* Schedule */}
|
||||
<ScheduleSection />
|
||||
|
||||
|
||||
49
src/shared/components/MqttBadge.tsx
Normal file
49
src/shared/components/MqttBadge.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "~/shared/lib/api";
|
||||
|
||||
interface MqttStatusPayload {
|
||||
status: "connected" | "disconnected" | "error" | "not_configured";
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const LABEL: Record<MqttStatusPayload["status"], string> = {
|
||||
connected: "MQTT: connected",
|
||||
disconnected: "MQTT: disconnected",
|
||||
error: "MQTT: error",
|
||||
not_configured: "MQTT: not configured",
|
||||
};
|
||||
|
||||
const STYLES: Record<MqttStatusPayload["status"], string> = {
|
||||
connected: "text-green-700 bg-green-50 border-green-300",
|
||||
disconnected: "text-amber-700 bg-amber-50 border-amber-300",
|
||||
error: "text-red-700 bg-red-50 border-red-300",
|
||||
not_configured: "text-gray-500 bg-gray-50 border-gray-300",
|
||||
};
|
||||
|
||||
export function MqttBadge() {
|
||||
const [state, setState] = useState<MqttStatusPayload>({ status: "not_configured", error: null });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const s = await api.get<MqttStatusPayload>("/api/settings/mqtt/status");
|
||||
if (!cancelled) setState(s);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
fetchStatus();
|
||||
const interval = setInterval(fetchStatus, 5000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span title={state.error ?? undefined} className={`text-xs px-2 py-0.5 rounded border ${STYLES[state.status]}`}>
|
||||
{LABEL[state.status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user