Merge branch 'feature/t34-universal-browser-casting' into develop (#34)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-06 22:40:34 +02:00
15 changed files with 1394 additions and 9 deletions

View File

@ -119,6 +119,37 @@ 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
@ -279,6 +310,20 @@ 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,6 +12,8 @@
"@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",
@ -178,6 +180,69 @@
"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",
@ -354,6 +419,16 @@
"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",
@ -436,12 +511,82 @@
"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",
@ -613,6 +758,18 @@
"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",
@ -844,6 +1001,12 @@
"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",
@ -1346,6 +1509,12 @@
"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",
@ -1448,6 +1617,19 @@
"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",
@ -1669,6 +1851,38 @@
"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",
@ -2100,6 +2314,12 @@
"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",
@ -2182,6 +2402,12 @@
"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",
@ -2376,6 +2602,15 @@
"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,6 +13,8 @@
"@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",
@ -27,6 +29,11 @@
"optionalDependencies": {
"ffmpeg-static": "^5.3.0"
},
"overrides": {
"castv2": {
"protobufjs": "^7.6.3"
}
},
"engines": {
"node": ">=18.0.0"
},

View File

@ -4610,6 +4610,88 @@ 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

@ -1218,11 +1218,27 @@
</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=6"></script>
<script src="/js/api.js?v=7"></script>
<script src="/js/services/castService.js?v=1"></script>
<script src="/js/components/VideoPlayer.js?v=6"></script>
<script src="/js/services/serverCastDevicePicker.js?v=1"></script>
<script src="/js/components/VideoPlayer.js?v=7"></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>
@ -1238,7 +1254,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=2"></script>
<script src="/js/pages/WatchPage.js?v=3"></script>
<script src="/js/app.js?v=4"></script>
<script>
// Check for SSO token in URL

View File

@ -65,6 +65,17 @@ 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}` : ''}`),

View File

@ -457,10 +457,23 @@ class VideoPlayer {
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;
button.disabled = !state.available || state.status === 'connecting';
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'
@ -470,7 +483,8 @@ class VideoPlayer {
button.addEventListener('click', async event => {
event.stopPropagation();
try {
await this.castService.prompt();
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') {
@ -481,6 +495,20 @@ class VideoPlayer {
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

@ -251,10 +251,23 @@ class WatchPage {
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;
button.disabled = !state.available || state.status === 'connecting';
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'
@ -264,7 +277,8 @@ class WatchPage {
button.addEventListener('click', async event => {
event.stopPropagation();
try {
await this.castService.prompt();
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') {
@ -275,6 +289,20 @@ class WatchPage {
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

@ -0,0 +1,181 @@
(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

@ -182,6 +182,7 @@ 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) => {

117
server/routes/cast.js Normal file
View File

@ -0,0 +1,117 @@
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

@ -0,0 +1,76 @@
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

@ -0,0 +1,126 @@
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

@ -0,0 +1,131 @@
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 };

301
test/t34ServerCast.test.js Normal file
View File

@ -0,0 +1,301 @@
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
);
}
});