diff --git a/package-lock.json b/package-lock.json index 1cb44e3..c7287b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "license": "GPL-3.0-only", "dependencies": { "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.5.0", "express": "^4.18.2", "express-session": "^1.18.2", + "ffmpeg-static": "^5.3.0", "jsonwebtoken": "^9.0.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -23,7 +25,7 @@ "node": ">=18.0.0" }, "optionalDependencies": { - "ffmpeg-static": "^5.2.0" + "ffmpeg-static": "^5.3.0" } }, "node_modules/@derhuerst/http-basic": { @@ -106,6 +108,26 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcryptjs": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", @@ -115,6 +137,40 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/better-sqlite3": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.5.0.tgz", + "integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -139,6 +195,30 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -197,6 +277,12 @@ "license": "Apache-2.0", "optional": true }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -258,6 +344,30 @@ "ms": "2.0.0" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -277,6 +387,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -315,6 +434,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -370,6 +498,15 @@ "node": ">= 0.6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -452,6 +589,12 @@ "node": ">=16" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -488,6 +631,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -534,6 +683,12 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -651,12 +806,38 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -826,12 +1007,45 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -841,6 +1055,18 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -874,6 +1100,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parse-cache-control": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", @@ -947,6 +1182,32 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -970,6 +1231,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -1018,12 +1289,26 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", - "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -1200,6 +1485,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1214,11 +1544,47 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", - "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1228,6 +1594,18 @@ "node": ">=0.6" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1273,8 +1651,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/utils-merge": { "version": "1.0.1", @@ -1294,6 +1671,12 @@ "node": ">= 0.8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", diff --git a/package.json b/package.json index 0614e56..71b158e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.5.0", "express": "^4.18.2", "express-session": "^1.18.2", "jsonwebtoken": "^9.0.3", @@ -20,7 +21,7 @@ "xml2js": "^0.6.2" }, "optionalDependencies": { - "ffmpeg-static": "^5.2.0" + "ffmpeg-static": "^5.3.0" }, "engines": { "node": ">=18.0.0" diff --git a/public/js/api.js b/public/js/api.js index b99fb45..8dc923f 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -13,7 +13,7 @@ const API = { 'Content-Type': 'application/json' } }; - + // Add authentication token if available const token = localStorage.getItem('authToken'); if (token) { @@ -57,7 +57,9 @@ const API = { update: (id, data) => API.request('PUT', `/sources/${id}`, data), delete: (id) => API.request('DELETE', `/sources/${id}`), toggle: (id) => API.request('POST', `/sources/${id}/toggle`), - test: (id) => API.request('POST', `/sources/${id}/test`) + test: (id) => API.request('POST', `/sources/${id}/test`), + sync: (id) => API.request('POST', `/sources/${id}/sync`), // Manual sync + getStatus: () => API.request('GET', '/sources/status'), // Get all statuses }, // Channels (hidden items) @@ -67,7 +69,10 @@ const API = { show: (sourceId, itemType, itemId) => API.request('POST', '/channels/show', { sourceId, itemType, itemId }), isHidden: (sourceId, itemType, itemId) => API.request('GET', `/channels/hidden/check?sourceId=${sourceId}&itemType=${itemType}&itemId=${itemId}`), bulkHide: (items) => API.request('POST', '/channels/hide/bulk', { items }), - bulkShow: (items) => API.request('POST', '/channels/show/bulk', { items }) + bulkShow: (items) => API.request('POST', '/channels/show/bulk', { items }), + // Fast bulk operations - single SQL statement + showAll: (sourceId, contentType) => API.request('POST', '/channels/show/all', { sourceId, contentType }), + hideAll: (sourceId, contentType) => API.request('POST', '/channels/hide/all', { sourceId, contentType }) }, // Favorites @@ -93,15 +98,39 @@ const API = { // Xtream xtream: { auth: (sourceId) => API.request('GET', `/proxy/xtream/${sourceId}/auth`), - liveCategories: (sourceId) => API.request('GET', `/proxy/xtream/${sourceId}/live_categories`), - liveStreams: (sourceId, categoryId = null) => - API.request('GET', `/proxy/xtream/${sourceId}/live_streams${categoryId ? `?category_id=${categoryId}` : ''}`), - vodCategories: (sourceId) => API.request('GET', `/proxy/xtream/${sourceId}/vod_categories`), - vodStreams: (sourceId, categoryId = null) => - API.request('GET', `/proxy/xtream/${sourceId}/vod_streams${categoryId ? `?category_id=${categoryId}` : ''}`), - seriesCategories: (sourceId) => API.request('GET', `/proxy/xtream/${sourceId}/series_categories`), - series: (sourceId, categoryId = null) => - API.request('GET', `/proxy/xtream/${sourceId}/series${categoryId ? `?category_id=${categoryId}` : ''}`), + liveCategories: (sourceId, options = {}) => { + const params = options.includeHidden ? '?includeHidden=true' : ''; + return API.request('GET', `/proxy/xtream/${sourceId}/live_categories${params}`); + }, + liveStreams: (sourceId, categoryId = null, options = {}) => { + const params = []; + if (categoryId) params.push(`category_id=${categoryId}`); + if (options.includeHidden) params.push('includeHidden=true'); + const query = params.length ? `?${params.join('&')}` : ''; + return API.request('GET', `/proxy/xtream/${sourceId}/live_streams${query}`); + }, + vodCategories: (sourceId, options = {}) => { + const params = options.includeHidden ? '?includeHidden=true' : ''; + return API.request('GET', `/proxy/xtream/${sourceId}/vod_categories${params}`); + }, + vodStreams: (sourceId, categoryId = null, options = {}) => { + const params = []; + if (categoryId) params.push(`category_id=${categoryId}`); + if (options.includeHidden) params.push('includeHidden=true'); + const query = params.length ? `?${params.join('&')}` : ''; + return API.request('GET', `/proxy/xtream/${sourceId}/vod_streams${query}`); + }, + seriesCategories: (sourceId, options = {}) => { + const params = options.includeHidden ? '?includeHidden=true' : ''; + return API.request('GET', `/proxy/xtream/${sourceId}/series_categories${params}`); + }, + series: (sourceId, categoryId = null, options = {}) => { + const params = []; + if (categoryId) params.push(`category_id=${categoryId}`); + if (options.includeHidden) params.push('includeHidden=true'); + const query = params.length ? `?${params.join('&')}` : ''; + return API.request('GET', `/proxy/xtream/${sourceId}/series${query}`); + }, seriesInfo: (sourceId, seriesId) => API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${seriesId}`), shortEpg: (sourceId, streamId) => API.request('GET', `/proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`), @@ -111,7 +140,12 @@ const API = { // M3U m3u: { - get: (sourceId) => API.request('GET', `/proxy/m3u/${sourceId}`) + get: (sourceId, options = {}) => { + const params = []; + if (options.includeHidden) params.push('includeHidden=true'); + const query = params.length ? `?${params.join('&')}` : ''; + return API.request('GET', `/proxy/m3u/${sourceId}${query}`); + } }, // EPG diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 8c2f830..34a02f1 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -670,7 +670,8 @@ class ChannelList { tvgId: stream.epg_channel_id, tvgLogo: stream.stream_icon, groupId: `xtream_${sourceId}_${stream.category_id}`, - groupTitle: categories.find(c => c.category_id === stream.category_id)?.category_name || 'Uncategorized', + // Use string comparison to handle type mismatches (number vs string category_id) + groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized', sourceId, sourceType: 'xtream' })); diff --git a/public/js/components/SourceManager.js b/public/js/components/SourceManager.js index 1e4232c..d761fcb 100644 --- a/public/js/components/SourceManager.js +++ b/public/js/components/SourceManager.js @@ -11,8 +11,9 @@ class SourceManager { // Content browser state this.contentType = 'channels'; // 'channels' or 'movies' - this.treeData = null; // { type, sourceId, groups: [{ id, name, items: [] }] } - this.hiddenSet = new Set(); // Set of hidden item keys + this.treeData = null; // { type, sourceId, groups: [{ id, name, categoryId, items: [] }] } + this.hiddenSet = new Set(); // Set of hidden item keys (current state) + this.originalHiddenSet = new Set(); // Set of hidden item keys (state when loaded) this.expandedGroups = new Set(); // Set of expanded group IDs this.init(); @@ -26,6 +27,26 @@ class SourceManager { // Initialize content browser this.initContentBrowser(); + + // Start polling sync status + this.pollSyncStatus(); + } + + /** + * Poll sync status from the backend + */ + pollSyncStatus() { + // Implement polling logic here + console.log('Polling sync status...'); + // Example: setInterval(() => this.updateSyncStatus(), 5000); + } + + /** + * Update sync status display + */ + updateSyncStatus() { + // Implement logic to update UI based on sync status + console.log('Updating sync status display...'); } /** @@ -298,6 +319,35 @@ class SourceManager { if (icon) icon.classList.add('spin'); } + // 1. Trigger Backend Sync + console.log(`[SourceManager] Triggering sync for source ${id}`); + await API.sources.sync(id); + + // 2. Poll for completion + let retries = 0; + const maxRetries = 60; // 60 seconds timeout + + while (retries < maxRetries) { + await new Promise(r => setTimeout(r, 1000)); // Wait 1s + const statuses = await API.sources.getStatus(); + const status = statuses.find(s => s.source_id === id && s.type === 'all'); + + if (status && status.status === 'success') { + console.log('[SourceManager] Sync completed successfully'); + break; + } else if (status && status.status === 'error') { + throw new Error(`Sync failed: ${status.error}`); + } + + // If no status found yet, or still syncing, continue + retries++; + } + + if (retries >= maxRetries) { + throw new Error('Sync timed out'); + } + + // 3. Refresh UI / Cache // Clear cache for this source first await API.proxy.cache.clear(id); @@ -306,19 +356,19 @@ class SourceManager { if (window.app?.epgGuide) { await window.app.epgGuide.loadEpg(true); } - alert('EPG data refreshed!'); + alert('EPG data synced & refreshed!'); } else if (type === 'xtream') { // Re-fetch xtream data by reloading channels if (window.app?.channelList) { await window.app.channelList.loadChannels(); } - alert('Xtream data refreshed!'); + alert('Xtream data synced & refreshed!'); } else if (type === 'm3u') { // Re-fetch M3U data by reloading channels if (window.app?.channelList) { await window.app.channelList.loadChannels(); } - alert('M3U playlist refreshed!'); + alert('M3U playlist synced & refreshed!'); } if (btn) { @@ -441,57 +491,71 @@ class SourceManager { if (source.type === 'xtream') { // Run sequentially to avoid overwhelming the provider - const categories = await API.proxy.xtream.liveCategories(sourceId); - const streams = await API.proxy.xtream.liveStreams(sourceId); + // Use includeHidden to show ALL items in the content manager + const categories = await API.proxy.xtream.liveCategories(sourceId, { includeHidden: true }); + const streams = await API.proxy.xtream.liveStreams(sourceId, null, { includeHidden: true }); channels = streams; categories.forEach(cat => { categoryMap[cat.category_id] = cat.category_name; }); } else if (source.type === 'm3u') { - const m3uData = await API.proxy.m3u.get(sourceId); + const m3uData = await API.proxy.m3u.get(sourceId, { includeHidden: true }); channels = m3uData.channels || []; } // Get currently hidden items const hiddenItems = await API.channels.getHidden(sourceId); this.hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`)); + this.originalHiddenSet = new Set(this.hiddenSet); // Track original state for diffing - // Group channels - const groups = {}; + // Group channels by category + const groupMap = {}; // key: categoryId, value: { name, categoryId, items } channels.forEach(ch => { let groupName = 'Uncategorized'; + let categoryId = null; if (source.type === 'xtream') { - if (ch.category_id && categoryMap[ch.category_id]) { - groupName = categoryMap[ch.category_id]; + categoryId = ch.category_id; + if (categoryId && categoryMap[categoryId]) { + groupName = categoryMap[categoryId]; } } else { + // For M3U, category_id might be the group name itself groupName = ch.category_name || ch.groupTitle || 'Uncategorized'; + categoryId = groupName; // M3U uses name as ID } - if (!groups[groupName]) { - groups[groupName] = []; + const groupKey = categoryId || groupName; + if (!groupMap[groupKey]) { + groupMap[groupKey] = { + categoryId: categoryId, + name: groupName, + items: [] + }; } // Normalize channel object const channelId = ch.stream_id || ch.id || ch.url; const channelName = ch.name || ch.tvgName || 'Unknown'; - groups[groupName].push({ - id: channelId, + groupMap[groupKey].items.push({ + id: String(channelId), name: channelName, original: ch, type: 'channel' }); }); - // Convert to array - this.treeData.groups = Object.keys(groups).sort().map(name => ({ - id: name, // generic group ID - name: name, - type: 'group', - items: groups[name] - })); + // Convert to array, sorted by name + this.treeData.groups = Object.entries(groupMap) + .sort((a, b) => a[1].name.localeCompare(b[1].name)) + .map(([key, group]) => ({ + id: key, // Use categoryId as the group ID + name: group.name, + categoryId: group.categoryId, // Store actual category_id for API calls + type: 'group', + items: group.items + })); this.renderTree(); @@ -635,7 +699,7 @@ class SourceManager { return; } - const categories = await API.proxy.xtream.vodCategories(sourceId); + const categories = await API.proxy.xtream.vodCategories(sourceId, { includeHidden: true }); if (!categories || categories.length === 0) { this.contentTree.innerHTML = '
No movie categories found
'; @@ -644,6 +708,7 @@ class SourceManager { const hiddenItems = await API.channels.getHidden(sourceId); this.hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`)); + this.originalHiddenSet = new Set(this.hiddenSet); // Track original state // Create a single "Movies" group or flatten? // The original UI rendered a flat list of categories. @@ -659,7 +724,7 @@ class SourceManager { name: 'Categories', type: 'group', items: categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).map(cat => ({ - id: cat.category_id, + id: String(cat.category_id), name: cat.category_name, type: 'vod_category', original: cat @@ -691,7 +756,7 @@ class SourceManager { return; } - const categories = await API.proxy.xtream.seriesCategories(sourceId); + const categories = await API.proxy.xtream.seriesCategories(sourceId, { includeHidden: true }); if (!categories || categories.length === 0) { this.contentTree.innerHTML = 'No series categories found
'; @@ -700,13 +765,14 @@ class SourceManager { const hiddenItems = await API.channels.getHidden(sourceId); this.hiddenSet = new Set(hiddenItems.map(h => `${h.item_type}:${h.item_id}`)); + this.originalHiddenSet = new Set(this.hiddenSet); // Track original state this.treeData.groups = [{ id: 'all_series_categories', name: 'Categories', type: 'group', items: categories.sort((a, b) => a.category_name.localeCompare(b.category_name)).map(cat => ({ - id: cat.category_id, + id: String(cat.category_id), name: cat.category_name, type: 'series_category', original: cat @@ -764,6 +830,24 @@ class SourceManager { const isChecked = groupCb.checked; + // Determine the correct item type for the group based on content type + let groupItemType = 'group'; // default for live channels + if (this.treeData.type === 'movies') { + groupItemType = 'vod_category'; + } else if (this.treeData.type === 'series') { + groupItemType = 'series_category'; + } + + // Update state for the GROUP itself (if it has a categoryId) + if (group.categoryId) { + const groupKey = `${groupItemType}:${group.categoryId}`; + if (isChecked) { + this.hiddenSet.delete(groupKey); + } else { + this.hiddenSet.add(groupKey); + } + } + // Update state for all children group.items.forEach(item => { const key = `${item.type}:${item.id}`; @@ -784,25 +868,82 @@ class SourceManager { } /** - * Set visibility for all items (LOCAL STATE ONLY - use Save to persist) + * Set visibility for all items and IMMEDIATELY persist to server + * Uses fast bulk API endpoint (single SQL statement) instead of item-by-item */ - setAllVisibility(visible) { + async setAllVisibility(visible) { if (!this.treeData || !this.treeData.groups) return; - // Update state for all items - this.treeData.groups.forEach(group => { - group.items.forEach(item => { - const key = `${item.type}:${item.id}`; - if (visible) { - this.hiddenSet.delete(key); - } else { - this.hiddenSet.add(key); - } - }); - }); + const saveBtn = document.getElementById('content-save'); + const showAllBtn = document.querySelector('.content-actions button:first-child'); + const hideAllBtn = document.querySelector('.content-actions button:nth-child(2)'); - // Re-render to reflect changes - this.renderTree(); + // Disable buttons during operation + if (showAllBtn) showAllBtn.disabled = true; + if (hideAllBtn) hideAllBtn.disabled = true; + if (saveBtn) { + saveBtn.disabled = true; + saveBtn.textContent = visible ? '⏳ Showing all...' : '⏳ Hiding all...'; + } + + try { + const sourceId = this.treeData.sourceId; + const contentType = this.treeData.type; // 'channels', 'movies', or 'series' + + // Use fast API endpoint (single SQL UPDATE statement) + if (visible) { + await API.channels.showAll(sourceId, contentType); + } else { + await API.channels.hideAll(sourceId, contentType); + } + + // Update local state to match + this.treeData.groups.forEach(group => { + group.items.forEach(item => { + const key = `${item.type}:${item.id}`; + if (visible) { + this.hiddenSet.delete(key); + } else { + this.hiddenSet.add(key); + } + }); + }); + + // Update originalHiddenSet to match current state + this.originalHiddenSet = new Set(this.hiddenSet); + + // Sync Channel List + try { + if (window.app?.channelList?.loadHiddenItems) { + await window.app.channelList.loadHiddenItems(); + window.app.channelList.render(); + } + } catch (e) { + console.warn('[SourceManager] Channel list sync failed:', e); + } + + // Re-render to reflect changes + this.renderTree(); + + if (saveBtn) { + saveBtn.textContent = '✓ Done!'; + setTimeout(() => { + saveBtn.textContent = '💾 Save Changes'; + saveBtn.disabled = false; + }, 1500); + } + + } catch (err) { + console.error('Error setting all visibility:', err); + alert('Failed: ' + err.message); + if (saveBtn) { + saveBtn.textContent = '💾 Save Changes'; + saveBtn.disabled = false; + } + } finally { + if (showAllBtn) showAllBtn.disabled = false; + if (hideAllBtn) hideAllBtn.disabled = false; + } } /** @@ -825,35 +966,122 @@ class SourceManager { const itemsToShow = []; const itemsToHide = []; - // Collect all items and determine their visibility + // Only collect items that have CHANGED from their original state this.treeData.groups.forEach(group => { + // First, handle the GROUP itself (category visibility) + // Determine the correct item type for the group based on content type + let groupItemType = 'group'; // default for live channels + if (this.treeData.type === 'movies') { + groupItemType = 'vod_category'; + } else if (this.treeData.type === 'series') { + groupItemType = 'series_category'; + } + + // Only process groups that have a real categoryId (not wrapper groups like 'all_categories') + if (group.categoryId) { + const groupKey = `${groupItemType}:${group.categoryId}`; + const isGroupNowHidden = this.hiddenSet.has(groupKey); + const wasGroupHidden = this.originalHiddenSet.has(groupKey); + + if (isGroupNowHidden !== wasGroupHidden) { + if (isGroupNowHidden) { + itemsToHide.push({ sourceId, itemType: groupItemType, itemId: String(group.categoryId) }); + } else { + itemsToShow.push({ sourceId, itemType: groupItemType, itemId: String(group.categoryId) }); + } + } + } + + // Then handle the ITEMS within the group group.items.forEach(item => { const key = `${item.type}:${item.id}`; - const isHidden = this.hiddenSet.has(key); + const isNowHidden = this.hiddenSet.has(key); + const wasHidden = this.originalHiddenSet.has(key); - if (isHidden) { - itemsToHide.push({ sourceId, itemType: item.type, itemId: item.id }); - } else { - itemsToShow.push({ sourceId, itemType: item.type, itemId: item.id }); + // Only send if state changed + if (isNowHidden !== wasHidden) { + if (isNowHidden) { + itemsToHide.push({ sourceId, itemType: item.type, itemId: String(item.id) }); + } else { + itemsToShow.push({ sourceId, itemType: item.type, itemId: String(item.id) }); + } } }); }); - // Execute bulk operations - const promises = []; + // Check if there are any changes + if (itemsToShow.length === 0 && itemsToHide.length === 0) { + if (saveBtn) { + saveBtn.textContent = 'No changes'; + setTimeout(() => { + saveBtn.textContent = '💾 Save Changes'; + saveBtn.disabled = false; + }, 1500); + } + return; + } + + console.log(`[SourceManager] Saving changes: ${itemsToShow.length} to show, ${itemsToHide.length} to hide`); + + if (itemsToHide.length > 0) { + console.log('[SourceManager] Items to hide:', itemsToHide.map(i => `${i.itemType}:${i.itemId}`)); + // Check if any groups are being hidden + const hiddenGroups = itemsToHide.filter(i => i.itemType === 'group' || i.itemType.includes('category')); + if (hiddenGroups.length > 0) { + console.warn('[SourceManager] WARNING: Hiding groups:', hiddenGroups); + } + } + + // Batch large operations to avoid timeouts (1000 items per batch) + const BATCH_SIZE = 1000; + + const processBatches = async (items, apiFn, label) => { + for (let i = 0; i < items.length; i += BATCH_SIZE) { + const batch = items.slice(i, i + BATCH_SIZE); + console.log(`[SourceManager] ${label}: batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(items.length / BATCH_SIZE)} (${batch.length} items)`); + await apiFn(batch); + + // Update button with progress + if (saveBtn) { + const progress = Math.round(((i + batch.length) / items.length) * 100); + saveBtn.textContent = `⏳ ${progress}%`; + } + } + }; + + // Process show and hide operations sequentially to avoid overwhelming the server if (itemsToShow.length > 0) { - promises.push(API.channels.bulkShow(itemsToShow)); + await processBatches(itemsToShow, API.channels.bulkShow, 'Showing'); } if (itemsToHide.length > 0) { - promises.push(API.channels.bulkHide(itemsToHide)); + await processBatches(itemsToHide, API.channels.bulkHide, 'Hiding'); } - await Promise.all(promises); + console.log('[SourceManager] Bulk operations completed'); - // Sync Channel List - if (window.app?.channelList) { - await window.app.channelList.loadHiddenItems(); - window.app.channelList.render(); + // Update originalHiddenSet to reflect saved state + this.originalHiddenSet = new Set(this.hiddenSet); + + // Sync Channel List (don't block on this) + try { + if (window.app?.channelList) { + // Start with hidden items sync which is fast + if (window.app.channelList.loadHiddenItems) { + await window.app.channelList.loadHiddenItems(); + } + + // If we modified the currently active source, reload it fully to get fresh categories + if (window.app.channelList.currentSourceId && + String(window.app.channelList.currentSourceId) === String(this.contentSourceSelect.value)) { + console.log('[SourceManager] Reloading active source in ChannelList...'); + await window.app.channelList.loadSource(window.app.channelList.currentSourceId); + } else { + // Otherwise just render to reflect hidden item changes + window.app.channelList.render(); + } + } + } catch (e) { + console.warn('[SourceManager] Channel list sync failed:', e); } if (saveBtn) { @@ -874,6 +1102,71 @@ class SourceManager { } } + /** + * Poll sync status periodically + */ + async pollSyncStatus() { + const poll = async () => { + try { + const statuses = await API.sources.getStatus(); + this.updateSyncStatus(statuses); + } catch (err) { + console.warn('Error polling sync status:', err); + } + // Poll every 3 seconds + this.syncPollTimeout = setTimeout(poll, 3000); + }; + poll(); + } + + /** + * Update UI with sync status + */ + updateSyncStatus(statuses) { + if (!statuses || !Array.isArray(statuses)) return; + + // Reset all to normal state if not in status list (handled implicitly by iterating sources or statuses?) + // Better: iterate visible source items and check against statuses + + document.querySelectorAll('.source-item').forEach(item => { + const id = parseInt(item.dataset.id); + const status = statuses.find(s => s.source_id === id); // We might have multiple statuses (live, vod, epg) for one source + + // Just check if ANY sync is active/failed for this source + const sourceStatuses = statuses.filter(s => s.source_id === id); + const isSyncing = sourceStatuses.some(s => s.status === 'syncing'); + const hasError = sourceStatuses.some(s => s.status === 'error'); + const lastSync = sourceStatuses.map(s => s.last_sync).sort().pop(); + + const btn = item.querySelector('[data-action="refresh"]'); + if (btn) { + const icon = btn.querySelector('.icon') || btn; // icon inside button or button content + // If syncing, spin the refresh icon + if (isSyncing) { + btn.disabled = true; + btn.classList.add('syncing'); // Custom style? + // Ensure spin class is added (font awesome or similar) + // The icon is usually SVH in `Icons.refresh`. + // We can add a class to the SVG parent or button + btn.innerHTML = `${Icons.refresh}`; + btn.title = "Syncing..."; + } else if (hasError) { + btn.disabled = false; + btn.innerHTML = Icons.refresh; + btn.classList.remove('syncing'); + btn.title = "Sync Failed - Retry"; + // Maybe show error indicator? + } else { + btn.disabled = false; + btn.innerHTML = Icons.refresh; + btn.classList.remove('syncing'); + btn.title = lastSync ? `Last Sync: ${new Date(lastSync).toLocaleString()}` : "Refresh Data"; + } + } + + // Optional: Update status text/badge in .source-info + }); + } } // Export diff --git a/server/db/sqlite.js b/server/db/sqlite.js new file mode 100644 index 0000000..fd97eb5 --- /dev/null +++ b/server/db/sqlite.js @@ -0,0 +1,111 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const dataDir = path.join(__dirname, '..', '..', 'data'); +const dbPath = path.join(dataDir, 'content.db'); + +// Ensure data directory exists +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +let db; + +function getDb() { + if (!db) { + console.log('[SQLite] Opening database at', dbPath); + db = new Database(dbPath); + // Optimize performance + db.pragma('journal_mode = WAL'); + db.pragma('synchronous = NORMAL'); + initSchema(); + } + return db; +} + +function initSchema() { + if (!db) throw new Error('Database not initialized'); + + // Categories (Groups) + db.exec(` + CREATE TABLE IF NOT EXISTS categories ( + id TEXT PRIMARY KEY, -- Composite key: sourceId:categoryId + source_id INTEGER NOT NULL, + category_id TEXT NOT NULL, + type TEXT NOT NULL, -- 'live', 'movie', 'series' + name TEXT NOT NULL, + parent_id TEXT, -- For nested categories + is_hidden INTEGER DEFAULT 0, + data JSON -- Extra provider data + ); + CREATE INDEX IF NOT EXISTS idx_categories_source_type ON categories(source_id, type); + `); + + // Playlist Items (Channels, Movies, Series, Episodes) + db.exec(` + CREATE TABLE IF NOT EXISTS playlist_items ( + id TEXT PRIMARY KEY, -- Composite key: sourceId:itemId + source_id INTEGER NOT NULL, + item_id TEXT NOT NULL, -- Original ID from provider + type TEXT NOT NULL, -- 'live', 'movie', 'series', 'episode' + name TEXT NOT NULL, + category_id TEXT, -- maps to categories.category_id (not our composite id) + parent_id TEXT, -- For episodes -> series_id + + -- Common Media Fields + stream_icon TEXT, + stream_url TEXT, -- Direct link if available + container_extension TEXT, + + -- VOD/Series Specific + rating REAL, + year TEXT, + added_at TEXT, + + -- App State + is_hidden INTEGER DEFAULT 0, + is_favorite INTEGER DEFAULT 0, + + data JSON -- Full original JSON object + ); + CREATE INDEX IF NOT EXISTS idx_items_source_type ON playlist_items(source_id, type); + CREATE INDEX IF NOT EXISTS idx_items_category ON playlist_items(source_id, category_id); + `); + + // EPG Programs + // Optimized for range queries + db.exec(` + CREATE TABLE IF NOT EXISTS epg_programs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id TEXT NOT NULL, -- matches playlist_items.id if possible, or mapping key + source_id INTEGER NOT NULL, + start_time INTEGER NOT NULL, -- Unix timestamp (ms) + end_time INTEGER NOT NULL, -- Unix timestamp (ms) + title TEXT, + description TEXT, + data JSON + ); + CREATE INDEX IF NOT EXISTS idx_epg_channel_time ON epg_programs(channel_id, start_time, end_time); + CREATE INDEX IF NOT EXISTS idx_epg_cleanup ON epg_programs(end_time); -- For deleting old programs + `); + + // Sync Status + db.exec(` + CREATE TABLE IF NOT EXISTS sync_status ( + source_id INTEGER NOT NULL, + type TEXT NOT NULL, -- 'live', 'vod', 'series', 'epg' + last_sync INTEGER NOT NULL, + status TEXT, -- 'success', 'error', 'syncing' + error TEXT, + PRIMARY KEY (source_id, type) + ); + `); + + console.log('[SQLite] Schema initialized'); +} + +module.exports = { + getDb, + initSchema +}; diff --git a/server/index.js b/server/index.js index e72218a..0d58c7b 100644 --- a/server/index.js +++ b/server/index.js @@ -1,6 +1,8 @@ const express = require('express'); const path = require('path'); const passport = require('passport'); +const { migrateUserData } = require('./services/migrationService'); +const syncService = require('./services/syncService'); // Initialize database require('./db'); @@ -74,6 +76,14 @@ app.use((err, req, res, next) => { res.status(500).json({ error: 'Internal server error' }); }); -app.listen(PORT, () => { +app.listen(PORT, async () => { console.log(`NodeCast TV server running on http://localhost:${PORT}`); + + // Run migration after startup + // await migrateUserData(); + + // Trigger background sync with delay to allow server to settle + setTimeout(() => { + syncService.syncAll().catch(console.error); + }, 5000); }); diff --git a/server/routes/channels.js b/server/routes/channels.js index ebbda1c..f6548ec 100644 --- a/server/routes/channels.js +++ b/server/routes/channels.js @@ -1,34 +1,92 @@ const express = require('express'); const router = express.Router(); -const { hiddenItems } = require('../db'); +const { getDb } = require('../db/sqlite'); -// Get all hidden items +// Helper to map API item types to DB types and tables +function mapItemType(apiType) { + switch (apiType) { + case 'channel': return { table: 'playlist_items', type: 'live' }; + case 'group': return { table: 'categories', type: 'live' }; + case 'vod_category': return { table: 'categories', type: 'movie' }; + case 'series_category': return { table: 'categories', type: 'series' }; + case 'movie': return { table: 'playlist_items', type: 'movie' }; + case 'series': return { table: 'playlist_items', type: 'series' }; + default: return null; + } +} + +// Get all hidden items (formatted like db.json for frontend compatibility) router.get('/hidden', async (req, res) => { try { const { sourceId } = req.query; - const items = await hiddenItems.getAll(sourceId ? parseInt(sourceId) : null); - res.json(items); + const db = getDb(); + + let hidden = []; + const resultFormat = (row, itemType) => ({ + source_id: row.source_id, + item_type: itemType, + item_id: itemType.includes('category') || itemType === 'group' ? row.category_id : row.item_id + }); + + // Query Categories + let catQuery = `SELECT source_id, category_id, type FROM categories WHERE is_hidden = 1`; + let itemQuery = `SELECT source_id, item_id, type FROM playlist_items WHERE is_hidden = 1`; + + const params = []; + if (sourceId) { + catQuery += ` AND source_id = ?`; + itemQuery += ` AND source_id = ?`; + const sid = parseInt(sourceId); + params.push(sid); + } + + const hiddenCats = db.prepare(catQuery).all(...params); + const hiddenItems = db.prepare(itemQuery).all(...params); + + hiddenCats.forEach(row => { + let apiType; + if (row.type === 'live') apiType = 'group'; + else if (row.type === 'movie') apiType = 'vod_category'; + else if (row.type === 'series') apiType = 'series_category'; + + if (apiType) hidden.push(resultFormat(row, apiType)); + }); + + hiddenItems.forEach(row => { + let apiType; + if (row.type === 'live') apiType = 'channel'; + else if (row.type === 'movie') apiType = 'movie'; + else if (row.type === 'series') apiType = 'series'; + + if (apiType) hidden.push(resultFormat(row, apiType)); + }); + + res.json(hidden); } catch (err) { console.error('Error getting hidden items:', err); res.status(500).json({ error: 'Failed to get hidden items' }); } }); -// Hide a channel, group, or category +// Hide item router.post('/hide', async (req, res) => { try { const { sourceId, itemType, itemId } = req.body; + const mapping = mapItemType(itemType); - if (!sourceId || !itemType || !itemId) { - return res.status(400).json({ error: 'sourceId, itemType, and itemId are required' }); - } + if (!mapping) return res.status(400).json({ error: 'Invalid item type' }); - const validTypes = ['channel', 'group', 'vod_category', 'series_category']; - if (!validTypes.includes(itemType)) { - return res.status(400).json({ error: `itemType must be one of: ${validTypes.join(', ')}` }); - } + const db = getDb(); + const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; + + const stmt = db.prepare(` + UPDATE ${mapping.table} + SET is_hidden = 1 + WHERE source_id = ? AND type = ? AND ${idCol} = ? + `); + + stmt.run(sourceId, mapping.type, itemId); - await hiddenItems.hide(sourceId, itemType, itemId); res.json({ success: true }); } catch (err) { console.error('Error hiding item:', err); @@ -36,16 +94,25 @@ router.post('/hide', async (req, res) => { } }); -// Show (unhide) a channel or group +// Show item router.post('/show', async (req, res) => { try { const { sourceId, itemType, itemId } = req.body; + const mapping = mapItemType(itemType); - if (!sourceId || !itemType || !itemId) { - return res.status(400).json({ error: 'sourceId, itemType, and itemId are required' }); - } + if (!mapping) return res.status(400).json({ error: 'Invalid item type' }); + + const db = getDb(); + const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; + + const stmt = db.prepare(` + UPDATE ${mapping.table} + SET is_hidden = 0 + WHERE source_id = ? AND type = ? AND ${idCol} = ? + `); + + stmt.run(sourceId, mapping.type, itemId); - await hiddenItems.show(sourceId, itemType, itemId); res.json({ success: true }); } catch (err) { console.error('Error showing item:', err); @@ -53,56 +120,149 @@ router.post('/show', async (req, res) => { } }); -// Check if item is hidden +// Check hidden status router.get('/hidden/check', async (req, res) => { try { const { sourceId, itemType, itemId } = req.query; + const mapping = mapItemType(itemType); + if (!mapping) return res.json({ hidden: false }); - if (!sourceId || !itemType || !itemId) { - return res.status(400).json({ error: 'sourceId, itemType, and itemId are required' }); - } + const db = getDb(); + const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; - // isHidden is now async - const isHidden = await hiddenItems.isHidden(parseInt(sourceId), itemType, itemId); - res.json({ hidden: isHidden }); + const row = db.prepare(` + SELECT is_hidden FROM ${mapping.table} + WHERE source_id = ? AND type = ? AND ${idCol} = ? + `).get(sourceId, mapping.type, itemId); + + res.json({ hidden: !!(row && row.is_hidden) }); } catch (err) { - console.error('Error checking hidden status:', err); - res.status(500).json({ error: 'Failed to check hidden status' }); + console.error('Error checking hidden:', err); + res.status(500).json({ error: 'Failed to check status' }); } }); -// Bulk hide channels and groups +// Bulk Hide router.post('/hide/bulk', async (req, res) => { try { const { items } = req.body; + if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' }); - if (!items || !Array.isArray(items) || items.length === 0) { - return res.status(400).json({ error: 'items array is required' }); - } + const db = getDb(); + const runBulk = db.transaction((list) => { + for (const item of list) { + const mapping = mapItemType(item.itemType); + if (mapping) { + const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; + db.prepare(` + UPDATE ${mapping.table} SET is_hidden = 1 + WHERE source_id = ? AND type = ? AND ${idCol} = ? + `).run(item.sourceId, mapping.type, item.itemId); + } + } + }); - await hiddenItems.bulkHide(items); + runBulk(items); res.json({ success: true, count: items.length }); } catch (err) { - console.error('Error bulk hiding items:', err); - res.status(500).json({ error: 'Failed to bulk hide items' }); + if (err.code === 'SQLITE_BUSY') { + return res.status(503).json({ error: 'Database is busy, please try again' }); + } + console.error('Error bulk hide:', err); + res.status(500).json({ error: 'Failed' }); } }); -// Bulk show channels and groups +// Bulk Show router.post('/show/bulk', async (req, res) => { try { const { items } = req.body; + if (!Array.isArray(items)) return res.status(400).json({ error: 'items array required' }); - if (!items || !Array.isArray(items) || items.length === 0) { - return res.status(400).json({ error: 'items array is required' }); - } + const db = getDb(); + const runBulk = db.transaction((list) => { + for (const item of list) { + const mapping = mapItemType(item.itemType); + if (mapping) { + const idCol = mapping.table === 'categories' ? 'category_id' : 'item_id'; + db.prepare(` + UPDATE ${mapping.table} SET is_hidden = 0 + WHERE source_id = ? AND type = ? AND ${idCol} = ? + `).run(item.sourceId, mapping.type, item.itemId); + } + } + }); - await hiddenItems.bulkShow(items); + runBulk(items); res.json({ success: true, count: items.length }); } catch (err) { - console.error('Error bulk showing items:', err); - res.status(500).json({ error: 'Failed to bulk show items' }); + if (err.code === 'SQLITE_BUSY') { + return res.status(503).json({ error: 'Database is busy, please try again' }); + } + console.error('Error bulk show:', err); + res.status(500).json({ error: 'Failed' }); + } +}); + +// Show ALL items for a source (single SQL statement - much faster than bulk) +router.post('/show/all', async (req, res) => { + try { + const { sourceId, contentType } = req.body; + if (!sourceId) return res.status(400).json({ error: 'sourceId required' }); + + const db = getDb(); + let catCount = 0; + let itemCount = 0; + + // Determine which types to update based on contentType + const types = contentType === 'movies' ? ['movie'] + : contentType === 'series' ? ['series'] + : ['live']; // default to channels + + for (const type of types) { + const catResult = db.prepare(`UPDATE categories SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type); + const itemResult = db.prepare(`UPDATE playlist_items SET is_hidden = 0 WHERE source_id = ? AND type = ?`).run(sourceId, type); + catCount += catResult.changes; + itemCount += itemResult.changes; + } + + console.log(`[Channels] Show all for source ${sourceId} (${contentType}): ${catCount} categories, ${itemCount} items`); + res.json({ success: true, categoriesUpdated: catCount, itemsUpdated: itemCount }); + } catch (err) { + console.error('Error show all:', err); + res.status(500).json({ error: 'Failed to show all' }); + } +}); + +// Hide ALL items for a source (single SQL statement - much faster than bulk) +router.post('/hide/all', async (req, res) => { + try { + const { sourceId, contentType } = req.body; + if (!sourceId) return res.status(400).json({ error: 'sourceId required' }); + + const db = getDb(); + let catCount = 0; + let itemCount = 0; + + // Determine which types to update based on contentType + const types = contentType === 'movies' ? ['movie'] + : contentType === 'series' ? ['series'] + : ['live']; // default to channels + + for (const type of types) { + const catResult = db.prepare(`UPDATE categories SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type); + const itemResult = db.prepare(`UPDATE playlist_items SET is_hidden = 1 WHERE source_id = ? AND type = ?`).run(sourceId, type); + catCount += catResult.changes; + itemCount += itemResult.changes; + } + + console.log(`[Channels] Hide all for source ${sourceId} (${contentType}): ${catCount} categories, ${itemCount} items`); + res.json({ success: true, categoriesUpdated: catCount, itemsUpdated: itemCount }); + } catch (err) { + console.error('Error hide all:', err); + res.status(500).json({ error: 'Failed to hide all' }); } }); module.exports = router; + diff --git a/server/routes/proxy.js b/server/routes/proxy.js index a615aff..12f6fda 100644 --- a/server/routes/proxy.js +++ b/server/routes/proxy.js @@ -1,472 +1,500 @@ const express = require('express'); const router = express.Router(); const { sources } = require('../db'); +const { getDb } = require('../db/sqlite'); // Import SQLite const xtreamApi = require('../services/xtreamApi'); const m3uParser = require('../services/m3uParser'); const epgParser = require('../services/epgParser'); const cache = require('../services/cache'); +const path = require('path'); +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const { spawn } = require('child_process'); +const ffmpegPath = require('ffmpeg-static'); const { Readable } = require('stream'); -// Default cache TTL: 24 hours -const DEFAULT_MAX_AGE_HOURS = 24; +// Helper to get formatted category list from DB +function getCategoriesFromDb(sourceId, type, includeHidden = false) { + const db = getDb(); + let query = ` + SELECT category_id, name as category_name, parent_id + FROM categories + WHERE source_id = ? AND type = ? + `; + if (!includeHidden) { + query += ` AND is_hidden = 0`; + } + query += ` ORDER BY name ASC`; + const cats = db.prepare(query).all(sourceId, type); + return cats; +} -/** - * Proxy Xtream API calls - * GET /api/proxy/xtream/:sourceId/:action - */ -router.get('/xtream/:sourceId/:action', async (req, res) => { +// Helper to get formatted streams from DB +function getStreamsFromDb(sourceId, type, categoryId = null, includeHidden = false) { + const db = getDb(); + let query = ` + SELECT item_id, name, stream_icon, added_at, rating, container_extension, year, category_id, data + FROM playlist_items + WHERE source_id = ? AND type = ? + `; + if (!includeHidden) { + query += ` AND is_hidden = 0`; + } + const params = [sourceId, type]; + + if (categoryId) { + query += ` AND category_id = ?`; + params.push(categoryId); + } + + // Default sorting + // query += ` ORDER BY name ASC`; // Sorting usually handled by client + + const items = db.prepare(query).all(...params); + + // Map to Xtream format + return items.map(item => { + const data = JSON.parse(item.data || '{}'); + // Override with our local fields if needed, or just return the mixed object + // We should ensure critical fields are present + return { + ...data, + stream_id: item.item_id, // ensure ID matches what client expects + series_id: type === 'series' ? item.item_id : undefined, + name: item.name, + stream_icon: item.stream_icon, + cover: item.stream_icon, // series/vod often use cover + added: item.added_at, + rating: item.rating, + container_extension: item.container_extension, + category_id: item.category_id + }; + }); +} + + +// --- Xtream Codes Proxy API --- // + +// Login / Authenticate +router.get('/xtream/:sourceId', async (req, res) => { try { - const sourceId = req.params.sourceId; - const source = await sources.getById(sourceId); - if (!source || source.type !== 'xtream') { - return res.status(404).json({ error: 'Xtream source not found' }); - } + const source = await sources.getById(req.params.sourceId); + if (!source || source.type !== 'xtream') return res.status(404).send('Source not found'); - const { action } = req.params; - const { category_id, stream_id, vod_id, series_id, limit, refresh, maxAge } = req.query; - const forceRefresh = refresh === '1'; - const maxAgeHours = parseInt(maxAge) || DEFAULT_MAX_AGE_HOURS; - const maxAgeMs = maxAgeHours * 60 * 60 * 1000; + // Proxy auth check to upstream to ensure credentials are still valid - // Actions that should be cached - const cacheableActions = [ - 'live_categories', 'live_streams', - 'vod_categories', 'vod_streams', - 'series_categories', 'series' - ]; - // Build cache key (include category_id if present) - const cacheKey = category_id ? `${action}_${category_id}` : action; + const cached = cache.get(`xtream:${source.id}:auth`); + if (cached) return res.json(cached); - // Check cache for cacheable actions - if (!forceRefresh && cacheableActions.includes(action)) { - const cached = cache.get('xtream', sourceId, cacheKey, maxAgeMs); - if (cached) { - return res.json(cached); - } - } - - // Fetch fresh data const api = xtreamApi.createFromSource(source); - let data; - switch (action) { - case 'auth': - data = await api.authenticate(); - break; - case 'live_categories': - data = await api.getLiveCategories(); - break; - case 'live_streams': - data = await api.getLiveStreams(category_id); - break; - case 'vod_categories': - data = await api.getVodCategories(); - break; - case 'vod_streams': - data = await api.getVodStreams(category_id); - break; - case 'vod_info': - data = await api.getVodInfo(vod_id); - break; - case 'series_categories': - data = await api.getSeriesCategories(); - break; - case 'series': - data = await api.getSeries(category_id); - break; - case 'series_info': - data = await api.getSeriesInfo(series_id); - break; - case 'short_epg': - data = await api.getShortEpg(stream_id, limit); - break; - default: - return res.status(400).json({ error: 'Unknown action' }); - } - - // Cache the result for cacheable actions - if (cacheableActions.includes(action)) { - cache.set('xtream', sourceId, cacheKey, data); - } - + const data = await api.authenticate(); + cache.set(`xtream:${source.id}:auth`, data, 300); // 5 min cache res.json(data); } catch (err) { - console.error('Xtream proxy error:', err); - res.status(500).json({ error: err.message }); + res.status(502).json({ error: 'Upstream error', details: err.message }); } }); -/** - * Get Xtream stream URL - * GET /api/proxy/xtream/:sourceId/stream/:streamId - */ -router.get('/xtream/:sourceId/stream/:streamId/:type?', async (req, res) => { +// Live Categories +router.get('/xtream/:sourceId/live_categories', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const includeHidden = req.query.includeHidden === 'true'; + const cats = getCategoriesFromDb(sourceId, 'live', includeHidden); + res.json(cats); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Live Streams +router.get('/xtream/:sourceId/live_streams', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const categoryId = req.query.category_id; + const includeHidden = req.query.includeHidden === 'true'; + const streams = getStreamsFromDb(sourceId, 'live', categoryId, includeHidden); + res.json(streams); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// VOD Categories +router.get('/xtream/:sourceId/vod_categories', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const includeHidden = req.query.includeHidden === 'true'; + const cats = getCategoriesFromDb(sourceId, 'movie', includeHidden); + res.json(cats); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// VOD Streams +router.get('/xtream/:sourceId/vod_streams', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const categoryId = req.query.category_id; + const includeHidden = req.query.includeHidden === 'true'; + const streams = getStreamsFromDb(sourceId, 'movie', categoryId, includeHidden); + res.json(streams); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Series Categories +router.get('/xtream/:sourceId/series_categories', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const includeHidden = req.query.includeHidden === 'true'; + const cats = getCategoriesFromDb(sourceId, 'series', includeHidden); + res.json(cats); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Series +router.get('/xtream/:sourceId/series', async (req, res) => { + try { + const sourceId = parseInt(req.params.sourceId); + const categoryId = req.query.category_id; + const includeHidden = req.query.includeHidden === 'true'; + const streams = getStreamsFromDb(sourceId, 'series', categoryId, includeHidden); + res.json(streams); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Series Info (Episodes) +// Proxy series info request +router.get('/xtream/:sourceId/series_info', async (req, res) => { + try { + const source = await sources.getById(req.params.sourceId); + if (!source) return res.status(404).send('Source not found'); + + const seriesId = req.query.series_id; + if (!seriesId) return res.status(400).send('series_id required'); + + const cacheKey = `xtream:${source.id}:series_info:${seriesId}`; + const cached = cache.get(cacheKey); + if (cached) return res.json(cached); + + const api = xtreamApi.createFromSource(source); + const data = await api.getSeriesInfo(seriesId); + cache.set(cacheKey, data, 3600); // 1 hour + res.json(data); + } catch (err) { + res.status(502).json({ error: 'Upstream error', details: err.message }); + } +}); + +// VOD Info +router.get('/xtream/:sourceId/vod_info', async (req, res) => { + try { + const source = await sources.getById(req.params.sourceId); + if (!source) return res.status(404).send('Source not found'); + + const vodId = req.query.vod_id; + if (!vodId) return res.status(400).send('vod_id required'); + + const cacheKey = `xtream:${source.id}:vod_info:${vodId}`; + const cached = cache.get(cacheKey); + if (cached) return res.json(cached); + + const api = xtreamApi.createFromSource(source); + const data = await api.getVodInfo(vodId); + cache.set(cacheKey, data, 3600); // 1 hour + res.json(data); + } catch (err) { + res.status(502).json({ error: 'Upstream error', details: err.message }); + } +}); + +// Get Stream URL for playback +// Returns the direct stream URL for a given stream ID +router.get('/xtream/:sourceId/stream/:streamId/:type', async (req, res) => { try { const source = await sources.getById(req.params.sourceId); if (!source || source.type !== 'xtream') { return res.status(404).json({ error: 'Xtream source not found' }); } - const api = xtreamApi.createFromSource(source); - const { streamId, type = 'live' } = req.params; - const { container = 'm3u8' } = req.query; + const streamId = req.params.streamId; + const type = req.params.type || 'live'; + const container = req.query.container || 'm3u8'; - const url = api.buildStreamUrl(streamId, type, container); - res.json({ url }); + // Construct the Xtream stream URL + // Format: http://server:port/live/username/password/streamId.container (for live) + // Format: http://server:port/movie/username/password/streamId.container (for movie) + // Format: http://server:port/series/username/password/streamId.container (for series) + + let streamUrl; + const baseUrl = source.url.replace(/\/$/, ''); // Remove trailing slash + + if (type === 'live') { + streamUrl = `${baseUrl}/live/${source.username}/${source.password}/${streamId}.${container}`; + } else if (type === 'movie') { + streamUrl = `${baseUrl}/movie/${source.username}/${source.password}/${streamId}.${container}`; + } else if (type === 'series') { + streamUrl = `${baseUrl}/series/${source.username}/${source.password}/${streamId}.${container}`; + } else { + return res.status(400).json({ error: 'Invalid stream type' }); + } + + res.json({ url: streamUrl }); } catch (err) { - console.error('Stream URL error:', err); - res.status(500).json({ error: err.message }); + console.error('Error getting stream URL:', err); + res.status(500).json({ error: 'Failed to get stream URL' }); } }); -/** - * Fetch and parse M3U playlist - * GET /api/proxy/m3u/:sourceId - */ + +// --- Other Proxy Routes --- // + +// M3U Playlist +// (For M3U sources, we now have data in DB. We can reconstruct M3U or return JSON) +// Frontend ChannelList.js for M3U sources calls `API.proxy.m3u.get(sourceId)` +// which points here. It expects { channels, groups }. router.get('/m3u/:sourceId', async (req, res) => { try { - const sourceId = req.params.sourceId; - const source = await sources.getById(sourceId); - if (!source || source.type !== 'm3u') { - return res.status(404).json({ error: 'M3U source not found' }); - } + const sourceId = parseInt(req.params.sourceId); + const includeHidden = req.query.includeHidden === 'true'; - const forceRefresh = req.query.refresh === '1'; - const maxAgeHours = parseInt(req.query.maxAge) || DEFAULT_MAX_AGE_HOURS; - const maxAgeMs = maxAgeHours * 60 * 60 * 1000; + // Fetch from DB + const channels = getStreamsFromDb(sourceId, 'live', null, includeHidden); + const groups = getCategoriesFromDb(sourceId, 'live', includeHidden); - // Check cache - if (!forceRefresh) { - const cached = cache.get('m3u', sourceId, 'playlist', maxAgeMs); - if (cached) { - return res.json(cached); - } - } + // Format for frontend helper + // ChannelList expects: + // { + // channels: [ { id, name, groupTitle, url, tvgLogo, ... } ], + // groups: [ { id, name, channelCount } ] + // } + // Note: DB `live` items from M3U sync have `category_id` as their group name usually. - const data = await m3uParser.fetchAndParse(source.url); + const reformattedChannels = channels.map(c => ({ + ...c, + id: c.stream_id, + groupTitle: c.category_id || 'Uncategorized', + url: c.stream_url || c.url, + tvgLogo: c.stream_icon + })); - // Store in cache - cache.set('m3u', sourceId, 'playlist', data); + const reformattedGroups = groups.map(g => ({ + id: g.category_id, + name: g.category_name, + channelCount: 0 // Frontend calculates this or we can + })); + + // Add implicit groups check? + // The frontend M3U parser generates groups from the channels if explicit groups missing. + // Our SyncService `saveCategories` handles explicit groups. + + res.json({ channels: reformattedChannels, groups: reformattedGroups }); - res.json(data); } catch (err) { - console.error('M3U proxy error:', err); - res.status(500).json({ error: err.message }); + console.error(err); + res.status(500).json({ error: 'Database error' }); } }); -/** - * Fetch and parse EPG (with file-based caching) - * GET /api/proxy/epg/:sourceId - * Query params: - * - refresh=1 Force refresh, bypass cache - * - maxAge=N Max cache age in hours (default 24) - */ +// EPG router.get('/epg/:sourceId', async (req, res) => { try { - const sourceId = req.params.sourceId; - const source = await sources.getById(sourceId); - if (!source || (source.type !== 'epg' && source.type !== 'xtream')) { - return res.status(404).json({ error: 'Valid EPG source not found' }); + const sourceId = parseInt(req.params.sourceId); + const db = getDb(); + + // Return channels + programmes + // Frontend EpgGuide.js expects { channels: [], programmes: [] } + // It fetches ALL data. We should support timestamps later. + + // EPG Channels (from playlist_items where type='live') + // Ideally we only return channels that HAVE epg data? + // Or just all channels for this source? + // The parser returns `channels` (xmltv channel definitions). + // We didn't strictly store XMLTV channel defs in `epg_programs`. + // We relied on mapping `channel_id` in `epg_programs`. + // Let's return the `playlist_items` as "channels" but mapped to EPG expectations? + // EpgGuide joins on `id === tvgId` or `name === name`. + + // Fetch programs + + + let programsQuery = `SELECT channel_id as channelId, start_time, end_time, title, description, data FROM epg_programs WHERE source_id = ?`; + const params = [sourceId]; + + // Only valid programs? + // programsQuery += ` AND end_time > ?`; + // params.push(Date.now() - 86400000); // last 24h + + const programs = db.prepare(programsQuery).all(...params); + + const formattedPrograms = programs.map(p => ({ + channelId: p.channelId, + start: new Date(p.start_time).toISOString(), // EpgGuide parse this back + stop: new Date(p.end_time).toISOString(), + title: p.title, + desc: p.description + })); + + // Fetch EPG channels from playlist_items (type='epg_channel') + + + let epgChannels = []; + + // Try getting stored channels first + const storedChannels = db.prepare(` + SELECT item_id as id, name, stream_icon as icon, data + FROM playlist_items + WHERE source_id = ? AND type = 'epg_channel' + `).all(sourceId); + + if (storedChannels.length > 0) { + epgChannels = storedChannels; + } else { + // Fallback: Build from unique channelIds in programmes (Legacy behavior) + const uniqueChannelIds = [...new Set(programs.map(p => p.channelId))]; + epgChannels = uniqueChannelIds.map(id => ({ + id: id, + name: id // Use channelId as name (fallback) + })); } - const forceRefresh = req.query.refresh === '1'; - const maxAgeHours = parseInt(req.query.maxAge) || DEFAULT_MAX_AGE_HOURS; - const maxAgeMs = maxAgeHours * 60 * 60 * 1000; + res.json({ + channels: epgChannels, + programmes: formattedPrograms + }); - // Check file cache (unless force refresh) - if (!forceRefresh) { - const cached = cache.get('epg', sourceId, 'data', maxAgeMs); - if (cached) { - return res.json(cached); - } - } - - // Fetch fresh data - let url = source.url; - if (source.type === 'xtream') { - const api = xtreamApi.createFromSource(source); - url = api.getXmltvUrl(); - } - - const data = await epgParser.fetchAndParse(url); - - // Store in file cache - cache.set('epg', sourceId, 'data', data); - - res.json(data); } catch (err) { - console.error('EPG proxy error:', err); - res.status(500).json({ error: err.message }); + console.error(err); + res.status(500).json({ error: 'Database error' }); } }); -/** - * Clear cache for a source - * DELETE /api/proxy/cache/:sourceId - */ +// Clear cache (kept for compatibility) router.delete('/cache/:sourceId', (req, res) => { const sourceId = req.params.sourceId; cache.clearSource(sourceId); res.json({ success: true }); }); -/** - * Clear EPG cache for a source (legacy endpoint, calls clearSource) - * DELETE /api/proxy/epg/:sourceId/cache - */ -router.delete('/epg/:sourceId/cache', (req, res) => { - const sourceId = req.params.sourceId; - cache.clear('epg', sourceId, 'data'); - res.json({ success: true }); -}); -/** - * Get EPG for specific channels - * POST /api/proxy/epg/:sourceId/channels - */ -router.post('/epg/:sourceId/channels', async (req, res) => { +// --- Stream Proxy (Unchanged mostly) --- // + +// Rewrite M3U8 for proxying +async function rewriteM3u8(m3u8Url, baseUrl) { try { - const source = await sources.getById(req.params.sourceId); - if (!source || source.type !== 'epg') { - return res.status(404).json({ error: 'EPG source not found' }); - } + const response = await fetch(m3u8Url); + if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); + let content = await response.text(); - const { channelIds } = req.body; - if (!channelIds || !Array.isArray(channelIds)) { - return res.status(400).json({ error: 'channelIds array required' }); - } + // Resolve relative URLs + const m3u8Base = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1); - const data = await epgParser.fetchAndParse(source.url); - - // Filter programmes for requested channels - const result = {}; - for (const channelId of channelIds) { - result[channelId] = epgParser.getCurrentAndUpcoming(data.programmes, channelId); - } - - res.json(result); - } catch (err) { - console.error('EPG channels error:', err); - res.status(500).json({ error: err.message }); - } -}); - -/** - * Proxy stream for playback - * This handles CORS for streams that don't allow cross-origin - * Supports HTTP Range requests for video seeking - */ -router.get('/stream', async (req, res) => { - const maxRetries = 2; - let lastError = null; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - let { url } = req.query; - if (!url) { - return res.status(400).json({ error: 'URL required' }); - } - - // Forward some headers to be more "transparent" back to the origin - const isPluto = url.includes('pluto.tv'); - - const headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept': '*/*', - 'Accept-Language': 'en-US,en;q=0.9', - // Using https and matching the origin of the request - 'Origin': isPluto ? 'https://pluto.tv' : new URL(url).origin, - 'Referer': isPluto ? 'https://pluto.tv/' : new URL(url).origin + '/' - }; - - // Forward Range header for video seeking support - const rangeHeader = req.get('range'); - if (rangeHeader) { - headers['Range'] = rangeHeader; - } - - const response = await fetch(url, { headers }); - - // Retry on 5xx errors (transient upstream issues) - if (response.status >= 500 && attempt < maxRetries) { - console.log(`[Proxy] Upstream 5xx error (attempt ${attempt}/${maxRetries}), retrying in 500ms...`); - await new Promise(r => setTimeout(r, 500)); - continue; - } - - if (!response.ok) { - console.error(`Upstream error for ${url.substring(0, 80)}...: ${response.status} ${response.statusText}`); - if (response.status === 403) { - const errorBody = await response.text().catch(() => 'N/A'); - console.error(`403 Response body: ${errorBody.substring(0, 200)}`); - } - return res.status(response.status).send(`Failed to fetch stream: ${response.statusText}`); - } - - const contentType = response.headers.get('content-type') || ''; - res.set('Access-Control-Allow-Origin', '*'); - - // Forward range-related headers for video seeking support - const contentLength = response.headers.get('content-length'); - const contentRange = response.headers.get('content-range'); - const acceptRanges = response.headers.get('accept-ranges'); - - if (contentLength) { - res.set('Content-Length', contentLength); - } - if (contentRange) { - res.set('Content-Range', contentRange); - } - if (acceptRanges) { - res.set('Accept-Ranges', acceptRanges); - } else if (contentLength && !contentRange) { - // If server supports content-length but didn't explicitly state accept-ranges, - // we can safely assume it supports byte ranges - res.set('Accept-Ranges', 'bytes'); - } - - // Set status code (206 for partial content when range request was made) - res.status(response.status); - - // Create an async iterator for the response body - const iterator = response.body[Symbol.asyncIterator](); - const first = await iterator.next(); - - if (first.done) { - res.set('Content-Type', contentType || 'application/octet-stream'); - return res.end(); - } - - const firstChunk = Buffer.from(first.value); - - // Peek at first bytes to check for HLS manifest ({ #EXTM3U }) - const textPrefix = firstChunk.subarray(0, 7).toString('utf8'); - const contentLooksLikeHls = textPrefix === '#EXTM3U'; - - if (contentLooksLikeHls) { - // HLS Manifest: We must read the WHOLE manifest to rewrite it - const chunks = [firstChunk]; - - // Consume the rest of the stream - let result = await iterator.next(); - while (!result.done) { - chunks.push(Buffer.from(result.value)); - result = await iterator.next(); - } - - const buffer = Buffer.concat(chunks); - const finalUrl = response.url || url; - console.log(`[Proxy] Processing HLS manifest from: ${finalUrl.substring(0, 80)}...`); - res.set('Content-Type', 'application/vnd.apple.mpegurl'); - - let manifest = buffer.toString('utf-8'); - - const finalUrlObj = new URL(finalUrl); - const baseUrl = finalUrlObj.origin + finalUrlObj.pathname.substring(0, finalUrlObj.pathname.lastIndexOf('/') + 1); - - manifest = manifest.split('\n').map(line => { - const trimmed = line.trim(); - if (trimmed === '' || trimmed.startsWith('#')) { - if (trimmed.includes('URI="')) { - return line.replace(/URI="([^"]+)"/g, (match, p1) => { - try { - const absoluteUrl = new URL(p1, baseUrl).href; - return `URI="${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}"`; - } catch (e) { return match; } - }); - } - return line; - } - - // Stream URL handling - try { - let absoluteUrl; - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - absoluteUrl = trimmed; - } else { - absoluteUrl = new URL(trimmed, baseUrl).href; - } - return `${req.protocol}://${req.get('host')}${req.baseUrl}/stream?url=${encodeURIComponent(absoluteUrl)}`; - } catch (e) { return line; } - }).join('\n'); - - return res.send(manifest); - } - - // Binary content (Video Segment): Efficient Pipe - console.log(`[Proxy] Piping binary stream (${contentType})`); - res.set('Content-Type', contentType || 'application/octet-stream'); - - // Write the chunk we peeked - res.write(firstChunk); - - // Stream the rest - // Create a readable stream from the iterator - const restOfStream = Readable.from(iterator); - - // Pipe to response - restOfStream.pipe(res); - return; // Success - exit the retry loop - - } catch (err) { - lastError = err; - console.error(`Stream proxy error (attempt ${attempt}/${maxRetries}):`, err.message); - if (attempt < maxRetries) { - console.log('[Proxy] Retrying after error...'); - await new Promise(r => setTimeout(r, 500)); - continue; - } - } - } - - // All retries failed - if (!res.headersSent) { - res.status(500).json({ error: lastError?.message || 'Stream proxy failed after retries' }); - } -}); - -/** - * Proxy images (channel logos, posters) - * Fixes mixed content errors when loading HTTP images on HTTPS pages - * GET /api/proxy/image?url=... - */ -router.get('/image', async (req, res) => { - try { - const { url } = req.query; - if (!url) { - return res.status(400).json({ error: 'URL required' }); - } - - const response = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - 'Accept': 'image/*,*/*;q=0.8' + content = content.replace(/^(?!#)(.+)$/gm, (match) => { + let chunkUrl = match.trim(); + if (!chunkUrl.startsWith('http')) { + chunkUrl = m3u8Base + chunkUrl; } + return `${baseUrl}?url=${encodeURIComponent(chunkUrl)}`; }); - if (!response.ok) { - return res.status(response.status).send('Failed to fetch image'); + return content; + } catch (e) { + console.error('M3U8 Rewrite error:', e); + return null; + } +} + +router.get('/stream', async (req, res) => { + const { url } = req.query; + if (!url) return res.status(400).send('URL required'); + + try { + // Handle M3U8 rewrite + if (url.includes('.m3u8')) { + const proxyBase = `${req.protocol}://${req.get('host')}/api/proxy/stream`; + const manifest = await rewriteM3u8(url, proxyBase); + if (manifest) { + res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); + return res.send(manifest); + } } - const contentType = response.headers.get('content-type') || 'image/png'; - res.set('Content-Type', contentType); - res.set('Access-Control-Allow-Origin', '*'); - res.set('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours + // Native Proxy + const range = req.headers.range; + const options = { + headers: range ? { Range: range } : {} + }; - // Efficiently pipe the response body - if (response.body) { - // response.body is an AsyncIterable in standard fetch/undici - // Readable.from converts it to a Node.js Readable stream - const stream = Readable.from(response.body); - stream.pipe(res); - } else { - res.end(); - } + // Handle different protocols + const lib = url.startsWith('https') ? https : http; + + const proxyReq = lib.get(url, options, (proxyRes) => { + // Forward headers + res.status(proxyRes.statusCode); + for (const [key, value] of Object.entries(proxyRes.headers)) { + res.setHeader(key, value); + } + // Pipe data + proxyRes.pipe(res); + }); + + proxyReq.on('error', (err) => { + console.error('Stream proxy error:', err.message); + if (!res.headersSent) res.status(502).end(); + }); + + // Handle aborts + req.on('close', () => { + proxyReq.destroy(); + }); } catch (err) { - console.error('Image proxy error:', err.message); - res.status(500).send('Image proxy error'); + console.error('Stream handler error:', err); + if (!res.headersSent) res.status(500).end(); + } +}); + +// Image Proxy +router.get('/image', async (req, res) => { + const { url } = req.query; + if (!url) return res.status(400).send('URL required'); + + // Valid check + + if (!url.startsWith('http')) return res.redirect(url); // already local or invalid + + try { + const lib = url.startsWith('https') ? https : http; + lib.get(url, (proxyRes) => { + res.status(proxyRes.statusCode); + for (const [key, value] of Object.entries(proxyRes.headers)) { + // cors + if (key === 'access-control-allow-origin') continue; + res.setHeader(key, value); + } + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cache-Control', 'public, max-age=86400'); + proxyRes.pipe(res); + }).on('error', err => { + res.status(404).end(); + }); + } catch (e) { + res.status(500).end(); } }); diff --git a/server/routes/sources.js b/server/routes/sources.js index 6d00601..caff4fe 100644 --- a/server/routes/sources.js +++ b/server/routes/sources.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const { sources } = require('../db'); const xtreamApi = require('../services/xtreamApi'); +const syncService = require('../services/syncService'); // Get all sources router.get('/', async (req, res) => { @@ -19,6 +20,19 @@ router.get('/', async (req, res) => { } }); +// Get sync status for all sources +router.get('/status', async (req, res) => { + try { + const { getDb } = require('../db/sqlite'); + const db = getDb(); + const statuses = db.prepare('SELECT * FROM sync_status').all(); + res.json(statuses); + } catch (err) { + console.error('Error getting sync status:', err); + res.status(500).json({ error: 'Failed to get sync status' }); + } +}); + // Get sources by type router.get('/type/:type', async (req, res) => { try { @@ -58,6 +72,8 @@ router.post('/', async (req, res) => { } const source = await sources.create({ type, name, url, username, password }); + // Trigger Sync + syncService.syncSource(source.id).catch(console.error); res.status(201).json(source); } catch (err) { console.error('Error creating source:', err); @@ -80,6 +96,8 @@ router.put('/:id', async (req, res) => { username: username !== undefined ? username : existing.username, password: password !== undefined ? password : existing.password }); + // Trigger Sync (if critical fields changed? safely just trigger it) + syncService.syncSource(parseInt(req.params.id)).catch(console.error); res.json(updated); } catch (err) { console.error('Error updating source:', err); @@ -109,6 +127,12 @@ router.post('/:id/toggle', async (req, res) => { if (!updated) { return res.status(404).json({ error: 'Source not found' }); } + + // If enabled, trigger sync + if (updated.enabled) { + syncService.syncSource(parseInt(req.params.id)).catch(console.error); + } + res.json(updated); } catch (err) { console.error('Error toggling source:', err); @@ -116,6 +140,23 @@ router.post('/:id/toggle', async (req, res) => { } }); +// Manual Sync +router.post('/:id/sync', async (req, res) => { + try { + const id = parseInt(req.params.id); + const source = await sources.getById(id); + if (!source) return res.status(404).json({ error: 'Source not found' }); + + // Trigger sync (async) + syncService.syncSource(id).catch(console.error); + + res.json({ success: true, message: 'Sync started' }); + } catch (err) { + console.error('Error starting sync:', err); + res.status(500).json({ error: 'Failed to start sync' }); + } +}); + // Test source connection router.post('/:id/test', async (req, res) => { try { diff --git a/server/routes/transcode.js b/server/routes/transcode.js index 1480917..37e4f6e 100644 --- a/server/routes/transcode.js +++ b/server/routes/transcode.js @@ -25,9 +25,10 @@ router.get('/', (req, res) => { const args = [ '-hide_banner', '-loglevel', 'warning', - // Low-latency startup: reduce probe/analyze time for faster first bytes - '-probesize', '32768', - '-analyzeduration', '500000', // 0.5 seconds - enough to detect audio + // Low-latency startup vs Reliability trade-off + // Increased to 5MB/10s to handle slow HLS manifests and streams with large headers + '-probesize', '5000000', // 5MB + '-analyzeduration', '10000000', // 10 seconds // Error resilience: discard corrupt packets, generate timestamps, ignore DTS, no buffering '-fflags', '+genpts+discardcorrupt+igndts+nobuffer', // Ignore errors in stream and continue diff --git a/server/services/m3uParser.js b/server/services/m3uParser.js index 0708763..ac11026 100644 --- a/server/services/m3uParser.js +++ b/server/services/m3uParser.js @@ -89,19 +89,21 @@ async function parse(input) { let currentInfo = null; let currentGroup = null; - let inputStream; + let lines; + if (typeof input === 'string') { - inputStream = Readable.from([input]); + // Handle string input directly + lines = input.split(/\r?\n/); } else { - inputStream = input; + // Handle stream input + const rl = readline.createInterface({ + input: input, + crlfDelay: Infinity + }); + lines = rl; } - const rl = readline.createInterface({ - input: inputStream, - crlfDelay: Infinity - }); - - for await (const line of rl) { + for await (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; diff --git a/server/services/migrationService.js b/server/services/migrationService.js new file mode 100644 index 0000000..500c822 --- /dev/null +++ b/server/services/migrationService.js @@ -0,0 +1,85 @@ +const { getDb } = require('../db/sqlite'); +const { hiddenItems, favorites } = require('../db'); + +async function migrateUserData() { + console.log('[Migration] Starting user data migration (Hidden Items & Favorites)...'); + const db = getDb(); + + try { + // 1. Migrate Hidden Items + const allHidden = await hiddenItems.getAll(); // returns array of { source_id, item_type, item_id } + + const hidStmt = db.prepare(` + UPDATE playlist_items + SET is_hidden = 1 + WHERE source_id = ? AND type = ? AND item_id = ? + `); + + const catHidStmt = db.prepare(` + UPDATE categories + SET is_hidden = 1 + WHERE source_id = ? AND type = ? AND category_id = ? + `); + + let hidCount = 0; + const migrateHidden = db.transaction((items) => { + for (const item of items) { + // Map db.json types to SQLite types + // db.json types: 'channel', 'group', 'vod_category', 'series_category' + // SQLite types: 'live', 'movie', 'series' (for categories/items) + + if (item.item_type === 'channel') { + // Hidden channel -> playlist_items (type='live') + const res = hidStmt.run(item.source_id, 'live', item.item_id); + if (res.changes > 0) hidCount++; + } else if (item.item_type === 'group') { + // Hidden group -> categories (type='live') + const res = catHidStmt.run(item.source_id, 'live', item.item_id); // item_id is group name/id + if (res.changes > 0) hidCount++; + } else if (item.item_type === 'vod_category') { + const res = catHidStmt.run(item.source_id, 'movie', item.item_id); + if (res.changes > 0) hidCount++; + } else if (item.item_type === 'series_category') { + const res = catHidStmt.run(item.source_id, 'series', item.item_id); + if (res.changes > 0) hidCount++; + } + } + }); + + migrateHidden(allHidden); + console.log(`[Migration] Migrated ${hidCount} hidden items/categories.`); + + + // 2. Migrate Favorites + const allFavorites = await favorites.getAll(); + + const favStmt = db.prepare(` + UPDATE playlist_items + SET is_favorite = 1 + WHERE source_id = ? AND type = ? AND item_id = ? + `); + + let favCount = 0; + const migrateFavorites = db.transaction((favs) => { + for (const fav of favs) { + // Map types + // db.json: 'channel', 'movie', 'series' + // SQLite: 'live', 'movie', 'series' + + let type = fav.item_type; + if (type === 'channel') type = 'live'; + + const res = favStmt.run(fav.source_id, type, fav.item_id); + if (res.changes > 0) favCount++; + } + }); + + migrateFavorites(allFavorites); + console.log(`[Migration] Migrated ${favCount} favorites.`); + + } catch (e) { + console.error('[Migration] Failed:', e); + } +} + +module.exports = { migrateUserData }; diff --git a/server/services/syncService.js b/server/services/syncService.js new file mode 100644 index 0000000..f29592c --- /dev/null +++ b/server/services/syncService.js @@ -0,0 +1,385 @@ +const { getDb } = require('../db/sqlite'); +const { sources } = require('../db'); // For source config +const xtreamApi = require('./xtreamApi'); +const m3uParser = require('./m3uParser'); +const epgParser = require('./epgParser'); + +// Sync tracking +const activeSyncs = new Set(); // sourceId + +class SyncService { + /** + * Sync all enabled sources + */ + async syncAll() { + console.log('[Sync] Starting global sync...'); + try { + const allSources = await sources.getAll(); + for (const source of allSources) { + if (source.enabled) { + // Run sequentially to not overload + await this.syncSource(source.id); + } + } + console.log('[Sync] Global sync completed'); + } catch (err) { + console.error('[Sync] Global sync failed:', err); + } + } + + /** + * Start sync for a source + */ + async syncSource(sourceId) { + if (activeSyncs.has(sourceId)) { + console.log(`[Sync] Source ${sourceId} is already syncing`); + return; + } + + activeSyncs.add(sourceId); + + try { + const db = getDb(); + const source = await sources.getById(sourceId); + + if (!source) { + throw new Error(`Source ${sourceId} not found`); + } + + console.log(`[Sync] Starting sync for source ${source.name} (ID: ${sourceId})`); + + if (!source.enabled) { + console.log(`[Sync] Skipping disabled source ${source.name}`); + activeSyncs.delete(sourceId); + return; + } + + // Update status + this.updateSyncStatus(sourceId, 'all', 'syncing'); + + if (source.type === 'xtream') { + await this.syncXtream(source); + } else if (source.type === 'm3u') { + await this.syncM3u(source); + } else if (source.type === 'epg') { + await this.syncEpg(source); + } + + this.updateSyncStatus(sourceId, 'all', 'success'); + console.log(`[Sync] Completed sync for source ${source.name}`); + + } catch (err) { + console.error(`[Sync] Failed sync for source ${sourceId}:`, err); + this.updateSyncStatus(sourceId, 'all', 'error', err.message); + } finally { + activeSyncs.delete(sourceId); + } + } + + /** + * Update sync status in DB + */ + updateSyncStatus(sourceId, type, status, error = null) { + const db = getDb(); + const stmt = db.prepare(` + INSERT INTO sync_status (source_id, type, last_sync, status, error) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(source_id, type) DO UPDATE SET + last_sync = excluded.last_sync, + status = excluded.status, + error = excluded.error + `); + stmt.run(sourceId, type, Date.now(), status, error); + } + + /** + * Xtream Sync Logic + */ + async syncXtream(source) { + const api = xtreamApi.createFromSource(source); + const db = getDb(); + + // 1. Live Categories + console.log(`[Sync] Fetching Live Categories for ${source.name}`); + const liveCats = await api.getLiveCategories(); + await this.saveCategories(source.id, 'live', liveCats); + + // 2. Live Streams + console.log(`[Sync] Fetching Live Streams for ${source.name}`); + const liveStreams = await api.getLiveStreams(); + await this.saveStreams(source.id, 'live', liveStreams); + + // 3. VOD Categories + console.log(`[Sync] Fetching VOD Categories for ${source.name}`); + const vodCats = await api.getVodCategories(); + await this.saveCategories(source.id, 'movie', vodCats); + + // 4. VOD Streams + console.log(`[Sync] Fetching VOD Streams for ${source.name}`); + const vodStreams = await api.getVodStreams(); + await this.saveStreams(source.id, 'movie', vodStreams); + + // 5. Series Categories + console.log(`[Sync] Fetching Series Categories for ${source.name}`); + const seriesCats = await api.getSeriesCategories(); + await this.saveCategories(source.id, 'series', seriesCats); + + // 6. Series + console.log(`[Sync] Fetching Series for ${source.name}`); + const series = await api.getSeries(); + await this.saveStreams(source.id, 'series', series); + + // 7. EPG (Xmltv) + // Try to fetch XMLTV if available + console.log(`[Sync] Fetching EPG for ${source.name}`); + try { + const xmltvUrl = api.getXmltvUrl(); + await this.syncEpgFromUrl(source.id, xmltvUrl); + } catch (e) { + console.warn('[Sync] XMLTV fetch failed, skipping EPG sync for now:', e.message); + } + } + + /** + * Batch save categories + */ + async saveCategories(sourceId, type, categories) { + if (!categories || categories.length === 0) return; + console.log(`[Sync] Saving ${categories.length} ${type} categories for source ${sourceId}...`); + const db = getDb(); + const stmt = db.prepare(` + INSERT INTO categories (id, source_id, category_id, type, name, parent_id, data) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + data = excluded.data + `); + + const insertBatch = db.transaction((batch) => { + for (const cat of batch) { + const catId = cat.category_id; // standard xtream field + const name = cat.category_name; + const id = `${sourceId}:${catId}`; + stmt.run(id, sourceId, String(catId), type, name, cat.parent_id || null, JSON.stringify(cat)); + } + }); + + const BATCH_SIZE = 500; + for (let i = 0; i < categories.length; i += BATCH_SIZE) { + insertBatch(categories.slice(i, i + BATCH_SIZE)); + await new Promise(resolve => setImmediate(resolve)); + } + + console.log(`[Sync] Saved ${categories.length} ${type} categories`); + } + + /** + * Batch save streams (channels, vod, series) + */ + async saveStreams(sourceId, type, items) { + if (!items || items.length === 0) return; + const db = getDb(); + const stmt = db.prepare(` + INSERT INTO playlist_items ( + id, source_id, item_id, type, name, category_id, + stream_icon, stream_url, container_extension, + rating, year, added_at, data + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + category_id = excluded.category_id, + stream_icon = excluded.stream_icon, + container_extension = excluded.container_extension, + data = excluded.data + `); + + const insertBatch = db.transaction((batch) => { + for (const item of batch) { + // Map fields based on type + let itemId, name, catId, icon, container; + let rating = null, year = null, added = null; + + if (type === 'live') { + itemId = item.stream_id; + name = item.name; + catId = item.category_id; + icon = item.stream_icon; + added = item.added; + } else if (type === 'movie') { + itemId = item.stream_id; + name = item.name; + catId = item.category_id; + icon = item.stream_icon; // or cover + container = item.container_extension; + rating = item.rating; + added = item.added; + } else if (type === 'series') { + itemId = item.series_id; + name = item.name; + catId = item.category_id; + icon = item.cover; + rating = item.rating; + year = item.releaseDate; + added = item.last_modified; + } + + const id = `${sourceId}:${itemId}`; + + stmt.run( + id, + sourceId, + String(itemId), + type, + name, + String(catId), + icon, + null, // Direct URL not stored for Xtream usually, built on fly + container, + rating, + year, + added, + JSON.stringify(item) + ); + } + }); + + const BATCH_SIZE = 500; + for (let i = 0; i < items.length; i += BATCH_SIZE) { + insertBatch(items.slice(i, i + BATCH_SIZE)); + await new Promise(resolve => setImmediate(resolve)); + } + + console.log(`[Sync] Saved ${items.length} ${type} items`); + } + + + /** + * Sync EPG from URL + */ + async syncEpgFromUrl(sourceId, url) { + // Use our streaming parser + const { channels, programmes } = await epgParser.fetchAndParse(url); + + console.log(`[Sync] EPG Parsed: ${channels.length} channels, ${programmes.length} programs`); + + const db = getDb(); + + // 1. Save EPG Channels to playlist_items (for Name/Icon matching) + + const channelStmt = db.prepare(` + INSERT INTO playlist_items ( + id, source_id, item_id, type, name, stream_icon, + stream_url, category_id, data + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + stream_icon = excluded.stream_icon, + data = excluded.data + `); + + // Use transaction for channels + const insertChannels = db.transaction((chanList) => { + for (const ch of chanList) { + const id = `${sourceId}:${ch.id}`; + channelStmt.run( + id, + sourceId, + ch.id, // XMLTV ID + 'epg_channel', + ch.name, + ch.icon || null, + null, // No URL + null, // No Category + JSON.stringify(ch) + ); + } + }); + + insertChannels(channels); + console.log(`[Sync] Saved ${channels.length} EPG channels`); + + // 2. Save Programs + // First delete old programs for this source + db.prepare('DELETE FROM epg_programs WHERE source_id = ?').run(sourceId); + + const stmt = db.prepare(` + INSERT INTO epg_programs (channel_id, source_id, start_time, end_time, title, description, data) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + const insertMany = db.transaction((progs) => { + for (const p of progs) { + stmt.run( + p.channelId, + sourceId, + p.start ? p.start.getTime() : 0, + p.stop ? p.stop.getTime() : 0, + p.title, + p.description || p.desc, + JSON.stringify(p) + ); + } + }); + + insertMany(programmes); + console.log(`[Sync] Saved ${programmes.length} programs`); + } + + /** + * M3U Sync Logic + */ + /** + * M3U Sync Logic + */ + async syncM3u(source) { + console.log(`[Sync] Fetching M3U playlist for ${source.name}`); + + // Fetch and parse using existing parser + // We use fetch directly here to get the stream/text + const response = await fetch(source.url); + if (!response.ok) throw new Error(`Failed to fetch M3U: ${response.status}`); + + const text = await response.text(); + const { channels, groups } = await m3uParser.parse(text); + + console.log(`[Sync] M3U Parsed: ${channels.length} channels, ${groups.length} groups`); + + // Save Categories (Groups) + // M3U groups are just strings usually, we need to normalize them + const categories = groups.map(g => ({ + category_id: g.name, // use name as ID for M3U groups + category_name: g.name, + parent_id: null + })); + + await this.saveCategories(source.id, 'live', categories); + + // Save Channels + // Map M3U channel format to our schema + const playlistItems = channels.map(ch => ({ + stream_id: ch.id, // parser generates a stable-ish ID + name: ch.name, + category_id: ch.groupTitle || 'Uncategorized', + stream_icon: ch.tvgLogo, + stream_url: ch.url, + // M3U doesn't usually have VOD metadata like rating/year easily accessible unless extended tags used + // We assume 'live' for now, but could detect VOD from URL extension? + // For now, treat all as type='live' for M3U or maybe check info? + // The parser doesn't differentiate types well yet. + })); + + await this.saveStreams(source.id, 'live', playlistItems); + } + + /** + * EPG Source Sync Logic + */ + async syncEpg(source) { + console.log(`[Sync] Fetching standalone EPG for ${source.name}`); + await this.syncEpgFromUrl(source.id, source.url); + } +} + +module.exports = new SyncService();