Compare commits

...

2 Commits

Author SHA1 Message Date
a27e4f4025 close the jellyfin ping-pong via mqtt webhook subscriber
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.
2026-04-14 08:26:42 +02:00
2e8d790326 spec: jellyfin webhook ping-pong via mqtt 2026-04-14 08:14:18 +02:00
16 changed files with 1189 additions and 64 deletions

View File

@@ -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=="],
}
}

View File

@@ -0,0 +1,213 @@
# Jellyfin Webhook Ping-Pong
**Date:** 2026-04-14
**Status:** Design
**Scope:** Close the processing loop between netfelix-audio-fix and Jellyfin.
## Goal
After ffmpeg finishes, the server currently blocks on a Jellyfin refresh +
re-analysis before freeing the job queue. Move that responsibility to a
Jellyfin-driven event: we hand off the rescan to Jellyfin and return. When
Jellyfin detects the modified (or a newly added) file, it publishes an MQTT
message and we react — re-analyzing the item and either confirming the plan
as `done` or flipping it back to `pending`. The result is a ping-pong
between the two systems that terminates in plans Jellyfin itself has just
verified.
## Non-goals
- HTTP webhook fallback. One transport.
- Persisting missed MQTT messages. If the broker is unreachable while a
file changes, the user uses the existing manual rescan.
- Programmatic creation/deletion of the Jellyfin-side plugin config.
- MQTT TLS client certificates (`mqtts://` with username/password is
sufficient).
## Architecture
### 1. Outbound hand-off (execute.ts)
`server/api/execute.ts` currently calls `refreshItemFromJellyfin()` after
a successful job, which triggers Jellyfin's rescan, fetches the item, and
calls `upsertJellyfinItem()` to re-analyze. Remove the fetch and re-upsert
steps. Keep only the fire-and-forget refresh.
```
after ffmpeg exit 0:
markJobDone + markPlanDone (unchanged)
insertSubtitleFiles (unchanged)
refreshItem(jfCfg, jellyfinId) (best-effort; log + swallow)
return (do not await analysis)
```
The MQTT subscriber handles everything downstream of Jellyfin's rescan.
### 2. MQTT subscriber
A single long-lived MQTT client owned by the server process.
**Library:** `mqtt` (npm), Bun-compatible.
**Config (new `config` rows):**
- `mqtt_url` — e.g. `mqtt://192.168.1.10:1883`, `mqtts://…` for TLS
- `mqtt_topic` — default `jellyfin/events`
- `mqtt_username` — optional
- `mqtt_password` — optional, stored alongside other credentials and
overridable by env var using the existing `getEnvLockedKeys()` pattern
**Lifecycle:**
- On server boot, read config. If `mqtt_url` is set, connect and subscribe
to `mqtt_topic`.
- `mqtt.js` reconnects automatically. We log `connect`, `close`, and
`error` events and publish them through the existing SSE channel as
`mqtt_status` events so the UI badge can update live.
- When config changes (Settings save), tear down the current client and
start a new one.
**Message handling:** `handleWebhookMessage(db, jfCfg, payload)` is a pure
function. The MQTT subscriber's on-message callback parses JSON and calls
this. Unit tests drive it directly.
```
parse JSON payload → { event, itemId, itemType }
if event not in {'ItemAdded', 'ItemUpdated'}: drop
if itemType not in {'Movie', 'Episode'}: drop
if dedupeMap.has(itemId): drop (burst filter)
dedupeMap.set(itemId, Date.now()) (evict after 5s)
fresh = getItem(jfCfg, itemId) (one HTTP call)
if fresh is null: drop + log
upsertJellyfinItem(db, fresh, cfg, { source: 'webhook' })
```
### 3. "done is terminal" override for webhook-driven rescans
`rescan.ts` currently treats `review_plans.status = 'done'` as terminal
so scans don't reopen plans and spawn duplicate jobs (see commit
a06ab34). The webhook path wants the opposite: a post-processing event
should be able to flip `done` back to `pending` when the on-disk streams
no longer satisfy `is_noop`.
Add an options flag:
```ts
upsertJellyfinItem(db, item, cfg, opts: { executed?, source?: 'scan'|'webhook' })
```
Plan-status transition rules in rescan:
| Current | is_noop | source | Next |
|---------|---------|--------------|----------|
| done | 1 | any | done |
| done | 0 | `scan` | done | (current safety net)
| done | 0 | `webhook` | pending | (authoritative re-open)
| other | any | any | pending | (existing behavior)
Scan-flow callers default to `source: 'scan'` (or omit). Only the MQTT
handler passes `source: 'webhook'`.
### 4. Settings UI + connection status
**New Settings section — "Jellyfin webhook (MQTT)":**
- Broker URL, Topic (default `jellyfin/events`), Username, Password.
- "Test connection" button: connects to the broker with the submitted
credentials, subscribes to `<topic>/#`, waits up to 30 s. On any message
shows green success with a snippet of the payload; on timeout shows an
amber "connected but no traffic — trigger a library edit in Jellyfin"
message; on connect/auth error shows red with the error text.
**Webhook setup panel** (on the same Settings section, below the form):
- Uses the existing `jellyfin_url` + `jellyfin_api_key` to call `GET
/Plugins` and check whether the Webhook plugin is installed. If not,
shows install instructions with a deep-link to the Jellyfin plugin
catalog. Otherwise shows:
- The exact values to paste into the Webhook plugin's MQTT destination:
broker URL, port, topic, events (`Item Added`, `Item Updated`), item-type
filter (`Movie`, `Episode`), handlebars template:
```json
{
"event": "{{NotificationType}}",
"itemId": "{{ItemId}}",
"itemType": "{{ItemType}}"
}
```
Each value has a copy-to-clipboard button.
**Connection status badge** on the dashboard / Scan page: `MQTT:
connected | disconnected | not configured`, driven by the
`mqtt_status` SSE events above.
## Data flow
```
┌──────────────┐ ffmpeg done ┌─────────────┐
│ execute.ts │ ────────────▶ │ Jellyfin │
│ (job queue) │ rescan RPC │ │
└──────────────┘ └──────┬──────┘
▲ │ library scan finds
│ │ changed/new file
│ ▼
│ ┌─────────────┐
│ │ Webhook │
│ │ plugin │
│ └──────┬──────┘
│ │ publishes ItemUpdated
│ ▼
│ ┌─────────────┐
│ mqtt_status SSE ──────▶│ MQTT broker │
│ └──────┬──────┘
│ │
┌──────┴───────────┐ re-analyze │
│ mqtt subscriber │◀──────────────────┘
│ handler │
│ │ upsertJellyfinItem(..., source:'webhook')
│ │────────────▶ review_plans.status =
│ │ 'done' if is_noop, else 'pending'
└──────────────────┘
```
## Testing
- **`handleWebhookMessage` unit tests** (`server/services/__tests__/`):
seeds an in-memory DB with a known plan, feeds synthetic payloads,
asserts:
- `ItemUpdated` + `is_noop=1` leaves plan `done`.
- `ItemUpdated` + `is_noop=0` with `source:'webhook'` flips `done → pending`.
- Unknown itemId inserts a new media_item (same path as scan).
- Non-Movie/Episode types are ignored.
- Duplicate messages within 5 s are dropped.
- `getItem` returning null is logged and dropped.
- **execute.ts test**: after job success, `refreshItem` is called once and
`upsertJellyfinItem` is NOT called synchronously.
- No integration test against a real broker — the `mqtt` library itself
is not under test.
## Migration / rollout
- Database: two new `config` rows. No schema change needed; config is
key/value.
- Env-var overrides for `MQTT_URL`, `MQTT_USERNAME`, `MQTT_PASSWORD`,
`MQTT_TOPIC` via existing `getEnvLockedKeys()`.
- First deploy with `mqtt_url` unset: subscriber doesn't start, existing
flow (scan → review → approve → execute) continues unchanged. Users opt
in by filling in the Settings fields.
- Removing the old post-job re-analyze is a behavior change for users
who haven't configured MQTT. They lose automatic verification of
finished jobs until they set up the webhook. They can still trigger a
manual rescan.
## Risks & mitigations
- **Burst messages from Jellyfin's scan.** Multiple `ItemUpdated` events
per item during a library sweep. Mitigated by 5 s in-memory dedupe map.
- **Broker outage at the moment a job finishes.** Message is lost.
Accepted; the user can trigger a manual rescan, and the next actual
library event will resync.
- **Plugin template drift.** The Webhook plugin's handlebars variables
are stable across releases; if a future release renames `ItemId`, the
Settings panel's copyable template is the single place to update.
- **`done → pending` oscillation.** If analysis flaps between noop and
non-noop due to a Jellyfin metadata race, the UI could bounce. Same 5 s
dedupe protects against burst; a persistent non-noop indicates a real
problem worth surfacing.

View File

@@ -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",

View File

@@ -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)}`;

View File

@@ -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

View File

@@ -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). */

View File

@@ -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: "",
};

View File

@@ -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,

View 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
View 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) });
});
});
}

View File

@@ -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
View 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();
}

View File

@@ -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">

View 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>
);
}

View File

@@ -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 />

View 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>
);
}