diff --git a/bun.lock b/bun.lock index 4442ea9..325a3e5 100644 --- a/bun.lock +++ b/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=="], } } diff --git a/package.json b/package.json index bcca75a..d99bc88 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/api/execute.ts b/server/api/execute.ts index b57aa8f..3700292 100644 --- a/server/api/execute.ts +++ b/server/api/execute.ts @@ -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 { +async function handOffToJellyfin(itemId: number): Promise { 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 { } 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 { 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)}`; diff --git a/server/api/settings.ts b/server/api/settings.ts index 4775136..1e04152 100644 --- a/server/api/settings.ts +++ b/server/api/settings.ts @@ -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 diff --git a/server/db/index.ts b/server/db/index.ts index 7d5f5f6..ee59631 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -22,6 +22,10 @@ const ENV_MAP: Record = { 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). */ diff --git a/server/db/schema.ts b/server/db/schema.ts index a333455..0686aed 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -143,4 +143,9 @@ export const DEFAULT_CONFIG: Record = { process_schedule_enabled: "0", process_schedule_start: "01:00", process_schedule_end: "07:00", + + mqtt_url: "", + mqtt_topic: "jellyfin/events", + mqtt_username: "", + mqtt_password: "", }; diff --git a/server/index.tsx b/server/index.tsx index 5e6f71b..8cb834f 100644 --- a/server/index.tsx +++ b/server/index.tsx @@ -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, diff --git a/server/services/__tests__/webhook.test.ts b/server/services/__tests__/webhook.test.ts new file mode 100644 index 0000000..bf30fa8 --- /dev/null +++ b/server/services/__tests__/webhook.test.ts @@ -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 { + 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"); + }); +}); diff --git a/server/services/mqtt.ts b/server/services/mqtt.ts new file mode 100644 index 0000000..fda5616 --- /dev/null +++ b/server/services/mqtt.ts @@ -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 { + 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 { + if (!client) return; + const c = client; + client = null; + await new Promise((resolve) => { + c.end(false, {}, () => resolve()); + }); + setStatus("not_configured"); +} + +/** + * Test a candidate MQTT configuration without touching the running client. + * Connects, subscribes to `/#`, 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) }); + }); + }); +} diff --git a/server/services/rescan.ts b/server/services/rescan.ts index 0484121..d259ab2 100644 --- a/server/services/rescan.ts +++ b/server/services/rescan.ts @@ -38,8 +38,9 @@ export async function upsertJellyfinItem( db: Database, jellyfinItem: JellyfinItem, cfg: RescanConfig, - opts: { executed?: boolean } = {}, + opts: { executed?: boolean; source?: "scan" | "webhook" } = {}, ): Promise { + 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 }; diff --git a/server/services/webhook.ts b/server/services/webhook.ts new file mode 100644 index 0000000..caeda58 --- /dev/null +++ b/server/services/webhook.ts @@ -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(); + +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 { + 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 { + 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(); +} diff --git a/src/features/scan/ScanPage.tsx b/src/features/scan/ScanPage.tsx index 7d27f46..566c863 100644 --- a/src/features/scan/ScanPage.tsx +++ b/src/features/scan/ScanPage.tsx @@ -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 (
-

Scan

+
+

Scan

+ +
{stats && (
diff --git a/src/features/settings/MqttSection.tsx b/src/features/settings/MqttSection.tsx new file mode 100644 index 0000000..b696c48 --- /dev/null +++ b/src/features/settings/MqttSection.tsx @@ -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 ( +
+
{label}
+
+				{value}
+			
+ +
+ ); +} + +export function MqttSection({ cfg, locked }: { cfg: Record; locked: Set }) { + 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(null); + const [status, setStatus] = useState({ status: "not_configured", error: null }); + const [plugin, setPlugin] = useState(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("/api/settings/mqtt/status"); + if (!cancelled) setStatus(s); + } catch { + /* ignore */ + } + }; + fetchStatus(); + const interval = setInterval(fetchStatus, 5000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, []); + + useEffect(() => { + api + .get("/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("/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 ( +
+
+
+ Jellyfin webhook (MQTT) +
+ MQTT: {status.status} +
+

+ 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. +

+ + + +
+ + +
+ +
+ + + {savedMsg && ✓ {savedMsg}} +
+ + {testResult && ( +
+ {testResult.connected && testResult.receivedMessage && ( +
+ ✓ Connected and received a message. + {testResult.samplePayload && ( +
{testResult.samplePayload}
+ )} +
+ )} + {testResult.connected && !testResult.receivedMessage && !testResult.error && ( +
+ ⚠ Connected, but no traffic in the timeout window. Trigger a library scan in Jellyfin, then retry. +
+ )} + {testResult.error &&
✗ {testResult.error}
} +
+ )} + + {/* Plugin + setup instructions */} +
+
Jellyfin Webhook plugin
+ {plugin?.ok === false &&

Couldn't reach Jellyfin: {plugin.error}

} + {plugin?.ok && !plugin.installed && ( +

+ ⚠ The Webhook plugin is not installed on Jellyfin. Install it from{" "} + Jellyfin → Dashboard → Plugins → Catalog → Webhook, restart Jellyfin, then + configure an MQTT destination with the values below. +

+ )} + {plugin?.ok && plugin.installed && ( +

+ ✓ Plugin detected{plugin.plugin?.Version ? ` (v${plugin.plugin.Version})` : ""}. Add an MQTT destination with: +

+ )} +
+ + + + + +
+
+
+ ); +} diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index 835d9f9..6902d33 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -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) */} + + {/* Schedule */} diff --git a/src/shared/components/MqttBadge.tsx b/src/shared/components/MqttBadge.tsx new file mode 100644 index 0000000..78a05f5 --- /dev/null +++ b/src/shared/components/MqttBadge.tsx @@ -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 = { + connected: "MQTT: connected", + disconnected: "MQTT: disconnected", + error: "MQTT: error", + not_configured: "MQTT: not configured", +}; + +const STYLES: Record = { + 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({ status: "not_configured", error: null }); + + useEffect(() => { + let cancelled = false; + const fetchStatus = async () => { + try { + const s = await api.get("/api/settings/mqtt/status"); + if (!cancelled) setState(s); + } catch { + /* ignore */ + } + }; + fetchStatus(); + const interval = setInterval(fetchStatus, 5000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, []); + + return ( + + {LABEL[state.status]} + + ); +}