Compare commits

1 Commits

Author SHA1 Message Date
635e50bdcc Merge branch 'develop' into main for v1.0.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-06 21:41:30 +02:00
38 changed files with 170 additions and 3487 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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')
},

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

@ -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…' : '')

View File

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

View File

@ -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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
}
return { ServerCastDevicePicker };
});

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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