Refactored code to use sqlite for channel, epg and vod data
This commit is contained in:
Generated
+388
-5
@@ -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",
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+47
-13
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}));
|
||||
|
||||
@@ -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 = '<p class="hint">No movie categories found</p>';
|
||||
@@ -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 = '<p class="hint">No series categories found</p>';
|
||||
@@ -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 = `<span class="spin">${Icons.refresh}</span>`;
|
||||
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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
+11
-1
@@ -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);
|
||||
});
|
||||
|
||||
+201
-41
@@ -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;
|
||||
|
||||
|
||||
+427
-399
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user