Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 635e50bdcc |
45
README.md
45
README.md
@ -119,37 +119,6 @@ LAYA_BASE_URL=http://localhost:8000 npm start
|
||||
|
||||
Repository maintainers can build and publish both the nodecast-tv and Laya images with `./scripts/docker-build-push.sh`; this script is not needed by Compose users. `REGISTRY`, `OWNER`, `IMAGE_NAME`, `LAYA_IMAGE_NAME`, and `TAG` can override the publication targets. Use `--no-push` to build both images locally without publishing them.
|
||||
|
||||
#### Server-side Google Cast
|
||||
|
||||
Nodecast can discover and control Chromecast and compatible Android TV devices from the server. This makes casting available from any browser: the browser asks Nodecast to start playback, while the Nodecast server performs LAN discovery and communicates with the receiver.
|
||||
|
||||
Configure both of these variables:
|
||||
|
||||
- `CAST_PUBLIC_BASE_URL`: the absolute Nodecast URL that the receiver can reach on the LAN. It must not use `localhost`, `127.0.0.1`, or `::1`.
|
||||
- `CAST_SIGNING_SECRET`: a dedicated secret of at least 32 bytes, used for temporary cookie-free media URLs. Do not reuse a public value or commit the secret.
|
||||
|
||||
For example, add the following entries to the `nodecast-tv` service in `docker-compose.yml`, replacing the address and secret:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
nodecast-tv:
|
||||
environment:
|
||||
- CAST_PUBLIC_BASE_URL=http://192.168.1.50:3000
|
||||
- CAST_SIGNING_SECRET=${CAST_SIGNING_SECRET}
|
||||
```
|
||||
|
||||
Then define the secret in the deployment `.env` file. A suitable value can be generated with `openssl rand -hex 32`:
|
||||
|
||||
```env
|
||||
CAST_SIGNING_SECRET=replace-with-a-random-secret-of-at-least-32-bytes
|
||||
```
|
||||
|
||||
Restart Nodecast, open a Live or Watch player in any browser, select the Cast control, and refresh the device list if necessary. Nodecast discovers Google Cast receivers over mDNS from its own network environment; the browser itself does not need native Chromecast support.
|
||||
|
||||
Docker bridge networks may not pass multicast DNS traffic from the LAN. Depending on the host platform, discovery may require host networking or an explicitly configured Docker/LAN network. The receiver must also be able to open `CAST_PUBLIC_BASE_URL` and its media URLs.
|
||||
|
||||
Fire TV / Fire Stick is not a native Google Cast target. Matter Casting or Fire TV playback requires a compatible receiver application that is outside the scope of this integration; unsupported devices are shown as **Receiver required** rather than being presented as castable.
|
||||
|
||||
|
||||
### Hardware Acceleration Setup
|
||||
|
||||
@ -310,20 +279,6 @@ docker compose --profile laya pull laya
|
||||
docker compose --profile laya up -d --force-recreate laya
|
||||
```
|
||||
|
||||
### Server-side Cast
|
||||
|
||||
If no Chromecast or Android TV appears:
|
||||
|
||||
- Confirm that Nodecast and the receiver are on reachable LAN segments and that multicast DNS (`_googlecast._tcp`) is not blocked by Wi-Fi isolation, a firewall, VLAN rules, or Docker networking.
|
||||
- When Nodecast runs in Docker, test a host-network or LAN-compatible container configuration if the default bridge cannot see mDNS advertisements.
|
||||
- Refresh the device list from the Cast dialog and inspect `docker compose logs nodecast-tv` for discovery errors.
|
||||
|
||||
If a device is found but cannot start playback, open `CAST_PUBLIC_BASE_URL` from another device on the same network. The URL must contain the host's reachable LAN address or DNS name and the published port; a container name or `localhost` will not work from the receiver. Also check firewall access to that address.
|
||||
|
||||
If Nodecast reports that Cast signing is unavailable, set a dedicated `CAST_SIGNING_SECRET` containing at least 32 bytes and recreate the container. Nodecast deliberately refuses to issue predictable unsigned or weakly signed media URLs.
|
||||
|
||||
Fire TV, Fire Stick, and Matter Casting do not automatically provide Google Cast compatibility. Without a compatible receiver application, Nodecast reports **Receiver required**; use a Chromecast or Google Cast-enabled Android TV for this server-cast integration.
|
||||
|
||||
### Video Won't Play (Black Screen or Loading Forever)
|
||||
|
||||
| Symptom | Likely Cause | Solution |
|
||||
|
||||
235
package-lock.json
generated
235
package-lock.json
generated
@ -12,8 +12,6 @@
|
||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"bonjour-service": "^1.4.4",
|
||||
"chromecast-client": "^1.0.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.2",
|
||||
@ -180,69 +178,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@leichtgewicht/ip-codec": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
||||
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
|
||||
"integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
|
||||
@ -419,16 +354,6 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bonjour-service": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.4.tgz",
|
||||
"integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"multicast-dns": "^7.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
@ -511,82 +436,12 @@
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/castv2": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/castv2/-/castv2-0.1.10.tgz",
|
||||
"integrity": "sha512-3QWevHrjT22KdF08Y2a217IYCDQDP7vEJaY4n0lPBeC5UBYbMFMadDfVTsaQwq7wqsEgYUHElPGm3EO1ey+TNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"protobufjs": "^6.8.8"
|
||||
}
|
||||
},
|
||||
"node_modules/castv2/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/castv2/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/chromecast-client": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chromecast-client/-/chromecast-client-1.0.1.tgz",
|
||||
"integrity": "sha512-79QTIu6g1oYHvI9i8sPc8kpwrZGb6hXiNnEJmoiFLfjTYTJO+kKc8ML1uqNNKZbKMMFsgR/89da0A3ezD5p92A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"castv2": "^0.1.10",
|
||||
"debug": "^4.3.4",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://www.patreon.com/dantaylor"
|
||||
}
|
||||
},
|
||||
"node_modules/chromecast-client/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/chromecast-client/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@ -758,18 +613,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dns-packet": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
|
||||
"integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@leichtgewicht/ip-codec": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/domexception": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
|
||||
@ -1001,12 +844,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ffmpeg-static": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
|
||||
@ -1509,12 +1346,6 @@
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@ -1617,19 +1448,6 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multicast-dns": {
|
||||
"version": "7.2.5",
|
||||
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
|
||||
"integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dns-packet": "^5.2.2",
|
||||
"thunky": "^1.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"multicast-dns": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
@ -1851,38 +1669,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.6.6",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz",
|
||||
"integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.1",
|
||||
"@protobufjs/fetch": "^1.1.1",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs/node_modules/@types/node": {
|
||||
"version": "26.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
|
||||
"integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@ -2314,12 +2100,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/thunky": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
||||
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@ -2402,12 +2182,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
@ -2602,15 +2376,6 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,8 +13,6 @@
|
||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"bonjour-service": "^1.4.4",
|
||||
"chromecast-client": "^1.0.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.2",
|
||||
@ -29,11 +27,6 @@
|
||||
"optionalDependencies": {
|
||||
"ffmpeg-static": "^5.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"castv2": {
|
||||
"protobufjs": "^7.6.3"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
||||
@ -789,7 +789,7 @@ h4 {
|
||||
.channel-variant {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(52px, auto) minmax(64px, auto) minmax(64px, auto) 1fr auto;
|
||||
grid-template-columns: minmax(52px, auto) minmax(64px, auto) minmax(64px, auto) 1fr;
|
||||
gap: var(--space-sm);
|
||||
padding: 6px var(--space-sm);
|
||||
border: 0;
|
||||
@ -813,17 +813,6 @@ h4 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.variant-playing-badge {
|
||||
align-self: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg-primary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.variant-language {
|
||||
justify-self: start;
|
||||
padding: 1px 5px;
|
||||
@ -895,29 +884,10 @@ h4 {
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.live-analysis-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.live-analysis-action {
|
||||
display: flex;
|
||||
max-width: 19rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.live-analysis-heading {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.live-analysis-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.laya-model-panel [hidden] {
|
||||
@ -4640,88 +4610,6 @@ select.form-input optgroup:disabled {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.cast-device-dialog {
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
max-height: min(80vh, 680px);
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.cast-device-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
|
||||
.cast-dialog-header,
|
||||
.cast-dialog-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.cast-device-dialog .form-input {
|
||||
width: 100%;
|
||||
margin: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.cast-device-message {
|
||||
min-height: 1.25rem;
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.cast-device-message[data-state="error"] {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.cast-device-list {
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
max-height: 360px;
|
||||
margin-bottom: var(--space-md);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cast-device-item {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-height: 56px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cast-device-item:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.cast-device-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cast-device-status,
|
||||
.cast-device-explanation {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.cast-dialog-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -234,12 +234,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="watch-btn" id="player-cast" title="Play on a device" aria-label="Play on a device" aria-describedby="player-cast-status" hidden>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.92-11-11-11zm3-7c-1.1 0-2 .9-2 2v3h2V5h16v12h-6v2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2H4z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span id="player-cast-status" class="sr-only" role="status" aria-live="polite"></span>
|
||||
<button class="watch-btn" id="btn-pip" title="Picture-in-Picture">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path
|
||||
@ -489,17 +483,9 @@
|
||||
<strong>Channel analysis</strong>
|
||||
<div class="setting-hint">Group matching streams into channels and update detected languages.</div>
|
||||
</div>
|
||||
<div class="live-analysis-actions">
|
||||
<div class="live-analysis-action">
|
||||
<button type="button" class="btn btn-primary" id="live-check-new-button">Check for new streams</button>
|
||||
<span class="setting-hint">Sync IPTV sources and analyze only streams that are not already in the catalog.</span>
|
||||
</div>
|
||||
<div class="live-analysis-action">
|
||||
<button type="button" class="btn btn-secondary" id="live-rebuild-catalog-button">Run full refresh</button>
|
||||
<span class="setting-hint">Refresh IPTV and EPG data, re-analyze every stream, and rebuild channel-to-EPG matches.</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="live-analysis-button">Analyze channels</button>
|
||||
</div>
|
||||
<p class="setting-hint">Use this to analyze channels that were added before automatic analysis was enabled.</p>
|
||||
<div id="live-analysis-status" class="setting-hint" role="status">Checking status…</div>
|
||||
<div id="live-analysis-progress" class="laya-progress" hidden>
|
||||
<div id="live-analysis-progress-track" class="laya-progress-track" role="progressbar" aria-label="Channel analysis progress">
|
||||
@ -1033,12 +1019,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="watch-btn" id="watch-cast" title="Play on a device" aria-label="Play on a device" aria-describedby="watch-cast-status" hidden>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path d="M1 18v3h3c0-1.66-1.34-3-3-3zm0-4v2c2.76 0 5 2.24 5 5h2c0-3.87-3.13-7-7-7zm0-4v2c4.97 0 9 4.03 9 9h2c0-6.08-4.92-11-11-11zm3-7c-1.1 0-2 .9-2 2v3h2V5h16v12h-6v2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2H4z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span id="watch-cast-status" class="sr-only" role="status" aria-live="polite"></span>
|
||||
<button class="watch-btn" id="watch-pip" title="Picture-in-Picture">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path
|
||||
@ -1226,27 +1206,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="cast-device-dialog" class="cast-device-dialog">
|
||||
<div class="cast-dialog-header">
|
||||
<h2>Play on a device</h2>
|
||||
<button type="button" class="btn btn-ghost" data-cast-action="close" aria-label="Close device picker">×</button>
|
||||
</div>
|
||||
<p class="setting-hint">Choose a device connected to the same network as Nodecast.</p>
|
||||
<input id="cast-device-search" class="form-input" type="search" placeholder="Search devices…" aria-label="Search devices">
|
||||
<div id="cast-device-message" class="cast-device-message" role="status" aria-live="polite"></div>
|
||||
<div id="cast-device-list" class="cast-device-list"></div>
|
||||
<div class="cast-dialog-actions">
|
||||
<button type="button" class="btn btn-secondary" data-cast-action="refresh">Refresh devices</button>
|
||||
<button type="button" class="btn btn-secondary" id="cast-browser-picker">Use browser device picker</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/api.js?v=7"></script>
|
||||
<script src="/js/services/castService.js?v=1"></script>
|
||||
<script src="/js/services/serverCastDevicePicker.js?v=1"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=7"></script>
|
||||
<script src="/js/api.js?v=6"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=5"></script>
|
||||
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
|
||||
<script src="/js/services/channelLanguageFusion.js?v=1"></script>
|
||||
<script src="/js/services/layaStatusPresentation.js?v=6"></script>
|
||||
@ -1262,7 +1225,7 @@
|
||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||
<script src="/js/pages/Settings.js?v=8"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=3"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=1"></script>
|
||||
<script src="/js/app.js?v=4"></script>
|
||||
<script>
|
||||
// Check for SSO token in URL
|
||||
|
||||
@ -65,17 +65,6 @@ const API = {
|
||||
estimateByUrl: (url, type) => API.request('POST', '/sources/estimate', { url, type }), // Estimate by URL (before creation)
|
||||
},
|
||||
|
||||
cast: {
|
||||
getDevices: () => API.request('GET', '/cast/devices'),
|
||||
devices: () => API.request('GET', '/cast/devices'),
|
||||
refreshDevices: () => API.request('POST', '/cast/devices/refresh'),
|
||||
startSession: (payload) => API.request('POST', '/cast/sessions', payload),
|
||||
createSession: (payload) => API.request('POST', '/cast/sessions', payload),
|
||||
getSessions: () => API.request('GET', '/cast/sessions'),
|
||||
getSession: (id) => API.request('GET', `/cast/sessions/${encodeURIComponent(id)}`),
|
||||
stopSession: (id) => API.request('DELETE', `/cast/sessions/${encodeURIComponent(id)}`)
|
||||
},
|
||||
|
||||
// Channels (hidden items)
|
||||
channels: {
|
||||
getHidden: (sourceId = null) => API.request('GET', `/channels/hidden${sourceId ? `?sourceId=${sourceId}` : ''}`),
|
||||
@ -90,27 +79,14 @@ const API = {
|
||||
},
|
||||
|
||||
live: {
|
||||
getChannels: ({ sourceId = null, includeHidden = false, q = '', languages = [], limit = 100, offset = 0 } = {}) => {
|
||||
getChannels: ({ sourceId = null, includeHidden = false, q = '', languages = [] } = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (sourceId) params.set('sourceId', sourceId);
|
||||
params.set('includeHidden', String(Boolean(includeHidden)));
|
||||
if (q) params.set('q', q);
|
||||
if (languages.length) params.set('languages', languages.join(','));
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(offset));
|
||||
params.set('page', String(Math.floor(offset / limit) + 1));
|
||||
return API.request('GET', `/live/channels?${params.toString()}`);
|
||||
},
|
||||
getChannelVariants: (logicalChannelId, { includeHidden = false, limit = 100, offset = 0 } = {}) => {
|
||||
const params = new URLSearchParams({
|
||||
includeHidden: String(Boolean(includeHidden)),
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
page: String(Math.floor(offset / limit) + 1)
|
||||
});
|
||||
return API.request('GET', `/live/channels/${encodeURIComponent(logicalChannelId)}/variants?${params.toString()}`);
|
||||
},
|
||||
checkNewStreams: () => API.request('POST', '/live/projection/check-new'),
|
||||
rebuildProjection: () => API.request('POST', '/live/projection/rebuild'),
|
||||
getProjectionStatus: () => API.request('GET', '/live/projection/status')
|
||||
},
|
||||
|
||||
@ -28,9 +28,6 @@ class ChannelList {
|
||||
this.listMode = 'grouped';
|
||||
this.logicalChannels = [];
|
||||
this.projectedLogicalChannels = null;
|
||||
this.projectionPage = null;
|
||||
this.projectionLimit = 100;
|
||||
this.variantPages = new Map();
|
||||
this.availableLanguages = [];
|
||||
this.selectedLanguages = new Set();
|
||||
this.selectedVariants = new Map();
|
||||
@ -197,9 +194,8 @@ class ChannelList {
|
||||
this.searchInput.addEventListener('input', () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
if (this.projectionPage) this.loadChannels();
|
||||
else if (this.projectedLogicalChannels || this.channels.length) this.render();
|
||||
else this.loadChannels();
|
||||
if (this.projectedLogicalChannels || this.channels.length) this.render();
|
||||
else this.loadChannels({ refreshLanguages: false });
|
||||
}, this.projectedLogicalChannels || this.channels.length ? 75 : 250);
|
||||
});
|
||||
|
||||
@ -219,8 +215,7 @@ class ChannelList {
|
||||
if (this.searchInput?.value) {
|
||||
this.searchInput.value = '';
|
||||
clearTimeout(searchTimeout);
|
||||
if (this.projectionPage) this.loadChannels();
|
||||
else this.render();
|
||||
this.render();
|
||||
this.searchInput.focus();
|
||||
}
|
||||
});
|
||||
@ -231,10 +226,7 @@ class ChannelList {
|
||||
|
||||
// Show hidden toggle
|
||||
if (this.showHiddenCheckbox) {
|
||||
this.showHiddenCheckbox.addEventListener('change', () => {
|
||||
if (this.projectionPage) this.loadChannels();
|
||||
else this.render();
|
||||
});
|
||||
this.showHiddenCheckbox.addEventListener('change', () => this.render());
|
||||
}
|
||||
|
||||
// Context menu handlers
|
||||
@ -374,11 +366,13 @@ class ChannelList {
|
||||
}
|
||||
|
||||
setLanguageDetectionMode(mode) {
|
||||
const safeMode = ['algo', 'laya', 'hybrid'].includes(mode) ? mode : 'algo';
|
||||
this.layaLanguageResults.clear();
|
||||
this.languageDetectionNotice = '';
|
||||
this.languageDetectionFallback = false;
|
||||
if (this.channels.length > 0) {
|
||||
this.render();
|
||||
void this.refreshLayaLanguages(safeMode);
|
||||
}
|
||||
}
|
||||
|
||||
@ -633,7 +627,6 @@ class ChannelList {
|
||||
|
||||
// Start observing loader for additional batches
|
||||
this.observer.observe(this.loader);
|
||||
this.appendProjectionPager();
|
||||
}
|
||||
|
||||
renderFlatList(channels, searchTerm) {
|
||||
@ -663,41 +656,6 @@ class ChannelList {
|
||||
this.container.appendChild(this.loader);
|
||||
this.renderNextFlatBatch();
|
||||
this.observer.observe(this.loader);
|
||||
this.appendProjectionPager();
|
||||
}
|
||||
|
||||
appendProjectionPager() {
|
||||
if (!this.projectionPage?.hasMore) return;
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'btn btn-ghost load-more-channels';
|
||||
button.textContent = 'Load more channels';
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
await this.loadMoreProjectedChannels();
|
||||
});
|
||||
this.container.appendChild(button);
|
||||
}
|
||||
|
||||
async loadMoreProjectedChannels() {
|
||||
if (!this.projectionPage?.hasMore) return;
|
||||
const sourceValue = this.sourceSelect.value;
|
||||
const [, rawSourceId] = sourceValue ? sourceValue.split(':') : [];
|
||||
const response = await API.live.getChannels({
|
||||
sourceId: rawSourceId ? Number.parseInt(rawSourceId, 10) : null,
|
||||
includeHidden: Boolean(this.showHiddenCheckbox?.checked),
|
||||
q: this.searchInput?.value || '',
|
||||
languages: [...this.selectedLanguages],
|
||||
limit: this.projectionPage.limit || this.projectionLimit,
|
||||
offset: this.projectionPage.nextOffset
|
||||
});
|
||||
const mapped = LiveChannelProjection.mapResponse(response, this.sources);
|
||||
this.channels.push(...mapped.channels);
|
||||
this.projectedLogicalChannels.push(...mapped.logicalChannels);
|
||||
this.projectionPage = response.page || null;
|
||||
mapped.favorites.forEach(key => this.visibleFavorites.add(key));
|
||||
mapped.hiddenItems.forEach(key => this.hiddenItems.add(key));
|
||||
this.render();
|
||||
}
|
||||
|
||||
renderNextFlatBatch() {
|
||||
@ -853,20 +811,10 @@ class ChannelList {
|
||||
});
|
||||
}
|
||||
});
|
||||
groupEl.querySelectorAll('.variant-toggle').forEach(button => button.addEventListener('click', async (e) => {
|
||||
groupEl.querySelectorAll('.variant-toggle').forEach(button => button.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const logicalId = button.closest('.channel-item').dataset.logicalId;
|
||||
if (this.expandedVariants.has(logicalId)) {
|
||||
this.expandedVariants.delete(logicalId);
|
||||
} else {
|
||||
this.expandedVariants.add(logicalId);
|
||||
await this.loadChannelVariants(logicalId);
|
||||
}
|
||||
this.render();
|
||||
}));
|
||||
groupEl.querySelectorAll('.load-more-variants').forEach(button => button.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await this.loadChannelVariants(button.dataset.logicalId, true);
|
||||
const fingerprint = button.closest('.channel-item').dataset.logicalId;
|
||||
this.expandedVariants.has(fingerprint) ? this.expandedVariants.delete(fingerprint) : this.expandedVariants.add(fingerprint);
|
||||
this.render();
|
||||
}));
|
||||
groupEl.querySelectorAll('.channel-variant').forEach(item => item.addEventListener('click', (e) => {
|
||||
@ -879,8 +827,7 @@ class ChannelList {
|
||||
const selected = channel.preferredVariant;
|
||||
const selectedIsFavorite = this.isFavorite(selected.sourceId, selected.id);
|
||||
const expanded = this.expandedVariants.has(channel.fingerprint);
|
||||
const variantState = this.variantPages?.get(channel.fingerprint);
|
||||
const variants = expanded ? channel.variants.map(variant => {
|
||||
const variants = channel.variants.map(variant => {
|
||||
const hidden = this.isHidden('channel', variant.sourceId, variant.streamId || variant.id);
|
||||
if (hidden && !this.showHidden) return '';
|
||||
const source = this.sources.find(item => String(item.id) === String(variant.sourceId));
|
||||
@ -891,29 +838,15 @@ class ChannelList {
|
||||
const languageDescription = language.code === 'unknown'
|
||||
? 'Unknown language'
|
||||
: `${language.label} — ${language.confidence}% confidence`;
|
||||
const variantKey = `${variant.sourceId}:${variant.id}`;
|
||||
const playerVariant = window.app?.player?.currentChannel;
|
||||
const currentVariant = this.currentChannel?.preferredVariant;
|
||||
const playingKeys = new Set([
|
||||
channel.selectedVariantId,
|
||||
playerVariant?.sourceId !== undefined && playerVariant?.id !== undefined
|
||||
? `${playerVariant.sourceId}:${playerVariant.id}` : '',
|
||||
currentVariant?.sourceId !== undefined && currentVariant?.id !== undefined
|
||||
? `${currentVariant.sourceId}:${currentVariant.id}` : ''
|
||||
].filter(Boolean).map(String));
|
||||
const isPlaying = playingKeys.has(variantKey);
|
||||
return `<button class="channel-variant ${hidden ? 'is-hidden' : ''} ${isPlaying ? 'is-playing' : ''}" type="button"
|
||||
return `<button class="channel-variant ${hidden ? 'is-hidden' : ''}" type="button"
|
||||
data-logical-id="${this.escapeHtml(channel.fingerprint)}" data-variant-id="${this.escapeHtml(String(variant.id))}"
|
||||
data-source-id="${variant.sourceId}" data-render-id="${renderId}" data-render-group="${this.escapeHtml(renderGroup)}">
|
||||
<span class="variant-quality">${ChannelVariantGrouping.getQuality(variant)}</span>
|
||||
<span class="variant-language" title="${this.escapeHtml(languageDescription)}" aria-label="${this.escapeHtml(languageDescription)}">${languageLabel}</span>
|
||||
<span class="variant-source">${this.escapeHtml(source?.name || variant.sourceType)}</span>
|
||||
<span class="variant-name">${this.escapeHtml(variant.name)}</span>
|
||||
${isPlaying ? '<span class="variant-playing-badge">Playing</span>' : ''}
|
||||
</button>`;
|
||||
}).join('') : '';
|
||||
const variantCount = Number(channel.variantCount) || channel.variants.length;
|
||||
const hasMoreVariants = expanded && variantState?.page?.hasMore;
|
||||
}).join('');
|
||||
const channelLanguage = channel.language || { code: 'und', short: 'UND', label: 'Unknown' };
|
||||
const isUnknownLanguage = ['und', 'unknown'].includes(channelLanguage.code);
|
||||
const languageBadge = isUnknownLanguage ? '?' : (channelLanguage.short || channelLanguage.code.toUpperCase());
|
||||
@ -928,36 +861,13 @@ class ChannelList {
|
||||
<span class="channel-language-badge" aria-label="${this.escapeHtml(languageAccessibleName)}" title="${this.escapeHtml(languageAccessibleName)}">${this.escapeHtml(languageBadge)}</span></div>
|
||||
<div class="channel-info"><div class="channel-name">${this.escapeHtml(channel.displayName)}</div>
|
||||
<div class="channel-program">${this.escapeHtml(programLabel)}</div></div>
|
||||
${variantCount > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" aria-pressed="${expanded}" title="Show streams"><span>${variantCount} streams</span><span class="variant-chevron">⌄</span></button>` : ''}
|
||||
${channel.variants.length > 1 ? `<button class="variant-toggle" type="button" aria-expanded="${expanded}" aria-pressed="${expanded}" title="Show streams"><span>${channel.variants.length} streams</span><span class="variant-chevron">⌄</span></button>` : ''}
|
||||
<button class="favorite-btn ${channel.isFavorite ? 'active' : ''}" title="${selectedIsFavorite ? 'Remove selected stream from Favorites' : 'Add selected stream to Favorites'}">${channel.isFavorite ? Icons.favorite : Icons.favoriteOutline}</button>
|
||||
</div>
|
||||
${variantCount > 1 && expanded ? `<div class="channel-variants">${variants}${hasMoreVariants ? `<button type="button" class="btn btn-ghost load-more-variants" data-logical-id="${this.escapeHtml(channel.fingerprint)}">Load more streams</button>` : ''}</div>` : ''}
|
||||
${channel.variants.length > 1 ? `<div class="channel-variants" ${expanded ? '' : 'hidden'}>${variants}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async loadChannelVariants(logicalId, append = false) {
|
||||
const channel = this.projectedLogicalChannels?.find(item => item.fingerprint === logicalId);
|
||||
if (!channel || !API.live.getChannelVariants) return;
|
||||
this.variantPages ||= new Map();
|
||||
const current = this.variantPages.get(logicalId);
|
||||
if (!append && current?.loaded) return;
|
||||
const offset = append ? (current?.page?.nextOffset || 0) : 0;
|
||||
const response = await API.live.getChannelVariants(channel.logicalChannelId || logicalId, {
|
||||
includeHidden: Boolean(this.showHiddenCheckbox?.checked),
|
||||
limit: 100,
|
||||
offset
|
||||
});
|
||||
const rawVariants = append ? [...(current?.rawVariants || []), ...(response.variants || [])] : (response.variants || []);
|
||||
const mapped = LiveChannelProjection.mapResponse({
|
||||
channels: [{ ...channel, variants: rawVariants }]
|
||||
}, this.sources).logicalChannels?.[0];
|
||||
if (!mapped) return;
|
||||
channel.variants = mapped.variants;
|
||||
channel.visibleVariants = mapped.visibleVariants;
|
||||
channel.preferredVariant = mapped.preferredVariant;
|
||||
this.variantPages.set(logicalId, { loaded: true, rawVariants, page: response.page || null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render channels for a specific group (called when expanding a collapsed group)
|
||||
*/
|
||||
@ -1035,7 +945,7 @@ class ChannelList {
|
||||
/**
|
||||
* Load channels from selected source
|
||||
*/
|
||||
async loadChannels() {
|
||||
async loadChannels({ refreshLanguages = true } = {}) {
|
||||
if (this.isLoading) return;
|
||||
this.isLoading = true;
|
||||
this.currentRenderId = null; // Reset render tracking
|
||||
@ -1052,7 +962,7 @@ class ChannelList {
|
||||
|
||||
if (!sourceValue) {
|
||||
// Load from all sources
|
||||
await this.loadAllChannels();
|
||||
await this.loadAllChannels({ refreshLanguages });
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
@ -1064,6 +974,7 @@ class ChannelList {
|
||||
|
||||
if (await this.loadProjectedChannels(parseInt(id, 10))) {
|
||||
this.render();
|
||||
if (refreshLanguages) void this.refreshLayaLanguages();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1080,6 +991,7 @@ class ChannelList {
|
||||
]);
|
||||
|
||||
this.render();
|
||||
if (refreshLanguages) void this.refreshLayaLanguages();
|
||||
} catch (err) {
|
||||
console.error('Error loading channels:', err);
|
||||
if (previousState.channels.length > 0) {
|
||||
@ -1099,7 +1011,7 @@ class ChannelList {
|
||||
/**
|
||||
* Load channels from all enabled sources
|
||||
*/
|
||||
async loadAllChannels() {
|
||||
async loadAllChannels({ refreshLanguages = true } = {}) {
|
||||
const previousState = {
|
||||
channels: this.channels,
|
||||
groups: this.groups,
|
||||
@ -1114,6 +1026,7 @@ class ChannelList {
|
||||
|
||||
if (await this.loadProjectedChannels()) {
|
||||
this.render();
|
||||
if (refreshLanguages) void this.refreshLayaLanguages();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1134,6 +1047,7 @@ class ChannelList {
|
||||
this.loadFavorites()
|
||||
]);
|
||||
this.render();
|
||||
if (refreshLanguages) void this.refreshLayaLanguages();
|
||||
} catch (err) {
|
||||
console.error('Error loading all channels:', err);
|
||||
if (previousState.channels.length > 0) {
|
||||
@ -1152,11 +1066,9 @@ class ChannelList {
|
||||
try {
|
||||
const response = await API.live.getChannels({
|
||||
sourceId,
|
||||
includeHidden: Boolean(this.showHiddenCheckbox?.checked),
|
||||
q: this.searchInput?.value || '',
|
||||
languages: [...this.selectedLanguages],
|
||||
limit: this.projectionLimit,
|
||||
offset: 0
|
||||
includeHidden: true,
|
||||
q: '',
|
||||
languages: []
|
||||
});
|
||||
if (!LiveChannelProjection.canUse(response)) {
|
||||
this.projectedLogicalChannels = null;
|
||||
@ -1166,9 +1078,6 @@ class ChannelList {
|
||||
const mapped = LiveChannelProjection.mapResponse(response, this.sources);
|
||||
this.channels = mapped.channels;
|
||||
this.projectedLogicalChannels = mapped.logicalChannels;
|
||||
this.projectionPage = response.page || null;
|
||||
this.variantPages ||= new Map();
|
||||
this.variantPages.clear();
|
||||
this.availableLanguages = mapped.availableLanguages;
|
||||
this.reconcileLanguageSelection();
|
||||
this.renderLanguageFilter();
|
||||
@ -1178,7 +1087,6 @@ class ChannelList {
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.projectedLogicalChannels = null;
|
||||
this.projectionPage = null;
|
||||
console.warn('[ChannelList] Live projection unavailable; using legacy loading:', err.message);
|
||||
this.projectionNotice = '';
|
||||
return false;
|
||||
@ -1215,9 +1123,8 @@ class ChannelList {
|
||||
sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages]));
|
||||
} catch {}
|
||||
this.renderLanguageFilter();
|
||||
if (this.projectionPage) this.loadChannels();
|
||||
else if (this.projectedLogicalChannels || this.channels.length) this.render();
|
||||
else this.loadChannels();
|
||||
if (this.projectedLogicalChannels || this.channels.length) this.render();
|
||||
else this.loadChannels({ refreshLanguages: false });
|
||||
}
|
||||
|
||||
closeLanguagePanel() {
|
||||
|
||||
@ -36,7 +36,6 @@ class VideoPlayer {
|
||||
this.originalStreamUrl = null;
|
||||
this.sourceStreamInfo = null;
|
||||
this.currentLogicalChannel = null;
|
||||
this.castService = null;
|
||||
|
||||
// Settings - start with defaults, load from server async
|
||||
this.settings = this.getDefaultSettings();
|
||||
@ -364,7 +363,6 @@ class VideoPlayer {
|
||||
e.stopPropagation();
|
||||
this.togglePictureInPicture();
|
||||
});
|
||||
this.initCastControl();
|
||||
|
||||
// Overflow Menu
|
||||
const btnOverflow = document.getElementById('btn-overflow');
|
||||
@ -448,67 +446,6 @@ class VideoPlayer {
|
||||
updateVolumeUI();
|
||||
}
|
||||
|
||||
initCastControl() {
|
||||
const button = document.getElementById('player-cast');
|
||||
const status = document.getElementById('player-cast-status');
|
||||
if (!button || !this.video || typeof CastService === 'undefined') {
|
||||
if (button) button.hidden = true;
|
||||
if (status) status.textContent = 'Not supported on this browser';
|
||||
return;
|
||||
}
|
||||
this.castService = new CastService(this.video);
|
||||
const serverAvailable = typeof ServerCastDevicePicker !== 'undefined'
|
||||
&& typeof API !== 'undefined'
|
||||
&& Boolean(API.cast);
|
||||
this.serverCastPicker = serverAvailable ? new ServerCastDevicePicker({
|
||||
document,
|
||||
api: API.cast,
|
||||
nativeCastService: this.castService,
|
||||
mediaUrlProvider: () => this.getServerCastMediaPath(),
|
||||
onState: state => {
|
||||
if (status && state.status === 'connected') status.textContent = 'Playing on the selected device.';
|
||||
else if (status && state.status === 'error') status.textContent = state.error || 'Could not connect to a playback device.';
|
||||
}
|
||||
}) : null;
|
||||
this.castService.subscribe(state => {
|
||||
const unsupported = state.type === 'unsupported';
|
||||
button.hidden = unsupported && !serverAvailable;
|
||||
button.disabled = serverAvailable ? false : (!state.available || state.status === 'connecting');
|
||||
button.setAttribute('aria-pressed', String(state.status === 'connected'));
|
||||
if (status) status.textContent = unsupported ? 'Not supported on this browser'
|
||||
: state.status === 'connected' ? 'Playing on a device'
|
||||
: state.status === 'connecting' ? 'Opening device picker…'
|
||||
: state.available ? 'Playback device available' : 'No playback devices available';
|
||||
});
|
||||
button.addEventListener('click', async event => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
if (this.serverCastPicker) await this.serverCastPicker.open();
|
||||
else await this.castService.prompt();
|
||||
} catch (error) {
|
||||
if (status) status.textContent = 'Could not connect to a playback device.';
|
||||
if (error?.name !== 'NotAllowedError' && error?.name !== 'AbortError') {
|
||||
console.warn('[Player] Device playback picker failed:', error?.message || error);
|
||||
}
|
||||
}
|
||||
});
|
||||
void this.castService.watchAvailability();
|
||||
}
|
||||
|
||||
getServerCastMediaPath() {
|
||||
const value = String(this.currentUrl || '');
|
||||
if (!value) return null;
|
||||
let path = value;
|
||||
try {
|
||||
const url = new URL(value, window.location.origin);
|
||||
if (url.origin !== window.location.origin || url.search) return null;
|
||||
path = url.pathname;
|
||||
} catch { return null; }
|
||||
const castable = /^\/api\/transcode\/[A-Za-z0-9_-]{1,128}\/stream\.m3u8$/.test(path)
|
||||
|| /^\/api\/proxy\/xtream\/\d+\/stream\/[A-Za-z0-9._-]{1,128}\/(?:live|movie|series)$/.test(path);
|
||||
return castable ? path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the live quality control to the existing player overflow menu.
|
||||
*/
|
||||
|
||||
@ -107,12 +107,7 @@ class SettingsPage {
|
||||
}
|
||||
|
||||
initLiveAnalysis() {
|
||||
document.getElementById('live-check-new-button')?.addEventListener('click', () => void this.runLiveAnalysis('check-new'));
|
||||
document.getElementById('live-rebuild-catalog-button')?.addEventListener('click', async () => {
|
||||
if (await this.confirmFullRefresh()) void this.runLiveAnalysis('rebuild');
|
||||
});
|
||||
// Compatibility with pages cached before the actions were split.
|
||||
document.getElementById('live-analysis-button')?.addEventListener('click', () => void this.runLiveAnalysis('rebuild'));
|
||||
document.getElementById('live-analysis-button')?.addEventListener('click', () => void this.runLiveAnalysis());
|
||||
document.getElementById('live-analysis-details-toggle')?.addEventListener('click', () => {
|
||||
const button = document.getElementById('live-analysis-details-toggle');
|
||||
const details = document.getElementById('live-analysis-error-details');
|
||||
@ -136,11 +131,7 @@ class SettingsPage {
|
||||
}
|
||||
|
||||
renderLiveAnalysisStatus(status = {}) {
|
||||
const buttons = [
|
||||
document.getElementById('live-check-new-button'),
|
||||
document.getElementById('live-rebuild-catalog-button'),
|
||||
document.getElementById('live-analysis-button')
|
||||
].filter(Boolean);
|
||||
const button = document.getElementById('live-analysis-button');
|
||||
const statusElement = document.getElementById('live-analysis-status');
|
||||
const progress = document.getElementById('live-analysis-progress');
|
||||
const track = document.getElementById('live-analysis-progress-track');
|
||||
@ -149,18 +140,11 @@ class SettingsPage {
|
||||
const fallback = document.getElementById('live-analysis-fallback');
|
||||
const detailsButton = document.getElementById('live-analysis-details-toggle');
|
||||
const details = document.getElementById('live-analysis-error-details');
|
||||
if (!buttons.length || !statusElement) return;
|
||||
if (!button || !statusElement) return;
|
||||
|
||||
const presentation = LiveAnalysisPresentation.present(status);
|
||||
buttons.forEach(button => { button.disabled = presentation.busy; });
|
||||
const rebuildButton = document.getElementById('live-rebuild-catalog-button');
|
||||
const fullRefreshPhases = new Set([
|
||||
'refreshing_live_sources', 'refreshing_epg_sources', 'matching_epg',
|
||||
'laya_epg_matching', 'writing_epg_matches'
|
||||
]);
|
||||
const fullRefreshBusy = presentation.busy &&
|
||||
(this.currentLiveAnalysisAction === 'rebuild' || fullRefreshPhases.has(presentation.phase));
|
||||
if (rebuildButton) rebuildButton.textContent = fullRefreshBusy ? 'Refreshing…' : 'Run full refresh';
|
||||
button.textContent = presentation.buttonLabel;
|
||||
button.disabled = presentation.busy;
|
||||
statusElement.textContent = presentation.statusLabel;
|
||||
progress.hidden = !presentation.busy;
|
||||
track.classList.toggle('indeterminate', presentation.progressIndeterminate);
|
||||
@ -185,57 +169,22 @@ class SettingsPage {
|
||||
details.textContent = presentation.errorDetail;
|
||||
}
|
||||
|
||||
async runLiveAnalysis(action = 'rebuild') {
|
||||
const buttons = ['live-check-new-button', 'live-rebuild-catalog-button', 'live-analysis-button']
|
||||
.map(id => document.getElementById(id)).filter(Boolean);
|
||||
if (buttons.some(button => button.disabled)) return;
|
||||
this.currentLiveAnalysisAction = action;
|
||||
async runLiveAnalysis() {
|
||||
const button = document.getElementById('live-analysis-button');
|
||||
if (button?.disabled) return;
|
||||
this.liveAnalysisStatus = { ...(this.liveAnalysisStatus || {}), status: 'queued', processed: 0 };
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
try {
|
||||
const response = action === 'check-new'
|
||||
? await API.live.checkNewStreams()
|
||||
: await API.live.rebuildProjection();
|
||||
this.liveAnalysisStatus = response.projection || (response.started
|
||||
? { status: 'queued', processed: 0 }
|
||||
: response);
|
||||
const response = await API.live.rebuildProjection();
|
||||
this.liveAnalysisStatus = response.projection || response;
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
this.startLiveAnalysisPolling();
|
||||
} catch (err) {
|
||||
this.liveAnalysisStatus = { status: 'error', error: err.message };
|
||||
this.currentLiveAnalysisAction = '';
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
}
|
||||
}
|
||||
|
||||
confirmFullRefresh() {
|
||||
const titleText = 'Run a full refresh?';
|
||||
const bodyText = 'Nodecast will refresh IPTV sources, re-analyze every channel and language, refresh EPG data, and rebuild all EPG matches. This may take several minutes.';
|
||||
const modal = document.getElementById('modal');
|
||||
const title = document.getElementById('modal-title');
|
||||
const body = document.getElementById('modal-body');
|
||||
const footer = document.getElementById('modal-footer');
|
||||
const close = modal?.querySelector('.modal-close');
|
||||
if (!modal || !title || !body || !footer || !close) {
|
||||
return Promise.resolve(confirm(`${titleText}\n\n${bodyText}`));
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
title.textContent = titleText;
|
||||
body.textContent = bodyText;
|
||||
footer.innerHTML = '<button type="button" class="btn btn-secondary" id="full-refresh-cancel">Cancel</button>' +
|
||||
'<button type="button" class="btn btn-primary" id="full-refresh-confirm">Start full refresh</button>';
|
||||
modal.classList.add('active');
|
||||
const finish = confirmed => {
|
||||
modal.classList.remove('active');
|
||||
close.onclick = null;
|
||||
resolve(confirmed);
|
||||
};
|
||||
close.onclick = () => finish(false);
|
||||
document.getElementById('full-refresh-cancel').onclick = () => finish(false);
|
||||
document.getElementById('full-refresh-confirm').onclick = () => finish(true);
|
||||
});
|
||||
}
|
||||
|
||||
startLiveAnalysisPolling() {
|
||||
if (this.liveAnalysisPollTimer) return;
|
||||
this.liveAnalysisPollTimer = setInterval(async () => {
|
||||
@ -248,8 +197,30 @@ class SettingsPage {
|
||||
clearInterval(this.liveAnalysisPollTimer);
|
||||
this.liveAnalysisPollTimer = null;
|
||||
if (presentation.complete) {
|
||||
await this.app.channelList?.loadChannels();
|
||||
this.currentLiveAnalysisAction = '';
|
||||
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
|
||||
await this.app.channelList?.loadChannels({
|
||||
refreshLanguages: mode !== 'laya' && mode !== 'hybrid'
|
||||
});
|
||||
if (mode === 'laya' || mode === 'hybrid') {
|
||||
this.liveAnalysisStatus = {
|
||||
status: 'building',
|
||||
phase: 'laya_language',
|
||||
languageProcessed: null,
|
||||
languageTotal: null
|
||||
};
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
await this.app.channelList?.refreshLayaLanguages(mode, {
|
||||
onProgress: ({ processed, total }) => {
|
||||
this.liveAnalysisStatus = {
|
||||
status: 'building',
|
||||
phase: 'laya_language',
|
||||
languageProcessed: processed,
|
||||
languageTotal: total
|
||||
};
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.liveAnalysisStatus = status;
|
||||
this.renderLiveAnalysisStatus(status);
|
||||
}
|
||||
@ -301,6 +272,11 @@ class SettingsPage {
|
||||
this.layaStatus = { runtimeAvailable: false, modelPresent: false, error: err.message };
|
||||
}
|
||||
this.renderLayaStatus(this.layaStatus);
|
||||
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
|
||||
if (mode !== 'algo' && this.layaStatus?.modelPresent && this.layaStatus?.runtimeAvailable &&
|
||||
this.app.channelList?.languageDetectionNotice) {
|
||||
void this.app.channelList.refreshLayaLanguages(mode);
|
||||
}
|
||||
if (this.layaStatus?.downloading) this.startLayaStatusPolling();
|
||||
return this.layaStatus;
|
||||
}
|
||||
|
||||
@ -35,7 +35,6 @@ class WatchPage {
|
||||
this.timeTotal = document.getElementById('watch-time-total');
|
||||
this.scrollHint = document.getElementById('watch-scroll-hint');
|
||||
this.loadingSpinner = document.getElementById('watch-loading');
|
||||
this.castService = null;
|
||||
|
||||
// Next episode
|
||||
this.nextEpisodePanel = document.getElementById('watch-next-episode');
|
||||
@ -154,7 +153,6 @@ class WatchPage {
|
||||
// Picture-in-Picture
|
||||
const pipBtn = document.getElementById('watch-pip');
|
||||
pipBtn?.addEventListener('click', () => this.togglePictureInPicture());
|
||||
this.initCastControl();
|
||||
|
||||
// Overflow Menu
|
||||
const overflowBtn = document.getElementById('watch-overflow');
|
||||
@ -242,67 +240,6 @@ class WatchPage {
|
||||
});
|
||||
}
|
||||
|
||||
initCastControl() {
|
||||
const button = document.getElementById('watch-cast');
|
||||
const status = document.getElementById('watch-cast-status');
|
||||
if (!button || !this.video || typeof CastService === 'undefined') {
|
||||
if (button) button.hidden = true;
|
||||
if (status) status.textContent = 'Not supported on this browser';
|
||||
return;
|
||||
}
|
||||
this.castService = new CastService(this.video);
|
||||
const serverAvailable = typeof ServerCastDevicePicker !== 'undefined'
|
||||
&& typeof API !== 'undefined'
|
||||
&& Boolean(API.cast);
|
||||
this.serverCastPicker = serverAvailable ? new ServerCastDevicePicker({
|
||||
document,
|
||||
api: API.cast,
|
||||
nativeCastService: this.castService,
|
||||
mediaUrlProvider: () => this.getServerCastMediaPath(),
|
||||
onState: state => {
|
||||
if (status && state.status === 'connected') status.textContent = 'Playing on the selected device.';
|
||||
else if (status && state.status === 'error') status.textContent = state.error || 'Could not connect to a playback device.';
|
||||
}
|
||||
}) : null;
|
||||
this.castService.subscribe(state => {
|
||||
const unsupported = state.type === 'unsupported';
|
||||
button.hidden = unsupported && !serverAvailable;
|
||||
button.disabled = serverAvailable ? false : (!state.available || state.status === 'connecting');
|
||||
button.setAttribute('aria-pressed', String(state.status === 'connected'));
|
||||
if (status) status.textContent = unsupported ? 'Not supported on this browser'
|
||||
: state.status === 'connected' ? 'Playing on a device'
|
||||
: state.status === 'connecting' ? 'Opening device picker…'
|
||||
: state.available ? 'Playback device available' : 'No playback devices available';
|
||||
});
|
||||
button.addEventListener('click', async event => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
if (this.serverCastPicker) await this.serverCastPicker.open();
|
||||
else await this.castService.prompt();
|
||||
} catch (error) {
|
||||
if (status) status.textContent = 'Could not connect to a playback device.';
|
||||
if (error?.name !== 'NotAllowedError' && error?.name !== 'AbortError') {
|
||||
console.warn('[Watch] Device playback picker failed:', error?.message || error);
|
||||
}
|
||||
}
|
||||
});
|
||||
void this.castService.watchAvailability();
|
||||
}
|
||||
|
||||
getServerCastMediaPath() {
|
||||
const value = String(this.currentUrl || '');
|
||||
if (!value) return null;
|
||||
let path = value;
|
||||
try {
|
||||
const url = new URL(value, window.location.origin);
|
||||
if (url.origin !== window.location.origin || url.search) return null;
|
||||
path = url.pathname;
|
||||
} catch { return null; }
|
||||
const castable = /^\/api\/transcode\/[A-Za-z0-9_-]{1,128}\/stream\.m3u8$/.test(path)
|
||||
|| /^\/api\/proxy\/xtream\/\d+\/stream\/[A-Za-z0-9._-]{1,128}\/(?:live|movie|series)$/.test(path);
|
||||
return castable ? path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point - play content
|
||||
* @param {Object} content - Movie or episode info
|
||||
|
||||
@ -1,108 +0,0 @@
|
||||
(function (root, factory) {
|
||||
const CastService = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = CastService;
|
||||
if (root) root.CastService = CastService;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
class CastService {
|
||||
constructor(video) {
|
||||
this.video = video || null;
|
||||
this.listeners = new Set();
|
||||
this.availabilityWatchId = null;
|
||||
this.state = { status: 'unsupported', available: false, type: 'unsupported', error: '' };
|
||||
this.handleRemoteConnect = () => this.setState({ status: 'connected', available: true });
|
||||
this.handleRemoteDisconnect = () => this.setState({ status: 'available', available: true });
|
||||
this.handleAirPlayAvailability = event => this.setState({
|
||||
status: event?.availability === 'available' ? 'available' : 'unavailable',
|
||||
available: event?.availability === 'available',
|
||||
type: 'airplay'
|
||||
});
|
||||
this.handleAirPlayState = () => this.setState({
|
||||
status: this.video?.webkitCurrentPlaybackTargetIsWireless ? 'connected' : 'available',
|
||||
available: true,
|
||||
type: 'airplay'
|
||||
});
|
||||
this.attachStateEvents();
|
||||
}
|
||||
|
||||
attachStateEvents() {
|
||||
this.video?.remote?.addEventListener?.('connect', this.handleRemoteConnect);
|
||||
this.video?.remote?.addEventListener?.('disconnect', this.handleRemoteDisconnect);
|
||||
this.video?.addEventListener?.('webkitplaybacktargetavailabilitychanged', this.handleAirPlayAvailability);
|
||||
this.video?.addEventListener?.('webkitcurrentplaybacktargetiswirelesschanged', this.handleAirPlayState);
|
||||
}
|
||||
|
||||
setState(update) {
|
||||
this.state = { ...this.state, ...update };
|
||||
this.listeners.forEach(listener => listener({ ...this.state }));
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
this.listeners.add(listener);
|
||||
listener({ ...this.state });
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
async watchAvailability() {
|
||||
const remote = this.video?.remote;
|
||||
if (remote && typeof remote.watchAvailability === 'function') {
|
||||
this.setState({ status: 'unavailable', available: false, type: 'remote-playback', error: '' });
|
||||
try {
|
||||
this.availabilityWatchId = await remote.watchAvailability(available => {
|
||||
this.setState({
|
||||
status: available ? 'available' : 'unavailable',
|
||||
available: Boolean(available),
|
||||
type: 'remote-playback',
|
||||
error: ''
|
||||
});
|
||||
});
|
||||
return this.state;
|
||||
} catch (error) {
|
||||
if (typeof this.video?.webkitShowPlaybackTargetPicker !== 'function') {
|
||||
this.setState({ status: 'unavailable', available: false, type: 'remote-playback', error: error?.message || '' });
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof this.video?.webkitShowPlaybackTargetPicker === 'function') {
|
||||
this.setState({ status: 'available', available: true, type: 'airplay', error: '' });
|
||||
return this.state;
|
||||
}
|
||||
this.setState({ status: 'unsupported', available: false, type: 'unsupported', error: '' });
|
||||
return this.state;
|
||||
}
|
||||
|
||||
async prompt() {
|
||||
if (this.state.type === 'remote-playback' && typeof this.video?.remote?.prompt === 'function') {
|
||||
this.setState({ status: 'connecting', error: '' });
|
||||
try {
|
||||
await this.video.remote.prompt();
|
||||
const connected = this.video.remote.state === 'connected';
|
||||
this.setState({ status: connected ? 'connected' : (this.state.available ? 'available' : 'unavailable') });
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.setState({ status: this.state.available ? 'available' : 'unavailable', error: error?.message || 'Unable to open device picker.' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (this.state.type === 'airplay' && typeof this.video?.webkitShowPlaybackTargetPicker === 'function') {
|
||||
this.video.webkitShowPlaybackTargetPicker();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.availabilityWatchId !== null) {
|
||||
this.video?.remote?.cancelWatchAvailability?.(this.availabilityWatchId).catch?.(() => {});
|
||||
}
|
||||
this.video?.remote?.removeEventListener?.('connect', this.handleRemoteConnect);
|
||||
this.video?.remote?.removeEventListener?.('disconnect', this.handleRemoteDisconnect);
|
||||
this.video?.removeEventListener?.('webkitplaybacktargetavailabilitychanged', this.handleAirPlayAvailability);
|
||||
this.video?.removeEventListener?.('webkitcurrentplaybacktargetiswirelesschanged', this.handleAirPlayState);
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return CastService;
|
||||
});
|
||||
@ -5,9 +5,6 @@
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const BUSY_STATUSES = new Set(['queued', 'building', 'running']);
|
||||
const FAILED_STATUSES = new Set(['error', 'failed']);
|
||||
const INDETERMINATE_PHASES = new Set([
|
||||
'writing', 'refreshing_live_sources', 'refreshing_epg_sources', 'writing_epg_matches'
|
||||
]);
|
||||
|
||||
function progressValues(status, phase) {
|
||||
if (phase === 'laya_identity') {
|
||||
@ -36,7 +33,7 @@
|
||||
const progress = progressValues(status, phase);
|
||||
const processed = Number(progress.processed);
|
||||
const total = Number(progress.total);
|
||||
const determined = busy && !INDETERMINATE_PHASES.has(phase) &&
|
||||
const determined = busy && phase !== 'writing' &&
|
||||
progress.processed !== null && progress.processed !== undefined &&
|
||||
progress.total !== null && progress.total !== undefined &&
|
||||
Number.isFinite(processed) && Number.isFinite(total) && total > 0;
|
||||
@ -45,15 +42,7 @@
|
||||
: null;
|
||||
const previouslyCompleted = Boolean(status.completedAt) || Number(status.projectedCount) > 0;
|
||||
const statusLabel = busy
|
||||
? phase === 'refreshing_live_sources'
|
||||
? 'Refreshing IPTV sources…'
|
||||
: phase === 'refreshing_epg_sources'
|
||||
? 'Refreshing EPG data…'
|
||||
: ['matching_epg', 'laya_epg_matching'].includes(phase)
|
||||
? 'Matching channels to EPG data…'
|
||||
: phase === 'writing_epg_matches'
|
||||
? 'Finalizing…'
|
||||
: phase === 'laya_identity'
|
||||
? phase === 'laya_identity'
|
||||
? 'Analyzing with local AI…'
|
||||
: phase === 'laya_language'
|
||||
? 'Detecting stream languages…'
|
||||
@ -63,7 +52,7 @@
|
||||
? 'Saving channel catalog…'
|
||||
: 'Analyzing channels…'
|
||||
: complete ? 'Analysis complete' : failed ? 'Analysis failed' : 'Ready to analyze';
|
||||
const progressText = ['writing', 'writing_epg_matches'].includes(phase) && busy
|
||||
const progressText = phase === 'writing' && busy
|
||||
? 'Finalizing…'
|
||||
: progressPercent === null
|
||||
? (busy ? 'In progress…' : '')
|
||||
|
||||
@ -98,7 +98,7 @@
|
||||
visibleVariants: mappedVariants.filter(variant => !variant.isHidden),
|
||||
preferredVariant,
|
||||
language,
|
||||
selectedVariantId: logicalChannel.selectedVariantId || `${preferredVariant.sourceId}:${preferredVariant.id}`,
|
||||
selectedVariantId: `${preferredVariant.sourceId}:${preferredVariant.id}`,
|
||||
groupTitle: preferredVariant.groupTitle,
|
||||
tvgLogo: preferredVariant.tvgLogo,
|
||||
tvgId: logicalChannel.epg?.channelId || preferredVariant.tvgId,
|
||||
|
||||
@ -1,181 +0,0 @@
|
||||
(function (root, factory) {
|
||||
const exports = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = exports;
|
||||
if (root) root.ServerCastDevicePicker = exports.ServerCastDevicePicker;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
class ServerCastDevicePicker {
|
||||
constructor(options = {}) {
|
||||
this.document = options.document || (typeof document !== 'undefined' ? document : null);
|
||||
this.api = options.api;
|
||||
this.nativeCastService = options.nativeCastService || null;
|
||||
this.mediaUrlProvider = options.mediaUrlProvider || (() => '');
|
||||
this.onState = options.onState || (() => {});
|
||||
this.devices = [];
|
||||
this.query = '';
|
||||
this.ensureDialog();
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
ensureDialog() {
|
||||
if (!this.document) return;
|
||||
this.dialog = this.document.getElementById('cast-device-dialog');
|
||||
if (!this.dialog) {
|
||||
this.dialog = this.document.createElement('dialog');
|
||||
this.dialog.id = 'cast-device-dialog';
|
||||
this.dialog.className = 'cast-device-dialog';
|
||||
this.dialog.innerHTML = this.dialogContent();
|
||||
this.document.body.appendChild(this.dialog);
|
||||
}
|
||||
this.list = this.document.getElementById('cast-device-list');
|
||||
this.search = this.document.getElementById('cast-device-search');
|
||||
this.message = this.document.getElementById('cast-device-message');
|
||||
this.nativeButton = this.document.getElementById('cast-browser-picker');
|
||||
}
|
||||
|
||||
dialogContent() {
|
||||
return `<div class="cast-dialog-header"><h2>Play on a device</h2>
|
||||
<button type="button" class="btn btn-ghost" data-cast-action="close" aria-label="Close device picker">×</button></div>
|
||||
<p class="setting-hint">Choose a device connected to the same network as Nodecast.</p>
|
||||
<input id="cast-device-search" class="form-input" type="search" placeholder="Search devices…" aria-label="Search devices">
|
||||
<div id="cast-device-message" class="cast-device-message" role="status" aria-live="polite"></div>
|
||||
<div id="cast-device-list" class="cast-device-list"></div>
|
||||
<div class="cast-dialog-actions">
|
||||
<button type="button" class="btn btn-secondary" data-cast-action="refresh">Refresh devices</button>
|
||||
<button type="button" class="btn btn-secondary" id="cast-browser-picker">Use browser device picker</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
this.dialog?.querySelector('[data-cast-action="close"]')?.addEventListener('click', () => this.close());
|
||||
this.dialog?.querySelector('[data-cast-action="refresh"]')?.addEventListener('click', () => void this.loadDevices(true));
|
||||
this.search?.addEventListener('input', event => {
|
||||
this.query = event.target.value.toLowerCase().trim();
|
||||
this.renderDevices();
|
||||
});
|
||||
this.nativeButton?.addEventListener('click', async () => {
|
||||
try {
|
||||
await this.nativeCastService?.prompt?.();
|
||||
this.setMessage('Browser device picker opened.');
|
||||
} catch (error) {
|
||||
this.setMessage('The browser device picker could not be opened.', 'error');
|
||||
}
|
||||
});
|
||||
this.dialog?.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
async open() {
|
||||
if (!this.dialog) return;
|
||||
if (typeof this.dialog.showModal === 'function') this.dialog.showModal();
|
||||
else {
|
||||
this.dialog.hidden = false;
|
||||
this.dialog.setAttribute('open', '');
|
||||
}
|
||||
this.updateNativeAction();
|
||||
await this.loadDevices(false);
|
||||
this.search?.focus();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (typeof this.dialog?.close === 'function') this.dialog.close();
|
||||
else {
|
||||
this.dialog?.removeAttribute('open');
|
||||
if (this.dialog) this.dialog.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
updateNativeAction() {
|
||||
if (!this.nativeButton) return;
|
||||
const state = this.nativeCastService?.state;
|
||||
const supported = Boolean(state && (state.type !== 'unsupported' || state.available));
|
||||
this.nativeButton.hidden = !supported;
|
||||
}
|
||||
|
||||
async loadDevices(refresh) {
|
||||
this.setMessage(refresh ? 'Refreshing devices…' : 'Looking for devices…');
|
||||
try {
|
||||
const result = refresh ? await this.api.refreshDevices() : await this.api.getDevices();
|
||||
this.devices = Array.isArray(result) ? result : (result?.devices || []);
|
||||
this.renderDevices();
|
||||
this.setMessage(this.devices.length ? '' : 'No server-managed devices found.');
|
||||
} catch (error) {
|
||||
this.devices = [];
|
||||
this.renderDevices();
|
||||
const message = error?.message || 'Unable to discover devices.';
|
||||
this.setMessage(/base url|localhost|lan|nodecast/i.test(message)
|
||||
? `Nodecast is not reachable by devices: ${message}`
|
||||
: message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
renderDevices() {
|
||||
if (!this.list) return;
|
||||
const devices = this.devices.filter(device =>
|
||||
!this.query || `${device.name || ''} ${device.type || device.kind || ''}`.toLowerCase().includes(this.query)
|
||||
);
|
||||
this.list.innerHTML = devices.map(device => {
|
||||
const type = device.type || device.kind || 'device';
|
||||
const receiverRequired = type === 'fire_tv' && device.status === 'receiver_required';
|
||||
const connectable = !receiverRequired && device.ready !== false && ['google_cast', 'android_tv_cast'].includes(type);
|
||||
const status = receiverRequired
|
||||
? 'Receiver required'
|
||||
: connectable ? 'Ready' : (device.status || 'Unavailable');
|
||||
const explanation = receiverRequired
|
||||
? 'Install and configure a compatible receiver app on this Fire TV before connecting.'
|
||||
: '';
|
||||
return `<button type="button" class="cast-device-item" data-device-id="${this.escape(device.id)}" ${connectable ? '' : 'disabled'}>
|
||||
<span class="cast-device-name">${this.escape(device.name || 'Unnamed device')}</span>
|
||||
<span class="cast-device-status">${this.escape(status)}</span>
|
||||
${explanation ? `<span class="cast-device-explanation">${this.escape(explanation)}</span>` : ''}
|
||||
</button>`;
|
||||
}).join('') || '<p class="hint">No devices match your search.</p>';
|
||||
this.list.querySelectorAll('[data-device-id]:not(:disabled)').forEach(button => {
|
||||
button.addEventListener('click', () => void this.connect(button.dataset.deviceId));
|
||||
});
|
||||
}
|
||||
|
||||
async connect(deviceId) {
|
||||
const mediaPath = this.mediaUrlProvider();
|
||||
if (!this.isAllowedMediaPath(mediaPath)) {
|
||||
this.setMessage('This stream is not ready for server casting yet. Continue playback here or use the browser device picker.', 'error');
|
||||
return;
|
||||
}
|
||||
this.setMessage('Connecting…');
|
||||
this.onState({ status: 'connecting', deviceId });
|
||||
try {
|
||||
const session = await this.api.startSession({ deviceId, mediaPath });
|
||||
this.setMessage('Playing on the selected device.');
|
||||
this.onState({ status: 'connected', deviceId, session });
|
||||
this.close();
|
||||
} catch (error) {
|
||||
const message = error?.message || 'Unable to start device playback.';
|
||||
this.setMessage(/base url|localhost|lan|nodecast/i.test(message)
|
||||
? `Nodecast is not reachable by this device: ${message}`
|
||||
: message, 'error');
|
||||
this.onState({ status: 'error', deviceId, error: message });
|
||||
}
|
||||
}
|
||||
|
||||
isAllowedMediaPath(value) {
|
||||
if (typeof value !== 'string' || value.includes('?')) return false;
|
||||
return /^\/api\/transcode\/[A-Za-z0-9_-]{1,128}\/stream\.m3u8$/.test(value)
|
||||
|| /^\/api\/proxy\/xtream\/\d+\/stream\/[A-Za-z0-9._-]{1,128}\/(?:live|movie|series)$/.test(value);
|
||||
}
|
||||
|
||||
setMessage(message, state = '') {
|
||||
if (this.message) {
|
||||
this.message.textContent = message;
|
||||
this.message.dataset.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
escape(value) {
|
||||
return String(value ?? '').replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
|
||||
return { ServerCastDevicePicker };
|
||||
});
|
||||
@ -54,20 +54,6 @@ const LIVE_PROJECTION_SCHEMA_SQL = `
|
||||
item_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS live_epg_matches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
logical_channel_id TEXT NOT NULL,
|
||||
language_code TEXT NOT NULL,
|
||||
source_id INTEGER,
|
||||
channel_id TEXT,
|
||||
match_confidence REAL NOT NULL DEFAULT 0,
|
||||
data JSON,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE (logical_channel_id, language_code)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_live_epg_matches_channel
|
||||
ON live_epg_matches(logical_channel_id, language_code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS catalog_projection_state (
|
||||
projection TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
|
||||
@ -182,7 +182,6 @@ app.use('/api/settings', require('./routes/settings'));
|
||||
app.use('/api/history', require('./routes/history'));
|
||||
app.use('/api/laya', require('./routes/laya'));
|
||||
app.use('/api/live', require('./routes/live'));
|
||||
app.use('/api/cast', require('./routes/cast'));
|
||||
|
||||
// Version endpoint
|
||||
app.get('/api/version', (req, res) => {
|
||||
|
||||
@ -1,117 +0,0 @@
|
||||
const express = require('express');
|
||||
const { Readable } = require('stream');
|
||||
const { requireAuth } = require('../auth');
|
||||
const { createCastMediaUrlService } = require('../services/castMediaUrlService');
|
||||
const { createCastSessionService } = require('../services/castSessionService');
|
||||
|
||||
const router = express.Router();
|
||||
const publicBaseUrl = process.env.CAST_PUBLIC_BASE_URL;
|
||||
const mediaUrls = createCastMediaUrlService({
|
||||
publicBaseUrl: publicBaseUrl || 'http://127.0.0.1',
|
||||
secret: process.env.CAST_SIGNING_SECRET
|
||||
});
|
||||
const cast = createCastSessionService({ publicBaseUrl, mediaUrlSigner: mediaUrls });
|
||||
|
||||
function statusFor(error) {
|
||||
if (error.code === 'CAST_DEVICE_NOT_FOUND' || error.code === 'CAST_SESSION_NOT_FOUND') return 404;
|
||||
if (error.code === 'CAST_ADAPTER_UNAVAILABLE' || error.code === 'CAST_CONTROL_UNAVAILABLE' || error.code === 'CAST_CONFIG_INVALID') return 503;
|
||||
if (error.code?.startsWith('CAST_')) return 409;
|
||||
return 400;
|
||||
}
|
||||
|
||||
function validateCommandBody(action, body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Command body must be an object');
|
||||
const keys = Object.keys(body);
|
||||
if (action === 'seek') {
|
||||
if (keys.length !== 1 || keys[0] !== 'currentTime' || !Number.isFinite(body.currentTime) || body.currentTime < 0) {
|
||||
throw new Error('seek requires currentTime as a number greater than or equal to 0');
|
||||
}
|
||||
return { currentTime: body.currentTime };
|
||||
}
|
||||
if (action === 'volume') {
|
||||
if (keys.some(key => !['level', 'muted'].includes(key)) || !keys.length) throw new Error('volume accepts only level and muted');
|
||||
if (body.level !== undefined && (!Number.isFinite(body.level) || body.level < 0 || body.level > 1)) throw new Error('volume level must be between 0 and 1');
|
||||
if (body.muted !== undefined && typeof body.muted !== 'boolean') throw new Error('volume muted must be boolean');
|
||||
return { ...(body.level !== undefined ? { level: body.level } : {}), ...(body.muted !== undefined ? { muted: body.muted } : {}) };
|
||||
}
|
||||
if (keys.length) throw new Error(`${action} does not accept a request body`);
|
||||
return {};
|
||||
}
|
||||
|
||||
function validId(value) {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
}
|
||||
|
||||
// Signed media delivery intentionally precedes authentication: a Cast receiver has
|
||||
// no browser cookie. The short-lived opaque HMAC token is the authorization.
|
||||
router.get('/media/:id', async (req, res) => {
|
||||
try {
|
||||
const { path } = mediaUrls.validate(req.params.id, req.query.token);
|
||||
if (path.startsWith('/api/cast/')) return res.status(400).json({ error: 'Invalid cast media target' });
|
||||
const target = new URL(path, publicBaseUrl || `${req.protocol}://${req.get('host')}`);
|
||||
const headers = {};
|
||||
if (req.get('range')) headers.range = req.get('range');
|
||||
const response = await fetch(target, { headers, redirect: 'follow' });
|
||||
res.status(response.status);
|
||||
for (const header of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'cache-control']) {
|
||||
const value = response.headers.get(header);
|
||||
if (value) res.setHeader(header, value);
|
||||
}
|
||||
if (!response.body) return res.end();
|
||||
Readable.fromWeb(response.body).pipe(res);
|
||||
} catch (error) {
|
||||
console.warn('[Cast] Public media request failed:', error.message);
|
||||
return res.status(/token/i.test(error.message) ? 401 : 502).json({ error: 'Cast media is unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get('/devices', async (req, res) => {
|
||||
try { res.json({ devices: await cast.getDevices() }); }
|
||||
catch (error) { res.status(statusFor(error)).json({ error: error.message, code: error.code }); }
|
||||
});
|
||||
|
||||
router.post('/devices/refresh', async (req, res) => {
|
||||
try { res.json({ devices: await cast.refreshDevices() }); }
|
||||
catch (error) { res.status(statusFor(error)).json({ error: error.message, code: error.code }); }
|
||||
});
|
||||
|
||||
router.get('/sessions', (req, res) => res.json({ sessions: cast.listSessions() }));
|
||||
|
||||
router.post('/sessions', async (req, res) => {
|
||||
try { res.status(201).json(await cast.startSession(req.body)); }
|
||||
catch (error) { res.status(statusFor(error)).json({ error: error.message, code: error.code }); }
|
||||
});
|
||||
|
||||
router.get('/sessions/:id', (req, res) => {
|
||||
if (!validId(req.params.id)) return res.status(400).json({ error: 'Invalid session ID' });
|
||||
const session = cast.getSession(req.params.id);
|
||||
return session ? res.json(session) : res.status(404).json({ error: 'Cast session not found' });
|
||||
});
|
||||
|
||||
router.delete('/sessions/:id', async (req, res) => {
|
||||
try {
|
||||
if (!validId(req.params.id)) return res.status(400).json({ error: 'Invalid session ID' });
|
||||
const stopped = await cast.stopSession(req.params.id);
|
||||
return stopped ? res.status(204).end() : res.status(404).json({ error: 'Cast session not found' });
|
||||
} catch (error) { return res.status(statusFor(error)).json({ error: error.message, code: error.code }); }
|
||||
});
|
||||
|
||||
for (const action of ['play', 'pause', 'stop', 'seek', 'volume']) {
|
||||
router.post(`/sessions/:id/${action}`, async (req, res) => {
|
||||
try {
|
||||
if (!validId(req.params.id)) return res.status(400).json({ error: 'Invalid session ID' });
|
||||
const body = validateCommandBody(action, req.body || {});
|
||||
if (action === 'stop') {
|
||||
const stopped = await cast.stopSession(req.params.id);
|
||||
return stopped ? res.json({ success: true }) : res.status(404).json({ error: 'Cast session not found' });
|
||||
}
|
||||
res.json(await cast.command(req.params.id, action, body));
|
||||
}
|
||||
catch (error) { res.status(statusFor(error)).json({ error: error.message, code: error.code }); }
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports.validateCommandBody = validateCommandBody;
|
||||
@ -36,8 +36,6 @@ router.get('/channels', requireAuth, async (req, res) => {
|
||||
const includeHidden = req.query.includeHidden === 'true';
|
||||
const q = String(req.query.q || '').slice(0, 200);
|
||||
const languages = String(req.query.languages || '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
|
||||
const page = Number.parseInt(req.query.page, 10) || 1;
|
||||
const limit = Number.parseInt(req.query.limit, 10) || 100;
|
||||
if (languages.some(code => !/^(?:[a-z]{2,3}|und)$/.test(code))) {
|
||||
return res.status(400).json({ error: 'languages must be comma-separated language codes' });
|
||||
}
|
||||
@ -45,13 +43,13 @@ router.get('/channels', requireAuth, async (req, res) => {
|
||||
try {
|
||||
let state = liveProjection.projectionState();
|
||||
if (state.status !== 'success' && state.status !== 'building') {
|
||||
state = await liveProjection.startRebuild(null, { mode: 'algo' }).promise;
|
||||
state = await liveProjection.startRebuild().promise;
|
||||
}
|
||||
const epgSources = await sources.getByType('epg');
|
||||
const result = liveProjection.getChannelPage({
|
||||
sourceId, includeHidden, userId: req.user.id, epgSources, q, languages, page, limit
|
||||
const { channels, availableLanguages } = liveProjection.getChannelPage({
|
||||
sourceId, includeHidden, userId: req.user.id, epgSources, q, languages
|
||||
});
|
||||
return res.json({ projection: serializeState(state), filters: { availableLanguages: result.availableLanguages }, channels: result.channels, page: result.page });
|
||||
return res.json({ projection: serializeState(state), filters: { availableLanguages }, channels });
|
||||
} catch (error) {
|
||||
console.error('[LiveProjection] Failed to serve projected channels:', error.message);
|
||||
const state = liveProjection.projectionState();
|
||||
@ -63,24 +61,6 @@ router.get('/channels', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/channels/:logicalChannelId/variants', requireAuth, (req, res) => {
|
||||
try {
|
||||
const page = Number.parseInt(req.query.page, 10) || 1;
|
||||
const limit = Number.parseInt(req.query.limit, 10) || 100;
|
||||
const includeHidden = req.query.includeHidden === 'true';
|
||||
return res.json(liveProjection.getChannelVariants({
|
||||
logicalChannelId: req.params.logicalChannelId,
|
||||
includeHidden,
|
||||
userId: req.user.id,
|
||||
page,
|
||||
limit
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[LiveProjection] Failed to serve channel variants:', error.message);
|
||||
return res.status(500).json({ error: 'Unable to read channel variants' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/projection/status', requireAuth, (req, res) => {
|
||||
try {
|
||||
return res.json(serializeState(liveProjection.projectionState()));
|
||||
@ -91,13 +71,13 @@ router.get('/projection/status', requireAuth, (req, res) => {
|
||||
|
||||
router.post('/projection/rebuild', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const fullRebuild = liveProjection.fullRebuildOrchestrator();
|
||||
fullRebuild.catch(error => {
|
||||
const started = liveProjection.startRebuild();
|
||||
started.promise.catch(error => {
|
||||
console.error('[LiveProjection] Manual rebuild failed:', error.message);
|
||||
});
|
||||
return res.status(202).json({
|
||||
started: true,
|
||||
projection: serializeState(liveProjection.projectionState())
|
||||
alreadyRunning: started.alreadyRunning,
|
||||
projection: serializeState(started.state)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[LiveProjection] Unable to start manual rebuild:', error.message);
|
||||
@ -105,17 +85,6 @@ router.post('/projection/rebuild', requireAuth, requireAdmin, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/projection/check-new', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const check = liveProjection.checkNewStreams();
|
||||
check.catch(error => console.error('[LiveProjection] Incremental check failed:', error.message));
|
||||
return res.status(202).json({ started: true });
|
||||
} catch (error) {
|
||||
console.error('[LiveProjection] Unable to start incremental check:', error.message);
|
||||
return res.status(500).json({ error: 'Unable to start incremental Live analysis' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/projection/standards/refresh', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const result = await iptvOrgCatalog.refresh(getDb());
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
function encode(value) {
|
||||
return Buffer.from(value).toString('base64url');
|
||||
}
|
||||
|
||||
function timingSafeEqual(left, right) {
|
||||
const a = Buffer.from(left);
|
||||
const b = Buffer.from(right);
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function createCastMediaUrlService(options = {}) {
|
||||
const secret = String(options.secret || process.env.CAST_SIGNING_SECRET || '');
|
||||
const publicBaseUrl = String(options.publicBaseUrl || process.env.CAST_PUBLIC_BASE_URL || '').replace(/\/$/, '');
|
||||
const ttlMs = Number(options.ttlMs || 5 * 60 * 1000);
|
||||
const now = options.now || Date.now;
|
||||
const paths = new Map();
|
||||
|
||||
function requireSecret() {
|
||||
if (Buffer.byteLength(secret, 'utf8') < 32) {
|
||||
throw Object.assign(new Error('CAST_SIGNING_SECRET must contain at least 32 bytes'), { code: 'CAST_CONFIG_INVALID' });
|
||||
}
|
||||
}
|
||||
|
||||
function signature(payload) {
|
||||
requireSecret();
|
||||
return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
||||
}
|
||||
|
||||
function sign(path) {
|
||||
if (!path || !String(path).startsWith('/')) throw new Error('Cast media path must be absolute');
|
||||
if (!publicBaseUrl) throw new Error('CAST_PUBLIC_BASE_URL is required for cast media URLs');
|
||||
const id = crypto.randomBytes(18).toString('base64url');
|
||||
const exp = now() + ttlMs;
|
||||
const pathHash = crypto.createHash('sha256').update(String(path)).digest('base64url');
|
||||
const payload = encode(JSON.stringify({ id, exp, pathHash }));
|
||||
paths.set(id, { path: String(path), exp });
|
||||
const token = `${payload}.${signature(payload)}`;
|
||||
return `${publicBaseUrl}/api/cast/media/${id}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function validate(id, token) {
|
||||
requireSecret();
|
||||
if (!id || typeof id !== 'string') throw new Error('Invalid cast media token');
|
||||
if (!token || typeof token !== 'string') throw new Error('Invalid cast media token');
|
||||
const [payload, providedSignature, extra] = token.split('.');
|
||||
if (!payload || !providedSignature || extra || !timingSafeEqual(signature(payload), providedSignature)) {
|
||||
throw new Error('Invalid cast media token');
|
||||
}
|
||||
let claims;
|
||||
try { claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); }
|
||||
catch { throw new Error('Invalid cast media token'); }
|
||||
if (claims.id !== id) throw new Error('Invalid cast media token');
|
||||
if (!Number.isFinite(claims.exp) || now() > claims.exp) {
|
||||
paths.delete(claims.id);
|
||||
throw new Error('Expired cast media token');
|
||||
}
|
||||
const entry = paths.get(claims.id);
|
||||
if (!entry || entry.exp !== claims.exp) throw new Error('Invalid cast media token');
|
||||
const pathHash = crypto.createHash('sha256').update(entry.path).digest('base64url');
|
||||
if (!claims.pathHash || !timingSafeEqual(pathHash, claims.pathHash)) throw new Error('Invalid cast media token');
|
||||
return { path: entry.path };
|
||||
}
|
||||
|
||||
function revoke(idOrUrl) {
|
||||
const value = String(idOrUrl || '');
|
||||
let id = value;
|
||||
try { id = new URL(value, publicBaseUrl || 'http://invalid').pathname.split('/').filter(Boolean).at(-1); } catch { /* use raw id */ }
|
||||
return paths.delete(id);
|
||||
}
|
||||
|
||||
return { revoke, sign, validate };
|
||||
}
|
||||
|
||||
module.exports = { createCastMediaUrlService };
|
||||
@ -1,126 +0,0 @@
|
||||
const crypto = require('crypto');
|
||||
const { createGoogleCastAdapter } = require('./googleCastAdapter');
|
||||
const { createCastMediaUrlService } = require('./castMediaUrlService');
|
||||
|
||||
function validatePublicBaseUrl(value) {
|
||||
let url;
|
||||
try { url = new URL(value); } catch { throw new Error('A castable LAN public base URL is required'); }
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Cast public base URL must use HTTP or HTTPS');
|
||||
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (!host || host === 'localhost' || host === '127.0.0.1' || host === '::1') {
|
||||
throw new Error('Cast public base URL must use a LAN address, not localhost');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function assertExactKeys(value, allowed, label) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
||||
const unknown = Object.keys(value).find(key => !allowed.includes(key));
|
||||
if (unknown) throw new Error(`Unknown ${label} field: ${unknown}`);
|
||||
}
|
||||
|
||||
function validateMediaPath(value) {
|
||||
if (typeof value !== 'string' || !value || value.length > 512) throw new Error('mediaPath must be a string of at most 512 characters');
|
||||
if (/url\s*=\s*(?:https?%3a|https?:)/i.test(value) || value.startsWith('/api/proxy/stream')) {
|
||||
throw new Error('Raw upstream media URLs cannot be cast');
|
||||
}
|
||||
const path = value.split('?')[0];
|
||||
const safe = /^\/api\/transcode\/[A-Za-z0-9_-]{1,128}\/stream\.m3u8$/.test(path) ||
|
||||
/^\/api\/proxy\/xtream\/\d+\/stream\/[A-Za-z0-9._-]{1,128}\/(?:live|movie|series)$/.test(path);
|
||||
if (!safe || value.includes('?')) throw new Error('mediaPath is not an allowed cast media endpoint');
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateStartRequest(request) {
|
||||
assertExactKeys(request, ['deviceId', 'mediaPath', 'media'], 'session');
|
||||
if (typeof request.deviceId !== 'string' || !request.deviceId.trim() || request.deviceId.length > 128) {
|
||||
throw new Error('deviceId must be a non-empty string of at most 128 characters');
|
||||
}
|
||||
validateMediaPath(request.mediaPath);
|
||||
if (request.media !== undefined) {
|
||||
assertExactKeys(request.media, ['contentType', 'streamType', 'autoplay', 'currentTime', 'title'], 'media');
|
||||
if (request.media.contentType !== undefined && (typeof request.media.contentType !== 'string' || request.media.contentType.length > 128)) throw new Error('Invalid media contentType');
|
||||
if (request.media.streamType !== undefined && !['LIVE', 'BUFFERED', 'OTHER'].includes(request.media.streamType)) throw new Error('Invalid media streamType');
|
||||
if (request.media.autoplay !== undefined && typeof request.media.autoplay !== 'boolean') throw new Error('Invalid media autoplay');
|
||||
if (request.media.currentTime !== undefined && (!Number.isFinite(request.media.currentTime) || request.media.currentTime < 0)) throw new Error('Invalid media currentTime');
|
||||
if (request.media.title !== undefined && (typeof request.media.title !== 'string' || request.media.title.length > 200)) throw new Error('Invalid media title');
|
||||
}
|
||||
}
|
||||
|
||||
function createCastSessionService(options = {}) {
|
||||
const adapter = options.adapter || createGoogleCastAdapter();
|
||||
const publicBaseUrl = options.publicBaseUrl || process.env.CAST_PUBLIC_BASE_URL;
|
||||
const mediaUrlSigner = options.mediaUrlSigner || createCastMediaUrlService({
|
||||
publicBaseUrl: publicBaseUrl || 'http://127.0.0.1',
|
||||
secret: options.secret
|
||||
});
|
||||
const sessions = new Map();
|
||||
const discoveryTtlMs = Number(options.discoveryTtlMs || 15_000);
|
||||
const now = options.now || Date.now;
|
||||
let devices = [];
|
||||
let devicesRefreshedAt = 0;
|
||||
|
||||
async function refreshDevices() {
|
||||
devices = (await adapter.discover()).map(device => ({ ...device }));
|
||||
devicesRefreshedAt = now();
|
||||
return devices;
|
||||
}
|
||||
|
||||
async function getDevices() {
|
||||
if (!devices.length || now() - devicesRefreshedAt >= discoveryTtlMs) await refreshDevices();
|
||||
return devices;
|
||||
}
|
||||
|
||||
async function startSession(request = {}) {
|
||||
const base = validatePublicBaseUrl(publicBaseUrl);
|
||||
validateStartRequest(request);
|
||||
const available = await getDevices();
|
||||
const device = available.find(item => String(item.id) === String(request.deviceId));
|
||||
if (!device) throw Object.assign(new Error('Cast device not found'), { code: 'CAST_DEVICE_NOT_FOUND' });
|
||||
const type = device.type || device.kind;
|
||||
if (type === 'fire_tv' && device.status === 'receiver_required') {
|
||||
throw Object.assign(new Error('Fire TV requires a compatible receiver application'), { code: 'CAST_RECEIVER_REQUIRED' });
|
||||
}
|
||||
if (device.ready === false) throw Object.assign(new Error('Cast device is not ready'), { code: 'CAST_DEVICE_NOT_READY' });
|
||||
const signed = mediaUrlSigner.sign(String(request.mediaPath));
|
||||
const mediaUrl = new URL(signed, base).toString();
|
||||
const result = await adapter.startSession({ ...request, mediaUrl, device });
|
||||
const session = {
|
||||
id: result.sessionId || crypto.randomUUID(),
|
||||
sessionId: result.sessionId || null,
|
||||
deviceId: request.deviceId,
|
||||
mediaUrl,
|
||||
state: result.state || 'playing',
|
||||
...result
|
||||
};
|
||||
session.sessionId ||= session.id;
|
||||
sessions.set(session.sessionId, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
function getSession(id) { return sessions.get(String(id)) || null; }
|
||||
function listSessions() { return [...sessions.values()]; }
|
||||
|
||||
async function command(id, action, payload = {}) {
|
||||
const session = getSession(id);
|
||||
if (!session) throw Object.assign(new Error('Cast session not found'), { code: 'CAST_SESSION_NOT_FOUND' });
|
||||
if (typeof adapter.command !== 'function') throw Object.assign(new Error('Cast controls are unavailable'), { code: 'CAST_CONTROL_UNAVAILABLE' });
|
||||
const result = await adapter.command({ session, action, ...payload });
|
||||
Object.assign(session, result || {}, { state: result?.state || action });
|
||||
return session;
|
||||
}
|
||||
|
||||
async function stopSession(id) {
|
||||
const session = getSession(id);
|
||||
if (!session) return false;
|
||||
if (typeof adapter.stopSession === 'function') await adapter.stopSession(session);
|
||||
else if (typeof adapter.command === 'function') await adapter.command({ session, action: 'stop' });
|
||||
mediaUrlSigner.revoke?.(session.mediaUrl);
|
||||
sessions.delete(String(id));
|
||||
return true;
|
||||
}
|
||||
|
||||
return { command, getDevices, getSession, listSessions, refreshDevices, startSession, stopSession };
|
||||
}
|
||||
|
||||
module.exports = { createCastSessionService, validateMediaPath, validatePublicBaseUrl, validateStartRequest };
|
||||
@ -58,54 +58,4 @@ function findBestMatch(database, channel, epgSources, now = Date.now()) {
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildPersistedEpgMatches(database, epgSources, now = Date.now()) {
|
||||
const rows = database.prepare(`
|
||||
SELECT p.*, l.display_name, c.name AS category_name
|
||||
FROM playlist_items p
|
||||
JOIN logical_channels l ON l.id = p.logical_channel_id
|
||||
LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live' AND c.category_id = p.category_id
|
||||
WHERE p.type = 'live' AND p.logical_channel_id IS NOT NULL
|
||||
ORDER BY p.logical_channel_id, p.language_code, p.variant_rank
|
||||
`).all();
|
||||
const groups = new Map();
|
||||
for (const row of rows) {
|
||||
const languageCode = row.language_code || 'und';
|
||||
const key = `${row.logical_channel_id}\0${languageCode}`;
|
||||
if (!groups.has(key)) groups.set(key, {
|
||||
logicalChannelId: row.logical_channel_id,
|
||||
displayName: row.display_name,
|
||||
standardName: row.display_name,
|
||||
language: { code: languageCode },
|
||||
variants: []
|
||||
});
|
||||
let data = {};
|
||||
try { data = row.data ? JSON.parse(row.data) : {}; } catch {}
|
||||
groups.get(key).variants.push({
|
||||
name: row.name,
|
||||
tvgId: data.tvgId || data.tvg_id || null,
|
||||
epg_channel_id: data.epg_channel_id || null
|
||||
});
|
||||
}
|
||||
const replace = database.prepare(`
|
||||
INSERT INTO live_epg_matches
|
||||
(logical_channel_id, language_code, source_id, channel_id, match_confidence, data, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(logical_channel_id, language_code) DO UPDATE SET
|
||||
source_id = excluded.source_id, channel_id = excluded.channel_id,
|
||||
match_confidence = excluded.match_confidence, data = excluded.data,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
const persist = database.transaction(() => {
|
||||
database.prepare('DELETE FROM live_epg_matches').run();
|
||||
for (const channel of groups.values()) {
|
||||
const match = findBestMatch(database, channel, epgSources, now);
|
||||
replace.run(channel.logicalChannelId, channel.language.code,
|
||||
match?.sourceId ?? null, match?.channelId ?? null,
|
||||
match?.matchConfidence ?? 0, match ? JSON.stringify(match) : null, now);
|
||||
}
|
||||
});
|
||||
persist();
|
||||
return { matched: [...groups.values()].length };
|
||||
}
|
||||
|
||||
module.exports = { candidateScore, chooseBestMatch, findBestMatch, normalize, rebuildPersistedEpgMatches };
|
||||
module.exports = { candidateScore, chooseBestMatch, findBestMatch, normalize };
|
||||
|
||||
@ -1,131 +0,0 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
function defaultDependencies() {
|
||||
return { Bonjour: require('bonjour-service'), castClient: require('chromecast-client') };
|
||||
}
|
||||
|
||||
function firstAddress(service) {
|
||||
return (service.addresses || []).find(address => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(address)) ||
|
||||
service.addresses?.[0] || service.referer?.address || service.host || null;
|
||||
}
|
||||
|
||||
function normalizeDevice(service) {
|
||||
const txt = service.txt || {};
|
||||
const model = String(txt.md || txt.model || '').trim();
|
||||
const descriptor = `${model} ${service.name || ''}`.toLowerCase();
|
||||
const host = firstAddress(service);
|
||||
return {
|
||||
id: String(txt.id || service.fqdn || service.name || `${host}:${service.port || 8009}`),
|
||||
name: String(txt.fn || service.name || model || 'Google Cast device'),
|
||||
type: /android tv|google tv|chromecast built-in|bravia|shield/.test(descriptor) ? 'android_tv_cast' : 'google_cast',
|
||||
ready: Boolean(host),
|
||||
host,
|
||||
port: Number(service.port || 8009),
|
||||
model: model || null
|
||||
};
|
||||
}
|
||||
|
||||
function unwrap(result, castClient) {
|
||||
if (result && typeof result.unwrapAndThrow === 'function') return result.unwrapAndThrow();
|
||||
if (castClient.Result?.unwrapAndThrow) return castClient.Result.unwrapAndThrow(result);
|
||||
if (result && result.isOk === false) throw result.value;
|
||||
if (result && result.isOk === true) return result.value;
|
||||
return result;
|
||||
}
|
||||
|
||||
function createGoogleCastAdapter(options = {}) {
|
||||
let dependencies;
|
||||
try { dependencies = options.dependencies || defaultDependencies(); }
|
||||
catch (error) {
|
||||
return {
|
||||
async discover() { return []; },
|
||||
async startSession() {
|
||||
throw Object.assign(new Error(`Google Cast support is unavailable: ${error.message}`), { code: 'CAST_ADAPTER_UNAVAILABLE' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const { Bonjour, castClient } = dependencies;
|
||||
const discoveryTimeoutMs = Number(options.discoveryTimeoutMs || 1500);
|
||||
const connectTimeoutMs = Number(options.connectTimeoutMs || 8000);
|
||||
const sessions = new Map();
|
||||
|
||||
async function discover() {
|
||||
const bonjour = new Bonjour(undefined, () => {});
|
||||
const found = new Map();
|
||||
let browser;
|
||||
let timer;
|
||||
try {
|
||||
await new Promise(resolve => {
|
||||
timer = setTimeout(resolve, discoveryTimeoutMs);
|
||||
browser = bonjour.find({ type: 'googlecast', protocol: 'tcp' }, service => {
|
||||
const device = normalizeDevice(service);
|
||||
if (device.host) found.set(device.id, device);
|
||||
});
|
||||
browser?.on?.('error', resolve);
|
||||
});
|
||||
return [...found.values()].sort((left, right) => left.name.localeCompare(right.name));
|
||||
} catch (error) {
|
||||
throw Object.assign(new Error(`Google Cast discovery failed: ${error.message}`), { code: 'CAST_DISCOVERY_FAILED' });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
try { browser?.stop?.(); } catch { /* best effort */ }
|
||||
try { bonjour.destroy(); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function startSession({ device, mediaUrl, media = {} }) {
|
||||
if (!device?.host) throw Object.assign(new Error('Google Cast device has no reachable host'), { code: 'CAST_DEVICE_UNREACHABLE' });
|
||||
let client;
|
||||
let controller;
|
||||
try {
|
||||
client = await castClient.connect({ host: device.host, port: device.port || 8009, timeout: connectTimeoutMs });
|
||||
controller = unwrap(await castClient.DefaultMediaApp.launchAndJoin({ client }), castClient);
|
||||
const status = unwrap(await controller.load({
|
||||
media: {
|
||||
contentId: mediaUrl,
|
||||
contentType: media.contentType || 'application/vnd.apple.mpegurl',
|
||||
streamType: media.streamType || 'LIVE',
|
||||
metadata: media.metadata || { metadataType: 0, title: media.title || 'Nodecast TV' }
|
||||
},
|
||||
autoplay: media.autoplay !== false,
|
||||
currentTime: Number(media.currentTime || 0)
|
||||
}), castClient);
|
||||
const sessionId = crypto.randomUUID();
|
||||
sessions.set(sessionId, { client, controller, device });
|
||||
return { sessionId, state: String(status?.playerState || 'PLAYING').toLowerCase(), currentTime: Number(status?.currentTime || 0) };
|
||||
} catch (error) {
|
||||
try { controller?.dispose?.(); } catch { /* best effort */ }
|
||||
try { client?.close?.(); } catch { /* best effort */ }
|
||||
throw Object.assign(new Error(`Unable to start Google Cast playback: ${error.message}`), { code: 'CAST_START_FAILED' });
|
||||
}
|
||||
}
|
||||
|
||||
async function command({ session, action, ...payload }) {
|
||||
const active = sessions.get(session.sessionId || session.id);
|
||||
if (!active) throw Object.assign(new Error('Google Cast connection is no longer active'), { code: 'CAST_SESSION_DISCONNECTED' });
|
||||
if (action === 'volume') throw Object.assign(new Error('Volume control is unavailable for this Cast adapter'), { code: 'CAST_CONTROL_UNAVAILABLE' });
|
||||
const method = active.controller[action];
|
||||
if (typeof method !== 'function') throw Object.assign(new Error(`Unsupported Cast command: ${action}`), { code: 'CAST_CONTROL_UNAVAILABLE' });
|
||||
const argument = action === 'seek'
|
||||
? (payload.currentTime !== undefined ? { currentTime: Number(payload.currentTime) } : { relativeTime: Number(payload.relativeTime || 0) })
|
||||
: undefined;
|
||||
const status = unwrap(await (argument ? method(argument) : method()), castClient);
|
||||
return { state: String(status?.playerState || action).toLowerCase(), currentTime: Number(status?.currentTime || 0) };
|
||||
}
|
||||
|
||||
async function stopSession(session) {
|
||||
const id = session.sessionId || session.id;
|
||||
const active = sessions.get(id);
|
||||
if (!active) return;
|
||||
try { unwrap(await active.controller.stop(), castClient); } finally {
|
||||
try { active.controller.dispose?.(); } catch { /* best effort */ }
|
||||
try { active.client.close?.(); } catch { /* best effort */ }
|
||||
sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
return { command, discover, startSession, stopSession };
|
||||
}
|
||||
|
||||
module.exports = { createGoogleCastAdapter, normalizeDevice };
|
||||
@ -6,8 +6,6 @@ const iptvOrgCatalog = require('./iptvOrgCatalogService');
|
||||
const epgMatching = require('./epgMatchingService');
|
||||
|
||||
const MAX_SYNCHRONOUS_ENRICHMENTS = 250;
|
||||
const DEFAULT_PAGE_LIMIT = 100;
|
||||
const MAX_PAGE_LIMIT = 200;
|
||||
|
||||
const QUALITY_TOKENS = new Set([
|
||||
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
|
||||
@ -71,22 +69,6 @@ function stableHash(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function languageInputHash(item) {
|
||||
return stableHash(`${item.name || ''}\0${item.category_name || ''}\0${item.tvg_id || ''}`);
|
||||
}
|
||||
|
||||
function hasPersistedLanguage(item) {
|
||||
if (!/^[a-z]{2,3}$/i.test(item.language_code || '') || String(item.language_code).toLowerCase() === 'und') return false;
|
||||
if (!item.language_detector || !item.language_input_hash) return false;
|
||||
const persistedHash = String(item.language_input_hash);
|
||||
return !/^[a-f0-9]{64}$/i.test(persistedHash) || persistedHash === languageInputHash(item);
|
||||
}
|
||||
|
||||
function parseNullableData(value) {
|
||||
if (value == null) return null;
|
||||
return parseData(value);
|
||||
}
|
||||
|
||||
function projectCatalog(items, options = {}) {
|
||||
const candidateBuckets = new Map();
|
||||
for (const item of items) {
|
||||
@ -100,22 +82,11 @@ function projectCatalog(items, options = {}) {
|
||||
qualityLabel: quality.label,
|
||||
qualityRank: quality.rank
|
||||
};
|
||||
if (hasPersistedLanguage(item)) {
|
||||
projected.languageCode = item.language_code;
|
||||
projected.languageConfidence = item.language_confidence;
|
||||
projected.languageScores = parseNullableData(item.language_scores);
|
||||
projected.languageEvidence = parseNullableData(item.language_evidence);
|
||||
projected.languageDetector = item.language_detector;
|
||||
projected.languageInputHash = item.language_input_hash;
|
||||
} else {
|
||||
const language = detectLanguage({ ...projected, data: parseData(projected.data) });
|
||||
projected.languageCode = language.code;
|
||||
projected.languageConfidence = language.confidence;
|
||||
projected.languageScores = language.scores;
|
||||
projected.languageEvidence = language.evidence;
|
||||
projected.languageDetector = 'algorithm';
|
||||
projected.languageInputHash = languageInputHash(projected);
|
||||
}
|
||||
const language = detectLanguage({ ...projected, data: parseData(projected.data) });
|
||||
projected.languageCode = language.code;
|
||||
projected.languageConfidence = language.confidence;
|
||||
projected.languageScores = language.scores;
|
||||
projected.languageEvidence = language.evidence;
|
||||
const itemIdentity = identity(fingerprint);
|
||||
const candidateKey = itemIdentity.family && itemIdentity.number
|
||||
? `${itemIdentity.family}:${itemIdentity.number}`
|
||||
@ -153,14 +124,7 @@ function safeIdentityDescriptor(item) {
|
||||
};
|
||||
}
|
||||
|
||||
function identityCandidatePairs(items, options = {}) {
|
||||
const hasScope = Object.hasOwn(options, 'scopeItemIds');
|
||||
const scopeItemIds = hasScope
|
||||
? new Set(Array.from(options.scopeItemIds || [], String))
|
||||
: null;
|
||||
const isScoped = item => !scopeItemIds
|
||||
|| scopeItemIds.has(String(item.id))
|
||||
|| scopeItemIds.has(String(item.item_id));
|
||||
function identityCandidatePairs(items) {
|
||||
const buckets = new Map();
|
||||
for (const item of items) {
|
||||
const fingerprint = normalizeChannelName(item.name);
|
||||
@ -174,7 +138,6 @@ function identityCandidatePairs(items, options = {}) {
|
||||
for (const bucket of buckets.values()) {
|
||||
for (let left = 0; left < bucket.length; left++) {
|
||||
for (let right = left + 1; right < bucket.length; right++) {
|
||||
if (!isScoped(bucket[left]) && !isScoped(bucket[right])) continue;
|
||||
if (bucket[left].fingerprint === bucket[right].fingerprint) continue;
|
||||
if (scoreCandidate(bucket[left], bucket[right], { mode: 'algo' }).blocker) continue;
|
||||
pairs.push({
|
||||
@ -191,10 +154,7 @@ function identityCandidatePairs(items, options = {}) {
|
||||
|
||||
async function collectIdentityHints(items, mode, layaClient, options = {}) {
|
||||
if (mode === 'algo' || !layaClient) return new Map();
|
||||
const pairOptions = Object.hasOwn(options, 'scopeItemIds')
|
||||
? { scopeItemIds: options.scopeItemIds }
|
||||
: {};
|
||||
const pairs = identityCandidatePairs(items, pairOptions);
|
||||
const pairs = identityCandidatePairs(items);
|
||||
const total = Math.ceil(pairs.length / 64);
|
||||
let processed = 0;
|
||||
const hints = new Map();
|
||||
@ -218,55 +178,6 @@ async function collectOptionalIdentityHints(items, mode, layaClient, options = {
|
||||
}
|
||||
}
|
||||
|
||||
function analysisScopeItems(items, options = {}) {
|
||||
if (options.analyzeAll === true) return items;
|
||||
if (Object.hasOwn(options, 'scopeItemIds')) {
|
||||
const ids = new Set(Array.from(options.scopeItemIds || [], String));
|
||||
return items.filter(item => ids.has(String(item.id)) || ids.has(String(item.item_id)));
|
||||
}
|
||||
return items.filter(item => !hasPersistedLanguage(item));
|
||||
}
|
||||
|
||||
function languageDescriptor(item) {
|
||||
const parts = [item.name, item.category_name, item.tvg_id].filter(Boolean).map(String);
|
||||
return { id: item.id, text: parts.join(' | ').slice(0, 4096) || String(item.id) };
|
||||
}
|
||||
|
||||
async function collectOptionalLayaLanguages(items, layaClient, options = {}) {
|
||||
if (!layaClient?.detectBatch || items.length === 0) return;
|
||||
const total = Math.ceil(items.length / 64);
|
||||
let processed = 0;
|
||||
options.onProgress?.({ processed, total });
|
||||
try {
|
||||
for (let index = 0; index < items.length; index += 64) {
|
||||
const batch = items.slice(index, index + 64);
|
||||
const response = await layaClient.detectBatch({
|
||||
items: batch.map(languageDescriptor),
|
||||
model: 'multilingual'
|
||||
});
|
||||
const results = new Map((response?.results || []).map(result => [String(result.id), result]));
|
||||
for (const item of batch) {
|
||||
const result = results.get(String(item.id));
|
||||
const code = String(result?.language || '').toLowerCase();
|
||||
if (!/^[a-z]{2,3}$/.test(code) || code === 'und') continue;
|
||||
const confidence = Number(result.confidence) || 0;
|
||||
item.language_code = code;
|
||||
item.language_confidence = confidence;
|
||||
item.language_scores = JSON.stringify({ [code]: confidence });
|
||||
item.language_evidence = JSON.stringify([
|
||||
{ code, weight: confidence, type: 'laya', value: 'Local AI model' }
|
||||
]);
|
||||
item.language_detector = 'laya';
|
||||
item.language_input_hash = languageInputHash(item);
|
||||
}
|
||||
processed += 1;
|
||||
options.onProgress?.({ processed, total });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[LiveProjection] Laya language detection unavailable; keeping persisted or algorithmic languages');
|
||||
}
|
||||
}
|
||||
|
||||
function catalogInputHash(items) {
|
||||
return stableHash(items
|
||||
.map(item => `${item.id}\0${item.name}\0${item.category_id || ''}`)
|
||||
@ -316,8 +227,7 @@ function markProjectionBuilding(database = null) {
|
||||
function loadProjectionItems(database) {
|
||||
return database.prepare(`
|
||||
SELECT p.id, p.source_id, p.item_id, p.name, p.category_id, p.data,
|
||||
p.language_code, p.language_confidence, p.language_scores, p.language_evidence,
|
||||
p.language_detector, p.language_input_hash, c.name AS category_name
|
||||
c.name AS category_name
|
||||
FROM playlist_items p
|
||||
LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live' AND c.category_id = p.category_id
|
||||
WHERE p.type = 'live'
|
||||
@ -342,7 +252,9 @@ function rebuild(database = null, options = {}) {
|
||||
database.prepare('DELETE FROM logical_channels').run();
|
||||
database.prepare(`
|
||||
UPDATE playlist_items SET logical_channel_id = NULL, grouping_fingerprint = NULL,
|
||||
grouping_input_hash = NULL, quality_label = NULL, quality_rank = NULL, variant_rank = NULL
|
||||
grouping_input_hash = NULL, quality_label = NULL, quality_rank = NULL, variant_rank = NULL,
|
||||
language_code = NULL, language_confidence = NULL, language_scores = NULL,
|
||||
language_evidence = NULL, language_detector = NULL, language_input_hash = NULL
|
||||
WHERE type = 'live'
|
||||
`).run();
|
||||
const insertLogical = database.prepare(`
|
||||
@ -354,7 +266,7 @@ function rebuild(database = null, options = {}) {
|
||||
UPDATE playlist_items SET logical_channel_id = ?, grouping_fingerprint = ?,
|
||||
grouping_input_hash = ?, quality_label = ?, quality_rank = ?, variant_rank = ?,
|
||||
language_code = ?, language_confidence = ?, language_scores = ?,
|
||||
language_evidence = ?, language_detector = ?, language_input_hash = ?
|
||||
language_evidence = ?, language_detector = 'algorithm', language_input_hash = ?
|
||||
WHERE id = ? AND type = 'live'
|
||||
`);
|
||||
const now = Date.now();
|
||||
@ -364,9 +276,8 @@ function rebuild(database = null, options = {}) {
|
||||
updateVariant.run(group.id, group.fingerprint, variant.groupingInputHash,
|
||||
variant.qualityLabel, variant.qualityRank, variant.variantRank,
|
||||
variant.languageCode, variant.languageConfidence,
|
||||
variant.languageScores == null ? null : JSON.stringify(variant.languageScores),
|
||||
variant.languageEvidence == null ? null : JSON.stringify(variant.languageEvidence),
|
||||
variant.languageDetector, variant.languageInputHash, variant.id);
|
||||
JSON.stringify(variant.languageScores), JSON.stringify(variant.languageEvidence),
|
||||
stableHash(`${variant.name || ''}\0${variant.category_name || ''}\0${variant.tvg_id || ''}`), variant.id);
|
||||
}
|
||||
}
|
||||
database.prepare(`
|
||||
@ -388,18 +299,6 @@ function rebuild(database = null, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fullRebuildOrchestrator(database = null, dependencies = {}) {
|
||||
database ||= defaultDatabase();
|
||||
const syncService = dependencies.syncService || require('./syncService');
|
||||
const sourceRepository = dependencies.sources || require('../db').sources;
|
||||
await syncService.refreshEnabledLiveSources({ rebuild: false });
|
||||
await startRebuild(database, { analyzeAll: true }).promise;
|
||||
await syncService.refreshEnabledEpgSources();
|
||||
const epgSources = await sourceRepository.getByType('epg');
|
||||
epgMatching.rebuildPersistedEpgMatches(database, epgSources);
|
||||
return projectionState(database);
|
||||
}
|
||||
|
||||
async function rebuildWithSignals(database = null, options = {}) {
|
||||
database ||= defaultDatabase();
|
||||
const items = loadProjectionItems(database);
|
||||
@ -408,25 +307,12 @@ async function rebuildWithSignals(database = null, options = {}) {
|
||||
let identityHints = new Map();
|
||||
if (mode !== 'algo') {
|
||||
const layaClient = options.layaClient || new (require('./layaService').LayaService)();
|
||||
const scopedItems = analysisScopeItems(items, options);
|
||||
const identityOptions = options.analyzeAll === true
|
||||
? {}
|
||||
: Object.hasOwn(options, 'scopeItemIds')
|
||||
? { scopeItemIds: options.scopeItemIds }
|
||||
: { scopeItemIds: scopedItems.map(item => item.id) };
|
||||
identityHints = await collectOptionalIdentityHints(items, mode, layaClient, {
|
||||
...identityOptions,
|
||||
onProgress: ({ processed, total }) => updateProjectionProgress(database, 'laya_identity', {
|
||||
layaProcessed: processed,
|
||||
layaTotal: total
|
||||
})
|
||||
});
|
||||
await collectOptionalLayaLanguages(scopedItems, layaClient, {
|
||||
onProgress: ({ processed, total }) => updateProjectionProgress(database, 'laya_language', {
|
||||
layaProcessed: processed,
|
||||
layaTotal: total
|
||||
})
|
||||
});
|
||||
}
|
||||
return rebuild(database, { ...options, items, mode, identityHints });
|
||||
}
|
||||
@ -437,17 +323,14 @@ let activeState = null;
|
||||
function startRebuild(database = null, options = {}) {
|
||||
if (activeRebuild) return { alreadyRunning: true, state: activeState, promise: activeRebuild };
|
||||
database ||= defaultDatabase();
|
||||
const {
|
||||
mark = markProjectionBuilding,
|
||||
runner = rebuildWithSignals,
|
||||
schedule = setImmediate,
|
||||
...rebuildOptions
|
||||
} = options;
|
||||
const mark = options.mark || markProjectionBuilding;
|
||||
const runner = options.runner || rebuildWithSignals;
|
||||
const schedule = options.schedule || setImmediate;
|
||||
activeState = mark(database);
|
||||
activeRebuild = new Promise((resolve, reject) => {
|
||||
schedule(() => {
|
||||
Promise.resolve()
|
||||
.then(() => runner(database, { ...rebuildOptions, stateAlreadyBuilding: true }))
|
||||
.then(() => runner(database, { stateAlreadyBuilding: true }))
|
||||
.then(resolve, reject);
|
||||
});
|
||||
}).finally(() => {
|
||||
@ -457,53 +340,18 @@ function startRebuild(database = null, options = {}) {
|
||||
return { alreadyRunning: false, state: activeState, promise: activeRebuild };
|
||||
}
|
||||
|
||||
const queueRebuild = (database = null) => startRebuild(database, { mode: 'algo' }).promise;
|
||||
function queueRebuild(database = null) {
|
||||
return startRebuild(database).promise;
|
||||
}
|
||||
|
||||
function parseData(value) {
|
||||
try { return value ? JSON.parse(value) : {}; } catch { return {}; }
|
||||
}
|
||||
|
||||
function pageBounds(page = 1, limit = DEFAULT_PAGE_LIMIT) {
|
||||
const normalizedPage = Math.max(1, Number.parseInt(page, 10) || 1);
|
||||
const normalizedLimit = Math.min(MAX_PAGE_LIMIT, Math.max(1, Number.parseInt(limit, 10) || DEFAULT_PAGE_LIMIT));
|
||||
return { page: normalizedPage, limit: normalizedLimit, offset: (normalizedPage - 1) * normalizedLimit };
|
||||
}
|
||||
|
||||
function pageMetadata(total, bounds) {
|
||||
const nextOffset = bounds.offset + bounds.limit;
|
||||
const hasMore = nextOffset < total;
|
||||
return {
|
||||
page: bounds.page,
|
||||
limit: bounds.limit,
|
||||
offset: bounds.offset,
|
||||
total,
|
||||
hasMore,
|
||||
nextOffset: hasMore ? nextOffset : null
|
||||
};
|
||||
}
|
||||
|
||||
function listChannel(channel) {
|
||||
const preferredVariant = channel.preferredVariant || channel.variants[0] || null;
|
||||
const variants = [];
|
||||
const addPreview = variant => {
|
||||
if (variant && variants.length < 3 && !variants.some(candidate =>
|
||||
candidate.id === variant.id && candidate.sourceId === variant.sourceId
|
||||
)) variants.push(variant);
|
||||
};
|
||||
addPreview(preferredVariant);
|
||||
channel.variants.filter(variant => variant.isFavorite).forEach(addPreview);
|
||||
channel.variants.filter(variant => !variant.isHidden).forEach(addPreview);
|
||||
return {
|
||||
...channel,
|
||||
preferredVariant,
|
||||
variantCount: channel.variants.length,
|
||||
variants
|
||||
};
|
||||
}
|
||||
|
||||
function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null, dependencies = {}) {
|
||||
function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [] }, database = null, dependencies = {}) {
|
||||
database ||= defaultDatabase();
|
||||
const standardCatalog = dependencies.iptvOrgCatalog || iptvOrgCatalog;
|
||||
const epgMatcher = dependencies.epgMatching || epgMatching;
|
||||
const params = [];
|
||||
let sourceFilter = '';
|
||||
if (sourceId !== null) {
|
||||
@ -536,69 +384,21 @@ function getChannelPage({ sourceId = null, includeHidden = false, userId = null,
|
||||
const filtered = serialized.filter(channel =>
|
||||
(!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q)
|
||||
);
|
||||
const persistedEpg = new Map(database.prepare(`
|
||||
SELECT logical_channel_id, language_code, source_id, channel_id, match_confidence, data
|
||||
FROM live_epg_matches
|
||||
`).all().map(match => [`${match.logical_channel_id}\0${match.language_code}`, match]));
|
||||
for (const channel of filtered) {
|
||||
const match = persistedEpg.get(`${channel.logicalChannelId}\0${channel.language.code}`);
|
||||
if (!match) continue;
|
||||
let data = {};
|
||||
try { data = match.data ? JSON.parse(match.data) : {}; } catch {}
|
||||
channel.epg = {
|
||||
...channel.epg,
|
||||
...data,
|
||||
sourceId: match.source_id,
|
||||
channelId: match.channel_id,
|
||||
matchConfidence: match.match_confidence,
|
||||
language: channel.language.code
|
||||
};
|
||||
}
|
||||
const bounds = pageBounds(page, limit);
|
||||
const paged = filtered.slice(bounds.offset, bounds.offset + bounds.limit);
|
||||
// Catalog loading must stay bounded: both enrichers perform synchronous SQLite work,
|
||||
// and EPG matching is especially expensive against large XMLTV catalogs. Enrich only
|
||||
// a genuinely targeted, bounded search; base projection data remains usable otherwise.
|
||||
const shouldEnrich = Boolean(String(q).trim()) && filtered.length <= MAX_SYNCHRONOUS_ENRICHMENTS;
|
||||
const enriched = !shouldEnrich ? paged : paged.map(channel => {
|
||||
const channels = !shouldEnrich ? filtered : filtered.map(channel => {
|
||||
const match = standardCatalog.findMatch(database, channel);
|
||||
if (match) {
|
||||
channel.standardName = match.name;
|
||||
channel.standard = { provider: 'iptv-org', channelId: match.channelId, country: match.country, confidence: match.confidence };
|
||||
}
|
||||
const epgMatch = epgMatcher.findBestMatch(database, channel, epgSources);
|
||||
if (epgMatch) channel.epg = { ...channel.epg, ...epgMatch, language: channel.language.code };
|
||||
return channel;
|
||||
});
|
||||
return {
|
||||
channels: enriched.map(listChannel),
|
||||
availableLanguages,
|
||||
page: pageMetadata(filtered.length, bounds)
|
||||
};
|
||||
}
|
||||
|
||||
function getChannelVariants({ logicalChannelId, includeHidden = false, userId = null, page = 1, limit = DEFAULT_PAGE_LIMIT }, database = null) {
|
||||
database ||= defaultDatabase();
|
||||
const rows = database.prepare(`
|
||||
SELECT p.*, CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite,
|
||||
l.display_name, l.preferred_variant_id, c.name AS category_name,
|
||||
COALESCE(c.is_hidden, 0) AS category_hidden
|
||||
FROM playlist_items p
|
||||
JOIN logical_channels l ON l.id = p.logical_channel_id
|
||||
LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live' AND c.category_id = p.category_id
|
||||
LEFT JOIN favorites f ON f.user_id = ? AND f.source_id = p.source_id
|
||||
AND f.item_id = p.item_id AND f.item_type = 'channel'
|
||||
WHERE p.type = 'live' AND p.logical_channel_id = ?
|
||||
ORDER BY p.variant_rank, p.id
|
||||
`).all(userId ?? -1, logicalChannelId);
|
||||
const variants = serializeChannels(rows, includeHidden).flatMap(channel => channel.variants);
|
||||
const bounds = pageBounds(page, limit);
|
||||
return {
|
||||
variants: variants.slice(bounds.offset, bounds.offset + bounds.limit),
|
||||
page: pageMetadata(variants.length, bounds)
|
||||
};
|
||||
}
|
||||
|
||||
function checkNewStreams() {
|
||||
return require('./syncService').refreshEnabledLiveSources({ rebuild: true });
|
||||
return { channels, availableLanguages };
|
||||
}
|
||||
|
||||
function getChannels(options, database = null) {
|
||||
@ -680,10 +480,7 @@ module.exports = {
|
||||
collectIdentityHints,
|
||||
collectOptionalIdentityHints,
|
||||
displayNameForFingerprint,
|
||||
checkNewStreams,
|
||||
fullRebuildOrchestrator,
|
||||
getChannelPage,
|
||||
getChannelVariants,
|
||||
getChannels,
|
||||
identityCandidatePairs,
|
||||
normalizeChannelName,
|
||||
|
||||
@ -8,18 +8,6 @@ const liveProjection = require('./liveProjectionService');
|
||||
// Sync tracking
|
||||
const activeSyncs = new Set(); // sourceId
|
||||
|
||||
async function rebuildLiveProjection(newIds) {
|
||||
const currentSettings = await settings.get();
|
||||
const mode = currentSettings.languageDetectionMode;
|
||||
if (newIds.size > 0 && (mode === 'laya' || mode === 'hybrid')) {
|
||||
return liveProjection.startRebuild(undefined, {
|
||||
mode,
|
||||
scopeItemIds: [...newIds]
|
||||
}).promise;
|
||||
}
|
||||
return liveProjection.queueRebuild();
|
||||
}
|
||||
|
||||
class SyncService {
|
||||
constructor() {
|
||||
this.lastSyncTime = null; // Track when global sync last completed
|
||||
@ -115,24 +103,6 @@ class SyncService {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshEnabledLiveSources({ rebuild = false } = {}) {
|
||||
const allSources = await sources.getAll();
|
||||
for (const source of allSources) {
|
||||
if (!source.enabled || !['xtream', 'm3u'].includes(source.type)) continue;
|
||||
if (source.type === 'xtream') {
|
||||
const newIds = await this.syncXtreamLive(source);
|
||||
if (rebuild) await rebuildLiveProjection(newIds);
|
||||
} else {
|
||||
await this.syncM3u(source, { rebuild });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refreshEnabledEpgSources() {
|
||||
const epgSources = await sources.getByType('epg');
|
||||
for (const source of epgSources) await this.syncEpg(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start sync for a source
|
||||
*/
|
||||
@ -203,8 +173,18 @@ class SyncService {
|
||||
*/
|
||||
async syncXtream(source) {
|
||||
const api = xtreamApi.createFromSource(source);
|
||||
const newIds = await this.syncXtreamLive(source, api);
|
||||
await rebuildLiveProjection(newIds);
|
||||
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);
|
||||
await liveProjection.queueRebuild();
|
||||
|
||||
// 3. VOD Categories
|
||||
console.log(`[Sync] Fetching VOD Categories for ${source.name}`);
|
||||
@ -237,16 +217,6 @@ class SyncService {
|
||||
}
|
||||
}
|
||||
|
||||
async syncXtreamLive(source, api = xtreamApi.createFromSource(source)) {
|
||||
console.log(`[Sync] Fetching Live Categories for ${source.name}`);
|
||||
const liveCats = await api.getLiveCategories();
|
||||
await this.saveCategories(source.id, 'live', liveCats);
|
||||
console.log(`[Sync] Fetching Live Streams for ${source.name}`);
|
||||
const liveStreams = await api.getLiveStreams();
|
||||
const { newIds } = await this.saveStreams(source.id, 'live', liveStreams);
|
||||
return newIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch save categories
|
||||
*/
|
||||
@ -289,17 +259,15 @@ class SyncService {
|
||||
* @param {string} type - Type of items (live, movie, series)
|
||||
* @param {Array} items - Items to save
|
||||
* @param {Object} options - Options { skipPurge: boolean }
|
||||
* @returns {{ syncedIds: Set<string>, newIds: Set<string> }} Synced and newly inserted IDs
|
||||
* @returns {Set} Set of synced IDs (for external purge if skipPurge was true)
|
||||
*/
|
||||
async saveStreams(sourceId, type, items, options = {}) {
|
||||
if (!items || items.length === 0) return { syncedIds: new Set(), newIds: new Set() };
|
||||
if (!items || items.length === 0) return new Set();
|
||||
const db = getDb();
|
||||
const { skipPurge = false } = options;
|
||||
|
||||
// Collect all IDs we're syncing
|
||||
const syncedIds = new Set();
|
||||
const newIds = new Set();
|
||||
const existingItem = db.prepare('SELECT 1 FROM playlist_items WHERE id = ?');
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO playlist_items (
|
||||
@ -348,7 +316,6 @@ class SyncService {
|
||||
|
||||
const id = `${sourceId}:${itemId}`;
|
||||
syncedIds.add(id);
|
||||
if (!existingItem.get(id)) newIds.add(id);
|
||||
|
||||
stmt.run(
|
||||
id,
|
||||
@ -382,7 +349,7 @@ class SyncService {
|
||||
}
|
||||
|
||||
console.log(`[Sync] Saved ${items.length} ${type} items`);
|
||||
return { syncedIds, newIds };
|
||||
return syncedIds;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -531,7 +498,7 @@ class SyncService {
|
||||
* M3U Sync Logic (Streaming - Memory Efficient)
|
||||
* Processes M3U files in batches to avoid OOM on large playlists
|
||||
*/
|
||||
async syncM3u(source, { rebuild = true } = {}) {
|
||||
async syncM3u(source) {
|
||||
console.log(`[Sync] Fetching M3U playlist for ${source.name}`);
|
||||
|
||||
// Temporary memory logging for verification
|
||||
@ -544,7 +511,6 @@ class SyncService {
|
||||
|
||||
const allGroups = new Set();
|
||||
const allSyncedIds = new Set(); // Collect IDs across all batches
|
||||
const allNewIds = new Set();
|
||||
let totalChannels = 0;
|
||||
let batchCount = 0;
|
||||
|
||||
@ -564,9 +530,8 @@ class SyncService {
|
||||
|
||||
// Save this batch immediately (skip purge - we'll do it at the end)
|
||||
if (playlistItems.length > 0) {
|
||||
const { syncedIds, newIds } = await this.saveStreams(source.id, 'live', playlistItems, { skipPurge: true });
|
||||
syncedIds.forEach(id => allSyncedIds.add(id));
|
||||
newIds.forEach(id => allNewIds.add(id));
|
||||
const batchIds = await this.saveStreams(source.id, 'live', playlistItems, { skipPurge: true });
|
||||
batchIds.forEach(id => allSyncedIds.add(id));
|
||||
totalChannels += playlistItems.length;
|
||||
}
|
||||
|
||||
@ -596,9 +561,8 @@ class SyncService {
|
||||
}));
|
||||
|
||||
await this.saveCategories(source.id, 'live', categories);
|
||||
if (rebuild) await rebuildLiveProjection(allNewIds);
|
||||
await liveProjection.queueRebuild();
|
||||
console.log(`[Sync] M3U sync complete for ${source.name}`);
|
||||
return allNewIds;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -57,29 +57,6 @@ test('presents writing as an indeterminate finalization phase', () => {
|
||||
assert.equal(view.progressText, 'Finalizing…');
|
||||
});
|
||||
|
||||
test('presents full refresh and EPG matching phases with user-facing labels', () => {
|
||||
assert.equal(
|
||||
present({ status: 'building', phase: 'refreshing_live_sources' }).statusLabel,
|
||||
'Refreshing IPTV sources…'
|
||||
);
|
||||
assert.equal(
|
||||
present({ status: 'building', phase: 'refreshing_epg_sources' }).statusLabel,
|
||||
'Refreshing EPG data…'
|
||||
);
|
||||
assert.equal(
|
||||
present({ status: 'building', phase: 'matching_epg', processed: 4, total: 10 }).statusLabel,
|
||||
'Matching channels to EPG data…'
|
||||
);
|
||||
assert.equal(
|
||||
present({ status: 'building', phase: 'laya_epg_matching', processed: 4, total: 10 }).statusLabel,
|
||||
'Matching channels to EPG data…'
|
||||
);
|
||||
const finalizing = present({ status: 'building', phase: 'writing_epg_matches', processed: 10, total: 10 });
|
||||
assert.equal(finalizing.statusLabel, 'Finalizing…');
|
||||
assert.equal(finalizing.progressIndeterminate, true);
|
||||
assert.equal(finalizing.progressText, 'Finalizing…');
|
||||
});
|
||||
|
||||
test('presents success and retry states', () => {
|
||||
assert.deepEqual(
|
||||
{ label: present({ status: 'success', projectedCount: 12 }).statusLabel, button: present({ status: 'success' }).buttonLabel },
|
||||
|
||||
@ -141,12 +141,12 @@ test('Settings renders Laya batch progress and removes misleading values when it
|
||||
assert.equal(bar.style.width, '');
|
||||
});
|
||||
|
||||
test('analysis completion reloads the persisted projection', async () => {
|
||||
test('analysis remains visibly busy while Laya language batches run after projection completes', async () => {
|
||||
const { window } = createDom();
|
||||
let rebuildClicked = false;
|
||||
let projectionReloadStarted = false;
|
||||
let releaseProjectionReload;
|
||||
const projectionReload = new Promise(resolve => { releaseProjectionReload = resolve; });
|
||||
let languageRefreshStarted = false;
|
||||
let releaseLanguageRefresh;
|
||||
const languageRefresh = new Promise(resolve => { releaseLanguageRefresh = resolve; });
|
||||
window.API = {
|
||||
live: {
|
||||
getProjectionStatus: async () => rebuildClicked
|
||||
@ -176,11 +176,11 @@ test('analysis completion reloads the persisted projection', async () => {
|
||||
saveSettings() {}
|
||||
},
|
||||
channelList: {
|
||||
loadChannels: async () => {
|
||||
projectionReloadStarted = true;
|
||||
await projectionReload;
|
||||
},
|
||||
refreshLayaLanguages: async () => assert.fail('Browser-side Laya refresh must not run')
|
||||
loadChannels: async () => {},
|
||||
refreshLayaLanguages: async () => {
|
||||
languageRefreshStarted = true;
|
||||
await languageRefresh;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.app = app;
|
||||
@ -193,19 +193,28 @@ test('analysis completion reloads the persisted projection', async () => {
|
||||
window.document.getElementById('live-analysis-button').click();
|
||||
|
||||
try {
|
||||
await waitFor(() => projectionReloadStarted, 'Expected persisted projection reload after analysis completion');
|
||||
assert.equal(window.document.getElementById('live-analysis-status').textContent, 'Analysis complete');
|
||||
await waitFor(() => languageRefreshStarted, 'Expected Laya language refresh to start after projection completion');
|
||||
assert.notEqual(window.document.getElementById('live-analysis-status').textContent, 'Analysis complete');
|
||||
assert.equal(window.document.getElementById('live-analysis-progress').hidden, false);
|
||||
} finally {
|
||||
releaseProjectionReload();
|
||||
releaseLanguageRefresh();
|
||||
window.clearInterval(settingsPage.liveAnalysisPollTimer);
|
||||
}
|
||||
});
|
||||
|
||||
test('user analysis click reloads persisted languages without a browser-side Laya batch', async () => {
|
||||
test('user analysis click keeps visible channels after Local AI language failure', async () => {
|
||||
const { window } = createDom();
|
||||
let rebuildClicked = 0;
|
||||
let layaBatchCalls = 0;
|
||||
let channelLoads = 0;
|
||||
const consoleWarnings = [];
|
||||
window.console = {
|
||||
...console,
|
||||
warn: (...args) => {
|
||||
consoleWarnings.push(args.join(' '));
|
||||
console.warn(...args);
|
||||
}
|
||||
};
|
||||
window.API = {
|
||||
live: {
|
||||
getChannels: async () => {
|
||||
@ -256,27 +265,30 @@ test('user analysis click reloads persisted languages without a browser-side Lay
|
||||
window.document.getElementById('live-analysis-button').click();
|
||||
assert.equal(rebuildClicked, 1);
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
channelList.expandedVariants.add('sports one');
|
||||
channelList.expandAll();
|
||||
window.clearInterval(channelList._epgRefreshTimer);
|
||||
window.clearInterval(settingsPage.liveAnalysisPollTimer);
|
||||
|
||||
const notice = window.document.querySelector('.laya-language-notice');
|
||||
const afterNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent);
|
||||
const languageBadges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent);
|
||||
const groupNames = [...window.document.querySelectorAll('.group-name')].map(item => item.textContent);
|
||||
|
||||
assert.equal(channelLoads, 2);
|
||||
assert.equal(layaBatchCalls, 0);
|
||||
assert.equal(layaBatchCalls, 1);
|
||||
assert.ok(notice);
|
||||
assert.match(notice.textContent, /Local AI language detection failed/);
|
||||
assert.deepEqual(afterNames, ['Sports One', 'Sports One']);
|
||||
assert.deepEqual([...new Set(languageBadges)].sort(), ['EN 100%', 'FR 100%']);
|
||||
assert.deepEqual(groupNames, ['Favorites', 'Sports France']);
|
||||
assert.equal(window.document.querySelector('.empty-state'), null);
|
||||
assert.equal(window.document.querySelector('#channel-list > .loading'), null);
|
||||
assert.equal(channelList.languageDetectionFallback, false);
|
||||
assert.equal(channelList.languageDetectionFallback, true);
|
||||
assert.equal(channelList.logicalChannels[0].variants.length, 2);
|
||||
assert.ok(consoleWarnings.some(line => line.includes('Local AI language detection unavailable')));
|
||||
});
|
||||
|
||||
test('hybrid analysis completion relies on persisted projection without Standard browser fallback', async () => {
|
||||
test('successful Standard fallback after Local AI failure keeps channels and reports the AI failure', async () => {
|
||||
const { window } = createDom();
|
||||
let rebuildClicked = 0;
|
||||
let layaBatchCalls = 0;
|
||||
@ -333,13 +345,16 @@ test('hybrid analysis completion relies on persisted projection without Standard
|
||||
window.clearInterval(settingsPage.liveAnalysisPollTimer);
|
||||
|
||||
const afterNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent);
|
||||
const notice = window.document.querySelector('.laya-language-notice');
|
||||
|
||||
assert.equal(layaBatchCalls, 0);
|
||||
assert.equal(layaBatchCalls, 1);
|
||||
assert.deepEqual(afterNames, ['Sports One', 'Sports One']);
|
||||
assert.equal(window.document.querySelector('.empty-state'), null);
|
||||
assert.equal(window.document.querySelector('#channel-list > .loading'), null);
|
||||
assert.equal(window.document.querySelector('.laya-language-notice'), null);
|
||||
assert.equal(channelList.languageDetectionFallback, false);
|
||||
assert.ok(notice);
|
||||
assert.match(notice.textContent, /Local AI language detection failed/);
|
||||
assert.match(notice.textContent, /Standard matching/);
|
||||
assert.equal(channelList.languageDetectionFallback, true);
|
||||
});
|
||||
|
||||
test('successful Laya language batch updates visible variant languages without Standard fallback', async () => {
|
||||
@ -442,7 +457,6 @@ test('usable Laya language labels do not leave visible variants as unknown', asy
|
||||
|
||||
await channelList.loadChannels();
|
||||
await channelList.refreshLayaLanguages('laya');
|
||||
channelList.expandedVariants.add('sports one');
|
||||
channelList.expandAll();
|
||||
|
||||
const badges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent);
|
||||
|
||||
@ -15,27 +15,6 @@ test('projection rebuild route requires authentication and admin authorization',
|
||||
assert.match(routeSource, /router\.post\('\/projection\/rebuild', requireAuth, requireAdmin,/);
|
||||
});
|
||||
|
||||
test('projection rebuild route requests a complete orchestration', () => {
|
||||
assert.match(routeSource, /liveProjection\.fullRebuildOrchestrator\(\)/);
|
||||
});
|
||||
|
||||
test('live channels route accepts pagination and forwards page bounds to the projection service', () => {
|
||||
assert.ok(/req\.query\.page/.test(routeSource), 'GET /channels should parse req.query.page');
|
||||
assert.ok(/req\.query\.limit/.test(routeSource), 'GET /channels should parse req.query.limit');
|
||||
assert.ok(/getChannelPage\(\{[^}]*page[^}]*limit/s.test(routeSource), 'GET /channels should forward page and limit to getChannelPage');
|
||||
});
|
||||
|
||||
test('live variants route is paginated and authenticated', () => {
|
||||
assert.ok(/router\.get\('\/channels\/:logicalChannelId\/variants', requireAuth,/.test(routeSource), 'GET /channels/:logicalChannelId/variants should exist and require auth');
|
||||
assert.ok(/getChannelVariants\(\{[^}]*logicalChannelId[^}]*page[^}]*limit/s.test(routeSource), 'variants route should call getChannelVariants with page and limit');
|
||||
});
|
||||
|
||||
test('projection check-new route exists separately from complete rebuild', () => {
|
||||
assert.ok(/router\.post\('\/projection\/check-new', requireAuth, requireAdmin,/.test(routeSource), 'POST /projection/check-new should exist and require admin');
|
||||
assert.ok(/liveProjection\.(?:checkNewStreams|startIncrementalAnalysis|analyzeNewStreams)/.test(routeSource), 'check-new route should call an incremental analysis helper');
|
||||
assert.ok(!/\/projection\/check-new'[\s\S]*analyzeAll: true/.test(routeSource), 'check-new route must not request analyzeAll');
|
||||
});
|
||||
|
||||
test('projection authorization distinguishes viewer and admin roles', () => {
|
||||
assert.equal(isAdmin({ role: 'viewer' }), false);
|
||||
assert.equal(isAdmin({ role: 'admin' }), true);
|
||||
|
||||
@ -121,28 +121,6 @@ test('manual rebuild scheduling coalesces concurrent requests', async () => {
|
||||
assert.equal(runs, 1);
|
||||
});
|
||||
|
||||
test('startRebuild passes business options to the runner without infrastructure options', async () => {
|
||||
let runnerOptions;
|
||||
const result = startRebuild({}, {
|
||||
mode: 'algo',
|
||||
layaClient: { identityHintBatch: async () => [] },
|
||||
mark: () => ({ status: 'building' }),
|
||||
runner: async (_database, options) => {
|
||||
runnerOptions = options;
|
||||
return { status: 'success' };
|
||||
},
|
||||
schedule: callback => callback()
|
||||
});
|
||||
|
||||
await result.promise;
|
||||
assert.equal(runnerOptions.mode, 'algo');
|
||||
assert.equal(typeof runnerOptions.layaClient.identityHintBatch, 'function');
|
||||
assert.equal(runnerOptions.stateAlreadyBuilding, true);
|
||||
assert.equal('mark' in runnerOptions, false);
|
||||
assert.equal('runner' in runnerOptions, false);
|
||||
assert.equal('schedule' in runnerOptions, false);
|
||||
});
|
||||
|
||||
test('Laya rebuild progress advances after a successful identity batch while analysis is still running', async () => {
|
||||
const db = createProjectionDb();
|
||||
for (let index = 0; index < 12; index++) {
|
||||
@ -291,7 +269,7 @@ test('API serialization preserves an M3U groupTitle over the stored category fal
|
||||
assert.equal(channel.variants[0].categoryName, 'M3U Sports France');
|
||||
});
|
||||
|
||||
test('search and language filters run before standard enrichment without read-time EPG matching', () => {
|
||||
test('search and language filters run before expensive standard and EPG enrichment', () => {
|
||||
const db = createProjectionDb();
|
||||
seedProjectionChannel(db, 'bein-fr', '|FR| beIN Sports 1 HD');
|
||||
seedProjectionChannel(db, 'bein-en', '|UK| beIN Sports 1 4K');
|
||||
@ -309,7 +287,7 @@ test('search and language filters run before standard enrichment without read-ti
|
||||
assert.equal(page.channels[0].language.code, 'fr');
|
||||
assert.deepEqual(page.availableLanguages.map(language => language.code).sort(), ['en', 'fr']);
|
||||
assert.equal(standardCalls, 1);
|
||||
assert.equal(epgCalls, 0);
|
||||
assert.equal(epgCalls, 1);
|
||||
});
|
||||
|
||||
test('full catalog listing skips synchronous per-channel enrichment', () => {
|
||||
@ -327,8 +305,7 @@ test('full catalog listing skips synchronous per-channel enrichment', () => {
|
||||
epgMatching: { findBestMatch: () => { epgCalls += 1; return null; } }
|
||||
});
|
||||
|
||||
assert.equal(page.channels.length, 100);
|
||||
assert.deepEqual(page.page, { page: 1, limit: 100, offset: 0, total: 500, hasMore: true, nextOffset: 100 });
|
||||
assert.equal(page.channels.length, 500);
|
||||
assert.equal(standardCalls, 0);
|
||||
assert.equal(epgCalls, 0);
|
||||
assert.ok(Date.now() - started < 1000);
|
||||
@ -343,8 +320,6 @@ test('broad searches above the enrichment guard remain bounded', () => {
|
||||
iptvOrgCatalog: { findMatch: () => { calls += 1; return null; } },
|
||||
epgMatching: { findBestMatch: () => { calls += 1; return null; } }
|
||||
});
|
||||
assert.equal(page.channels.length, 100);
|
||||
assert.equal(page.page.total, 300);
|
||||
assert.equal(page.page.hasMore, true);
|
||||
assert.equal(page.channels.length, 300);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
@ -25,7 +25,6 @@ function createChannelList() {
|
||||
window.eval(script('js/services/channelLanguageDetection.js'));
|
||||
window.eval(script('js/services/channelLanguageFusion.js'));
|
||||
window.eval(script('js/services/channelVariantGrouping.js'));
|
||||
window.eval(script('js/services/layaStatusPresentation.js'));
|
||||
window.eval(script('js/services/liveChannelProjection.js'));
|
||||
window.eval(`${script('js/components/ChannelList.js')}\nwindow.ChannelList = ChannelList;`);
|
||||
window.app = {
|
||||
@ -96,36 +95,6 @@ test('language selection filters the complete loaded catalog immediately without
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test('loading the projected channel list does not call Laya language detection during display', async () => {
|
||||
const { dom, window, channelList } = createChannelList();
|
||||
window.app.player.settings.languageDetectionMode = 'laya';
|
||||
let layaStatusCalls = 0;
|
||||
let layaBatchCalls = 0;
|
||||
window.API = {
|
||||
live: { getChannels: async () => projectedResponse() },
|
||||
laya: {
|
||||
getStatus: async () => {
|
||||
layaStatusCalls += 1;
|
||||
return { modelPresent: true, runtimeAvailable: true };
|
||||
},
|
||||
detectLanguages: async () => {
|
||||
layaBatchCalls += 1;
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await channelList.loadAllChannels();
|
||||
await wait(20);
|
||||
|
||||
assert.equal(layaStatusCalls, 0);
|
||||
assert.equal(layaBatchCalls, 0);
|
||||
} finally {
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Escape clears channel search while preserving language-panel Escape behavior', async () => {
|
||||
const { dom, window, channelList } = createChannelList();
|
||||
window.API = { live: { getChannels: async () => projectedResponse() } };
|
||||
|
||||
@ -1,82 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const castServicePath = path.join(root, 'public', 'js', 'services', 'castService.js');
|
||||
|
||||
function loadCastService() {
|
||||
assert.ok(fs.existsSync(castServicePath), 'CastService module should exist at public/js/services/castService.js');
|
||||
return require(castServicePath);
|
||||
}
|
||||
|
||||
test('CastService reports Remote Playback availability and prompts through video.remote', async () => {
|
||||
const CastService = loadCastService();
|
||||
let availabilityCallback;
|
||||
let promptCalls = 0;
|
||||
const video = {
|
||||
remote: {
|
||||
watchAvailability: async callback => {
|
||||
availabilityCallback = callback;
|
||||
callback(true);
|
||||
return 7;
|
||||
},
|
||||
prompt: async () => { promptCalls += 1; }
|
||||
}
|
||||
};
|
||||
|
||||
const service = new CastService(video);
|
||||
await service.watchAvailability();
|
||||
assert.equal(service.state.available, true);
|
||||
assert.equal(service.state.type, 'remote-playback');
|
||||
availabilityCallback(false);
|
||||
assert.equal(service.state.available, false);
|
||||
|
||||
await service.prompt();
|
||||
assert.equal(promptCalls, 1);
|
||||
});
|
||||
|
||||
test('CastService uses AirPlay picker when webkitShowPlaybackTargetPicker is available', async () => {
|
||||
const CastService = loadCastService();
|
||||
let airplayCalls = 0;
|
||||
const video = {
|
||||
webkitShowPlaybackTargetPicker: () => { airplayCalls += 1; }
|
||||
};
|
||||
|
||||
const service = new CastService(video);
|
||||
await service.watchAvailability();
|
||||
assert.equal(service.state.available, true);
|
||||
assert.equal(service.state.type, 'airplay');
|
||||
|
||||
await service.prompt();
|
||||
assert.equal(airplayCalls, 1);
|
||||
});
|
||||
|
||||
test('CastService stays unsupported without native casting APIs', async () => {
|
||||
const CastService = loadCastService();
|
||||
const service = new CastService({});
|
||||
await assert.doesNotReject(() => service.watchAvailability());
|
||||
await assert.doesNotReject(() => service.prompt());
|
||||
assert.equal(service.state.available, false);
|
||||
assert.equal(service.state.type, 'unsupported');
|
||||
});
|
||||
|
||||
test('Live and Watch players expose cast controls and load CastService', () => {
|
||||
const html = fs.readFileSync(path.join(root, 'public', 'index.html'), 'utf8');
|
||||
assert.match(html, /id="player-cast"/, 'Live player should expose a cast button');
|
||||
assert.match(html, /id="watch-cast"/, 'Watch player should expose a cast button');
|
||||
assert.match(html, /\/js\/services\/castService\.js/, 'index.html should load CastService before player pages');
|
||||
});
|
||||
|
||||
test('PWA/service worker cache rules do not precache live stream endpoints', () => {
|
||||
const publicDir = path.join(root, 'public');
|
||||
const candidates = fs.readdirSync(publicDir)
|
||||
.filter(name => /(?:service-worker|sw|workbox).*\.js$/i.test(name))
|
||||
.map(name => path.join(publicDir, name));
|
||||
for (const file of candidates) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
assert.doesNotMatch(text, /\/api\/(?:proxy|transcode|live)/, `${path.basename(file)} should not precache stream API routes`);
|
||||
assert.doesNotMatch(text, /\.m3u8|\.ts\b/, `${path.basename(file)} should not precache HLS playlists or segments`);
|
||||
}
|
||||
});
|
||||
@ -1,301 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { JSDOM } = require('jsdom');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
|
||||
function requireExisting(relativePath) {
|
||||
const absolutePath = path.join(root, relativePath);
|
||||
assert.ok(fs.existsSync(absolutePath), `${relativePath} should exist`);
|
||||
delete require.cache[require.resolve(absolutePath)];
|
||||
return require(absolutePath);
|
||||
}
|
||||
|
||||
test('server registers authenticated cast routes for discovery, refresh and session creation', () => {
|
||||
const indexSource = fs.readFileSync(path.join(root, 'server', 'index.js'), 'utf8');
|
||||
assert.match(indexSource, /app\.use\('\/api\/cast',\s*require\('\.\/routes\/cast'\)\)/);
|
||||
|
||||
const routeSource = fs.readFileSync(path.join(root, 'server', 'routes', 'cast.js'), 'utf8');
|
||||
assert.match(routeSource, /router\.get\('\/devices'/, 'GET /api/cast/devices should be routed');
|
||||
assert.match(routeSource, /router\.post\('\/devices\/refresh'/, 'POST /api/cast/devices/refresh should be routed');
|
||||
assert.match(routeSource, /router\.post\('\/sessions'/, 'POST /api/cast/sessions should be routed');
|
||||
assert.match(routeSource, /requireAuth/, 'cast API should require authenticated users');
|
||||
});
|
||||
|
||||
test('server cast session service rejects non-castable base URLs and calls the adapter with an absolute media URL', async () => {
|
||||
const { createCastSessionService } = requireExisting('server/services/castSessionService.js');
|
||||
assert.equal(typeof createCastSessionService, 'function');
|
||||
|
||||
const adapterCalls = [];
|
||||
const adapter = {
|
||||
discover: async () => [
|
||||
{ id: 'living-room', kind: 'google_cast', name: 'Living Room TV', ready: true }
|
||||
],
|
||||
startSession: async request => {
|
||||
adapterCalls.push(request);
|
||||
return { sessionId: 'session-1' };
|
||||
}
|
||||
};
|
||||
|
||||
const localhostService = createCastSessionService({
|
||||
adapter,
|
||||
publicBaseUrl: 'http://localhost:3000',
|
||||
mediaUrlSigner: { sign: pathValue => `/api/cast/media/${pathValue}?token=abc` }
|
||||
});
|
||||
await assert.rejects(
|
||||
() => localhostService.startSession({ deviceId: 'living-room', mediaPath: '/api/proxy/stream?url=http%3A%2F%2Fexample.test%2Flive.m3u8' }),
|
||||
/LAN|castable|localhost|base URL/i
|
||||
);
|
||||
|
||||
const lanService = createCastSessionService({
|
||||
adapter,
|
||||
publicBaseUrl: 'http://192.168.1.40:3000',
|
||||
mediaUrlSigner: { sign: pathValue => `/api/cast/media${pathValue}?token=abc` }
|
||||
});
|
||||
const result = await lanService.startSession({
|
||||
deviceId: 'living-room',
|
||||
mediaPath: '/api/transcode/session-safe/stream.m3u8'
|
||||
});
|
||||
|
||||
assert.equal(result.sessionId, 'session-1');
|
||||
assert.equal(adapterCalls.length, 1);
|
||||
assert.equal(adapterCalls[0].deviceId, 'living-room');
|
||||
assert.match(adapterCalls[0].mediaUrl, /^http:\/\/192\.168\.1\.40:3000\/api\/cast\/media/);
|
||||
assert.doesNotMatch(adapterCalls[0].mediaUrl, /localhost|127\.0\.0\.1/);
|
||||
});
|
||||
|
||||
test('cast media URL signer creates absolute cookie-free URLs and rejects invalid or expired tokens', () => {
|
||||
const { createCastMediaUrlService } = requireExisting('server/services/castMediaUrlService.js');
|
||||
assert.equal(typeof createCastMediaUrlService, 'function');
|
||||
|
||||
let now = 1_000;
|
||||
const service = createCastMediaUrlService({
|
||||
secret: 'test-secret-with-at-least-32-bytes-long',
|
||||
publicBaseUrl: 'http://192.168.1.40:3000',
|
||||
ttlMs: 30_000,
|
||||
now: () => now
|
||||
});
|
||||
|
||||
const mediaUrl = service.sign('/api/transcode/session-1/stream.m3u8');
|
||||
assert.match(mediaUrl, /^http:\/\/192\.168\.1\.40:3000\/api\/cast\/media\//);
|
||||
assert.match(mediaUrl, /token=/);
|
||||
assert.doesNotMatch(mediaUrl, /Cookie|authToken|session/i);
|
||||
|
||||
const parsed = new URL(mediaUrl);
|
||||
const id = parsed.pathname.split('/').at(-1);
|
||||
const token = parsed.searchParams.get('token');
|
||||
const validated = service.validate(id, token);
|
||||
assert.deepEqual(validated, { path: '/api/transcode/session-1/stream.m3u8' });
|
||||
|
||||
assert.throws(() => service.validate(id, 'bad-token'), /invalid|token/i);
|
||||
assert.throws(() => service.validate('different-id', token), /invalid|token/i);
|
||||
now += 31_000;
|
||||
assert.throws(() => service.validate(id, token), /expired|token/i);
|
||||
});
|
||||
|
||||
test('cast signing requires a dedicated strong secret and supports revocation', () => {
|
||||
const { createCastMediaUrlService } = requireExisting('server/services/castMediaUrlService.js');
|
||||
const missing = createCastMediaUrlService({ publicBaseUrl: 'http://192.168.1.40:3000', secret: '' });
|
||||
assert.throws(() => missing.sign('/api/transcode/session-1/stream.m3u8'), /CAST_SIGNING_SECRET|32 bytes/i);
|
||||
const weak = createCastMediaUrlService({ publicBaseUrl: 'http://192.168.1.40:3000', secret: 'too-short' });
|
||||
assert.throws(() => weak.sign('/api/transcode/session-1/stream.m3u8'), /CAST_SIGNING_SECRET|32 bytes/i);
|
||||
|
||||
const service = createCastMediaUrlService({
|
||||
publicBaseUrl: 'http://192.168.1.40:3000', secret: 'another-test-secret-with-32-bytes-minimum'
|
||||
});
|
||||
const mediaUrl = service.sign('/api/transcode/session-1/stream.m3u8');
|
||||
const parsed = new URL(mediaUrl);
|
||||
const id = parsed.pathname.split('/').at(-1);
|
||||
const token = parsed.searchParams.get('token');
|
||||
assert.equal(service.revoke(mediaUrl), true);
|
||||
assert.throws(() => service.validate(id, token), /invalid|token/i);
|
||||
});
|
||||
|
||||
test('cast session validation rejects upstream URLs, unknown fields and oversized identifiers', async () => {
|
||||
const { createCastSessionService } = requireExisting('server/services/castSessionService.js');
|
||||
let discoveries = 0;
|
||||
const service = createCastSessionService({
|
||||
publicBaseUrl: 'http://192.168.1.40:3000',
|
||||
adapter: {
|
||||
discover: async () => { discoveries += 1; return [{ id: 'cast-1', type: 'google_cast', ready: true }]; },
|
||||
startSession: async () => ({ sessionId: 'session-1' })
|
||||
},
|
||||
mediaUrlSigner: { sign: () => '/api/cast/media/id?token=abc' },
|
||||
discoveryTtlMs: 60_000
|
||||
});
|
||||
await assert.rejects(() => service.startSession({
|
||||
deviceId: 'cast-1', mediaPath: '/api/proxy/stream?url=http%3A%2F%2Fprovider.test%2Flive.m3u8'
|
||||
}), /upstream|allowed/i);
|
||||
await assert.rejects(() => service.startSession({
|
||||
deviceId: 'cast-1', mediaPath: '/api/transcode/session-1/stream.m3u8', unexpected: true
|
||||
}), /unknown/i);
|
||||
await assert.rejects(() => service.startSession({
|
||||
deviceId: 'x'.repeat(129), mediaPath: '/api/transcode/session-1/stream.m3u8'
|
||||
}), /deviceId/i);
|
||||
await service.getDevices();
|
||||
await service.getDevices();
|
||||
assert.equal(discoveries, 1, 'device discovery should be cached within its TTL');
|
||||
});
|
||||
|
||||
test('cast command validation rejects malformed seek, volume and unknown fields', () => {
|
||||
const { validateCommandBody } = requireExisting('server/routes/cast.js');
|
||||
assert.throws(() => validateCommandBody('seek', { currentTime: -1 }), /greater than or equal/i);
|
||||
assert.throws(() => validateCommandBody('seek', { currentTime: 5, extra: true }), /requires currentTime/i);
|
||||
assert.throws(() => validateCommandBody('volume', { level: 1.1 }), /between 0 and 1/i);
|
||||
assert.throws(() => validateCommandBody('volume', { muted: 'yes' }), /boolean/i);
|
||||
assert.throws(() => validateCommandBody('play', { unexpected: true }), /does not accept/i);
|
||||
assert.deepEqual(validateCommandBody('seek', { currentTime: 12 }), { currentTime: 12 });
|
||||
assert.deepEqual(validateCommandBody('volume', { level: 0.5, muted: false }), { level: 0.5, muted: false });
|
||||
});
|
||||
|
||||
test('Google Cast adapter discovers mDNS devices and loads media through Default Media Receiver', async () => {
|
||||
const { createGoogleCastAdapter } = requireExisting('server/services/googleCastAdapter.js');
|
||||
const calls = [];
|
||||
let browserStopped = false;
|
||||
let bonjourDestroyed = false;
|
||||
class FakeBonjour {
|
||||
find(options, onup) {
|
||||
calls.push(['find', options]);
|
||||
onup({
|
||||
name: 'Living Room', fqdn: 'living-room._googlecast._tcp.local', port: 8009,
|
||||
addresses: ['192.168.1.55'], txt: { id: 'cast-1', fn: 'Living Room TV', md: 'Chromecast' }
|
||||
});
|
||||
return { on() {}, stop() { browserStopped = true; } };
|
||||
}
|
||||
destroy() { bonjourDestroyed = true; }
|
||||
}
|
||||
const controller = {
|
||||
load: async payload => {
|
||||
calls.push(['load', payload]);
|
||||
return { unwrapAndThrow: () => ({ playerState: 'PLAYING', currentTime: 0 }) };
|
||||
},
|
||||
stop: async () => ({ unwrapAndThrow: () => ({ playerState: 'IDLE' }) }),
|
||||
dispose: () => calls.push(['dispose'])
|
||||
};
|
||||
const client = { close: () => calls.push(['close']) };
|
||||
const castClient = {
|
||||
connect: async options => { calls.push(['connect', options]); return client; },
|
||||
DefaultMediaApp: {
|
||||
launchAndJoin: async request => {
|
||||
calls.push(['launch', request]);
|
||||
return { unwrapAndThrow: () => controller };
|
||||
}
|
||||
}
|
||||
};
|
||||
const adapter = createGoogleCastAdapter({
|
||||
dependencies: { Bonjour: FakeBonjour, castClient },
|
||||
discoveryTimeoutMs: 5
|
||||
});
|
||||
|
||||
const devices = await adapter.discover();
|
||||
assert.deepEqual(devices, [{
|
||||
id: 'cast-1', name: 'Living Room TV', type: 'google_cast', ready: true,
|
||||
host: '192.168.1.55', port: 8009, model: 'Chromecast'
|
||||
}]);
|
||||
assert.equal(browserStopped, true);
|
||||
assert.equal(bonjourDestroyed, true);
|
||||
|
||||
const session = await adapter.startSession({
|
||||
device: devices[0], mediaUrl: 'http://192.168.1.40:3000/api/cast/media/id?token=signed', media: {}
|
||||
});
|
||||
assert.equal(session.state, 'playing');
|
||||
assert.deepEqual(calls.find(call => call[0] === 'connect')[1], { host: '192.168.1.55', port: 8009, timeout: 8000 });
|
||||
const load = calls.find(call => call[0] === 'load')[1];
|
||||
assert.equal(load.media.contentType, 'application/vnd.apple.mpegurl');
|
||||
assert.equal(load.media.streamType, 'LIVE');
|
||||
await adapter.stopSession(session);
|
||||
assert.ok(calls.some(call => call[0] === 'dispose'));
|
||||
assert.ok(calls.some(call => call[0] === 'close'));
|
||||
});
|
||||
|
||||
test('frontend exposes server cast API helpers and a device picker with honest Fire TV handling', async () => {
|
||||
const dom = new JSDOM(`
|
||||
<button id="player-cast" title="Play on a device" aria-label="Play on a device"></button>
|
||||
<button id="watch-cast" title="Play on a device" aria-label="Play on a device"></button>
|
||||
`, { url: 'http://nodecast.test/', runScripts: 'outside-only' });
|
||||
const requests = [];
|
||||
dom.window.localStorage.setItem('authToken', 'token-1');
|
||||
dom.window.fetch = async (url, options = {}) => {
|
||||
requests.push({ url, options });
|
||||
if (url === '/api/cast/devices') {
|
||||
return {
|
||||
ok: true,
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => ({
|
||||
devices: [
|
||||
{ id: 'cast-1', type: 'google_cast', name: 'Chromecast', ready: true },
|
||||
{ id: 'atv-1', type: 'android_tv_cast', name: 'Android TV', ready: true },
|
||||
{ id: 'fire-1', type: 'fire_tv', name: 'Fire TV', ready: false, status: 'receiver_required' }
|
||||
]
|
||||
})
|
||||
};
|
||||
}
|
||||
return { ok: true, headers: { get: () => 'application/json' }, json: async () => ({ sessionId: 'session-1' }) };
|
||||
};
|
||||
|
||||
dom.window.eval(fs.readFileSync(path.join(root, 'public', 'js', 'api.js'), 'utf8'));
|
||||
assert.ok(dom.window.API.cast, 'API.cast should expose server-driven cast helpers');
|
||||
assert.equal(typeof dom.window.API.cast.getDevices, 'function');
|
||||
assert.equal(typeof dom.window.API.cast.refreshDevices, 'function');
|
||||
assert.equal(typeof dom.window.API.cast.startSession, 'function');
|
||||
|
||||
const html = fs.readFileSync(path.join(root, 'public', 'index.html'), 'utf8');
|
||||
assert.match(html, /id="cast-device-dialog"/, 'cast button should open a device picker dialog');
|
||||
assert.match(html, /id="cast-device-list"/, 'cast UI should render a device list');
|
||||
|
||||
const { ServerCastDevicePicker } = requireExisting('public/js/services/serverCastDevicePicker.js');
|
||||
const picker = new ServerCastDevicePicker({
|
||||
document: dom.window.document,
|
||||
api: dom.window.API.cast,
|
||||
nativeCastService: { state: { available: true }, prompt: async () => {} },
|
||||
mediaUrlProvider: () => '/api/transcode/session-safe/stream.m3u8'
|
||||
});
|
||||
|
||||
await picker.open();
|
||||
assert.equal(requests[0].url, '/api/cast/devices');
|
||||
assert.match(dom.window.document.body.textContent, /Chromecast/);
|
||||
assert.match(dom.window.document.body.textContent, /Android TV/);
|
||||
assert.match(dom.window.document.body.textContent, /Receiver required/);
|
||||
assert.match(dom.window.document.body.textContent, /Use browser device picker/);
|
||||
|
||||
const fireButton = dom.window.document.querySelector('[data-device-id="fire-1"]');
|
||||
assert.ok(fireButton.disabled, 'Fire TV without a receiver app should not be connectable');
|
||||
fireButton.click();
|
||||
assert.equal(requests.filter(request => request.url === '/api/cast/sessions').length, 0);
|
||||
|
||||
dom.window.document.querySelector('[data-device-id="cast-1"]').click();
|
||||
assert.equal(requests.at(-1).url, '/api/cast/sessions');
|
||||
assert.equal(JSON.parse(requests.at(-1).options.body).deviceId, 'cast-1');
|
||||
});
|
||||
|
||||
test('frontend does not create a server cast session for proxy, external or otherwise unsafe media paths', async () => {
|
||||
const unsafePaths = [
|
||||
'/api/proxy/stream?url=http%3A%2F%2Fexample.test%2Flive.m3u8',
|
||||
'https://example.test/live.m3u8',
|
||||
'/api/transcode/session-safe/stream.m3u8?token=unsafe'
|
||||
];
|
||||
|
||||
for (const mediaPath of unsafePaths) {
|
||||
const dom = new JSDOM('', { url: 'http://nodecast.test/' });
|
||||
let sessionPosts = 0;
|
||||
const { ServerCastDevicePicker } = requireExisting('public/js/services/serverCastDevicePicker.js');
|
||||
const picker = new ServerCastDevicePicker({
|
||||
document: dom.window.document,
|
||||
api: {
|
||||
getDevices: async () => ({ devices: [] }),
|
||||
startSession: async () => { sessionPosts += 1; }
|
||||
},
|
||||
nativeCastService: { state: { available: true }, prompt: async () => {} },
|
||||
mediaUrlProvider: () => mediaPath
|
||||
});
|
||||
|
||||
await picker.connect('cast-1');
|
||||
assert.equal(sessionPosts, 0, `${mediaPath} must not create a cast session`);
|
||||
assert.match(
|
||||
dom.window.document.getElementById('cast-device-message').textContent,
|
||||
/not ready for server casting yet.*browser device picker/i
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -1,157 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const Database = require('better-sqlite3');
|
||||
const {
|
||||
ensureColumns,
|
||||
LIVE_PROJECTION_COLUMNS,
|
||||
LIVE_PROJECTION_SCHEMA_SQL
|
||||
} = require('../server/db/liveProjectionSchema');
|
||||
const appDb = require('../server/db');
|
||||
const layaModule = require('../server/services/layaService');
|
||||
const liveProjection = require('../server/services/liveProjectionService');
|
||||
|
||||
const liveRouteSource = fs.readFileSync(path.join(__dirname, '..', 'server', 'routes', 'live.js'), 'utf8');
|
||||
|
||||
function createDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE playlist_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category_id TEXT,
|
||||
parent_id TEXT,
|
||||
stream_icon TEXT,
|
||||
stream_url TEXT,
|
||||
container_extension TEXT,
|
||||
rating REAL,
|
||||
year TEXT,
|
||||
added_at TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedChannel(db, sourceId, itemId, name) {
|
||||
db.prepare(`
|
||||
INSERT INTO playlist_items
|
||||
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data)
|
||||
VALUES (?, ?, ?, 'live', ?, 'sports', '', ?, '{}')
|
||||
`).run(`${sourceId}:${itemId}`, sourceId, itemId, name, `https://streams.test/${itemId}.m3u8`);
|
||||
}
|
||||
|
||||
async function simulateLiveListOpen(database) {
|
||||
let state = liveProjection.projectionState(database);
|
||||
if (state.status !== 'success' && state.status !== 'building') {
|
||||
state = await liveProjection.startRebuild(database, {
|
||||
mode: 'algo',
|
||||
schedule: callback => callback()
|
||||
}).promise;
|
||||
}
|
||||
return liveProjection.getChannelPage({ includeHidden: false, userId: 7 }, database, {
|
||||
iptvOrgCatalog: { findMatch: () => null },
|
||||
epgMatching: { findBestMatch: () => null }
|
||||
});
|
||||
}
|
||||
|
||||
test('opening the live list with a missing projection must not start a global Laya identity batch', async () => {
|
||||
assert.match(
|
||||
liveRouteSource,
|
||||
/liveProjection\.startRebuild\(null, \{ mode: 'algo' \}\)/,
|
||||
'the live-list repair path must explicitly request the algorithmic rebuild mode'
|
||||
);
|
||||
const db = createDb();
|
||||
for (let index = 0; index < 12; index++) {
|
||||
seedChannel(db, 1, `existing-${index}`, `beIN Sports 1 regional ${index}`);
|
||||
}
|
||||
|
||||
let layaCalls = 0;
|
||||
let layaPairCount = 0;
|
||||
const originalSettingsGet = appDb.settings.get;
|
||||
const OriginalLayaService = layaModule.LayaService;
|
||||
appDb.settings.get = async () => ({ ...appDb.getDefaultSettings(), languageDetectionMode: 'laya' });
|
||||
layaModule.LayaService = class {
|
||||
async identityHintBatch(pairs) {
|
||||
layaCalls += 1;
|
||||
layaPairCount += pairs.length;
|
||||
return pairs.map(pair => ({
|
||||
id: pair.id,
|
||||
sameIdentityProbability: 0.9,
|
||||
confidence: 0.9,
|
||||
family: 'bein sports',
|
||||
number: '1'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const page = await simulateLiveListOpen(db);
|
||||
assert.ok(page.channels.length > 0);
|
||||
assert.equal(layaCalls, 0, `unexpected Laya identity batches while opening live list; batches=${layaCalls}, pairs=${layaPairCount}`);
|
||||
} finally {
|
||||
appDb.settings.get = originalSettingsGet;
|
||||
layaModule.LayaService = OriginalLayaService;
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('background live sync rebuilds must not start Laya identity batches in configured Laya mode', async () => {
|
||||
const db = createDb();
|
||||
for (let index = 0; index < 12; index++) {
|
||||
seedChannel(db, 1, `existing-${index}`, `beIN Sports 1 regional ${index}`);
|
||||
}
|
||||
|
||||
let layaCalls = 0;
|
||||
let layaPairCount = 0;
|
||||
const originalSettingsGet = appDb.settings.get;
|
||||
const OriginalLayaService = layaModule.LayaService;
|
||||
appDb.settings.get = async () => ({ ...appDb.getDefaultSettings(), languageDetectionMode: 'laya' });
|
||||
layaModule.LayaService = class {
|
||||
async identityHintBatch(pairs) {
|
||||
layaCalls += 1;
|
||||
layaPairCount += pairs.length;
|
||||
return pairs.map(pair => ({
|
||||
id: pair.id,
|
||||
sameIdentityProbability: 0.9,
|
||||
confidence: 0.9,
|
||||
family: 'bein sports',
|
||||
number: '1'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await liveProjection.queueRebuild(db);
|
||||
assert.equal(layaCalls, 0, `unexpected Laya identity batches during background sync rebuild; batches=${layaCalls}, pairs=${layaPairCount}`);
|
||||
} finally {
|
||||
appDb.settings.get = originalSettingsGet;
|
||||
layaModule.LayaService = OriginalLayaService;
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
@ -1,171 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Database = require('better-sqlite3');
|
||||
const {
|
||||
ensureColumns,
|
||||
LIVE_PROJECTION_COLUMNS,
|
||||
LIVE_PROJECTION_SCHEMA_SQL
|
||||
} = require('../server/db/liveProjectionSchema');
|
||||
const liveProjection = require('../server/services/liveProjectionService');
|
||||
|
||||
function createDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE playlist_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category_id TEXT,
|
||||
parent_id TEXT,
|
||||
stream_icon TEXT,
|
||||
stream_url TEXT,
|
||||
container_extension TEXT,
|
||||
rating REAL,
|
||||
year TEXT,
|
||||
added_at TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedChannel(db, id, name, extra = {}) {
|
||||
db.prepare(`
|
||||
INSERT INTO playlist_items
|
||||
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data,
|
||||
language_code, language_confidence, language_detector, language_input_hash)
|
||||
VALUES (?, 1, ?, 'live', ?, 'general', '', ?, '{}', ?, ?, ?, ?)
|
||||
`).run(
|
||||
`1:${id}`,
|
||||
id,
|
||||
name,
|
||||
`https://streams.test/${id}.m3u8`,
|
||||
extra.languageCode || null,
|
||||
extra.languageConfidence || null,
|
||||
extra.languageDetector || null,
|
||||
extra.languageInputHash || null
|
||||
);
|
||||
}
|
||||
|
||||
test('background projection rebuild keeps a persisted stream language instead of redetecting it', () => {
|
||||
const db = createDb();
|
||||
seedChannel(db, 'persisted', 'World News', {
|
||||
languageCode: 'fr',
|
||||
languageConfidence: 0.99,
|
||||
languageDetector: 'laya',
|
||||
languageInputHash: 'stable-language-input'
|
||||
});
|
||||
|
||||
try {
|
||||
liveProjection.rebuild(db, { mode: 'algo' });
|
||||
const row = db.prepare(`
|
||||
SELECT language_code, language_detector, language_confidence
|
||||
FROM playlist_items
|
||||
WHERE id = '1:persisted'
|
||||
`).get();
|
||||
|
||||
assert.equal(row.language_code, 'fr');
|
||||
assert.equal(row.language_detector, 'laya');
|
||||
assert.equal(Number(row.language_confidence), 0.99);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Laya analysis for one newly detected stream does not send old catalog pairs', async () => {
|
||||
const db = createDb();
|
||||
for (let index = 0; index < 8; index++) {
|
||||
seedChannel(db, `old-${index}`, `beIN Sports 1 legacy ${index}`, {
|
||||
languageCode: 'fr',
|
||||
languageConfidence: 0.95,
|
||||
languageDetector: 'laya',
|
||||
languageInputHash: `old-${index}-hash`
|
||||
});
|
||||
}
|
||||
seedChannel(db, 'new-stream', 'beIN Sports 1 new feed');
|
||||
|
||||
const oldCatalogPairs = [];
|
||||
const layaClient = {
|
||||
identityHintBatch: async pairs => {
|
||||
for (const pair of pairs) {
|
||||
if (!pair.left.name.includes('new feed') && !pair.right.name.includes('new feed')) {
|
||||
oldCatalogPairs.push(pair);
|
||||
}
|
||||
}
|
||||
return pairs.map(pair => ({
|
||||
id: pair.id,
|
||||
sameIdentityProbability: 0.9,
|
||||
confidence: 0.9,
|
||||
family: 'bein sports',
|
||||
number: '1'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await liveProjection.rebuildWithSignals(db, { mode: 'laya', layaClient });
|
||||
assert.equal(oldCatalogPairs.length, 0, `unexpected old-catalog Laya pairs=${oldCatalogPairs.length}`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('analyzeAll sends persisted catalog pairs to Laya', async () => {
|
||||
const db = createDb();
|
||||
seedChannel(db, 'persisted-a', 'beIN Sports 1 east', {
|
||||
languageCode: 'fr',
|
||||
languageConfidence: 0.95,
|
||||
languageDetector: 'laya',
|
||||
languageInputHash: 'persisted-a-hash'
|
||||
});
|
||||
seedChannel(db, 'persisted-b', 'beIN Sports 1 west', {
|
||||
languageCode: 'fr',
|
||||
languageConfidence: 0.95,
|
||||
languageDetector: 'laya',
|
||||
languageInputHash: 'persisted-b-hash'
|
||||
});
|
||||
|
||||
let layaPairs = 0;
|
||||
const layaClient = {
|
||||
identityHintBatch: async pairs => {
|
||||
layaPairs += pairs.length;
|
||||
return pairs.map(pair => ({
|
||||
id: pair.id,
|
||||
sameIdentityProbability: 0.9,
|
||||
confidence: 0.9,
|
||||
family: 'bein sports',
|
||||
number: '1'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await liveProjection.rebuildWithSignals(db, { mode: 'laya', layaClient, analyzeAll: true });
|
||||
assert.ok(layaPairs > 0);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
@ -1,201 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
function createDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE playlist_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category_id TEXT,
|
||||
parent_id TEXT,
|
||||
stream_icon TEXT,
|
||||
stream_url TEXT,
|
||||
container_extension TEXT,
|
||||
rating REAL,
|
||||
year TEXT,
|
||||
added_at TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE sync_status (
|
||||
source_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
last_sync INTEGER NOT NULL,
|
||||
status TEXT,
|
||||
error TEXT,
|
||||
PRIMARY KEY (source_id, type)
|
||||
);
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedLive(db, sourceId, streamId, name = `Channel ${streamId}`) {
|
||||
db.prepare(`
|
||||
INSERT INTO playlist_items
|
||||
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data)
|
||||
VALUES (?, ?, ?, 'live', ?, 'sports', '', ?, '{}')
|
||||
`).run(`${sourceId}:${streamId}`, sourceId, String(streamId), name, `https://streams.test/${streamId}.m3u8`);
|
||||
}
|
||||
|
||||
function loadSyncServiceWithDb(db) {
|
||||
const sqlite = require('../server/db/sqlite');
|
||||
const originalGetDb = sqlite.getDb;
|
||||
sqlite.getDb = () => db;
|
||||
const syncServicePath = require.resolve('../server/services/syncService');
|
||||
delete require.cache[syncServicePath];
|
||||
const syncService = require('../server/services/syncService');
|
||||
return {
|
||||
syncService,
|
||||
restore() {
|
||||
sqlite.getDb = originalGetDb;
|
||||
delete require.cache[syncServicePath];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function stream(streamId, name = `Channel ${streamId}`) {
|
||||
return {
|
||||
stream_id: streamId,
|
||||
name,
|
||||
category_id: 'sports',
|
||||
stream_icon: '',
|
||||
added: 'now'
|
||||
};
|
||||
}
|
||||
|
||||
function patchLiveProjection() {
|
||||
const liveProjection = require('../server/services/liveProjectionService');
|
||||
const originalQueueRebuild = liveProjection.queueRebuild;
|
||||
const originalStartRebuild = liveProjection.startRebuild;
|
||||
const requests = [];
|
||||
liveProjection.queueRebuild = async (...args) => {
|
||||
requests.push({ method: 'queueRebuild', args });
|
||||
return { status: 'success' };
|
||||
};
|
||||
liveProjection.startRebuild = (...args) => {
|
||||
requests.push({ method: 'startRebuild', args });
|
||||
return { promise: Promise.resolve({ status: 'success' }) };
|
||||
};
|
||||
return {
|
||||
requests,
|
||||
restore() {
|
||||
liveProjection.queueRebuild = originalQueueRebuild;
|
||||
liveProjection.startRebuild = originalStartRebuild;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function patchLanguageMode(mode) {
|
||||
const appDb = require('../server/db');
|
||||
const originalGet = appDb.settings.get;
|
||||
appDb.settings.get = async () => ({ ...appDb.getDefaultSettings(), languageDetectionMode: mode });
|
||||
return () => { appDb.settings.get = originalGet; };
|
||||
}
|
||||
|
||||
test('saveStreams returns only newly inserted live stream ids, not updated ids', async () => {
|
||||
const db = createDb();
|
||||
seedLive(db, 1, 'old', 'Old Sports');
|
||||
const { syncService, restore } = loadSyncServiceWithDb(db);
|
||||
|
||||
try {
|
||||
const result = await syncService.saveStreams(1, 'live', [
|
||||
stream('old', 'Old Sports Updated'),
|
||||
stream('new', 'New Sports')
|
||||
], { skipPurge: true });
|
||||
const newIds = result?.newIds || new Set();
|
||||
assert.deepEqual([...newIds].sort(), ['1:new']);
|
||||
} finally {
|
||||
restore();
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('syncXtream with new live ids requests one targeted analysis for only those ids', async () => {
|
||||
const db = createDb();
|
||||
seedLive(db, 1, 'old', 'Old Sports');
|
||||
const { syncService, restore } = loadSyncServiceWithDb(db);
|
||||
const liveProjectionPatch = patchLiveProjection();
|
||||
const restoreLanguageMode = patchLanguageMode('laya');
|
||||
const xtreamApi = require('../server/services/xtreamApi');
|
||||
const originalCreateFromSource = xtreamApi.createFromSource;
|
||||
xtreamApi.createFromSource = () => ({
|
||||
getLiveCategories: async () => [{ category_id: 'sports', category_name: 'Sports' }],
|
||||
getLiveStreams: async () => [stream('old', 'Old Sports Updated'), stream('new', 'New Sports')],
|
||||
getVodCategories: async () => [],
|
||||
getVodStreams: async () => [],
|
||||
getSeriesCategories: async () => [],
|
||||
getSeries: async () => [],
|
||||
getXmltvUrl: () => { throw new Error('no epg'); }
|
||||
});
|
||||
|
||||
try {
|
||||
await syncService.syncXtream({ id: 1, name: 'Xtream Fixture' });
|
||||
assert.deepEqual(liveProjectionPatch.requests, [{
|
||||
method: 'startRebuild',
|
||||
args: [undefined, { mode: 'laya', scopeItemIds: ['1:new'] }]
|
||||
}]);
|
||||
} finally {
|
||||
xtreamApi.createFromSource = originalCreateFromSource;
|
||||
restoreLanguageMode();
|
||||
liveProjectionPatch.restore();
|
||||
restore();
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('syncM3u aggregates new ids from all batches and requests one final targeted analysis', async () => {
|
||||
const db = createDb();
|
||||
seedLive(db, 1, 'old-a', 'Old A');
|
||||
seedLive(db, 1, 'old-b', 'Old B');
|
||||
const { syncService, restore } = loadSyncServiceWithDb(db);
|
||||
const liveProjectionPatch = patchLiveProjection();
|
||||
const restoreLanguageMode = patchLanguageMode('laya');
|
||||
const m3uParser = require('../server/services/m3uParser');
|
||||
const originalFetchAndParseStreaming = m3uParser.fetchAndParseStreaming;
|
||||
m3uParser.fetchAndParseStreaming = async function* () {
|
||||
yield {
|
||||
channels: [
|
||||
{ id: 'old-a', name: 'Old A Updated', groupTitle: 'Sports', tvgLogo: '', url: 'https://streams.test/old-a.m3u8' },
|
||||
{ id: 'new-a', name: 'New A', groupTitle: 'Sports', tvgLogo: '', url: 'https://streams.test/new-a.m3u8' }
|
||||
],
|
||||
groups: ['Sports']
|
||||
};
|
||||
yield {
|
||||
channels: [
|
||||
{ id: 'old-b', name: 'Old B Updated', groupTitle: 'News', tvgLogo: '', url: 'https://streams.test/old-b.m3u8' },
|
||||
{ id: 'new-b', name: 'New B', groupTitle: 'News', tvgLogo: '', url: 'https://streams.test/new-b.m3u8' }
|
||||
],
|
||||
groups: ['News']
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
await syncService.syncM3u({ id: 1, name: 'M3U Fixture', url: 'https://playlist.test/live.m3u' });
|
||||
assert.deepEqual(liveProjectionPatch.requests, [{
|
||||
method: 'startRebuild',
|
||||
args: [undefined, { mode: 'laya', scopeItemIds: ['1:new-a', '1:new-b'] }]
|
||||
}]);
|
||||
} finally {
|
||||
m3uParser.fetchAndParseStreaming = originalFetchAndParseStreaming;
|
||||
restoreLanguageMode();
|
||||
liveProjectionPatch.restore();
|
||||
restore();
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
@ -1,125 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Database = require('better-sqlite3');
|
||||
const liveProjection = require('../server/services/liveProjectionService');
|
||||
const {
|
||||
ensureColumns,
|
||||
LIVE_PROJECTION_COLUMNS,
|
||||
LIVE_PROJECTION_SCHEMA_SQL
|
||||
} = require('../server/db/liveProjectionSchema');
|
||||
|
||||
function createProjectionDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE playlist_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category_id TEXT,
|
||||
parent_id TEXT,
|
||||
stream_icon TEXT,
|
||||
stream_url TEXT,
|
||||
container_extension TEXT,
|
||||
rating REAL,
|
||||
year TEXT,
|
||||
added_at TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedProjectedChannel(db, index) {
|
||||
const logicalId = `logical:${index}`;
|
||||
db.prepare(`
|
||||
INSERT INTO logical_channels
|
||||
(id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, 1, 1)
|
||||
`).run(logicalId, `channel ${index}`, `Channel ${index}`, `1:${index}`);
|
||||
db.prepare(`
|
||||
INSERT INTO playlist_items
|
||||
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data,
|
||||
logical_channel_id, grouping_fingerprint, grouping_input_hash, quality_label, quality_rank,
|
||||
variant_rank, language_code, language_confidence, language_detector, language_input_hash)
|
||||
VALUES (?, 1, ?, 'live', ?, 'general', '', ?, '{}', ?, ?, ?, 'HD', 2, 0, 'en', 1, 'laya', ?)
|
||||
`).run(
|
||||
`1:${index}`,
|
||||
String(index),
|
||||
`Channel ${index}`,
|
||||
`https://streams.test/${index}.m3u8`,
|
||||
logicalId,
|
||||
`channel ${index}`,
|
||||
`group-hash-${index}`,
|
||||
`language-hash-${index}`
|
||||
);
|
||||
}
|
||||
|
||||
test('/api/live/channels projection response is bounded for an oversized catalog', () => {
|
||||
const db = createProjectionDb();
|
||||
for (let index = 0; index < 1500; index++) seedProjectedChannel(db, index);
|
||||
|
||||
try {
|
||||
const result = liveProjection.getChannelPage({ includeHidden: false, userId: 7 }, db, {
|
||||
iptvOrgCatalog: { findMatch: () => null },
|
||||
epgMatching: { findBestMatch: () => null }
|
||||
});
|
||||
assert.ok(result.channels.length <= 250, `unbounded live channel response returned ${result.channels.length} entries`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('projected live list returns only a bounded variant preview', () => {
|
||||
const db = createProjectionDb();
|
||||
const logicalId = 'logical:heavy';
|
||||
db.prepare(`
|
||||
INSERT INTO logical_channels
|
||||
(id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at)
|
||||
VALUES (?, 'heavy channel', 'Heavy Channel', '1:0', 2500, 1, 1)
|
||||
`).run(logicalId);
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO playlist_items
|
||||
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data,
|
||||
logical_channel_id, grouping_fingerprint, grouping_input_hash, quality_label, quality_rank,
|
||||
variant_rank, language_code, language_confidence, language_detector, language_input_hash)
|
||||
VALUES (?, 1, ?, 'live', ?, 'general', '', ?, '{}', ?, 'heavy channel', ?, 'HD', 2, ?, 'en', 1, 'laya', ?)
|
||||
`);
|
||||
const insertAll = db.transaction(() => {
|
||||
for (let index = 0; index < 2500; index++) {
|
||||
insert.run(`1:${index}`, String(index), `Heavy Channel variant ${index}`,
|
||||
`https://streams.test/${index}.m3u8`, logicalId, `group-${index}`, index, `language-${index}`);
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
try {
|
||||
const result = liveProjection.getChannelPage({ includeHidden: false, userId: 7 }, db);
|
||||
assert.equal(result.channels.length, 1);
|
||||
assert.equal(result.channels[0].variantCount, 2500);
|
||||
assert.ok(result.channels[0].variants.length <= 3);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
@ -1,22 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const indexHtml = fs.readFileSync(path.join(root, 'public', 'index.html'), 'utf8');
|
||||
const settingsSource = fs.readFileSync(path.join(root, 'public', 'js', 'pages', 'Settings.js'), 'utf8');
|
||||
|
||||
test('Settings exposes separate live check-new and full rebuild actions', () => {
|
||||
assert.ok(/id="live-check-new-button"[^>]*>\s*Check for new streams\s*</.test(indexHtml), 'Settings should expose a Check for new streams button');
|
||||
assert.ok(/id="live-rebuild-catalog-button"[^>]*>\s*Run full refresh\s*</.test(indexHtml), 'Settings should expose a Run full refresh button');
|
||||
assert.match(indexHtml, /Sync IPTV sources and analyze only streams that are not already in the catalog\./);
|
||||
assert.match(indexHtml, /Refresh IPTV and EPG data, re-analyze every stream, and rebuild channel-to-EPG matches\./);
|
||||
});
|
||||
|
||||
test('Settings wires check-new and full rebuild buttons to distinct API actions', () => {
|
||||
assert.ok(/getElementById\('live-check-new-button'\).*addEventListener/s.test(settingsSource), 'Settings should bind the check-new button');
|
||||
assert.ok(/getElementById\('live-rebuild-catalog-button'\).*addEventListener/s.test(settingsSource), 'Settings should bind the full rebuild button');
|
||||
assert.ok(/projection\/check-new|checkNewStreams|checkNew/.test(settingsSource), 'Settings should call a check-new API action');
|
||||
assert.ok(/projection\/rebuild|rebuildProjection|runLiveAnalysis/.test(settingsSource), 'Settings should keep a full rebuild API action');
|
||||
});
|
||||
@ -1,168 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
const liveProjection = require('../server/services/liveProjectionService');
|
||||
const {
|
||||
LIVE_PROJECTION_COLUMNS,
|
||||
LIVE_PROJECTION_SCHEMA_SQL,
|
||||
ensureColumns
|
||||
} = require('../server/db/liveProjectionSchema');
|
||||
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const routeSource = fs.readFileSync(path.join(repoRoot, 'server', 'routes', 'live.js'), 'utf8');
|
||||
const projectionSource = fs.readFileSync(path.join(repoRoot, 'server', 'services', 'liveProjectionService.js'), 'utf8');
|
||||
const syncSource = fs.readFileSync(path.join(repoRoot, 'server', 'services', 'syncService.js'), 'utf8');
|
||||
|
||||
function routeBlock(endpoint) {
|
||||
const marker = `router.post('${endpoint}'`;
|
||||
const start = routeSource.indexOf(marker);
|
||||
assert.notEqual(start, -1, `${endpoint} route should exist`);
|
||||
const next = routeSource.indexOf('\nrouter.', start + marker.length);
|
||||
return routeSource.slice(start, next === -1 ? undefined : next);
|
||||
}
|
||||
|
||||
function createProjectionDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE playlist_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category_id TEXT,
|
||||
parent_id TEXT,
|
||||
stream_icon TEXT,
|
||||
stream_url TEXT,
|
||||
container_extension TEXT,
|
||||
rating REAL,
|
||||
year TEXT,
|
||||
added_at TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedProjectedChannel(db) {
|
||||
db.prepare(`
|
||||
INSERT INTO categories (id, source_id, category_id, type, name)
|
||||
VALUES ('1:sports', 1, 'sports', 'live', 'Sports')
|
||||
`).run();
|
||||
db.prepare(`
|
||||
INSERT INTO playlist_items
|
||||
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data,
|
||||
language_code, language_confidence, language_detector, language_input_hash)
|
||||
VALUES ('1:bein-fr', 1, 'bein-fr', 'live', '|FR| beIN Sports 1 HD', 'sports',
|
||||
'', 'https://streams.test/bein-fr.m3u8', '{"tvgId":"bein.fr"}',
|
||||
'fr', 0.99, 'laya', 'fixture-hash')
|
||||
`).run();
|
||||
liveProjection.rebuild(db);
|
||||
}
|
||||
|
||||
test('manual full rebuild route delegates to a complete orchestration service, not startRebuild directly', () => {
|
||||
const rebuildRoute = routeBlock('/projection/rebuild');
|
||||
assert.match(
|
||||
rebuildRoute,
|
||||
/(?:orchestr|full).*rebuild|rebuild.*(?:orchestr|full)/i,
|
||||
'POST /api/live/projection/rebuild should call a full rebuild orchestrator'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
rebuildRoute,
|
||||
/liveProjection\.startRebuild\(/,
|
||||
'full rebuild route should not bypass orchestration with liveProjection.startRebuild'
|
||||
);
|
||||
});
|
||||
|
||||
test('full rebuild orchestration contract orders live refresh, analyzeAll, EPG refresh, then persisted EPG match rebuild', () => {
|
||||
const combinedSource = `${routeSource}\n${projectionSource}\n${syncSource}`;
|
||||
const liveRefresh = combinedSource.search(/refresh(?:Enabled)?Live|syncLive|syncLiveSources|refreshLiveSources/i);
|
||||
const analyzeAll = combinedSource.search(/analyzeAll\s*:\s*true/);
|
||||
const epgRefresh = combinedSource.search(/refresh(?:Enabled)?Epg|syncEpg|refreshEpgSources/i);
|
||||
const epgMatchRebuild = combinedSource.search(/rebuild(?:Persisted)?EpgMatch|persist(?:ed)?.*epg.*match|epg.*match.*persist/i);
|
||||
|
||||
assert.ok(liveRefresh >= 0, 'full rebuild should refresh enabled live sources first');
|
||||
assert.ok(analyzeAll > liveRefresh, 'full rebuild should analyze all live channels after live refresh');
|
||||
assert.ok(epgRefresh > analyzeAll, 'full rebuild should refresh enabled EPG sources after analyzeAll');
|
||||
assert.ok(epgMatchRebuild > epgRefresh, 'full rebuild should rebuild persisted EPG matches after EPG refresh');
|
||||
});
|
||||
|
||||
test('full rebuild orchestration does not use per-source queueRebuild or Xtream VOD/series sync', () => {
|
||||
const rebuildRelatedSource = `${routeBlock('/projection/rebuild')}\n${projectionSource}`;
|
||||
assert.match(
|
||||
rebuildRelatedSource,
|
||||
/refresh(?:Enabled)?Live|syncLive|syncLiveSources|refreshLiveSources/i,
|
||||
'full rebuild should expose a live-only source refresh step'
|
||||
);
|
||||
assert.doesNotMatch(rebuildRelatedSource, /queueRebuild\(/, 'full rebuild should not enqueue per-source projection rebuilds');
|
||||
assert.doesNotMatch(rebuildRelatedSource, /getVod(?:Categories|Streams)\(/, 'full rebuild should not sync Xtream VOD');
|
||||
assert.doesNotMatch(rebuildRelatedSource, /getSeries(?:Categories)?\(/, 'full rebuild should not sync Xtream series');
|
||||
});
|
||||
|
||||
test('/api/live/channels reads persisted EPG matches instead of opportunistic heavy matching', () => {
|
||||
const db = createProjectionDb();
|
||||
seedProjectedChannel(db);
|
||||
try {
|
||||
assert.doesNotThrow(() => liveProjection.getChannelPage({
|
||||
q: 'bein sports 1',
|
||||
epgSources: [{ id: 7, enabled: true, type: 'epg' }]
|
||||
}, db, {
|
||||
iptvOrgCatalog: { findMatch: () => null },
|
||||
epgMatching: {
|
||||
findBestMatch: () => {
|
||||
throw new Error('opportunistic EPG matching should not run while reading live channels');
|
||||
}
|
||||
}
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('check-new performs only incremental live analysis, without full EPG refresh or analyzeAll', () => {
|
||||
const checkNewRoute = routeBlock('/projection/check-new');
|
||||
const checkNewImplementation = projectionSource.slice(
|
||||
projectionSource.indexOf('function checkNewStreams'),
|
||||
projectionSource.indexOf('function getChannels')
|
||||
);
|
||||
const checkNewSource = `${checkNewRoute}\n${checkNewImplementation}`;
|
||||
assert.doesNotMatch(checkNewSource, /analyzeAll\s*:\s*true/, 'check-new must not analyze the full catalog');
|
||||
assert.doesNotMatch(checkNewSource, /\.syncAll\(/, 'check-new must not run global sync');
|
||||
assert.doesNotMatch(checkNewSource, /syncEpg|refresh(?:Enabled)?Epg/i, 'check-new must not refresh all EPG sources');
|
||||
});
|
||||
|
||||
test('EPG matches are persisted by logical channel and language', () => {
|
||||
assert.match(
|
||||
LIVE_PROJECTION_SCHEMA_SQL,
|
||||
/CREATE TABLE IF NOT EXISTS .*epg.*match/i,
|
||||
'projection schema should declare a persisted EPG match table'
|
||||
);
|
||||
assert.match(
|
||||
LIVE_PROJECTION_SCHEMA_SQL,
|
||||
/UNIQUE\s*\(\s*logical_channel_id\s*,\s*language_code\s*\)/i,
|
||||
'persisted EPG matches should be unique per logical channel and language'
|
||||
);
|
||||
});
|
||||
@ -1,216 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { JSDOM } = require('jsdom');
|
||||
const Database = require('better-sqlite3');
|
||||
const {
|
||||
ensureColumns,
|
||||
LIVE_PROJECTION_COLUMNS,
|
||||
LIVE_PROJECTION_SCHEMA_SQL
|
||||
} = require('../server/db/liveProjectionSchema');
|
||||
const liveProjection = require('../server/services/liveProjectionService');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const script = relativePath => fs.readFileSync(path.join(root, 'public', relativePath), 'utf8');
|
||||
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
|
||||
function createDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE playlist_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category_id TEXT,
|
||||
parent_id TEXT,
|
||||
stream_icon TEXT,
|
||||
stream_url TEXT,
|
||||
container_extension TEXT,
|
||||
rating REAL,
|
||||
year TEXT,
|
||||
added_at TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedLive(db, id, name) {
|
||||
db.prepare(`
|
||||
INSERT INTO playlist_items
|
||||
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data)
|
||||
VALUES (?, 1, ?, 'live', ?, 'sports', '', ?, ?)
|
||||
`).run(
|
||||
`1:${id}`,
|
||||
id,
|
||||
name,
|
||||
`https://streams.test/${id}.m3u8`,
|
||||
JSON.stringify({ groupTitle: 'Sports' })
|
||||
);
|
||||
}
|
||||
|
||||
function createChannelListDom() {
|
||||
const dom = new JSDOM(`<!doctype html>
|
||||
<input id="channel-search">
|
||||
<select id="source-select"><option value="">All Sources</option></select>
|
||||
<input id="show-hidden" type="checkbox">
|
||||
<button id="toggle-groups"></button>
|
||||
<div id="channel-language-filter"></div>
|
||||
<div id="channel-list-status"></div>
|
||||
<div id="context-menu"></div>
|
||||
<div id="channel-list"></div>
|
||||
`, { url: 'http://nodecast.test/', runScripts: 'outside-only', pretendToBeVisual: true });
|
||||
const { window } = dom;
|
||||
window.IntersectionObserver = class { observe() {} disconnect() {} };
|
||||
window.eval(script('js/icons.js'));
|
||||
window.eval(script('js/services/channelLanguageDetection.js'));
|
||||
window.eval(script('js/services/channelLanguageFusion.js'));
|
||||
window.eval(script('js/services/channelVariantGrouping.js'));
|
||||
window.eval(script('js/services/layaStatusPresentation.js'));
|
||||
window.eval(script('js/services/liveChannelProjection.js'));
|
||||
window.eval(`${script('js/components/ChannelList.js')}\nwindow.ChannelList = ChannelList;`);
|
||||
window.app = {
|
||||
player: { settings: { liveChannelListMode: 'flat', languageDetectionMode: 'algo' } },
|
||||
epgGuide: { getCurrentProgram: () => null, loadError: null }
|
||||
};
|
||||
const channelList = new window.ChannelList();
|
||||
channelList.sources = [{ id: 1, type: 'xtream', name: 'Fixture source', enabled: true }];
|
||||
return { dom, window, channelList };
|
||||
}
|
||||
|
||||
test('full refresh analyzeAll runs Laya language detection after identity and persists recalculated languages', async () => {
|
||||
const db = createDb();
|
||||
seedLive(db, 'bein-east', 'beIN Sports 1 East');
|
||||
seedLive(db, 'bein-west', 'beIN Sports 1 West');
|
||||
|
||||
const order = [];
|
||||
const languagePayloads = [];
|
||||
const layaClient = {
|
||||
identityHintBatch: async pairs => {
|
||||
order.push('identity');
|
||||
return pairs.map(pair => ({
|
||||
id: pair.id,
|
||||
sameIdentityProbability: 0.9,
|
||||
confidence: 0.9,
|
||||
family: 'bein sports',
|
||||
number: '1'
|
||||
}));
|
||||
},
|
||||
detectBatch: async payload => {
|
||||
order.push('language');
|
||||
languagePayloads.push(payload);
|
||||
return {
|
||||
results: payload.items.map(item => ({
|
||||
id: item.id,
|
||||
language: 'fr',
|
||||
confidence: 0.97
|
||||
}))
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await liveProjection.rebuildWithSignals(db, { mode: 'laya', layaClient, analyzeAll: true });
|
||||
const rows = db.prepare(`
|
||||
SELECT id, language_code, language_detector, language_confidence
|
||||
FROM playlist_items
|
||||
WHERE type = 'live'
|
||||
ORDER BY id
|
||||
`).all();
|
||||
|
||||
assert.deepEqual(order, ['identity', 'language']);
|
||||
assert.equal(languagePayloads.length, 1);
|
||||
assert.deepEqual(rows.map(row => row.language_code), ['fr', 'fr']);
|
||||
assert.deepEqual(rows.map(row => row.language_detector), ['laya', 'laya']);
|
||||
assert.ok(rows.every(row => Number(row.language_confidence) === 0.97));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('search result variant expansion uses logicalChannelId and lists the currently selected stream', async () => {
|
||||
const { dom, window, channelList } = createChannelListDom();
|
||||
let variantsRequestedFor = null;
|
||||
|
||||
window.API = {
|
||||
live: {
|
||||
getChannels: async options => {
|
||||
assert.equal(options.q, 'bein sports 1');
|
||||
return {
|
||||
projection: { stale: false },
|
||||
filters: { availableLanguages: [{ code: 'fr', short: 'FR', label: 'French' }] },
|
||||
channels: [{
|
||||
id: 'logical:bein-sports-1:fr',
|
||||
logicalChannelId: 'logical:bein-sports-1',
|
||||
displayName: 'beIN Sports 1',
|
||||
language: { code: 'fr', short: 'FR', label: 'French', confidence: 97 },
|
||||
selectedVariantId: '1:bein-4k',
|
||||
variantCount: 3,
|
||||
variants: [{
|
||||
id: 'bein-hd',
|
||||
sourceId: 1,
|
||||
name: 'beIN Sports 1 HD',
|
||||
categoryName: 'Sports',
|
||||
isHidden: false,
|
||||
language: { code: 'fr', short: 'FR', label: 'French', confidence: 97 }
|
||||
}]
|
||||
}]
|
||||
};
|
||||
},
|
||||
getChannelVariants: async logicalChannelId => {
|
||||
variantsRequestedFor = logicalChannelId;
|
||||
if (logicalChannelId !== 'logical:bein-sports-1') return { variants: [], page: { hasMore: false } };
|
||||
return {
|
||||
page: { hasMore: false },
|
||||
variants: [
|
||||
{ id: 'bein-hd', sourceId: 1, name: 'beIN Sports 1 HD', categoryName: 'Sports', isHidden: false },
|
||||
{ id: 'bein-4k', sourceId: 1, name: 'beIN Sports 1 4K', categoryName: 'Sports', isHidden: false },
|
||||
{ id: 'bein-backup', sourceId: 1, name: 'beIN Sports 1 Backup', categoryName: 'Sports', isHidden: false }
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
channelList.searchInput.value = 'bein sports 1';
|
||||
await channelList.loadChannels();
|
||||
|
||||
const toggle = window.document.querySelector('.variant-toggle');
|
||||
assert.ok(toggle, 'search result should expose a stream list toggle');
|
||||
toggle.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
|
||||
await wait(20);
|
||||
|
||||
assert.equal(variantsRequestedFor, 'logical:bein-sports-1');
|
||||
assert.match(channelList.container.textContent, /beIN Sports 1 4K/);
|
||||
const playing = channelList.container.querySelector('.variant-playing-badge');
|
||||
assert.equal(playing?.textContent, 'Playing');
|
||||
assert.equal(playing?.closest('.channel-variant')?.dataset.variantId, 'bein-4k');
|
||||
} finally {
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user