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:
45
README.md
45
README.md
@ -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
235
package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}` : ''}`),
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
181
public/js/services/serverCastDevicePicker.js
Normal file
181
public/js/services/serverCastDevicePicker.js
Normal 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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
|
||||
return { ServerCastDevicePicker };
|
||||
});
|
||||
@ -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
117
server/routes/cast.js
Normal 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;
|
||||
76
server/services/castMediaUrlService.js
Normal file
76
server/services/castMediaUrlService.js
Normal 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 };
|
||||
126
server/services/castSessionService.js
Normal file
126
server/services/castSessionService.js
Normal 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 };
|
||||
131
server/services/googleCastAdapter.js
Normal file
131
server/services/googleCastAdapter.js
Normal 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
301
test/t34ServerCast.test.js
Normal 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
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user