Merge branch 'develop' into main for v1.0.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@ -0,0 +1,18 @@
|
||||
.git
|
||||
.github
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
*.log
|
||||
.npm
|
||||
.cache
|
||||
coverage
|
||||
|
||||
# Runtime state must come from volumes or be created inside the image.
|
||||
data
|
||||
transcode-cache
|
||||
|
||||
# Never bake local secrets into an image.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@ -40,8 +40,9 @@ WORKDIR /app
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies (better-sqlite3 will build from source using g++ installed above)
|
||||
RUN npm ci --only=production
|
||||
# Install dependencies (better-sqlite3 will build for the image's Node.js ABI).
|
||||
# node_modules is excluded by .dockerignore so COPY below cannot overwrite it.
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
23
Dockerfile.laya
Normal file
23
Dockerfile.laya
Normal file
@ -0,0 +1,23 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ARG TORCH_VERSION=2.14.1
|
||||
|
||||
# Install the CPU wheel explicitly before Laya so pip does not select the much
|
||||
# larger CUDA build. Model weights remain runtime-only in the mounted cache.
|
||||
RUN python -m pip install --no-cache-dir \
|
||||
"torch==${TORCH_VERSION}" \
|
||||
--index-url https://download.pytorch.org/whl/cpu \
|
||||
&& python -m pip install --no-cache-dir "laya[serve]>=0.3.22,<0.4" \
|
||||
&& python -m pip check \
|
||||
&& python -c "import torch; assert torch.version.cuda is None, torch.version.cuda"
|
||||
|
||||
ENV HF_HOME=/models \
|
||||
LAYA_HOST=0.0.0.0 \
|
||||
LAYA_PORT=8000 \
|
||||
LAYA_PRELOAD=0 \
|
||||
LAYA_DEVICE=cpu
|
||||
|
||||
VOLUME ["/models"]
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["laya-serve"]
|
||||
110
README.md
110
README.md
@ -64,31 +64,60 @@ nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (V
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
You can run nodecast-tv easily using Docker.
|
||||
The provided `docker-compose.yml` is standalone: copy that file to an empty deployment directory and run Compose there. You do not need to clone the repository or keep any Dockerfile for a normal deployment. Application data is stored in the `./data` directory next to the Compose file.
|
||||
|
||||
1. Create a `docker-compose.yml` file (or copy the one from this repo):
|
||||
#### Standard deployment
|
||||
|
||||
```yaml
|
||||
services:
|
||||
nodecast-tv:
|
||||
build: https://github.com/technomancer702/nodecast-tv.git#main
|
||||
container_name: nodecast-tv
|
||||
ports:
|
||||
- "3000:3000" # Host:Container
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000 # Optional: Internal container port
|
||||
```
|
||||
Start nodecast-tv without the optional Laya runtime:
|
||||
|
||||
2. Run the container:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:3000`.
|
||||
Compose pulls `${NODECAST_IMAGE}` when set, or `gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest` by default. The application will be available at `http://localhost:3000`. Live channel analysis continues to work in **Standard** mode when Laya is not running.
|
||||
|
||||
#### Optional Laya runtime
|
||||
|
||||
Laya provides optional local-AI assistance for channel grouping and language detection. It is behind the Compose profile named `laya`, so a plain `docker compose up -d` does not start it.
|
||||
|
||||
Start nodecast-tv and Laya together:
|
||||
|
||||
```bash
|
||||
docker compose --profile laya up -d
|
||||
```
|
||||
|
||||
Pull newer images and recreate both services after an update:
|
||||
|
||||
```bash
|
||||
docker compose --profile laya pull
|
||||
docker compose --profile laya up -d --force-recreate
|
||||
```
|
||||
|
||||
Recreate only Laya:
|
||||
|
||||
```bash
|
||||
docker compose --profile laya pull laya
|
||||
docker compose --profile laya up -d --force-recreate laya
|
||||
```
|
||||
|
||||
The optional service uses `${LAYA_IMAGE}` when set, or the project image `gitea.anthonybouteiller.ovh/blomios/nodecast-tv-laya:latest` by default. That image must be published and accessible in your environment. If it is hosted under another registry or tag, create a `.env` file next to `docker-compose.yml`:
|
||||
|
||||
```env
|
||||
NODECAST_IMAGE=gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest
|
||||
LAYA_IMAGE=registry.example.com/team/nodecast-tv-laya:latest
|
||||
```
|
||||
|
||||
No third-party Laya image is selected implicitly. If no suitable Laya image is available, omit the `laya` profile and continue using Standard mode. The Laya model cache is persisted under `./data/models/laya`; its first analysis can take longer while the runtime downloads or loads the model.
|
||||
|
||||
Inside Compose, nodecast-tv reaches Laya at `http://laya:8000`. When running nodecast-tv directly on the host with a separately exposed Laya runtime, use:
|
||||
|
||||
```bash
|
||||
LAYA_BASE_URL=http://localhost:8000 npm start
|
||||
```
|
||||
|
||||
`http://laya:8000` is a Compose-internal hostname and does not resolve from a host process.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
### Hardware Acceleration Setup
|
||||
@ -211,6 +240,45 @@ All transcoding and stream processing settings are found in **Settings → Trans
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker Compose and optional Laya
|
||||
|
||||
Check that you are in the repository directory and that Compose sees both services:
|
||||
|
||||
```bash
|
||||
docker compose config --services
|
||||
docker compose --profile laya config --services
|
||||
docker compose config --profiles
|
||||
```
|
||||
|
||||
The standard configuration should list `nodecast-tv`; with the `laya` profile enabled it should list both `nodecast-tv` and `laya`. The available profiles should include `laya`.
|
||||
|
||||
If `docker compose ... laya` reports `no such service: laya`, you are usually using an old or different Compose file, or running the command from the wrong directory. Replace it with the current standalone `docker-compose.yml`, change to the directory containing that file, and run `docker compose config --services` again.
|
||||
|
||||
If Compose cannot pull the Laya image, verify that `LAYA_IMAGE` names a published image and authenticate to its registry if necessary. This does not prevent the standard nodecast-tv service from running without the `laya` profile.
|
||||
|
||||
If Settings reports **“The optional Laya runtime is unavailable”**, nodecast-tv remains usable and falls back to Standard analysis. To diagnose Laya, run:
|
||||
|
||||
```bash
|
||||
docker compose --profile laya ps
|
||||
docker compose --profile laya logs nodecast-tv
|
||||
docker compose --profile laya logs laya
|
||||
docker compose --profile laya config
|
||||
```
|
||||
|
||||
After configuration or image changes, pull and perform a clean recreation:
|
||||
|
||||
```bash
|
||||
docker compose --profile laya pull
|
||||
docker compose --profile laya up -d --force-recreate
|
||||
```
|
||||
|
||||
To recreate only the optional runtime:
|
||||
|
||||
```bash
|
||||
docker compose --profile laya pull laya
|
||||
docker compose --profile laya up -d --force-recreate laya
|
||||
```
|
||||
|
||||
### Video Won't Play (Black Screen or Loading Forever)
|
||||
|
||||
| Symptom | Likely Cause | Solution |
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
services:
|
||||
nodecast-tv:
|
||||
image: ghcr.io/technomancer702/nodecast-tv:latest
|
||||
build: https://github.com/technomancer702/nodecast-tv.git#main
|
||||
image: ${NODECAST_IMAGE:-gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest}
|
||||
container_name: nodecast-tv
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@ -11,3 +10,25 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000 # Internal container port
|
||||
- LAYA_BASE_URL=http://laya:8000
|
||||
- LAYA_BATCH_TIMEOUT_MS=120000
|
||||
devices:
|
||||
- /dev/dri:/dev/dri # Required for VAAPI/QuickSync/AMF (Linux)
|
||||
|
||||
laya:
|
||||
profiles: ["laya"]
|
||||
image: ${LAYA_IMAGE:-gitea.anthonybouteiller.ovh/blomios/nodecast-tv-laya:latest}
|
||||
volumes:
|
||||
- ./data/models/laya:/models
|
||||
environment:
|
||||
- HF_HOME=/models
|
||||
- LAYA_PRELOAD=0
|
||||
- LAYA_HOST=0.0.0.0
|
||||
- LAYA_PORT=8000
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
534
package-lock.json
generated
534
package-lock.json
generated
@ -23,6 +23,9 @@
|
||||
"sax": "^1.4.3",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^22.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@ -175,6 +178,16 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
|
||||
"integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "10.17.60",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
|
||||
@ -182,6 +195,14 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/abab": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
|
||||
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
|
||||
"deprecated": "Use your platform's native atob() and btoa() methods instead",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@ -199,8 +220,8 @@
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
@ -212,8 +233,8 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
@ -230,8 +251,8 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
@ -239,6 +260,13 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@ -414,6 +442,19 @@
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
@ -466,6 +507,34 @@
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz",
|
||||
"integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rrweb-cssom": "^0.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz",
|
||||
"integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abab": "^2.0.6",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"whatwg-url": "^12.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
@ -475,6 +544,13 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
@ -499,6 +575,16 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@ -527,6 +613,20 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/domexception": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
|
||||
"integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
@ -586,6 +686,19 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@ -626,6 +739,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@ -756,6 +885,23 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@ -856,10 +1002,26 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@ -868,6 +1030,19 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
||||
"integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@ -888,6 +1063,46 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
|
||||
"integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tootallnate/once": "2",
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent/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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-response-object": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
|
||||
@ -902,8 +1117,8 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
@ -916,8 +1131,8 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
@ -934,8 +1149,8 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
@ -990,6 +1205,56 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "22.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
|
||||
"integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abab": "^2.0.6",
|
||||
"cssstyle": "^3.0.0",
|
||||
"data-urls": "^4.0.0",
|
||||
"decimal.js": "^10.4.3",
|
||||
"domexception": "^4.0.0",
|
||||
"form-data": "^4.0.0",
|
||||
"html-encoding-sniffer": "^3.0.0",
|
||||
"http-proxy-agent": "^5.0.0",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.4",
|
||||
"parse5": "^7.1.2",
|
||||
"rrweb-cssom": "^0.6.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^4.1.2",
|
||||
"w3c-xmlserializer": "^4.0.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^2.0.0",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"whatwg-url": "^12.0.1",
|
||||
"ws": "^8.13.0",
|
||||
"xml-name-validator": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^2.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
@ -1210,6 +1475,13 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.28",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz",
|
||||
"integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oauth": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
|
||||
@ -1264,6 +1536,19 @@
|
||||
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@ -1397,6 +1682,19 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/lupomontero"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||
@ -1407,6 +1705,16 @@
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
@ -1422,6 +1730,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/random-bytes": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||
@ -1484,6 +1799,20 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz",
|
||||
"integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@ -1516,6 +1845,19 @@
|
||||
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
@ -1723,6 +2065,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
@ -1760,6 +2109,35 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz",
|
||||
"integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
@ -1804,6 +2182,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
@ -1813,6 +2201,17 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@ -1837,12 +2236,118 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
||||
"integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
|
||||
"integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz",
|
||||
"integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^4.1.1",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
|
||||
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
|
||||
"integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
|
||||
@ -1864,6 +2369,13 @@
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
"start": "node server/index.js",
|
||||
"dev": "node --watch server/index.js"
|
||||
"dev": "node --watch server/index.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||
@ -28,5 +29,8 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^22.1.0"
|
||||
}
|
||||
}
|
||||
@ -537,6 +537,82 @@ h4 {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.language-filter {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.language-filter-label {
|
||||
width: 100%;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.language-chip {
|
||||
min-height: 32px;
|
||||
padding: 4px 9px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.language-chip[aria-pressed="true"] {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-dim);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.language-more-panel {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.language-search {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
margin-bottom: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.language-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.source-select {
|
||||
flex: 1;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
@ -647,6 +723,28 @@ h4 {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.channel-logo-wrap {
|
||||
position: relative;
|
||||
flex: 0 0 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.channel-language-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
min-width: 20px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.channel-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@ -667,6 +765,242 @@ h4 {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.variant-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logical-channel.expanded .variant-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.channel-variants {
|
||||
margin: 0 var(--space-md) var(--space-xs) 56px;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.channel-variant {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(52px, auto) minmax(64px, auto) minmax(64px, auto) 1fr;
|
||||
gap: var(--space-sm);
|
||||
padding: 6px var(--space-sm);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-variant:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.channel-variant.is-hidden {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.variant-quality {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.variant-language {
|
||||
justify-self: start;
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.68rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-matching-settings {
|
||||
margin-top: var(--space-lg);
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.matching-mode-options {
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
margin: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.matching-mode-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.matching-mode-card:has(input:checked) {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-dim);
|
||||
}
|
||||
|
||||
.matching-mode-card span,
|
||||
.matching-mode-card small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.matching-mode-card small {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.laya-model-panel {
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.live-analysis-panel {
|
||||
margin-bottom: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.live-analysis-panel [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.live-analysis-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.live-analysis-heading {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.laya-model-panel [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.laya-model-heading,
|
||||
.laya-model-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.laya-component-status {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
#laya-service-state {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.laya-start-command {
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
overflow-x: auto;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.laya-notice-action {
|
||||
margin-left: var(--space-sm);
|
||||
padding: 2px var(--space-xs);
|
||||
}
|
||||
|
||||
.laya-progress {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.laya-progress-track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.laya-progress-bar {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--color-accent);
|
||||
transition: width var(--transition-fast);
|
||||
}
|
||||
|
||||
.laya-progress-track.indeterminate .laya-progress-bar {
|
||||
width: 35%;
|
||||
animation: laya-progress-indeterminate 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes laya-progress-indeterminate {
|
||||
from { transform: translateX(-120%); }
|
||||
to { transform: translateX(360%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.laya-progress-track.indeterminate .laya-progress-bar {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
opacity: .45;
|
||||
}
|
||||
}
|
||||
|
||||
.laya-details-toggle {
|
||||
margin-top: var(--space-sm);
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.laya-error-details {
|
||||
margin-top: var(--space-xs);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-muted);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.laya-model-actions {
|
||||
justify-content: flex-start;
|
||||
margin: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.laya-language-notice,
|
||||
.live-projection-notice {
|
||||
margin: var(--space-sm) var(--space-md);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.variant-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
Video Player Section
|
||||
===================================================== */
|
||||
@ -1284,11 +1618,124 @@ video::cue {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.source-meta {
|
||||
margin-top: 2px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
display: inline-block;
|
||||
margin-left: var(--space-xs);
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent-dim);
|
||||
color: var(--color-accent);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.language-chip,
|
||||
.variant-toggle,
|
||||
.source-actions .btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.source-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.epg-sources-section h4 {
|
||||
margin: var(--space-lg) 0 var(--space-xs);
|
||||
}
|
||||
|
||||
.epg-sources-section .setting-hint + .setting-hint {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.epg-order-status {
|
||||
min-height: 1.25rem;
|
||||
margin: var(--space-sm) 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.epg-order-status[data-state="success"] {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.epg-order-status[data-state="error"] {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.epg-priority-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.epg-priority-rank {
|
||||
display: grid;
|
||||
min-width: 68px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.6875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.epg-priority-rank strong {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.epg-move-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 44px);
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.epg-move-actions .btn,
|
||||
.epg-source-item .source-actions .btn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.epg-empty-state {
|
||||
padding: var(--space-lg);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.inactive-sources-heading {
|
||||
margin-top: var(--space-xl) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.epg-source-item {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.epg-priority-controls {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.epg-source-item .source-info {
|
||||
min-width: calc(100% - 64px);
|
||||
}
|
||||
|
||||
.epg-source-item .source-actions {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hidden Items */
|
||||
.hidden-list {
|
||||
display: flex;
|
||||
@ -1702,6 +2149,44 @@ kbd {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Native select popups do not consistently inherit the application theme. */
|
||||
select.source-select,
|
||||
select.form-input {
|
||||
color-scheme: dark;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
select.source-select:focus,
|
||||
select.form-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
select.source-select option,
|
||||
select.source-select optgroup,
|
||||
select.form-input option,
|
||||
select.form-input optgroup {
|
||||
background-color: #1a1a25;
|
||||
color: #f1f1f5;
|
||||
}
|
||||
|
||||
select.source-select option:hover,
|
||||
select.source-select option:focus,
|
||||
select.source-select option:checked,
|
||||
select.form-input option:hover,
|
||||
select.form-input option:focus,
|
||||
select.form-input option:checked {
|
||||
background-color: #2a2a3a;
|
||||
color: #f1f1f5;
|
||||
}
|
||||
|
||||
select.source-select option:disabled,
|
||||
select.source-select optgroup:disabled,
|
||||
select.form-input option:disabled,
|
||||
select.form-input optgroup:disabled {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
Context Menu
|
||||
===================================================== */
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<title>nodecast-tv | IPTV Player</title>
|
||||
<meta name="description" content="Stream live TV, movies, and series with EPG support">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
<link rel="stylesheet" href="css/main.css?v=8">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@800&display=swap"
|
||||
@ -126,8 +126,8 @@
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header-row">
|
||||
<div class="search-wrapper">
|
||||
<input type="text" id="channel-search" placeholder="Search channels..." class="search-input">
|
||||
<button type="button" class="search-clear" title="Clear search">×</button>
|
||||
<input type="text" id="channel-search" placeholder="Search channels…" class="search-input">
|
||||
<button type="button" class="search-clear" title="Clear search" aria-label="Clear channel search">×</button>
|
||||
</div>
|
||||
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn"
|
||||
title="Collapse sidebar">
|
||||
@ -148,6 +148,8 @@
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<div class="language-filter" id="channel-language-filter" aria-label="Languages"></div>
|
||||
<div id="channel-list-status" class="sr-only" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div id="channel-list" class="channel-list">
|
||||
<div class="empty-state">
|
||||
@ -419,12 +421,18 @@
|
||||
<div id="m3u-list" class="source-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="source-section">
|
||||
<div class="source-section epg-sources-section">
|
||||
<div class="section-header">
|
||||
<h3>EPG Sources</h3>
|
||||
<button class="btn btn-primary" id="add-epg">+ Add EPG</button>
|
||||
</div>
|
||||
<div id="epg-list" class="source-list"></div>
|
||||
<h4>Active sources</h4>
|
||||
<p class="setting-hint">Sources are checked from top to bottom.</p>
|
||||
<p class="setting-hint">Higher sources take priority; lower sources fill missing program data.</p>
|
||||
<div id="epg-order-status" class="epg-order-status" role="status" aria-live="polite"></div>
|
||||
<div id="epg-active-list" class="source-list epg-priority-list"></div>
|
||||
<h4 class="inactive-sources-heading">Inactive sources</h4>
|
||||
<div id="epg-inactive-list" class="source-list"></div>
|
||||
</div>
|
||||
|
||||
<div class="source-section">
|
||||
@ -454,6 +462,83 @@
|
||||
|
||||
<!-- Player Tab -->
|
||||
<div id="tab-player" class="tab-content">
|
||||
<div class="settings-section">
|
||||
<h3>Live TV</h3>
|
||||
<div class="setting-item">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" id="setting-group-live-channels" checked>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Group channels by category</span>
|
||||
<span class="setting-hint">Turn off to show all channels in a single list. Stream variants remain grouped by channel.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channel-matching-settings">
|
||||
<h4>Channel matching</h4>
|
||||
<p class="setting-hint">Choose how Nodecast TV identifies matching streams and languages.</p>
|
||||
<div class="live-analysis-panel" id="live-analysis-panel">
|
||||
<div class="live-analysis-heading">
|
||||
<div>
|
||||
<strong>Channel analysis</strong>
|
||||
<div class="setting-hint">Group matching streams into channels and update detected languages.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="live-analysis-button">Analyze channels</button>
|
||||
</div>
|
||||
<p class="setting-hint">Use this to analyze channels that were added before automatic analysis was enabled.</p>
|
||||
<div id="live-analysis-status" class="setting-hint" role="status">Checking status…</div>
|
||||
<div id="live-analysis-progress" class="laya-progress" hidden>
|
||||
<div id="live-analysis-progress-track" class="laya-progress-track" role="progressbar" aria-label="Channel analysis progress">
|
||||
<span id="live-analysis-progress-bar" class="laya-progress-bar"></span>
|
||||
</div>
|
||||
<div id="live-analysis-progress-text" class="setting-hint"></div>
|
||||
</div>
|
||||
<div id="live-analysis-fallback" class="setting-hint"></div>
|
||||
<button type="button" class="btn btn-ghost laya-details-toggle" id="live-analysis-details-toggle" aria-expanded="false" hidden>Show details</button>
|
||||
<pre id="live-analysis-error-details" class="laya-error-details" hidden></pre>
|
||||
</div>
|
||||
<div class="matching-mode-options">
|
||||
<label class="matching-mode-card">
|
||||
<input type="radio" name="language-detection-mode" value="algo" checked>
|
||||
<span><strong>Standard (Recommended)</strong><small>Use the built-in matching system. Fast and no download required.</small></span>
|
||||
</label>
|
||||
<label class="matching-mode-card">
|
||||
<input type="radio" name="language-detection-mode" value="laya">
|
||||
<span><strong>Local AI only</strong><small>Use the downloaded AI model instead of the built-in matching system.</small></span>
|
||||
</label>
|
||||
<label class="matching-mode-card">
|
||||
<input type="radio" name="language-detection-mode" value="hybrid">
|
||||
<span><strong>Enhanced</strong><small>Combine the local AI model with the built-in matching system for more evidence.</small></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="laya-model-panel" id="laya-model-panel">
|
||||
<div class="laya-model-heading">
|
||||
<div><strong>Local AI · Laya</strong><div id="laya-model-status" class="setting-hint" role="status">Checking status…</div></div>
|
||||
</div>
|
||||
<div class="laya-component-status"><span id="laya-model-state">Model: Checking…</span><span id="laya-model-size" class="setting-hint"></span></div>
|
||||
<div id="laya-service-state">Local AI service: Checking…</div>
|
||||
<div id="laya-model-message" class="setting-hint"></div>
|
||||
<div id="laya-download-progress" class="laya-progress" hidden>
|
||||
<div id="laya-progress-track" class="laya-progress-track" role="progressbar" aria-label="Local AI model download progress">
|
||||
<span id="laya-progress-bar" class="laya-progress-bar"></span>
|
||||
</div>
|
||||
<div id="laya-progress-text" class="setting-hint"></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost laya-details-toggle" id="laya-show-details" aria-expanded="false" hidden>Show details</button>
|
||||
<pre id="laya-error-details" class="laya-error-details" hidden></pre>
|
||||
<div class="laya-model-actions">
|
||||
<button type="button" class="btn btn-primary" id="laya-download-model">Download model</button>
|
||||
<button type="button" class="btn btn-ghost" id="laya-remove-model">Remove downloaded model</button>
|
||||
<button type="button" class="btn btn-ghost" id="laya-check-again" hidden>Check again</button>
|
||||
<button type="button" class="btn btn-ghost" id="laya-copy-command" hidden>Copy start command</button>
|
||||
<button type="button" class="btn btn-ghost" id="laya-use-standard" hidden>Use Standard</button>
|
||||
</div>
|
||||
<div id="laya-start-command" class="laya-start-command" hidden><code>docker compose --profile laya up -d</code></div>
|
||||
<p class="setting-hint">The model runs on this server. Channel names and metadata are not sent to an AI service.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Keyboard Controls</h3>
|
||||
<div class="setting-item">
|
||||
@ -539,7 +624,7 @@
|
||||
<h3>Encoder Settings</h3>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Hardware Encoder</span>
|
||||
<span class="setting-label">Transcoding Device</span>
|
||||
<span class="setting-hint">Select which GPU encoder to use for transcoding. Auto will use the best
|
||||
available.</span>
|
||||
</div>
|
||||
@ -554,7 +639,7 @@
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Max Resolution</span>
|
||||
<span class="setting-label">Maximum Transcoded Resolution</span>
|
||||
<span class="setting-hint">Limit transcoded output resolution. Lower = faster, more
|
||||
compatible.</span>
|
||||
</div>
|
||||
@ -567,7 +652,7 @@
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Quality Preset</span>
|
||||
<span class="setting-label">Encoding Quality</span>
|
||||
<span class="setting-hint">Higher quality uses more bandwidth and CPU/GPU.</span>
|
||||
</div>
|
||||
<select id="setting-quality" class="source-select" style="width: auto; min-width: 200px;">
|
||||
@ -578,7 +663,7 @@
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Audio Mix Preset</span>
|
||||
<span class="setting-label">Audio Downmix</span>
|
||||
<span class="setting-hint">How multi-channel audio (5.1/7.1) is mixed down to stereo for browser
|
||||
playback.</span>
|
||||
</div>
|
||||
@ -600,14 +685,14 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Enable Upscaling</span>
|
||||
<span class="setting-label">Upscale Low-Resolution Video</span>
|
||||
<span class="setting-hint">Upscale lower-resolution content to a higher resolution during
|
||||
transcoding.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item" id="upscale-method-container" style="display: none;">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Upscale Method</span>
|
||||
<span class="setting-label">Upscaling Method</span>
|
||||
<span class="setting-hint">Hardware is faster; Software (Lanczos) is higher quality.</span>
|
||||
</div>
|
||||
<select id="setting-upscale-method" class="source-select" style="width: auto; min-width: 200px;">
|
||||
@ -617,7 +702,7 @@
|
||||
</div>
|
||||
<div class="setting-item" id="upscale-target-container" style="display: none;">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Upscale Target</span>
|
||||
<span class="setting-label">Target Resolution</span>
|
||||
<span class="setting-hint">Target resolution for upscaled output.</span>
|
||||
</div>
|
||||
<select id="setting-upscale-target" class="source-select" style="width: auto; min-width: 200px;">
|
||||
@ -629,25 +714,43 @@
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Stream Processing</h3>
|
||||
<h3>Automatic Playback</h3>
|
||||
<div class="setting-item">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" id="setting-auto-transcode-tc">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Auto Transcode (Smart)</span>
|
||||
<span class="setting-label">Choose Playback Method Automatically</span>
|
||||
<span class="setting-hint">Automatically detect stream codecs and only transcode/remux when needed.
|
||||
Adds ~1-3s probe delay on first play.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Maximum Streaming Bandwidth</span>
|
||||
<span class="setting-hint">The bandwidth limit is evaluated when playback starts. Quality does not change automatically during playback.</span>
|
||||
</div>
|
||||
<select id="setting-max-direct-bitrate" class="source-select" style="width: auto; min-width: 200px;">
|
||||
<option value="0">No Limit</option>
|
||||
<option value="20000">20 Mbps</option>
|
||||
<option value="12000">12 Mbps</option>
|
||||
<option value="8000">8 Mbps</option>
|
||||
<option value="4000">4 Mbps</option>
|
||||
<option value="2000">2 Mbps</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Stream Processing</h3>
|
||||
<div class="setting-item">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" id="setting-force-transcode-tc">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Force Audio Transcode</span>
|
||||
<span class="setting-label">Always Convert Audio</span>
|
||||
<span class="setting-hint">Transcode audio to AAC for browser compatibility. Enable if you get video
|
||||
but no audio (fixes Dolby/AC3/EAC3).</span>
|
||||
</div>
|
||||
@ -658,7 +761,7 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Force Video Transcode</span>
|
||||
<span class="setting-label">Always Convert Video</span>
|
||||
<span class="setting-hint">Force full video transcoding (burn-in subtitles, incompatible video).
|
||||
Uses
|
||||
HW encoder.</span>
|
||||
@ -670,14 +773,14 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Force Remux</span>
|
||||
<span class="setting-label">Always Remux Streams</span>
|
||||
<span class="setting-hint">Remux streams to MP4 container. Enable for raw .ts streams from IPTV
|
||||
middleware like m3u-editor or dispatcharr.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Stream Output Format</span>
|
||||
<span class="setting-label">Stream Container</span>
|
||||
<span class="setting-hint">Container format for Xtream streams. Try TS if you experience buffering
|
||||
issues.</span>
|
||||
</div>
|
||||
@ -686,6 +789,15 @@
|
||||
<option value="ts">MPEG-TS (ts) - Better buffer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" id="setting-allow-hardware-fallback">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Use CPU if hardware transcoding fails</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
@ -1096,17 +1208,23 @@
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="/js/icons.js"></script>
|
||||
<script src="/js/api.js?v=2"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=2"></script>
|
||||
<script src="/js/components/ChannelList.js?v=3"></script>
|
||||
<script src="/js/components/SourceManager.js?v=2"></script>
|
||||
<script src="/js/components/EpgGuide.js?v=2"></script>
|
||||
<script src="/js/api.js?v=6"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=5"></script>
|
||||
<script src="/js/services/channelLanguageDetection.js?v=1"></script>
|
||||
<script src="/js/services/channelLanguageFusion.js?v=1"></script>
|
||||
<script src="/js/services/layaStatusPresentation.js?v=6"></script>
|
||||
<script src="/js/services/liveAnalysisPresentation.js?v=1"></script>
|
||||
<script src="/js/services/liveChannelProjection.js?v=5"></script>
|
||||
<script src="/js/services/channelVariantGrouping.js?v=7"></script>
|
||||
<script src="/js/components/ChannelList.js?v=13"></script>
|
||||
<script src="/js/components/SourceManager.js?v=4"></script>
|
||||
<script src="/js/components/EpgGuide.js?v=3"></script>
|
||||
<script src="/js/pages/HomePage.js?v=2"></script>
|
||||
<script src="/js/pages/LivePage.js?v=2"></script>
|
||||
<script src="/js/pages/LivePage.js?v=3"></script>
|
||||
<script src="/js/pages/Guide.js?v=2"></script>
|
||||
<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=2"></script>
|
||||
<script src="/js/pages/Settings.js?v=8"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=1"></script>
|
||||
<script src="/js/app.js?v=4"></script>
|
||||
<script>
|
||||
|
||||
@ -59,6 +59,7 @@ const API = {
|
||||
toggle: (id) => API.request('POST', `/sources/${id}/toggle`),
|
||||
test: (id) => API.request('POST', `/sources/${id}/test`),
|
||||
sync: (id) => API.request('POST', `/sources/${id}/sync`), // Manual sync
|
||||
updateEpgOrder: (orderedIds) => API.request('PUT', '/sources/epg/order', { orderedIds }),
|
||||
getStatus: () => API.request('GET', '/sources/status'), // Get all statuses
|
||||
estimate: (id) => API.request('GET', `/sources/${id}/estimate`), // Estimate M3U size
|
||||
estimateByUrl: (url, type) => API.request('POST', '/sources/estimate', { url, type }), // Estimate by URL (before creation)
|
||||
@ -77,6 +78,19 @@ const API = {
|
||||
hideAll: (sourceId, contentType) => API.request('POST', '/channels/hide/all', { sourceId, contentType })
|
||||
},
|
||||
|
||||
live: {
|
||||
getChannels: ({ sourceId = null, includeHidden = false, q = '', languages = [] } = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (sourceId) params.set('sourceId', sourceId);
|
||||
params.set('includeHidden', String(Boolean(includeHidden)));
|
||||
if (q) params.set('q', q);
|
||||
if (languages.length) params.set('languages', languages.join(','));
|
||||
return API.request('GET', `/live/channels?${params.toString()}`);
|
||||
},
|
||||
rebuildProjection: () => API.request('POST', '/live/projection/rebuild'),
|
||||
getProjectionStatus: () => API.request('GET', '/live/projection/status')
|
||||
},
|
||||
|
||||
// Favorites
|
||||
favorites: {
|
||||
getAll: (sourceId = null, itemType = null) => {
|
||||
@ -95,6 +109,13 @@ const API = {
|
||||
API.request('GET', `/favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`)
|
||||
},
|
||||
|
||||
laya: {
|
||||
getStatus: () => API.request('GET', '/laya/status'),
|
||||
downloadModel: (model = 'multilingual') => API.request('POST', '/laya/model/download', { model }),
|
||||
removeModel: () => API.request('DELETE', '/laya/model'),
|
||||
detectLanguages: (items, model = 'multilingual') => API.request('POST', '/laya/language/batch', { items, model })
|
||||
},
|
||||
|
||||
// Proxy
|
||||
proxy: {
|
||||
// Xtream
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,7 @@ class EpgGuide {
|
||||
this.timeOffset = 0; // Hours offset from now
|
||||
this.pixelsPerMinute = 6.67; // Width scaling (30min = 200px)
|
||||
this.favorites = new Set(); // Set<"sourceId:channelId">
|
||||
this.loadError = null;
|
||||
this.selectedGroup = 'Favorites'; // Default to Favorites
|
||||
|
||||
// Virtual scrolling properties
|
||||
@ -175,6 +176,7 @@ class EpgGuide {
|
||||
try {
|
||||
this.container.innerHTML = '<div class="loading"></div>';
|
||||
await this.fetchEpgData(forceRefresh);
|
||||
this.loadError = null;
|
||||
this.lastRefreshTime = new Date();
|
||||
this.render();
|
||||
|
||||
@ -182,6 +184,7 @@ class EpgGuide {
|
||||
// This ensures EPG data stays fresh while the app is open
|
||||
this.startBackgroundRefresh();
|
||||
} catch (err) {
|
||||
this.loadError = err;
|
||||
console.error('Error loading EPG:', err);
|
||||
this.container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
|
||||
@ -8,6 +8,12 @@ class SourceManager {
|
||||
this.xtreamList = document.getElementById('xtream-list');
|
||||
this.m3uList = document.getElementById('m3u-list');
|
||||
this.epgList = document.getElementById('epg-list');
|
||||
this.builtinEpgList = document.getElementById('builtin-epg-list');
|
||||
this.epgActiveList = document.getElementById('epg-active-list');
|
||||
this.epgInactiveList = document.getElementById('epg-inactive-list');
|
||||
this.epgOrderStatus = document.getElementById('epg-order-status');
|
||||
this.epgSources = [];
|
||||
this.isSavingEpgOrder = false;
|
||||
|
||||
// Content browser state
|
||||
this.contentType = 'channels'; // 'channels' or 'movies'
|
||||
@ -114,7 +120,8 @@ class SourceManager {
|
||||
|
||||
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream');
|
||||
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u');
|
||||
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg');
|
||||
const epgSources = sources.filter(s => s.type === 'epg');
|
||||
this.renderEpgSources(epgSources);
|
||||
} catch (err) {
|
||||
console.error('Error loading sources:', err);
|
||||
}
|
||||
@ -162,6 +169,170 @@ class SourceManager {
|
||||
});
|
||||
}
|
||||
|
||||
renderBuiltinEpgSources(sources) {
|
||||
if (!this.builtinEpgList) return;
|
||||
if (!sources.length) {
|
||||
this.builtinEpgList.innerHTML = '<p class="hint">No built-in EPG sources available</p>';
|
||||
return;
|
||||
}
|
||||
this.builtinEpgList.innerHTML = sources.map(source => {
|
||||
const coverage = [source.language?.toUpperCase(), source.country].filter(Boolean).join(' · ');
|
||||
return `<div class="source-item builtin-source ${source.enabled ? '' : 'disabled'}" data-id="${source.id}">
|
||||
<span class="source-icon">${Icons.series}</span>
|
||||
<div class="source-info">
|
||||
<div class="source-name">${source.name} <span class="source-badge">Built-in</span></div>
|
||||
<div class="source-meta">Free EPG data${coverage ? ` · ${coverage}` : ''}</div>
|
||||
</div>
|
||||
<div class="source-actions">
|
||||
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data" aria-label="Refresh ${source.name}">${Icons.refresh}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="toggle" aria-pressed="${source.enabled}" title="${source.enabled ? 'Disable' : 'Enable'}">${source.enabled ? 'Disable' : 'Enable'}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
this.builtinEpgList.querySelectorAll('.source-item').forEach(item => {
|
||||
const id = Number(item.dataset.id);
|
||||
item.querySelector('[data-action="refresh"]')?.addEventListener('click', () => this.refreshSource(id, 'epg'));
|
||||
item.querySelector('[data-action="toggle"]')?.addEventListener('click', () => this.toggleSource(id));
|
||||
});
|
||||
}
|
||||
|
||||
escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
renderEpgSources(sources, focusTarget = null) {
|
||||
if (!this.epgActiveList || !this.epgInactiveList) return;
|
||||
this.epgSources = sources.slice();
|
||||
const active = this.epgSources.filter(source => source.enabled);
|
||||
const inactive = this.epgSources.filter(source => !source.enabled);
|
||||
|
||||
if (!active.length) {
|
||||
this.epgActiveList.innerHTML = `<div class="empty-state epg-empty-state">
|
||||
<p>No active EPG sources</p>
|
||||
<p class="hint">Enable a source to load program information.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
this.epgActiveList.innerHTML = active.map((source, index) => this.renderEpgSourceRow(source, {
|
||||
active: true,
|
||||
rank: index + 1,
|
||||
canMoveUp: index > 0,
|
||||
canMoveDown: index < active.length - 1
|
||||
})).join('');
|
||||
}
|
||||
|
||||
this.epgInactiveList.innerHTML = inactive.length
|
||||
? inactive.map(source => this.renderEpgSourceRow(source, { active: false })).join('')
|
||||
: '<p class="hint">No inactive EPG sources</p>';
|
||||
|
||||
this.attachEpgSourceListeners();
|
||||
if (focusTarget) {
|
||||
const selector = `[data-id="${focusTarget.id}"] [data-action="${focusTarget.action}"]`;
|
||||
const requested = this.epgActiveList.querySelector(selector) || this.epgInactiveList.querySelector(selector);
|
||||
const row = requested?.closest('.epg-source-item');
|
||||
const focusable = requested && !requested.disabled
|
||||
? requested
|
||||
: row?.querySelector('button:not(:disabled)');
|
||||
focusable?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
renderEpgSourceRow(source, { active, rank = null, canMoveUp = false, canMoveDown = false }) {
|
||||
const name = this.escapeHtml(source.name);
|
||||
const coverage = [source.language?.toUpperCase(), source.country].filter(Boolean).join(' · ');
|
||||
const kind = source.builtinKey ? 'Built-in' : 'Custom';
|
||||
return `<div class="source-item epg-source-item ${active ? '' : 'disabled'}" data-id="${source.id}">
|
||||
${active ? `<div class="epg-priority-controls">
|
||||
<span class="epg-priority-rank" aria-label="Priority ${rank}"><strong>${rank}</strong><span>Priority ${rank}</span></span>
|
||||
<div class="epg-move-actions">
|
||||
<button class="btn btn-sm btn-secondary" data-action="move-up" aria-label="Move ${name} up" title="Move up" ${canMoveUp ? '' : 'disabled'}>↑</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="move-down" aria-label="Move ${name} down" title="Move down" ${canMoveDown ? '' : 'disabled'}>↓</button>
|
||||
</div>
|
||||
</div>` : ''}
|
||||
<span class="source-icon">${Icons.series}</span>
|
||||
<div class="source-info">
|
||||
<div class="source-name">${name} <span class="source-badge">${kind}</span></div>
|
||||
<div class="source-meta">${source.builtinKey ? 'Free EPG data' : 'Custom EPG source'}${coverage ? ` · ${this.escapeHtml(coverage)}` : ''} · ${active ? 'Active' : 'Inactive'}</div>
|
||||
</div>
|
||||
<div class="source-actions">
|
||||
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data" aria-label="Refresh ${name}">${Icons.refresh}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="toggle" aria-pressed="${active}" aria-label="${active ? 'Disable' : 'Enable'} ${name}">${active ? 'Disable' : 'Enable'}</button>
|
||||
${source.builtinKey ? '' : `<button class="btn btn-sm btn-secondary" data-action="edit" aria-label="Edit ${name}" title="Edit">${Icons.settings}</button>
|
||||
<button class="btn btn-sm btn-danger" data-action="delete" aria-label="Delete ${name}" title="Delete">${Icons.close}</button>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
attachEpgSourceListeners() {
|
||||
[this.epgActiveList, this.epgInactiveList].forEach(container => {
|
||||
container.querySelectorAll('.epg-source-item').forEach(item => {
|
||||
const id = Number(item.dataset.id);
|
||||
item.querySelector('[data-action="move-up"]')?.addEventListener('click', () => this.moveEpgSource(id, -1, 'move-up'));
|
||||
item.querySelector('[data-action="move-down"]')?.addEventListener('click', () => this.moveEpgSource(id, 1, 'move-down'));
|
||||
item.querySelector('[data-action="refresh"]')?.addEventListener('click', () => this.refreshSource(id, 'epg'));
|
||||
item.querySelector('[data-action="toggle"]')?.addEventListener('click', () => this.toggleEpgSource(id));
|
||||
item.querySelector('[data-action="edit"]')?.addEventListener('click', () => this.showEditModal(id, 'epg'));
|
||||
item.querySelector('[data-action="delete"]')?.addEventListener('click', () => this.deleteSource(id));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setEpgOrderStatus(message, state = '') {
|
||||
if (!this.epgOrderStatus) return;
|
||||
this.epgOrderStatus.textContent = message;
|
||||
this.epgOrderStatus.dataset.state = state;
|
||||
}
|
||||
|
||||
async moveEpgSource(id, direction, action) {
|
||||
if (this.isSavingEpgOrder) return;
|
||||
const previous = this.epgSources.slice();
|
||||
const active = previous.filter(source => source.enabled);
|
||||
const inactive = previous.filter(source => !source.enabled);
|
||||
const index = active.findIndex(source => source.id === id);
|
||||
const targetIndex = index + direction;
|
||||
if (index < 0 || targetIndex < 0 || targetIndex >= active.length) return;
|
||||
[active[index], active[targetIndex]] = [active[targetIndex], active[index]];
|
||||
const optimistic = [...active, ...inactive];
|
||||
this.isSavingEpgOrder = true;
|
||||
this.setEpgOrderStatus('Saving priority…', 'saving');
|
||||
this.renderEpgSources(optimistic, { id, action });
|
||||
try {
|
||||
const ordered = await API.sources.updateEpgOrder(optimistic.map(source => source.id));
|
||||
this.isSavingEpgOrder = false;
|
||||
this.renderEpgSources(ordered, { id, action });
|
||||
this.setEpgOrderStatus('EPG priority saved', 'success');
|
||||
} catch (err) {
|
||||
this.isSavingEpgOrder = false;
|
||||
this.renderEpgSources(previous, { id, action });
|
||||
this.setEpgOrderStatus('EPG priority couldn’t be saved. Try again.', 'error');
|
||||
console.error('Error saving EPG source priority:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleEpgSource(id) {
|
||||
try {
|
||||
await API.sources.toggle(id);
|
||||
const allSources = await API.sources.getAll();
|
||||
const epgSources = allSources.filter(source => source.type === 'epg');
|
||||
const active = epgSources.filter(source => source.enabled && source.id !== id);
|
||||
const toggled = epgSources.find(source => source.id === id);
|
||||
if (toggled?.enabled) active.push(toggled);
|
||||
const inactive = epgSources.filter(source => !source.enabled);
|
||||
const completeOrder = [...active, ...inactive];
|
||||
const ordered = await API.sources.updateEpgOrder(completeOrder.map(source => source.id));
|
||||
this.renderEpgSources(ordered, { id, action: 'toggle' });
|
||||
this.setEpgOrderStatus('EPG priority saved', 'success');
|
||||
} catch (err) {
|
||||
this.setEpgOrderStatus('EPG priority couldn’t be saved. Try again.', 'error');
|
||||
await this.loadSources();
|
||||
console.error('Error toggling EPG source:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show add source modal
|
||||
*/
|
||||
|
||||
@ -28,6 +28,14 @@ class VideoPlayer {
|
||||
this.isUsingProxy = false;
|
||||
this.currentUrl = null;
|
||||
this.settingsLoaded = false;
|
||||
this.liveQualityPreferenceKey = 'nodecast_tv_live_quality';
|
||||
this.requestedQuality = this.loadLiveQualityPreference();
|
||||
this.activeQuality = null;
|
||||
this.availableQualityProfiles = ['auto', 'source', '1080p', '720p', '480p'];
|
||||
this.qualitySwitchInProgress = false;
|
||||
this.originalStreamUrl = null;
|
||||
this.sourceStreamInfo = null;
|
||||
this.currentLogicalChannel = null;
|
||||
|
||||
// Settings - start with defaults, load from server async
|
||||
this.settings = this.getDefaultSettings();
|
||||
@ -49,15 +57,37 @@ class VideoPlayer {
|
||||
rememberVolume: true,
|
||||
lastVolume: 80,
|
||||
autoPlayNextEpisode: false,
|
||||
liveChannelListMode: 'grouped',
|
||||
languageDetectionMode: 'algo',
|
||||
forceProxy: false,
|
||||
forceTranscode: false,
|
||||
forceRemux: false,
|
||||
autoTranscode: true,
|
||||
maxDirectBitrateKbps: 0,
|
||||
targetTranscodeBitrateKbps: 6000,
|
||||
allowHardwareFallback: true,
|
||||
streamFormat: 'm3u8',
|
||||
epgRefreshInterval: '24'
|
||||
};
|
||||
}
|
||||
|
||||
loadLiveQualityPreference() {
|
||||
try {
|
||||
const saved = localStorage.getItem('nodecast_tv_live_quality');
|
||||
return ['auto', 'source', '1080p', '720p', '480p'].includes(saved) ? saved : 'auto';
|
||||
} catch {
|
||||
return 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
saveLiveQualityPreference(qualityMode) {
|
||||
try {
|
||||
localStorage.setItem(this.liveQualityPreferenceKey, qualityMode);
|
||||
} catch (err) {
|
||||
console.warn('[Player] Unable to persist live quality preference:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from server API
|
||||
*/
|
||||
@ -66,6 +96,7 @@ class VideoPlayer {
|
||||
const serverSettings = await API.settings.get();
|
||||
this.settings = { ...this.getDefaultSettings(), ...serverSettings };
|
||||
this.settingsLoaded = true;
|
||||
window.app?.channelList?.setListMode(this.settings.liveChannelListMode);
|
||||
console.log('[Player] Settings loaded from server');
|
||||
} catch (err) {
|
||||
console.warn('[Player] Failed to load settings from server, using defaults:', err.message);
|
||||
@ -337,6 +368,9 @@ class VideoPlayer {
|
||||
const btnOverflow = document.getElementById('btn-overflow');
|
||||
const overflowMenu = document.getElementById('player-overflow-menu');
|
||||
|
||||
this.initQualityControl(overflowMenu);
|
||||
this.initLanguageControl(overflowMenu);
|
||||
|
||||
btnOverflow?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
overflowMenu?.classList.toggle('hidden');
|
||||
@ -412,6 +446,120 @@ class VideoPlayer {
|
||||
updateVolumeUI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the live quality control to the existing player overflow menu.
|
||||
*/
|
||||
initQualityControl(overflowMenu) {
|
||||
if (!overflowMenu || document.getElementById('player-quality-control')) return;
|
||||
|
||||
const control = document.createElement('div');
|
||||
control.id = 'player-quality-control';
|
||||
control.setAttribute('role', 'group');
|
||||
control.setAttribute('aria-label', 'Qualité vidéo');
|
||||
control.style.borderTop = '1px solid var(--glass-border)';
|
||||
overflowMenu.style.minWidth = 'min(280px, calc(100vw - 24px))';
|
||||
overflowMenu.style.maxHeight = 'min(70vh, 420px)';
|
||||
overflowMenu.style.overflowY = 'auto';
|
||||
control.innerHTML = `
|
||||
<div id="player-quality-label" style="padding: .55rem 1rem .25rem; font-size: .75rem; text-transform: uppercase; color: var(--color-text-muted);">
|
||||
Qualité vidéo
|
||||
</div>
|
||||
<div id="player-quality-summary" aria-live="polite" style="padding: 0 1rem .45rem; font-size: .75rem; color: var(--color-text-muted);"></div>
|
||||
<div id="player-quality-options" role="menu" aria-labelledby="player-quality-label"></div>`;
|
||||
overflowMenu.prepend(control);
|
||||
this.renderQualityControl();
|
||||
}
|
||||
|
||||
initLanguageControl(overflowMenu) {
|
||||
if (!overflowMenu || document.getElementById('player-language-control')) return;
|
||||
const control = document.createElement('div');
|
||||
control.id = 'player-language-control';
|
||||
control.hidden = true;
|
||||
control.setAttribute('role', 'group');
|
||||
control.setAttribute('aria-label', 'Language');
|
||||
control.style.borderTop = '1px solid var(--glass-border)';
|
||||
control.innerHTML = `
|
||||
<div id="player-language-label" style="padding: .55rem 1rem .25rem; font-size: .75rem; text-transform: uppercase; color: var(--color-text-muted);">Language</div>
|
||||
<div id="player-language-options" role="menu" aria-labelledby="player-language-label"></div>`;
|
||||
overflowMenu.append(control);
|
||||
}
|
||||
|
||||
updateLanguageControl(logicalChannel) {
|
||||
this.currentLogicalChannel = logicalChannel;
|
||||
const control = document.getElementById('player-language-control');
|
||||
const list = document.getElementById('player-language-options');
|
||||
if (!control || !list) return;
|
||||
const languages = logicalChannel?.availableLanguages || [];
|
||||
control.hidden = languages.length < 2;
|
||||
list.innerHTML = '';
|
||||
if (control.hidden) return;
|
||||
|
||||
const channelList = window.app?.channelList;
|
||||
const selected = channelList?.channelLanguagePreferences?.get(logicalChannel.fingerprint) ||
|
||||
channelList?.preferredLanguage || 'auto';
|
||||
[{ code: 'auto', label: 'Auto' }, ...languages].forEach(language => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'overflow-menu-item';
|
||||
button.setAttribute('role', 'menuitemradio');
|
||||
button.setAttribute('aria-checked', String(selected === language.code));
|
||||
button.style.justifyContent = 'space-between';
|
||||
button.innerHTML = `<span>${language.label}</span><span aria-hidden="true">${selected === language.code ? '✓' : ''}</span>`;
|
||||
button.addEventListener('click', async event => {
|
||||
event.stopPropagation();
|
||||
await window.app?.channelList?.selectLanguageForCurrent(language.code);
|
||||
});
|
||||
list.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
qualityLabel(mode) {
|
||||
return ({ auto: 'Auto', source: 'Originale', '1080p': '1080p', '720p': '720p', '480p': '480p' })[mode] || mode;
|
||||
}
|
||||
|
||||
renderQualityControl() {
|
||||
const list = document.getElementById('player-quality-options');
|
||||
const summary = document.getElementById('player-quality-summary');
|
||||
if (!list || !summary) return;
|
||||
|
||||
const profiles = this.availableQualityProfiles;
|
||||
list.innerHTML = '';
|
||||
profiles.forEach(mode => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'overflow-menu-item';
|
||||
button.dataset.qualityMode = mode;
|
||||
button.setAttribute('role', 'menuitemradio');
|
||||
button.setAttribute('aria-checked', String(this.requestedQuality === mode));
|
||||
button.disabled = this.qualitySwitchInProgress;
|
||||
button.style.justifyContent = 'space-between';
|
||||
button.innerHTML = `<span>${this.qualityLabel(mode)}</span><span aria-hidden="true">${this.requestedQuality === mode ? '✓' : ''}</span>`;
|
||||
button.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
this.changeLiveQuality(mode);
|
||||
});
|
||||
list.appendChild(button);
|
||||
});
|
||||
|
||||
if (this.qualitySwitchInProgress) {
|
||||
summary.textContent = `Passage en ${this.qualityLabel(this.pendingQuality)}…`;
|
||||
} else if (this.requestedQuality === 'auto') {
|
||||
const autoKind = this.autoIsAdaptive ? 'adaptatif' : 'choix automatique au démarrage';
|
||||
summary.textContent = `Demandée : Auto (${autoKind})${this.activeQuality ? ` · Active : ${this.qualityLabel(this.activeQuality)}` : ''}`;
|
||||
} else {
|
||||
summary.textContent = `Demandée : ${this.qualityLabel(this.requestedQuality)}${this.activeQuality ? ` · Active : ${this.qualityLabel(this.activeQuality)}` : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
updateAvailableQualityProfiles(profiles, sourceHeight) {
|
||||
const ordered = ['auto', 'source', '1080p', '720p', '480p'];
|
||||
const backendProfiles = Array.isArray(profiles) ? profiles : ordered;
|
||||
const maxHeights = { '1080p': 1080, '720p': 720, '480p': 480 };
|
||||
this.availableQualityProfiles = ordered.filter(mode => backendProfiles.includes(mode) &&
|
||||
(!maxHeights[mode] || !sourceHeight || sourceHeight >= maxHeights[mode]));
|
||||
this.renderQualityControl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle fullscreen mode (cross-browser including Safari)
|
||||
*/
|
||||
@ -620,7 +768,9 @@ class VideoPlayer {
|
||||
width: this.video.videoWidth,
|
||||
height: this.video.videoHeight
|
||||
};
|
||||
this.activeQuality = this.getQualityLabel(this.video.videoHeight);
|
||||
this.updateQualityBadge();
|
||||
this.renderQualityControl();
|
||||
}
|
||||
});
|
||||
|
||||
@ -812,7 +962,8 @@ class VideoPlayer {
|
||||
/**
|
||||
* Start a HLS transcode session
|
||||
*/
|
||||
async startTranscodeSession(url, options = {}) {
|
||||
async startTranscodeSession(url, options = {}, allowLegacyFallback = true) {
|
||||
this.currentTranscodeSession = null;
|
||||
try {
|
||||
console.log('[Player] Starting HLS transcode session...', options);
|
||||
const res = await fetch('/api/transcode/session', {
|
||||
@ -820,38 +971,52 @@ class VideoPlayer {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, ...options })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to start session');
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || 'Failed to start session');
|
||||
}
|
||||
const session = await res.json();
|
||||
this.currentSessionId = session.sessionId;
|
||||
this.currentTranscodeSession = session;
|
||||
this.activeQuality = session.selectedProfile || options.qualityMode || null;
|
||||
this.autoIsAdaptive = session.mode === 'adaptive';
|
||||
this.updateAvailableQualityProfiles(session.availableProfiles, this.sourceStreamInfo?.height);
|
||||
this.renderQualityControl();
|
||||
return session.playlistUrl;
|
||||
} catch (err) {
|
||||
console.error('[Player] Session start failed:', err);
|
||||
// Fallback to direct transcode if session fails
|
||||
if (allowLegacyFallback) {
|
||||
return `/api/transcode?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and cleanup current transcode session
|
||||
*/
|
||||
async stopTranscodeSession() {
|
||||
if (this.currentSessionId) {
|
||||
console.log('[Player] Stopping transcode session:', this.currentSessionId);
|
||||
const sessionId = this.currentSessionId;
|
||||
if (sessionId) {
|
||||
console.log('[Player] Stopping transcode session:', sessionId);
|
||||
try {
|
||||
// Fire and forget cleanup
|
||||
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
|
||||
await fetch(`/api/transcode/${sessionId}`, { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
console.error('Failed to stop session:', err);
|
||||
}
|
||||
// A new session may have started while the DELETE was in flight.
|
||||
if (this.currentSessionId === sessionId) {
|
||||
this.currentSessionId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a channel
|
||||
*/
|
||||
async play(channel, streamUrl) {
|
||||
this.currentChannel = channel;
|
||||
this.originalStreamUrl = streamUrl;
|
||||
|
||||
try {
|
||||
// Stop any WatchPage playback (movies/series) before starting Live TV
|
||||
@ -870,10 +1035,22 @@ class VideoPlayer {
|
||||
|
||||
// Determine if HLS or direct stream
|
||||
this.currentUrl = streamUrl;
|
||||
this.sourceStreamInfo = null;
|
||||
|
||||
// CHECK: Auto Transcode (Smart) - probe first, then decide
|
||||
if (this.settings.autoTranscode) {
|
||||
// A persisted manual preference always creates a dedicated transcode session.
|
||||
if (this.requestedQuality !== 'auto') {
|
||||
await this.playManualQuality(channel, streamUrl, this.requestedQuality);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forced playback modes take priority over automatic selection below.
|
||||
const hasForcedPlaybackMode = this.settings.forceVideoTranscode ||
|
||||
this.settings.forceTranscode || this.settings.forceRemux;
|
||||
|
||||
// Automatic playback: probe once, then choose transcode, remux, or direct play.
|
||||
if (this.settings.autoTranscode && !hasForcedPlaybackMode) {
|
||||
console.log('[Player] Auto Transcode enabled. Probing stream...');
|
||||
this.updateTranscodeStatus('direct', 'Analyzing…');
|
||||
try {
|
||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
|
||||
const info = await probeRes.json();
|
||||
@ -881,6 +1058,8 @@ class VideoPlayer {
|
||||
|
||||
// Store probe result for quality badge display
|
||||
this.currentStreamInfo = info;
|
||||
this.sourceStreamInfo = info;
|
||||
this.updateAvailableQualityProfiles(null, info.height);
|
||||
this.updateQualityBadge();
|
||||
|
||||
// Handle subtitles from probe result
|
||||
@ -905,14 +1084,23 @@ class VideoPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
if (info.needsTranscode || this.settings.upscaleEnabled) {
|
||||
const maxDirectBitrateKbps = Number(this.settings.maxDirectBitrateKbps) || 0;
|
||||
const overallBitrateKbps = Number(info.overallBitrateKbps) || 0;
|
||||
const exceedsBandwidthLimit = maxDirectBitrateKbps > 0 &&
|
||||
overallBitrateKbps > maxDirectBitrateKbps;
|
||||
const requiresTranscode = info.needsTranscode || this.settings.upscaleEnabled ||
|
||||
(!info.needsRemux && exceedsBandwidthLimit);
|
||||
|
||||
if (requiresTranscode) {
|
||||
// Incompatible audio (AC3/EAC3/DTS) or Upscaling enabled - use transcode session
|
||||
console.log(`[Player] Auto: Using HLS transcode session (${this.settings.upscaleEnabled ? 'Upscaling' : 'Incompatible audio/video'})`);
|
||||
|
||||
// Heuristic: If video is h264, it's likely compatible, so only copy video (audio transcode only)
|
||||
// BUT: If upscaling is enabled, we MUST encode.
|
||||
const videoMode = (info.video && info.video.includes('h264') && !this.settings.upscaleEnabled) ? 'copy' : 'encode';
|
||||
const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : (this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)');
|
||||
const videoMode = (info.video && info.video.includes('h264') &&
|
||||
!this.settings.upscaleEnabled && !exceedsBandwidthLimit) ? 'copy' : 'encode';
|
||||
const statusText = videoMode === 'copy' ? 'Audio Transcode' :
|
||||
(this.settings.upscaleEnabled ? 'Upscaling' : 'Video Transcode');
|
||||
const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
|
||||
|
||||
this.updateTranscodeStatus(statusMode, statusText);
|
||||
@ -920,8 +1108,18 @@ class VideoPlayer {
|
||||
videoMode,
|
||||
videoCodec: info.video,
|
||||
audioCodec: info.audio,
|
||||
audioChannels: info.audioChannels
|
||||
audioChannels: info.audioChannels,
|
||||
targetVideoBitrateKbps: this.settings.targetTranscodeBitrateKbps ||
|
||||
this.settings.maxDirectBitrateKbps || 6000,
|
||||
allowHardwareFallback: this.settings.allowHardwareFallback !== false
|
||||
});
|
||||
if (this.currentTranscodeSession?.hardwareFallbackOccurred) {
|
||||
this.updateTranscodeStatus('transcoding', 'CPU Fallback');
|
||||
}
|
||||
if (exceedsBandwidthLimit) {
|
||||
this.updateQualityBadge(this.settings.targetTranscodeBitrateKbps ||
|
||||
this.settings.maxDirectBitrateKbps || 6000);
|
||||
}
|
||||
this.currentUrl = playlistUrl; // Update currentUrl for HLS reload
|
||||
|
||||
this.playHls(playlistUrl);
|
||||
@ -934,7 +1132,7 @@ class VideoPlayer {
|
||||
} else if (info.needsRemux) {
|
||||
// Raw .ts container - use remux
|
||||
console.log('[Player] Auto: Using remux (.ts container)');
|
||||
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
|
||||
this.updateTranscodeStatus('remuxing', 'Remux');
|
||||
const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
|
||||
this.currentUrl = remuxUrl;
|
||||
this.video.src = remuxUrl;
|
||||
@ -949,6 +1147,7 @@ class VideoPlayer {
|
||||
}
|
||||
// Compatible - fall through to normal HLS.js path
|
||||
console.log('[Player] Auto: Using HLS.js (compatible)');
|
||||
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||
} catch (err) {
|
||||
console.warn('[Player] Probe failed, using normal playback:', err.message);
|
||||
// Continue with normal playback on probe failure
|
||||
@ -957,11 +1156,19 @@ class VideoPlayer {
|
||||
|
||||
// CHECK: Force Video Transcode (Full) or Upscaling
|
||||
if (this.settings.forceVideoTranscode || this.settings.upscaleEnabled) {
|
||||
const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)';
|
||||
const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Video Transcode';
|
||||
const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
|
||||
console.log(`[Player] ${statusText} enabled. Starting session (encode)...`);
|
||||
this.updateTranscodeStatus(statusMode, statusText);
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' });
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, {
|
||||
videoMode: 'encode',
|
||||
targetVideoBitrateKbps: this.settings.targetTranscodeBitrateKbps ||
|
||||
this.settings.maxDirectBitrateKbps || 6000,
|
||||
allowHardwareFallback: this.settings.allowHardwareFallback !== false
|
||||
});
|
||||
if (this.currentTranscodeSession?.hardwareFallbackOccurred) {
|
||||
this.updateTranscodeStatus('transcoding', 'CPU Fallback');
|
||||
}
|
||||
this.currentUrl = playlistUrl;
|
||||
|
||||
// Load HLS
|
||||
@ -1002,7 +1209,7 @@ class VideoPlayer {
|
||||
// CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting
|
||||
if (this.settings.forceTranscode) {
|
||||
console.log('[Player] Force Audio Transcode enabled. Starting session (copy)...');
|
||||
this.updateTranscodeStatus('transcoding', 'Transcoding (Audio)');
|
||||
this.updateTranscodeStatus('transcoding', 'Audio Transcode');
|
||||
|
||||
// Probe to get video codec for HEVC tag handling
|
||||
let videoCodec = 'unknown';
|
||||
@ -1012,7 +1219,11 @@ class VideoPlayer {
|
||||
videoCodec = info.video;
|
||||
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
||||
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'copy', videoCodec });
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, {
|
||||
videoMode: 'copy',
|
||||
videoCodec,
|
||||
allowHardwareFallback: this.settings.allowHardwareFallback !== false
|
||||
});
|
||||
this.currentUrl = playlistUrl;
|
||||
|
||||
console.log('[Player] Playing transcoded HLS stream:', playlistUrl);
|
||||
@ -1053,7 +1264,7 @@ class VideoPlayer {
|
||||
if (this.settings.forceRemux && (isRawTs || isExtensionless)) {
|
||||
console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...');
|
||||
console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)');
|
||||
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
|
||||
this.updateTranscodeStatus('remuxing', 'Remux');
|
||||
const remuxUrl = this.getRemuxUrl(streamUrl);
|
||||
this.video.src = remuxUrl;
|
||||
this.video.play().catch(e => {
|
||||
@ -1082,7 +1293,7 @@ class VideoPlayer {
|
||||
|
||||
// Priority 1: Use HLS.js for HLS streams on browsers that support it
|
||||
if (looksLikeHls && Hls.isSupported()) {
|
||||
this.updateTranscodeStatus('direct', 'Direct HLS');
|
||||
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||
|
||||
// Use playHls helper logic here (or extract it)
|
||||
// For now, let's just use existing logic but wrapped/modularized if possible?
|
||||
@ -1093,12 +1304,24 @@ class VideoPlayer {
|
||||
this.hls.loadSource(finalUrl);
|
||||
this.hls.attachMedia(this.video);
|
||||
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
|
||||
const levels = data?.levels || this.hls.levels || [];
|
||||
this.autoIsAdaptive = this.requestedQuality === 'auto' && levels.length > 1;
|
||||
this.renderQualityControl();
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.log('Autoplay prevented:', e);
|
||||
});
|
||||
});
|
||||
|
||||
this.hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => {
|
||||
const level = this.hls?.levels?.[data.level];
|
||||
if (level?.height) {
|
||||
this.activeQuality = this.getQualityLabel(level.height);
|
||||
this.updateQualityBadge();
|
||||
this.renderQualityControl();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-attach error handler for the new Hls instance
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
@ -1146,7 +1369,7 @@ class VideoPlayer {
|
||||
} else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
|
||||
this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') {
|
||||
// Priority 2: Native HLS support (Safari on iOS/macOS where HLS.js may not work)
|
||||
this.updateTranscodeStatus('direct', 'Direct Native');
|
||||
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||
this.video.src = finalUrl;
|
||||
this.video.play().catch(e => {
|
||||
if (e.name === 'AbortError') return; // Ignore interruption by new load
|
||||
@ -1198,12 +1421,27 @@ class VideoPlayer {
|
||||
this.hls.loadSource(url);
|
||||
this.hls.attachMedia(this.video);
|
||||
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
|
||||
const levels = data?.levels || this.hls.levels || [];
|
||||
this.autoIsAdaptive = this.requestedQuality === 'auto' && levels.length > 1;
|
||||
if (this.autoIsAdaptive && this.hls.currentLevel >= 0) {
|
||||
this.activeQuality = this.getQualityLabel(levels[this.hls.currentLevel]?.height);
|
||||
}
|
||||
this.renderQualityControl();
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.log('Autoplay prevented:', e);
|
||||
});
|
||||
});
|
||||
|
||||
this.hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => {
|
||||
const level = this.hls?.levels?.[data.level];
|
||||
if (level?.height) {
|
||||
this.activeQuality = this.getQualityLabel(level.height);
|
||||
this.updateQualityBadge();
|
||||
this.renderQualityControl();
|
||||
}
|
||||
});
|
||||
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
// Simple error handling for forced HLS/transcode modes
|
||||
@ -1213,6 +1451,92 @@ class VideoPlayer {
|
||||
});
|
||||
}
|
||||
|
||||
async playManualQuality(channel, streamUrl, qualityMode, restorePaused = false) {
|
||||
this.updateTranscodeStatus('transcoding', `Passage en ${this.qualityLabel(qualityMode)}…`);
|
||||
let effectiveQuality = qualityMode;
|
||||
if (!this.sourceStreamInfo?.height) {
|
||||
try {
|
||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
|
||||
if (probeRes.ok) this.sourceStreamInfo = await probeRes.json();
|
||||
} catch (err) {
|
||||
console.warn('[Player] Could not probe source quality:', err.message);
|
||||
}
|
||||
}
|
||||
if (this.sourceStreamInfo?.height) {
|
||||
this.updateAvailableQualityProfiles(null, this.sourceStreamInfo.height);
|
||||
const requestedHeight = Number.parseInt(qualityMode, 10);
|
||||
if (requestedHeight > this.sourceStreamInfo.height) {
|
||||
effectiveQuality = ['1080p', '720p', '480p']
|
||||
.find(mode => Number.parseInt(mode, 10) <= this.sourceStreamInfo.height) || 'source';
|
||||
}
|
||||
}
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, {
|
||||
qualityMode: effectiveQuality,
|
||||
videoMode: effectiveQuality === 'source' ? undefined : 'encode',
|
||||
allowHardwareFallback: this.settings.allowHardwareFallback !== false
|
||||
}, false);
|
||||
this.currentUrl = playlistUrl;
|
||||
this.playHls(playlistUrl);
|
||||
if (restorePaused) {
|
||||
this.video.addEventListener('playing', () => this.video.pause(), { once: true });
|
||||
}
|
||||
this.video.addEventListener('canplay', () => {
|
||||
this.updateTranscodeStatus('transcoding', this.currentTranscodeSession?.hardwareFallbackOccurred ? 'CPU Fallback' : 'Transcodage');
|
||||
}, { once: true });
|
||||
this.updateQualityBadge();
|
||||
this.updateNowPlaying(channel);
|
||||
this.showNowPlayingOverlay();
|
||||
this.fetchEpgData(channel);
|
||||
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
|
||||
}
|
||||
|
||||
async changeLiveQuality(qualityMode) {
|
||||
if (!this.currentChannel || !this.originalStreamUrl || this.qualitySwitchInProgress || qualityMode === this.requestedQuality) return;
|
||||
|
||||
const previousQuality = this.requestedQuality;
|
||||
const wasPaused = this.video.paused;
|
||||
this.pendingQuality = qualityMode;
|
||||
this.qualitySwitchInProgress = true;
|
||||
this.renderQualityControl();
|
||||
this.loadingSpinner?.classList.add('show');
|
||||
|
||||
try {
|
||||
await this.stopTranscodeSession();
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
this.hls = null;
|
||||
}
|
||||
this.requestedQuality = qualityMode;
|
||||
this.saveLiveQualityPreference(qualityMode);
|
||||
|
||||
if (qualityMode === 'auto') {
|
||||
await this.play(this.currentChannel, this.originalStreamUrl);
|
||||
if (wasPaused) this.video.addEventListener('playing', () => this.video.pause(), { once: true });
|
||||
} else {
|
||||
await this.playManualQuality(this.currentChannel, this.originalStreamUrl, qualityMode, wasPaused);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Player] Quality switch failed, restoring previous quality:', err);
|
||||
this.requestedQuality = previousQuality;
|
||||
this.saveLiveQualityPreference(previousQuality);
|
||||
try {
|
||||
if (previousQuality === 'auto') {
|
||||
await this.play(this.currentChannel, this.originalStreamUrl);
|
||||
} else {
|
||||
await this.playManualQuality(this.currentChannel, this.originalStreamUrl, previousQuality, wasPaused);
|
||||
}
|
||||
} catch (restoreError) {
|
||||
console.error('[Player] Failed to restore previous quality:', restoreError);
|
||||
this.showError('Impossible de changer la qualité vidéo. Réessayez.');
|
||||
}
|
||||
} finally {
|
||||
this.pendingQuality = null;
|
||||
this.qualitySwitchInProgress = false;
|
||||
this.loadingSpinner?.classList.remove('show');
|
||||
this.renderQualityControl();
|
||||
}
|
||||
}
|
||||
|
||||
async updateTranscodeStatus(mode, text) {
|
||||
const el = document.getElementById('player-transcode-status');
|
||||
if (!el) return;
|
||||
@ -1247,11 +1571,20 @@ class VideoPlayer {
|
||||
/**
|
||||
* Update quality badge display
|
||||
*/
|
||||
updateQualityBadge() {
|
||||
updateQualityBadge(targetBitrateKbps = null) {
|
||||
const badge = document.getElementById('player-quality-badge');
|
||||
if (!badge) return;
|
||||
|
||||
if (this.currentStreamInfo?.height > 0) {
|
||||
if (this.requestedQuality && this.activeQuality) {
|
||||
badge.textContent = this.requestedQuality === 'auto'
|
||||
? `Auto · ${this.qualityLabel(this.activeQuality)}`
|
||||
: `${this.qualityLabel(this.requestedQuality)} · actif ${this.qualityLabel(this.activeQuality)}`;
|
||||
badge.classList.remove('hidden');
|
||||
} else if (targetBitrateKbps) {
|
||||
const targetMbps = targetBitrateKbps / 1000;
|
||||
badge.textContent = `${Number.isInteger(targetMbps) ? targetMbps : targetMbps.toFixed(1)} Mbps`;
|
||||
badge.classList.remove('hidden');
|
||||
} else if (this.currentStreamInfo?.height > 0) {
|
||||
badge.textContent = this.getQualityLabel(this.currentStreamInfo.height);
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
@ -1407,6 +1740,9 @@ class VideoPlayer {
|
||||
|
||||
// Hide quality badge
|
||||
this.currentStreamInfo = null;
|
||||
this.activeQuality = null;
|
||||
this.autoIsAdaptive = false;
|
||||
this.renderQualityControl();
|
||||
const badge = document.getElementById('player-quality-badge');
|
||||
if (badge) badge.classList.add('hidden');
|
||||
}
|
||||
@ -1531,11 +1867,18 @@ class VideoPlayer {
|
||||
if (channels.length === 0) return;
|
||||
|
||||
const currentIdx = this.currentChannel
|
||||
? channels.findIndex(c => c.id === this.currentChannel.id)
|
||||
? channels.findIndex(c => c.variants.some(variant =>
|
||||
variant.id === this.currentChannel.id && variant.sourceId === this.currentChannel.sourceId
|
||||
))
|
||||
: -1;
|
||||
|
||||
const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 1;
|
||||
window.app.channelList.selectChannel({ channelId: channels[prevIdx].id });
|
||||
const previous = channels[prevIdx];
|
||||
window.app.channelList.selectChannel({
|
||||
logicalId: previous.fingerprint,
|
||||
variantId: previous.preferredVariant.id,
|
||||
sourceId: previous.preferredVariant.sourceId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1547,11 +1890,18 @@ class VideoPlayer {
|
||||
if (channels.length === 0) return;
|
||||
|
||||
const currentIdx = this.currentChannel
|
||||
? channels.findIndex(c => c.id === this.currentChannel.id)
|
||||
? channels.findIndex(c => c.variants.some(variant =>
|
||||
variant.id === this.currentChannel.id && variant.sourceId === this.currentChannel.sourceId
|
||||
))
|
||||
: -1;
|
||||
|
||||
const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 1;
|
||||
window.app.channelList.selectChannel({ channelId: channels[nextIdx].id });
|
||||
const next = channels[nextIdx];
|
||||
window.app.channelList.selectChannel({
|
||||
logicalId: next.fingerprint,
|
||||
variantId: next.preferredVariant.id,
|
||||
sourceId: next.preferredVariant.sourceId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -23,6 +23,7 @@ class LivePage {
|
||||
// Update program info in existing DOM elements without re-rendering
|
||||
this.updateProgramInfo();
|
||||
} catch (err) {
|
||||
this.app.epgGuide.loadError = err;
|
||||
console.warn('Background EPG fetch failed:', err);
|
||||
}
|
||||
}
|
||||
@ -36,7 +37,7 @@ class LivePage {
|
||||
|
||||
// Build a map for O(1) channel lookups
|
||||
const channelMap = new Map();
|
||||
this.app.channelList.channels.forEach(c => channelMap.set(c.id, c));
|
||||
this.app.channelList.logicalChannels.forEach(c => channelMap.set(c.fingerprint, c));
|
||||
|
||||
// Process in small batches to avoid blocking UI
|
||||
const BATCH_SIZE = 50;
|
||||
@ -47,14 +48,12 @@ class LivePage {
|
||||
|
||||
for (let i = index; i < end; i++) {
|
||||
const item = channelItems[i];
|
||||
const channelId = item.dataset.channelId;
|
||||
const channel = channelMap.get(channelId);
|
||||
const channel = channelMap.get(item.dataset.logicalId);
|
||||
|
||||
if (channel) {
|
||||
const programDiv = item.querySelector('.channel-program');
|
||||
if (programDiv) {
|
||||
const programTitle = this.app.channelList.getProgramInfo(channel);
|
||||
programDiv.textContent = programTitle || '';
|
||||
programDiv.textContent = this.app.channelList.getProgramStatusText(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ class SettingsPage {
|
||||
const volumeValueDisplay = document.getElementById('volume-value');
|
||||
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
|
||||
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
|
||||
const groupLiveChannelsToggle = document.getElementById('setting-group-live-channels');
|
||||
|
||||
// Load current settings
|
||||
if (this.app.player?.settings) {
|
||||
@ -43,6 +44,7 @@ class SettingsPage {
|
||||
volumeValueDisplay.textContent = this.app.player.settings.defaultVolume + '%';
|
||||
rememberVolumeToggle.checked = this.app.player.settings.rememberVolume;
|
||||
autoPlayNextToggle.checked = this.app.player.settings.autoPlayNextEpisode;
|
||||
groupLiveChannelsToggle.checked = this.app.player.settings.liveChannelListMode !== 'flat';
|
||||
}
|
||||
|
||||
// Arrow keys toggle
|
||||
@ -77,6 +79,16 @@ class SettingsPage {
|
||||
this.app.player.saveSettings();
|
||||
});
|
||||
|
||||
groupLiveChannelsToggle.addEventListener('change', () => {
|
||||
const mode = groupLiveChannelsToggle.checked ? 'grouped' : 'flat';
|
||||
this.app.player.settings.liveChannelListMode = mode;
|
||||
this.app.channelList.setListMode(mode);
|
||||
this.app.player.saveSettings();
|
||||
});
|
||||
|
||||
this.initLayaSettings();
|
||||
this.initLiveAnalysis();
|
||||
|
||||
// EPG refresh interval
|
||||
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
|
||||
if (epgRefreshSelect && this.app.player?.settings) {
|
||||
@ -94,6 +106,330 @@ class SettingsPage {
|
||||
this.updateEpgLastRefreshed();
|
||||
}
|
||||
|
||||
initLiveAnalysis() {
|
||||
document.getElementById('live-analysis-button')?.addEventListener('click', () => void this.runLiveAnalysis());
|
||||
document.getElementById('live-analysis-details-toggle')?.addEventListener('click', () => {
|
||||
const button = document.getElementById('live-analysis-details-toggle');
|
||||
const details = document.getElementById('live-analysis-error-details');
|
||||
const expanded = button.getAttribute('aria-expanded') === 'true';
|
||||
button.setAttribute('aria-expanded', String(!expanded));
|
||||
details.hidden = expanded;
|
||||
});
|
||||
void this.loadLiveAnalysisStatus();
|
||||
}
|
||||
|
||||
async loadLiveAnalysisStatus() {
|
||||
try {
|
||||
this.liveAnalysisStatus = await API.live.getProjectionStatus();
|
||||
} catch (err) {
|
||||
this.liveAnalysisStatus = { status: 'error', error: err.message };
|
||||
}
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
const presentation = LiveAnalysisPresentation.present(this.liveAnalysisStatus);
|
||||
if (presentation.busy) this.startLiveAnalysisPolling();
|
||||
return this.liveAnalysisStatus;
|
||||
}
|
||||
|
||||
renderLiveAnalysisStatus(status = {}) {
|
||||
const button = document.getElementById('live-analysis-button');
|
||||
const statusElement = document.getElementById('live-analysis-status');
|
||||
const progress = document.getElementById('live-analysis-progress');
|
||||
const track = document.getElementById('live-analysis-progress-track');
|
||||
const bar = document.getElementById('live-analysis-progress-bar');
|
||||
const progressText = document.getElementById('live-analysis-progress-text');
|
||||
const fallback = document.getElementById('live-analysis-fallback');
|
||||
const detailsButton = document.getElementById('live-analysis-details-toggle');
|
||||
const details = document.getElementById('live-analysis-error-details');
|
||||
if (!button || !statusElement) return;
|
||||
|
||||
const presentation = LiveAnalysisPresentation.present(status);
|
||||
button.textContent = presentation.buttonLabel;
|
||||
button.disabled = presentation.busy;
|
||||
statusElement.textContent = presentation.statusLabel;
|
||||
progress.hidden = !presentation.busy;
|
||||
track.classList.toggle('indeterminate', presentation.progressIndeterminate);
|
||||
if (presentation.progressPercent === null) {
|
||||
track.removeAttribute('aria-valuemin');
|
||||
track.removeAttribute('aria-valuemax');
|
||||
track.removeAttribute('aria-valuenow');
|
||||
bar.style.width = '';
|
||||
} else {
|
||||
track.setAttribute('aria-valuemin', '0');
|
||||
track.setAttribute('aria-valuemax', '100');
|
||||
track.setAttribute('aria-valuenow', String(presentation.progressPercent));
|
||||
bar.style.width = `${presentation.progressPercent}%`;
|
||||
}
|
||||
progressText.textContent = presentation.progressText;
|
||||
track.setAttribute('aria-valuetext', presentation.progressText);
|
||||
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
|
||||
fallback.textContent = LiveAnalysisPresentation.layaFallbackMessage(mode, this.layaStatus);
|
||||
detailsButton.hidden = !presentation.errorDetail;
|
||||
detailsButton.setAttribute('aria-expanded', 'false');
|
||||
details.hidden = true;
|
||||
details.textContent = presentation.errorDetail;
|
||||
}
|
||||
|
||||
async runLiveAnalysis() {
|
||||
const button = document.getElementById('live-analysis-button');
|
||||
if (button?.disabled) return;
|
||||
this.liveAnalysisStatus = { ...(this.liveAnalysisStatus || {}), status: 'queued', processed: 0 };
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
try {
|
||||
const response = await API.live.rebuildProjection();
|
||||
this.liveAnalysisStatus = response.projection || response;
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
this.startLiveAnalysisPolling();
|
||||
} catch (err) {
|
||||
this.liveAnalysisStatus = { status: 'error', error: err.message };
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
}
|
||||
}
|
||||
|
||||
startLiveAnalysisPolling() {
|
||||
if (this.liveAnalysisPollTimer) return;
|
||||
this.liveAnalysisPollTimer = setInterval(async () => {
|
||||
if (this.liveAnalysisPollInFlight) return;
|
||||
this.liveAnalysisPollInFlight = true;
|
||||
try {
|
||||
const status = await this.loadLiveAnalysisStatus();
|
||||
const presentation = LiveAnalysisPresentation.present(status);
|
||||
if (!presentation.busy) {
|
||||
clearInterval(this.liveAnalysisPollTimer);
|
||||
this.liveAnalysisPollTimer = null;
|
||||
if (presentation.complete) {
|
||||
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
|
||||
await this.app.channelList?.loadChannels({
|
||||
refreshLanguages: mode !== 'laya' && mode !== 'hybrid'
|
||||
});
|
||||
if (mode === 'laya' || mode === 'hybrid') {
|
||||
this.liveAnalysisStatus = {
|
||||
status: 'building',
|
||||
phase: 'laya_language',
|
||||
languageProcessed: null,
|
||||
languageTotal: null
|
||||
};
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
await this.app.channelList?.refreshLayaLanguages(mode, {
|
||||
onProgress: ({ processed, total }) => {
|
||||
this.liveAnalysisStatus = {
|
||||
status: 'building',
|
||||
phase: 'laya_language',
|
||||
languageProcessed: processed,
|
||||
languageTotal: total
|
||||
};
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.liveAnalysisStatus = status;
|
||||
this.renderLiveAnalysisStatus(status);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.liveAnalysisPollInFlight = false;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
initLayaSettings() {
|
||||
const radios = document.querySelectorAll('input[name="language-detection-mode"]');
|
||||
const currentMode = this.app.player?.settings?.languageDetectionMode || 'algo';
|
||||
radios.forEach(radio => {
|
||||
radio.checked = radio.value === currentMode;
|
||||
radio.addEventListener('change', () => {
|
||||
if (!radio.checked) return;
|
||||
this.app.player.settings.languageDetectionMode = radio.value;
|
||||
this.app.player.saveSettings();
|
||||
this.app.channelList.setLanguageDetectionMode(radio.value);
|
||||
this.renderLayaStatus(this.layaStatus);
|
||||
this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('laya-download-model')?.addEventListener('click', () => this.runLayaModelAction('download'));
|
||||
document.getElementById('laya-remove-model')?.addEventListener('click', () => {
|
||||
if (confirm('Remove local AI model?\n\nYour sources, favorites, and hidden channels will not be deleted.')) {
|
||||
this.runLayaModelAction('remove');
|
||||
}
|
||||
});
|
||||
document.getElementById('laya-use-standard')?.addEventListener('click', () => this.useStandardLanguageDetection());
|
||||
document.getElementById('laya-check-again')?.addEventListener('click', () => void this.loadLayaStatus());
|
||||
document.getElementById('laya-copy-command')?.addEventListener('click', () => void this.copyLayaStartCommand());
|
||||
document.getElementById('laya-show-details')?.addEventListener('click', () => {
|
||||
const details = document.getElementById('laya-error-details');
|
||||
const button = document.getElementById('laya-show-details');
|
||||
const expanded = button.getAttribute('aria-expanded') === 'true';
|
||||
button.setAttribute('aria-expanded', String(!expanded));
|
||||
details.hidden = expanded;
|
||||
});
|
||||
void this.loadLayaStatus();
|
||||
}
|
||||
|
||||
async loadLayaStatus() {
|
||||
try {
|
||||
this.layaStatus = await API.laya.getStatus();
|
||||
} catch (err) {
|
||||
this.layaStatus = { runtimeAvailable: false, modelPresent: false, error: err.message };
|
||||
}
|
||||
this.renderLayaStatus(this.layaStatus);
|
||||
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
|
||||
if (mode !== 'algo' && this.layaStatus?.modelPresent && this.layaStatus?.runtimeAvailable &&
|
||||
this.app.channelList?.languageDetectionNotice) {
|
||||
void this.app.channelList.refreshLayaLanguages(mode);
|
||||
}
|
||||
if (this.layaStatus?.downloading) this.startLayaStatusPolling();
|
||||
return this.layaStatus;
|
||||
}
|
||||
|
||||
renderLayaStatus(status = {}) {
|
||||
const statusElement = document.getElementById('laya-model-status');
|
||||
const modelStateElement = document.getElementById('laya-model-state');
|
||||
const serviceStateElement = document.getElementById('laya-service-state');
|
||||
const sizeElement = document.getElementById('laya-model-size');
|
||||
const messageElement = document.getElementById('laya-model-message');
|
||||
const downloadButton = document.getElementById('laya-download-model');
|
||||
const removeButton = document.getElementById('laya-remove-model');
|
||||
const useStandardButton = document.getElementById('laya-use-standard');
|
||||
const checkAgainButton = document.getElementById('laya-check-again');
|
||||
const copyCommandButton = document.getElementById('laya-copy-command');
|
||||
const startCommand = document.getElementById('laya-start-command');
|
||||
const progress = document.getElementById('laya-download-progress');
|
||||
const progressTrack = document.getElementById('laya-progress-track');
|
||||
const progressBar = document.getElementById('laya-progress-bar');
|
||||
const progressText = document.getElementById('laya-progress-text');
|
||||
const detailsButton = document.getElementById('laya-show-details');
|
||||
const details = document.getElementById('laya-error-details');
|
||||
if (!statusElement) return;
|
||||
|
||||
const mode = this.app.player?.settings?.languageDetectionMode || 'algo';
|
||||
const presentation = LayaStatusPresentation.presentLayaStatus(status, mode);
|
||||
statusElement.textContent = presentation.statusLabel;
|
||||
modelStateElement.textContent = presentation.modelLabel;
|
||||
serviceStateElement.textContent = presentation.serviceLabel;
|
||||
const size = status.sizeBytes || status.runtime?.modelSizeBytes;
|
||||
sizeElement.textContent = size ? `· ${this.formatBytes(size)}` : '';
|
||||
downloadButton.hidden = !presentation.showDownload;
|
||||
downloadButton.textContent = presentation.downloadLabel;
|
||||
removeButton.hidden = !presentation.showRemove;
|
||||
useStandardButton.hidden = !presentation.showUseStandard;
|
||||
checkAgainButton.hidden = !presentation.showCheckAgain;
|
||||
copyCommandButton.hidden = !presentation.showStartCommand;
|
||||
startCommand.hidden = !presentation.showStartCommand;
|
||||
downloadButton.disabled = presentation.busy || Boolean(this.layaOperationPending);
|
||||
removeButton.disabled = presentation.busy || Boolean(this.layaOperationPending);
|
||||
|
||||
progress.hidden = !presentation.showProgress;
|
||||
progressTrack.classList.toggle('indeterminate', presentation.progressIndeterminate);
|
||||
if (presentation.progressPercent !== null) {
|
||||
progressTrack.setAttribute('aria-valuemin', '0');
|
||||
progressTrack.setAttribute('aria-valuemax', '100');
|
||||
progressTrack.setAttribute('aria-valuenow', String(presentation.progressPercent));
|
||||
progressBar.style.width = `${presentation.progressPercent}%`;
|
||||
progressText.textContent = `${presentation.progressPercent}%${presentation.bytesDownloaded !== null ? ` · ${this.formatBytes(presentation.bytesDownloaded)} downloaded` : ''}`;
|
||||
progressTrack.setAttribute('aria-valuetext', progressText.textContent);
|
||||
} else {
|
||||
progressTrack.removeAttribute('aria-valuemin');
|
||||
progressTrack.removeAttribute('aria-valuemax');
|
||||
progressTrack.removeAttribute('aria-valuenow');
|
||||
progressBar.style.width = '';
|
||||
progressText.textContent = presentation.bytesDownloaded !== null
|
||||
? `${this.formatBytes(presentation.bytesDownloaded)} downloaded`
|
||||
: 'Downloading…';
|
||||
progressTrack.setAttribute('aria-valuetext', progressText.textContent);
|
||||
}
|
||||
|
||||
detailsButton.hidden = !presentation.errorDetail;
|
||||
detailsButton.setAttribute('aria-expanded', 'false');
|
||||
details.hidden = true;
|
||||
details.textContent = presentation.errorDetail;
|
||||
if (presentation.errorMessage) messageElement.textContent = presentation.errorMessage;
|
||||
else if (presentation.availabilityMessage) messageElement.textContent = presentation.availabilityMessage;
|
||||
else if (this.layaOperationPending) messageElement.textContent = this.layaOperationPending;
|
||||
else if (mode !== 'algo' && (!status.modelPresent || !status.runtimeAvailable)) {
|
||||
messageElement.textContent = 'Local AI is not ready. Download the model and ensure the runtime is running. Live TV will remain available.';
|
||||
} else messageElement.textContent = '';
|
||||
if (this.liveAnalysisStatus) this.renderLiveAnalysisStatus(this.liveAnalysisStatus);
|
||||
}
|
||||
|
||||
formatBytes(bytes) {
|
||||
const value = Number(bytes);
|
||||
if (!Number.isFinite(value) || value <= 0) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
return `${(value / (1024 ** index)).toFixed(index > 1 ? 1 : 0)} ${units[index]}`;
|
||||
}
|
||||
|
||||
async runLayaModelAction(action) {
|
||||
if (this.layaOperationPending) return;
|
||||
this.layaOperationPending = action === 'download' ? 'Downloading model…' : 'Removing model…';
|
||||
this.layaStatus = {
|
||||
...(this.layaStatus || {}),
|
||||
phase: action === 'download' ? 'preparing' : 'deleting',
|
||||
downloading: action === 'download'
|
||||
};
|
||||
this.renderLayaStatus(this.layaStatus);
|
||||
try {
|
||||
this.layaStatus = action === 'download'
|
||||
? await API.laya.downloadModel('multilingual')
|
||||
: await API.laya.removeModel();
|
||||
this.layaOperationPending = '';
|
||||
this.renderLayaStatus(this.layaStatus);
|
||||
if (this.layaStatus?.downloading) this.startLayaStatusPolling();
|
||||
else await this.loadLayaStatus();
|
||||
} catch (err) {
|
||||
this.layaStatus = { ...(this.layaStatus || {}), error: err.message };
|
||||
this.layaOperationPending = '';
|
||||
await this.loadLayaStatus();
|
||||
}
|
||||
}
|
||||
|
||||
startLayaStatusPolling() {
|
||||
if (this.layaPollTimer) return;
|
||||
this.layaPollTimer = setInterval(async () => {
|
||||
if (this.layaPollInFlight) return;
|
||||
this.layaPollInFlight = true;
|
||||
try {
|
||||
const status = await this.loadLayaStatus();
|
||||
if (!status?.downloading && ['ready', 'failed', 'idle'].includes(status?.phase)) {
|
||||
clearInterval(this.layaPollTimer);
|
||||
this.layaPollTimer = null;
|
||||
if (status.phase === 'ready' && this.app.player.settings.languageDetectionMode !== 'algo') {
|
||||
this.app.channelList.setLanguageDetectionMode(this.app.player.settings.languageDetectionMode);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.layaPollInFlight = false;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
useStandardLanguageDetection() {
|
||||
const radio = document.querySelector('input[name="language-detection-mode"][value="algo"]');
|
||||
if (radio) radio.checked = true;
|
||||
this.app.player.settings.languageDetectionMode = 'algo';
|
||||
this.app.player.saveSettings();
|
||||
this.app.channelList.setLanguageDetectionMode('algo');
|
||||
this.renderLayaStatus(this.layaStatus);
|
||||
}
|
||||
|
||||
async copyLayaStartCommand() {
|
||||
const command = 'docker compose --profile laya up -d';
|
||||
const button = document.getElementById('laya-copy-command');
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
button.textContent = 'Copied';
|
||||
} catch {
|
||||
const code = document.querySelector('#laya-start-command code');
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(code);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
button.textContent = 'Command selected';
|
||||
}
|
||||
setTimeout(() => { button.textContent = 'Copy start command'; }, 2000);
|
||||
}
|
||||
|
||||
async initTranscodingSettings() {
|
||||
// Encoder settings
|
||||
const hwEncoderSelect = document.getElementById('setting-hw-encoder');
|
||||
@ -107,6 +443,8 @@ class SettingsPage {
|
||||
const forceVideoTranscodeToggle = document.getElementById('setting-force-video-transcode-tc');
|
||||
const forceRemuxToggle = document.getElementById('setting-force-remux-tc');
|
||||
const streamFormatSelect = document.getElementById('setting-stream-format-tc');
|
||||
const maxDirectBitrateSelect = document.getElementById('setting-max-direct-bitrate');
|
||||
const allowHardwareFallbackToggle = document.getElementById('setting-allow-hardware-fallback');
|
||||
|
||||
// User-Agent (Transcoding tab versions)
|
||||
const userAgentSelect = document.getElementById('setting-user-agent-tc');
|
||||
@ -131,6 +469,8 @@ class SettingsPage {
|
||||
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
|
||||
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
|
||||
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
|
||||
if (maxDirectBitrateSelect) maxDirectBitrateSelect.value = String(s.maxDirectBitrateKbps || 0);
|
||||
if (allowHardwareFallbackToggle) allowHardwareFallbackToggle.checked = s.allowHardwareFallback !== false;
|
||||
if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
|
||||
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
|
||||
if (customUaContainer) {
|
||||
@ -232,6 +572,16 @@ class SettingsPage {
|
||||
this.app.player.saveSettings();
|
||||
});
|
||||
|
||||
maxDirectBitrateSelect?.addEventListener('change', () => {
|
||||
this.app.player.settings.maxDirectBitrateKbps = parseInt(maxDirectBitrateSelect.value, 10) || 0;
|
||||
this.app.player.saveSettings();
|
||||
});
|
||||
|
||||
allowHardwareFallbackToggle?.addEventListener('change', () => {
|
||||
this.app.player.settings.allowHardwareFallback = allowHardwareFallbackToggle.checked;
|
||||
this.app.player.saveSettings();
|
||||
});
|
||||
|
||||
// User-Agent handlers
|
||||
const toggleCustomInput = () => {
|
||||
if (customUaContainer) {
|
||||
@ -557,6 +907,7 @@ class SettingsPage {
|
||||
const volumeValueDisplay = document.getElementById('volume-value');
|
||||
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
|
||||
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
|
||||
const groupLiveChannelsToggle = document.getElementById('setting-group-live-channels');
|
||||
const forceProxyToggle = document.getElementById('setting-force-proxy');
|
||||
const forceTranscodeToggle = document.getElementById('setting-force-transcode');
|
||||
const forceRemuxToggle = document.getElementById('setting-force-remux');
|
||||
@ -570,6 +921,10 @@ class SettingsPage {
|
||||
if (volumeValueDisplay) volumeValueDisplay.textContent = s.defaultVolume + '%';
|
||||
if (rememberVolumeToggle) rememberVolumeToggle.checked = s.rememberVolume;
|
||||
if (autoPlayNextToggle) autoPlayNextToggle.checked = s.autoPlayNextEpisode;
|
||||
if (groupLiveChannelsToggle) groupLiveChannelsToggle.checked = s.liveChannelListMode !== 'flat';
|
||||
document.querySelectorAll('input[name="language-detection-mode"]').forEach(radio => {
|
||||
radio.checked = radio.value === (s.languageDetectionMode || 'algo');
|
||||
});
|
||||
if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy || false;
|
||||
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false;
|
||||
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
|
||||
@ -592,6 +947,7 @@ class SettingsPage {
|
||||
|
||||
// Update EPG last refreshed display
|
||||
this.updateEpgLastRefreshed();
|
||||
void this.loadLayaStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
84
public/js/services/channelLanguageDetection.js
Normal file
84
public/js/services/channelLanguageDetection.js
Normal file
@ -0,0 +1,84 @@
|
||||
(function (root, factory) {
|
||||
const service = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = service;
|
||||
if (root) root.ChannelLanguageDetection = service;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const LANGUAGES = {
|
||||
fr: { code: 'fr', label: 'French' },
|
||||
en: { code: 'en', label: 'English' },
|
||||
es: { code: 'es', label: 'Spanish' }
|
||||
};
|
||||
const UNKNOWN = { code: 'unknown', label: 'Unknown', confidence: 0, status: 'unknown' };
|
||||
const EXPLICIT_CODES = { FR: 'fr', SFR: 'fr', 'FR-CAR': 'fr', EN: 'en', UK: 'en', US: 'en', AU: 'en', ES: 'es' };
|
||||
const WORDS = {
|
||||
fr: ['french', 'francais', 'france'],
|
||||
en: ['english', 'anglais', 'england', 'british'],
|
||||
es: ['spanish', 'espagnol', 'espanol', 'spain']
|
||||
};
|
||||
|
||||
function normalized(value) {
|
||||
return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function addEvidence(scores, evidence, code, weight, type, value) {
|
||||
if (!code) return;
|
||||
scores[code] = (scores[code] || 0) + weight;
|
||||
evidence.push({ code, weight, type, value: String(value || '') });
|
||||
}
|
||||
|
||||
function findExplicitPrefix(name) {
|
||||
const match = String(name || '').trim().match(/^(?:\|\s*([A-Z-]{2,8})\s*\||\[\s*([A-Z-]{2,8})\s*\]|([A-Z-]{2,8})\s*\|)/i);
|
||||
return match ? (match[1] || match[2] || match[3]).toUpperCase() : null;
|
||||
}
|
||||
|
||||
function scoreText(scores, evidence, value, weight, type, includeCountryCodes) {
|
||||
const text = normalized(value);
|
||||
if (!text) return;
|
||||
Object.entries(WORDS).forEach(([code, terms]) => {
|
||||
if (terms.some(term => new RegExp(`(?:^|[^a-z])${term}(?:$|[^a-z])`, 'i').test(text))) {
|
||||
addEvidence(scores, evidence, code, weight, type, value);
|
||||
}
|
||||
});
|
||||
if (includeCountryCodes) {
|
||||
const tokens = text.toUpperCase().split(/[^A-Z]+/).filter(Boolean);
|
||||
const mapped = tokens.map(token => EXPLICIT_CODES[token]).filter(Boolean);
|
||||
[...new Set(mapped)].forEach(code => addEvidence(scores, evidence, code, weight, type, value));
|
||||
}
|
||||
}
|
||||
|
||||
function detectLanguage(variant, context = {}) {
|
||||
const scores = {};
|
||||
const evidence = [];
|
||||
const prefix = findExplicitPrefix(variant.name);
|
||||
addEvidence(scores, evidence, EXPLICIT_CODES[prefix], 60, 'name-prefix', prefix);
|
||||
scoreText(scores, evidence, variant.name, 45, 'name-token', false);
|
||||
scoreText(scores, evidence, variant.groupTitle, 35, 'group', true);
|
||||
scoreText(scores, evidence, context.sourceName || variant.sourceName, 20, 'source', true);
|
||||
scoreText(scores, evidence, `${variant.tvgId || ''} ${variant.tvgName || ''}`, 20, 'metadata', true);
|
||||
|
||||
const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
||||
const total = ranked.reduce((sum, entry) => sum + entry[1], 0);
|
||||
const percentages = Object.fromEntries(ranked.map(([code, score]) => [code, Math.round(score / total * 100)]));
|
||||
if (!ranked.length || total < 20) {
|
||||
return { language: { ...UNKNOWN }, languageScores: percentages, languageEvidence: evidence };
|
||||
}
|
||||
|
||||
const [topCode, topScore] = ranked[0];
|
||||
const topConfidence = percentages[topCode];
|
||||
const secondConfidence = ranked[1] ? percentages[ranked[1][0]] : 0;
|
||||
if (topScore < 20 || topConfidence < 45 || topConfidence - secondConfidence < 10) {
|
||||
return { language: { ...UNKNOWN }, languageScores: percentages, languageEvidence: evidence };
|
||||
}
|
||||
|
||||
let status = 'low';
|
||||
if (topScore >= 60 && topConfidence >= 75) status = 'high';
|
||||
else if (topScore >= 35 && topConfidence >= 60) status = 'medium';
|
||||
return {
|
||||
language: { ...LANGUAGES[topCode], confidence: topConfidence, status },
|
||||
languageScores: percentages,
|
||||
languageEvidence: evidence
|
||||
};
|
||||
}
|
||||
|
||||
return { LANGUAGES, detectLanguage };
|
||||
});
|
||||
93
public/js/services/channelLanguageFusion.js
Normal file
93
public/js/services/channelLanguageFusion.js
Normal file
@ -0,0 +1,93 @@
|
||||
(function (root, factory) {
|
||||
const service = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = service;
|
||||
if (root) root.ChannelLanguageFusion = service;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const LABELS = { fr: 'French', en: 'English', es: 'Spanish', unknown: 'Unknown' };
|
||||
|
||||
function normalizeConfidence(value) {
|
||||
const number = Number(value) || 0;
|
||||
return Math.max(0, Math.min(100, Math.round(number <= 1 ? number * 100 : number)));
|
||||
}
|
||||
|
||||
function normalizeLanguageCode(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
const aliases = {
|
||||
fr: 'fr', french: 'fr', français: 'fr', francais: 'fr',
|
||||
en: 'en', english: 'en', anglais: 'en',
|
||||
es: 'es', spanish: 'es', español: 'es', espanol: 'es'
|
||||
};
|
||||
return aliases[normalized] || 'unknown';
|
||||
}
|
||||
|
||||
function layaDetection(result) {
|
||||
const rawCode = result?.language?.code || result?.language?.label || result?.language || 'unknown';
|
||||
const code = normalizeLanguageCode(rawCode);
|
||||
const confidence = normalizeConfidence(result?.language?.confidence ?? result?.confidence);
|
||||
return {
|
||||
language: {
|
||||
code,
|
||||
label: LABELS[code],
|
||||
confidence: code === 'unknown' ? 0 : confidence,
|
||||
status: code === 'unknown' ? 'unknown' : confidence >= 75 ? 'high' : confidence >= 60 ? 'medium' : 'low'
|
||||
},
|
||||
languageScores: code === 'unknown' ? {} : { [code]: confidence },
|
||||
languageEvidence: code === 'unknown' ? [] : [{ code, weight: confidence, type: 'laya', value: 'Local AI model' }]
|
||||
};
|
||||
}
|
||||
|
||||
function mergeHybrid(algo, result) {
|
||||
if (!result) return algo;
|
||||
const laya = layaDetection(result);
|
||||
if (laya.language.code === 'unknown') return {
|
||||
...algo,
|
||||
languageEvidence: [...algo.languageEvidence, ...laya.languageEvidence]
|
||||
};
|
||||
const combined = { ...algo.languageScores };
|
||||
combined[laya.language.code] = (combined[laya.language.code] || 0) + laya.language.confidence;
|
||||
const total = Object.values(combined).reduce((sum, value) => sum + value, 0);
|
||||
const scores = Object.fromEntries(Object.entries(combined).map(([code, value]) => [code, Math.round(value / total * 100)]));
|
||||
const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
||||
const [code, confidence] = ranked[0];
|
||||
const margin = confidence - (ranked[1]?.[1] || 0);
|
||||
const certain = confidence >= 45 && margin >= 10;
|
||||
return {
|
||||
language: certain ? {
|
||||
code,
|
||||
label: LABELS[code] || code.toUpperCase(),
|
||||
confidence,
|
||||
status: confidence >= 75 ? 'high' : confidence >= 60 ? 'medium' : 'low'
|
||||
} : { code: 'unknown', label: 'Unknown', confidence: 0, status: 'unknown' },
|
||||
languageScores: scores,
|
||||
languageEvidence: [...algo.languageEvidence, ...laya.languageEvidence]
|
||||
};
|
||||
}
|
||||
|
||||
function applyLanguageMode(algo, result, mode) {
|
||||
if (mode === 'algo') return algo;
|
||||
if (mode === 'hybrid') return mergeHybrid(algo, result);
|
||||
return result ? layaDetection(result) : {
|
||||
language: { code: 'unknown', label: 'Unknown', confidence: 0, status: 'unknown' },
|
||||
languageScores: {},
|
||||
languageEvidence: []
|
||||
};
|
||||
}
|
||||
|
||||
function buildLayaDescriptor(variant, variantKey, algo) {
|
||||
const fields = [
|
||||
['sourceId', variant.sourceId],
|
||||
['name', variant.name],
|
||||
['groupTitle', variant.groupTitle],
|
||||
['sourceName', variant.sourceName],
|
||||
['tvgId', variant.tvgId],
|
||||
['algorithmScores', JSON.stringify(algo.languageScores || {})]
|
||||
];
|
||||
return {
|
||||
id: variantKey,
|
||||
text: fields.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.map(([key, value]) => `${key}: ${value}`).join('\n')
|
||||
};
|
||||
}
|
||||
|
||||
return { applyLanguageMode, buildLayaDescriptor, layaDetection, mergeHybrid, normalizeConfidence, normalizeLanguageCode };
|
||||
});
|
||||
219
public/js/services/channelVariantGrouping.js
Normal file
219
public/js/services/channelVariantGrouping.js
Normal file
@ -0,0 +1,219 @@
|
||||
(function (root, factory) {
|
||||
const detector = typeof module === 'object' && module.exports
|
||||
? require('./channelLanguageDetection.js')
|
||||
: root.ChannelLanguageDetection;
|
||||
const fusion = typeof module === 'object' && module.exports
|
||||
? require('./channelLanguageFusion.js')
|
||||
: root.ChannelLanguageFusion;
|
||||
const service = factory(detector, fusion);
|
||||
if (typeof module === 'object' && module.exports) module.exports = service;
|
||||
if (root) root.ChannelVariantGrouping = service;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function (languageDetection, languageFusion) {
|
||||
const QUALITY_TOKENS = new Set([
|
||||
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
|
||||
'1080p', '1080i', '720p', '576p', '480p'
|
||||
]);
|
||||
|
||||
const REGION_PREFIX = /^(?:(?:\|\s*[a-z]{2,3}\s*\|)|(?:[a-z]{2,3}(?:-[a-z]{2,3})?\s*\|))\s*/i;
|
||||
const BEIN_SUFFIX_TOKENS = new Set(['fr', 'en', 'thai', 'tod', 'appel', 'app']);
|
||||
const BEIN_TIMESHIFT_TOKENS = new Set(['2h', '4h', '8h', '12h']);
|
||||
|
||||
function words(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(REGION_PREFIX, '')
|
||||
.replace(/[|()[\]{}_.,:;+/\\-]+/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeChannelName(name) {
|
||||
const tokens = words(name).filter(token => !QUALITY_TOKENS.has(token));
|
||||
const brandIndex = tokens.findIndex((token, index) => token === 'bein' && tokens[index + 1] === 'sports');
|
||||
if (brandIndex >= 0) {
|
||||
const branded = tokens.slice(brandIndex);
|
||||
const numberedBaseLength = branded[2] === 'max' && /^\d+$/.test(branded[3])
|
||||
? 4
|
||||
: (/^\d+$/.test(branded[2]) ? 3 : 0);
|
||||
if (numberedBaseLength) {
|
||||
const suffix = branded.slice(numberedBaseLength);
|
||||
const removable = suffix.every(token =>
|
||||
BEIN_SUFFIX_TOKENS.has(token) || BEIN_TIMESHIFT_TOKENS.has(token)
|
||||
);
|
||||
if (removable) return branded.slice(0, numberedBaseLength).join(' ');
|
||||
}
|
||||
return branded.join(' ').trim();
|
||||
}
|
||||
return tokens.join(' ').trim();
|
||||
}
|
||||
|
||||
function displayNameForFingerprint(fingerprint) {
|
||||
return fingerprint.split(' ').map(token => {
|
||||
if (token === 'bein') return 'beIN';
|
||||
if (/^\d+$/.test(token)) return token;
|
||||
return token.charAt(0).toUpperCase() + token.slice(1);
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
function getVariantKey(channel) {
|
||||
return `${channel.sourceId}:${channel.id}`;
|
||||
}
|
||||
|
||||
function getQuality(channel) {
|
||||
const tokens = words(channel.name);
|
||||
if (tokens.includes('4k') || tokens.includes('uhd')) return '4K';
|
||||
if (tokens.includes('fhd') || tokens.includes('fullhd') || tokens.includes('1080p') || tokens.includes('1080i')) return 'Full HD';
|
||||
if (tokens.includes('hd') || tokens.includes('720p')) return 'HD';
|
||||
if (tokens.includes('sd') || tokens.includes('576p') || tokens.includes('480p')) return 'SD';
|
||||
return 'Standard';
|
||||
}
|
||||
|
||||
function qualityScore(channel) {
|
||||
return { '4K': 4, 'Full HD': 3, HD: 2, Standard: 1, SD: 0 }[getQuality(channel)] ?? 1;
|
||||
}
|
||||
|
||||
function choosePreferredVariant(variants, options) {
|
||||
const selectedKey = options.selectedVariantKey;
|
||||
const selected = variants.find(variant => getVariantKey(variant) === selectedKey);
|
||||
if (selected && (options.showHidden || !options.isHidden(selected))) return selected;
|
||||
|
||||
const requestedLanguageCandidate = options.channelLanguagePreference || options.preferredLanguage;
|
||||
const requestedLanguage = variants.some(variant => variant.language.code === requestedLanguageCandidate)
|
||||
? requestedLanguageCandidate
|
||||
: null;
|
||||
const majorityLanguage = options.majorityLanguage;
|
||||
return variants.map((variant, index) => ({ variant, index })).sort((aEntry, bEntry) => {
|
||||
const a = aEntry.variant;
|
||||
const b = bEntry.variant;
|
||||
const hiddenDifference = Number(options.isHidden(a)) - Number(options.isHidden(b));
|
||||
if (!options.showHidden && hiddenDifference) return hiddenDifference;
|
||||
if (requestedLanguage && requestedLanguage !== 'auto') {
|
||||
const languageDifference = Number(b.language.code === requestedLanguage) - Number(a.language.code === requestedLanguage);
|
||||
if (languageDifference) return languageDifference;
|
||||
} else if (majorityLanguage) {
|
||||
const majorityDifference = Number(b.language.code === majorityLanguage) - Number(a.language.code === majorityLanguage);
|
||||
if (majorityDifference) return majorityDifference;
|
||||
}
|
||||
const favoriteDifference = Number(options.isFavorite(b)) - Number(options.isFavorite(a));
|
||||
if (favoriteDifference) return favoriteDifference;
|
||||
return qualityScore(b) - qualityScore(a) || aEntry.index - bEntry.index;
|
||||
})[0].variant;
|
||||
}
|
||||
|
||||
function groupChannels(channels, options = {}) {
|
||||
const isFavorite = options.isFavorite || (() => false);
|
||||
const isHidden = options.isHidden || (() => false);
|
||||
const showHidden = Boolean(options.showHidden);
|
||||
const selectedVariants = options.selectedVariants || new Map();
|
||||
const languagePreferences = options.languagePreferences || new Map();
|
||||
const buckets = new Map();
|
||||
|
||||
channels.forEach(channel => {
|
||||
const fingerprint = channel.logicalFingerprint || normalizeChannelName(channel.name) || `channel-${getVariantKey(channel)}`;
|
||||
if (!buckets.has(fingerprint)) buckets.set(fingerprint, []);
|
||||
buckets.get(fingerprint).push(channel);
|
||||
});
|
||||
|
||||
return Array.from(buckets, ([fingerprint, rawVariants]) => {
|
||||
const variants = rawVariants.map(variant => {
|
||||
const algo = languageDetection.detectLanguage(variant, { sourceName: variant.sourceName });
|
||||
const result = options.languageResults?.get(getVariantKey(variant));
|
||||
return {
|
||||
...variant,
|
||||
...languageFusion.applyLanguageMode(algo, result, options.languageMode || 'algo')
|
||||
};
|
||||
});
|
||||
const visibleVariants = variants.filter(variant => !isHidden(variant));
|
||||
const selectableVariants = showHidden ? variants : visibleVariants;
|
||||
const languageCounts = selectableVariants.reduce((counts, variant) => {
|
||||
counts[variant.language.code] = (counts[variant.language.code] || 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
const majorityLanguage = Object.entries(languageCounts)
|
||||
.filter(([code]) => code !== 'unknown')
|
||||
.sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||
const preferredVariant = choosePreferredVariant(variants, {
|
||||
isHidden,
|
||||
isFavorite,
|
||||
showHidden,
|
||||
selectedVariantKey: selectedVariants.get(fingerprint),
|
||||
channelLanguagePreference: languagePreferences.get(fingerprint),
|
||||
preferredLanguage: options.preferredLanguage,
|
||||
majorityLanguage
|
||||
});
|
||||
const availableLanguages = [...new Map(selectableVariants.map(variant => [
|
||||
variant.language.code,
|
||||
variant.language
|
||||
])).values()];
|
||||
const aggregateScores = {};
|
||||
selectableVariants.forEach(variant => Object.entries(variant.languageScores).forEach(([code, score]) => {
|
||||
aggregateScores[code] = (aggregateScores[code] || 0) + score;
|
||||
}));
|
||||
const scoreTotal = Object.values(aggregateScores).reduce((sum, score) => sum + score, 0);
|
||||
const languageScores = Object.fromEntries(Object.entries(aggregateScores).map(([code, score]) => [
|
||||
code, Math.round(score / scoreTotal * 100)
|
||||
]));
|
||||
return {
|
||||
...preferredVariant,
|
||||
id: preferredVariant.logicalId || `logical:${fingerprint}`,
|
||||
fingerprint,
|
||||
displayName: preferredVariant.logicalDisplayName || displayNameForFingerprint(fingerprint),
|
||||
name: preferredVariant.logicalDisplayName || displayNameForFingerprint(fingerprint),
|
||||
variants,
|
||||
visibleVariants,
|
||||
preferredVariant,
|
||||
language: preferredVariant.language,
|
||||
languageScores,
|
||||
availableLanguages,
|
||||
selectedVariantId: getVariantKey(preferredVariant),
|
||||
isFavorite: variants.some(isFavorite),
|
||||
isHidden: visibleVariants.length === 0,
|
||||
groupTitle: preferredVariant.groupTitle,
|
||||
tvgLogo: preferredVariant.tvgLogo,
|
||||
tvgId: preferredVariant.tvgId
|
||||
};
|
||||
}).filter(channel => showHidden || !channel.isHidden);
|
||||
}
|
||||
|
||||
function orderFlatChannels(channels) {
|
||||
return channels.map((channel, index) => ({ channel, index })).sort((a, b) => {
|
||||
const favoriteDifference = Number(b.channel.isFavorite) - Number(a.channel.isFavorite);
|
||||
return favoriteDifference || a.index - b.index;
|
||||
}).map(entry => entry.channel);
|
||||
}
|
||||
|
||||
function shouldUseFavoritesGroup(listMode) {
|
||||
return listMode === 'grouped';
|
||||
}
|
||||
|
||||
function selectVariantByLanguage(logicalChannel, languageCode, options = {}) {
|
||||
const isFavorite = options.isFavorite || (() => false);
|
||||
const isHidden = options.isHidden || (() => false);
|
||||
const visible = (logicalChannel?.variants || []).filter(variant => !isHidden(variant));
|
||||
if (!visible.length) return null;
|
||||
const preferredVisible = visible.find(variant => getVariantKey(variant) === getVariantKey(logicalChannel.preferredVariant));
|
||||
if (!languageCode || languageCode === 'auto') return preferredVisible || visible[0];
|
||||
const matching = languageCode && languageCode !== 'auto'
|
||||
? visible.filter(variant => variant.language?.code === languageCode)
|
||||
: visible;
|
||||
const candidates = matching.length ? matching : (preferredVisible ? [preferredVisible] : visible);
|
||||
return candidates.map((variant, index) => ({ variant, index })).sort((a, b) =>
|
||||
Number(isFavorite(b.variant)) - Number(isFavorite(a.variant)) ||
|
||||
qualityScore(b.variant) - qualityScore(a.variant) ||
|
||||
a.index - b.index
|
||||
)[0].variant;
|
||||
}
|
||||
|
||||
return {
|
||||
normalizeChannelName,
|
||||
displayNameForFingerprint,
|
||||
getVariantKey,
|
||||
getQuality,
|
||||
groupChannels,
|
||||
orderFlatChannels,
|
||||
selectVariantByLanguage,
|
||||
shouldUseFavoritesGroup
|
||||
};
|
||||
});
|
||||
97
public/js/services/layaStatusPresentation.js
Normal file
97
public/js/services/layaStatusPresentation.js
Normal file
@ -0,0 +1,97 @@
|
||||
(function (root, factory) {
|
||||
const service = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = service;
|
||||
if (root) root.LayaStatusPresentation = service;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const START_COMMAND = 'docker compose --profile laya up -d';
|
||||
const PHASE_LABELS = {
|
||||
idle: 'Model not downloaded',
|
||||
checking: 'Checking local AI model…',
|
||||
downloading: 'Downloading model…',
|
||||
verifying: 'Verifying model…',
|
||||
preparing: 'Preparing download…',
|
||||
ready: 'Model ready',
|
||||
failed: 'Model download failed',
|
||||
deleting: 'Removing model…'
|
||||
};
|
||||
|
||||
function errorParts(status) {
|
||||
const value = status.error || status.lastError;
|
||||
const fallback = status.lastError;
|
||||
if (!value) return { message: '', detail: '' };
|
||||
if (typeof value === 'string') return {
|
||||
message: value,
|
||||
detail: typeof fallback === 'object' ? String(fallback.detail || '') : ''
|
||||
};
|
||||
return {
|
||||
message: String(value.message || 'The local AI model operation failed.'),
|
||||
detail: String(value.detail || '')
|
||||
};
|
||||
}
|
||||
|
||||
function presentLayaStatus(status = {}, mode = 'algo') {
|
||||
const phase = PHASE_LABELS[status.phase] ? status.phase : 'idle';
|
||||
const busy = Boolean(status.downloading) || ['checking', 'downloading', 'verifying', 'preparing', 'deleting'].includes(phase);
|
||||
const percentValue = Number(status.progressPercent);
|
||||
const determined = phase === 'downloading' && status.progressPercent !== null &&
|
||||
status.progressPercent !== undefined && Number.isFinite(percentValue);
|
||||
const progressPercent = determined ? Math.max(0, Math.min(100, Math.round(percentValue))) : null;
|
||||
const error = errorParts(status);
|
||||
const failed = phase === 'failed';
|
||||
const modelPresent = status.modelPresent === true;
|
||||
const runtimeAvailable = status.runtimeAvailable === true;
|
||||
const runtimeUnavailable = status.runtimeAvailable === false;
|
||||
const runtimeHelp = runtimeUnavailable
|
||||
? `Nodecast cannot reach the optional Laya runtime. Standard matching remains available.\nStart the Laya service, then try again:\n${START_COMMAND}`
|
||||
: '';
|
||||
const ready = modelPresent && runtimeAvailable && !busy && !failed;
|
||||
const statusLabel = ready
|
||||
? 'Ready'
|
||||
: failed || busy
|
||||
? PHASE_LABELS[phase]
|
||||
: modelPresent && !runtimeAvailable
|
||||
? 'Local AI service unavailable'
|
||||
: !modelPresent
|
||||
? 'Model not downloaded'
|
||||
: PHASE_LABELS[phase];
|
||||
return {
|
||||
phase,
|
||||
ready,
|
||||
statusLabel,
|
||||
modelLabel: `Model: ${modelPresent ? 'Downloaded' : 'Not downloaded'}`,
|
||||
serviceLabel: `Local AI service: ${runtimeAvailable ? 'Available' : 'Unavailable'}`,
|
||||
busy,
|
||||
showProgress: phase === 'downloading',
|
||||
progressIndeterminate: phase === 'downloading' && progressPercent === null,
|
||||
progressPercent,
|
||||
bytesDownloaded: status.bytesDownloaded !== null && status.bytesDownloaded !== undefined && Number.isFinite(Number(status.bytesDownloaded))
|
||||
? Number(status.bytesDownloaded) : null,
|
||||
totalBytes: status.totalBytes !== null && status.totalBytes !== undefined && Number.isFinite(Number(status.totalBytes))
|
||||
? Number(status.totalBytes) : null,
|
||||
errorMessage: failed ? (error.message || 'The local AI model could not be downloaded.') : '',
|
||||
errorDetail: failed ? [error.detail, runtimeHelp].filter(Boolean).join('\n\n') : '',
|
||||
downloadLabel: failed ? 'Retry download' : 'Download model',
|
||||
showDownload: !busy && !modelPresent && (phase === 'idle' || failed),
|
||||
showRemove: !busy && modelPresent,
|
||||
showUseStandard: failed && mode !== 'algo',
|
||||
showCheckAgain: !busy && modelPresent && !runtimeAvailable,
|
||||
showStartCommand: !busy && runtimeUnavailable,
|
||||
availabilityMessage: modelPresent && !runtimeAvailable
|
||||
? 'The model is installed, but the Local AI service is not running. Standard channel matching is being used.'
|
||||
: ''
|
||||
};
|
||||
}
|
||||
|
||||
function channelListWarning(mode, status = {}, batchFailed = false) {
|
||||
if (mode === 'algo') return '';
|
||||
if (batchFailed) {
|
||||
return 'Local AI language detection failed. Channels were grouped with Standard matching and remain available.';
|
||||
}
|
||||
if (status.modelPresent !== true || status.runtimeAvailable !== true) {
|
||||
return 'Local AI service is unavailable. Standard channel matching is being used.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
return { channelListWarning, presentLayaStatus };
|
||||
});
|
||||
86
public/js/services/liveAnalysisPresentation.js
Normal file
86
public/js/services/liveAnalysisPresentation.js
Normal file
@ -0,0 +1,86 @@
|
||||
(function (root, factory) {
|
||||
const service = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = service;
|
||||
if (root) root.LiveAnalysisPresentation = service;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const BUSY_STATUSES = new Set(['queued', 'building', 'running']);
|
||||
const FAILED_STATUSES = new Set(['error', 'failed']);
|
||||
|
||||
function progressValues(status, phase) {
|
||||
if (phase === 'laya_identity') {
|
||||
return {
|
||||
processed: status.layaProcessed ?? status.phaseProcessed ?? status.batchProcessed ?? status.processedBatches,
|
||||
total: status.layaTotal ?? status.phaseTotal ?? status.batchTotal ?? status.totalBatches,
|
||||
prefix: 'AI batch'
|
||||
};
|
||||
}
|
||||
if (phase === 'laya_language') {
|
||||
return {
|
||||
processed: status.languageProcessed,
|
||||
total: status.languageTotal,
|
||||
prefix: 'Language batch'
|
||||
};
|
||||
}
|
||||
return { processed: status.processed, total: status.total, prefix: '', suffix: ' channels' };
|
||||
}
|
||||
|
||||
function present(status = {}) {
|
||||
const state = String(status.status || 'idle').toLowerCase();
|
||||
const busy = BUSY_STATUSES.has(state);
|
||||
const failed = FAILED_STATUSES.has(state);
|
||||
const complete = state === 'success';
|
||||
const phase = String(status.phase || '').toLowerCase();
|
||||
const progress = progressValues(status, phase);
|
||||
const processed = Number(progress.processed);
|
||||
const total = Number(progress.total);
|
||||
const determined = busy && phase !== 'writing' &&
|
||||
progress.processed !== null && progress.processed !== undefined &&
|
||||
progress.total !== null && progress.total !== undefined &&
|
||||
Number.isFinite(processed) && Number.isFinite(total) && total > 0;
|
||||
const progressPercent = determined
|
||||
? Math.max(0, Math.min(100, Math.round(processed / total * 100)))
|
||||
: null;
|
||||
const previouslyCompleted = Boolean(status.completedAt) || Number(status.projectedCount) > 0;
|
||||
const statusLabel = busy
|
||||
? phase === 'laya_identity'
|
||||
? 'Analyzing with local AI…'
|
||||
: phase === 'laya_language'
|
||||
? 'Detecting stream languages…'
|
||||
: phase === 'projecting'
|
||||
? 'Building channel catalog…'
|
||||
: phase === 'writing'
|
||||
? 'Saving channel catalog…'
|
||||
: 'Analyzing channels…'
|
||||
: complete ? 'Analysis complete' : failed ? 'Analysis failed' : 'Ready to analyze';
|
||||
const progressText = phase === 'writing' && busy
|
||||
? 'Finalizing…'
|
||||
: progressPercent === null
|
||||
? (busy ? 'In progress…' : '')
|
||||
: progress.prefix
|
||||
? `${progress.prefix} ${processed} of ${total} · ${progressPercent}%`
|
||||
: `${processed} of ${total}${progress.suffix || ''} · ${progressPercent}%`;
|
||||
|
||||
return {
|
||||
state,
|
||||
phase,
|
||||
busy,
|
||||
complete,
|
||||
failed,
|
||||
statusLabel,
|
||||
buttonLabel: failed ? 'Try again' : complete || previouslyCompleted ? 'Analyze again' : 'Analyze channels',
|
||||
progressIndeterminate: busy && progressPercent === null,
|
||||
progressPercent,
|
||||
progressText,
|
||||
errorDetail: failed && status.error ? String(status.error) : ''
|
||||
};
|
||||
}
|
||||
|
||||
function layaFallbackMessage(mode, layaStatus = {}) {
|
||||
if (!['laya', 'hybrid'].includes(mode)) return '';
|
||||
return layaStatus.modelPresent === false || layaStatus.runtimeAvailable === false
|
||||
? 'Local AI is unavailable. Standard channel matching will be used for this analysis.'
|
||||
: '';
|
||||
}
|
||||
|
||||
return { layaFallbackMessage, present };
|
||||
});
|
||||
132
public/js/services/liveChannelProjection.js
Normal file
132
public/js/services/liveChannelProjection.js
Normal file
@ -0,0 +1,132 @@
|
||||
(function (root, factory) {
|
||||
const service = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = service;
|
||||
if (root) root.LiveChannelProjection = service;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function normalizeSearch(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/([a-z])([0-9])/g, '$1 $2')
|
||||
.replace(/([0-9])([a-z])/g, '$1 $2')
|
||||
.replace(/[^a-z0-9]+/g, ' ').trim()
|
||||
.split(/\s+/).filter(Boolean)
|
||||
.map(token => token.length > 3 && token.endsWith('s') ? token.slice(0, -1) : token);
|
||||
}
|
||||
|
||||
function matchesSearch(channel, query) {
|
||||
const wanted = normalizeSearch(query);
|
||||
if (!wanted.length) return true;
|
||||
const corpus = normalizeSearch([
|
||||
channel.displayName, channel.standardName, channel.fingerprint,
|
||||
...(channel.variants || []).flatMap(variant => [
|
||||
variant.name, variant.tvgId, variant.categoryName, variant.groupTitle
|
||||
])
|
||||
].filter(Boolean).join(' '));
|
||||
return wanted.every(token => corpus.some(candidate => {
|
||||
if (/^\d+$/.test(token)) return /^\d+$/.test(candidate) && Number(candidate) === Number(token);
|
||||
return candidate.includes(token);
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeLanguage(language) {
|
||||
const rawCode = typeof language?.code === 'string' ? language.code.trim().toLowerCase() : '';
|
||||
const code = rawCode || 'und';
|
||||
const confidence = Number(language?.confidence);
|
||||
return {
|
||||
code,
|
||||
short: language?.short || (code === 'und' ? 'UND' : code.toUpperCase()),
|
||||
label: language?.label || (code === 'und' ? 'Unknown' : code.toUpperCase()),
|
||||
confidence: Number.isFinite(confidence) ? confidence : 0
|
||||
};
|
||||
}
|
||||
|
||||
function mapResponse(response, sources = []) {
|
||||
if (!response || !Array.isArray(response.channels)) {
|
||||
throw new Error('Invalid live channel projection response');
|
||||
}
|
||||
|
||||
const sourceById = new Map(sources.map(source => [String(source.id), source]));
|
||||
const favorites = new Set();
|
||||
const hiddenItems = new Set();
|
||||
const channels = [];
|
||||
const logicalChannels = [];
|
||||
const hasLanguageViews = response.channels.every(channel => channel?.logicalChannelId);
|
||||
|
||||
response.channels.forEach(logicalChannel => {
|
||||
if (!logicalChannel) return;
|
||||
const rawVariants = Array.isArray(logicalChannel.variants)
|
||||
? logicalChannel.variants.filter(Boolean)
|
||||
: (logicalChannel.preferredVariant ? [logicalChannel.preferredVariant] : []);
|
||||
const mappedVariants = [];
|
||||
rawVariants.forEach(variant => {
|
||||
const source = sourceById.get(String(variant.sourceId));
|
||||
const id = String(variant.id);
|
||||
const mapped = {
|
||||
...variant,
|
||||
id,
|
||||
logicalId: logicalChannel.id,
|
||||
logicalFingerprint: logicalChannel.fingerprint || logicalChannel.id,
|
||||
logicalDisplayName: logicalChannel.displayName || logicalChannel.name,
|
||||
streamId: variant.streamId ?? id,
|
||||
tvgId: variant.tvgId ?? variant.epg_channel_id ?? null,
|
||||
tvgLogo: variant.tvgLogo ?? variant.streamIcon ?? variant.stream_icon ?? '',
|
||||
url: variant.url ?? variant.streamUrl ?? variant.stream_url ?? '',
|
||||
groupTitle: variant.groupTitle ?? variant.categoryName ?? variant.categoryId ?? 'Uncategorized',
|
||||
sourceType: variant.sourceType ?? source?.type ?? 'xtream',
|
||||
sourceName: variant.sourceName ?? source?.name ?? ''
|
||||
};
|
||||
mapped.language = normalizeLanguage(variant.language || logicalChannel.language);
|
||||
channels.push(mapped);
|
||||
mappedVariants.push(mapped);
|
||||
const key = `${mapped.sourceId}:${id}`;
|
||||
if (variant.isFavorite) favorites.add(key);
|
||||
if (variant.isHidden) hiddenItems.add(`channel:${mapped.sourceId}:${mapped.streamId || id}`);
|
||||
});
|
||||
if (!mappedVariants.length) return;
|
||||
const preferredVariant = mappedVariants.find(variant =>
|
||||
`${variant.sourceId}:${variant.id}` === String(logicalChannel.selectedVariantId)
|
||||
) || mappedVariants.find(variant => String(variant.id) === String(logicalChannel.preferredVariant?.id))
|
||||
|| mappedVariants[0];
|
||||
const language = normalizeLanguage(logicalChannel.language || preferredVariant.language);
|
||||
if (hasLanguageViews) logicalChannels.push({
|
||||
...logicalChannel,
|
||||
fingerprint: logicalChannel.id,
|
||||
displayName: logicalChannel.displayName || logicalChannel.standardName || logicalChannel.name,
|
||||
name: logicalChannel.displayName || logicalChannel.standardName || logicalChannel.name,
|
||||
variants: mappedVariants,
|
||||
visibleVariants: mappedVariants.filter(variant => !variant.isHidden),
|
||||
preferredVariant,
|
||||
language,
|
||||
selectedVariantId: `${preferredVariant.sourceId}:${preferredVariant.id}`,
|
||||
groupTitle: preferredVariant.groupTitle,
|
||||
tvgLogo: preferredVariant.tvgLogo,
|
||||
tvgId: logicalChannel.epg?.channelId || preferredVariant.tvgId,
|
||||
isFavorite: mappedVariants.some(variant => variant.isFavorite),
|
||||
isHidden: mappedVariants.every(variant => variant.isHidden)
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
channels, logicalChannels: hasLanguageViews ? logicalChannels : null, favorites, hiddenItems,
|
||||
availableLanguages: (response.filters?.availableLanguages || []).filter(Boolean).map(normalizeLanguage),
|
||||
projection: response.projection || {}
|
||||
};
|
||||
}
|
||||
|
||||
function canUse(response) {
|
||||
if (!response || !Array.isArray(response.channels) || response.projection?.stale) return false;
|
||||
|
||||
const projection = response.projection || {};
|
||||
const expectedChannelCount = [
|
||||
projection.total,
|
||||
projection.processed,
|
||||
projection.catalogCount,
|
||||
projection.sourceChannelCount
|
||||
].some(count => Number(count) > 0);
|
||||
|
||||
return response.channels.length > 0 || !expectedChannelCount;
|
||||
}
|
||||
|
||||
return { canUse, mapResponse, matchesSearch, normalizeLanguage, normalizeSearch };
|
||||
});
|
||||
39
scripts/docker-build-push.sh
Executable file
39
scripts/docker-build-push.sh
Executable file
@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY=${REGISTRY:-gitea.anthonybouteiller.ovh}
|
||||
OWNER=${OWNER:-blomios}
|
||||
IMAGE_NAME=${IMAGE_NAME:-nodecast-tv}
|
||||
LAYA_IMAGE_NAME=${LAYA_IMAGE_NAME:-nodecast-tv-laya}
|
||||
TAG=${TAG:-latest}
|
||||
IMAGE="${REGISTRY}/${OWNER}/${IMAGE_NAME}:${TAG}"
|
||||
LAYA_IMAGE="${REGISTRY}/${OWNER}/${LAYA_IMAGE_NAME}:${TAG}"
|
||||
|
||||
push_image=true
|
||||
if [[ "${1:-}" == "--no-push" ]]; then
|
||||
push_image=false
|
||||
elif [[ $# -gt 0 ]]; then
|
||||
echo "Usage: $0 [--no-push]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Error: docker is required but was not found in PATH." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
|
||||
echo "Target Nodecast image: ${IMAGE}"
|
||||
echo "Target Laya image: ${LAYA_IMAGE}"
|
||||
docker build --file "${repo_dir}/Dockerfile" --tag "${IMAGE}" "${repo_dir}"
|
||||
docker build --file "${repo_dir}/Dockerfile.laya" --tag "${LAYA_IMAGE}" "${repo_dir}"
|
||||
|
||||
if [[ "${DRY_RUN:-false}" == "true" || "${push_image}" == "false" ]]; then
|
||||
echo "Push skipped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
docker push "${IMAGE}"
|
||||
docker push "${LAYA_IMAGE}"
|
||||
@ -1,6 +1,7 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const passport = require('passport');
|
||||
const { isAdmin } = require('./authz');
|
||||
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
||||
const { Strategy: LocalStrategy } = require('passport-local');
|
||||
|
||||
@ -234,7 +235,7 @@ const requireAuth = passport.authenticate('jwt', { session: false });
|
||||
* Middleware: Require admin role
|
||||
*/
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
if (!isAdmin(req.user)) {
|
||||
return res.status(403).json({ error: 'Forbidden - Admin access required' });
|
||||
}
|
||||
next();
|
||||
|
||||
5
server/authz.js
Normal file
5
server/authz.js
Normal file
@ -0,0 +1,5 @@
|
||||
function isAdmin(user) {
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
|
||||
module.exports = { isAdmin };
|
||||
39
server/db.js
39
server/db.js
@ -1,6 +1,8 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const { existsSync, mkdirSync } = require('fs');
|
||||
const { ensureBuiltinSources } = require('./services/builtinEpgRegistry');
|
||||
const { applyEpgOrder, nextEpgPriority, sortEpgSources } = require('./services/epgSourceOrdering');
|
||||
|
||||
// Ensure data directory exists (sync is fine for startup)
|
||||
const dataDir = path.join(__dirname, '..', 'data');
|
||||
@ -17,25 +19,28 @@ async function loadDb() {
|
||||
try {
|
||||
const fileContent = await fs.readFile(dbPath, 'utf-8');
|
||||
const data = JSON.parse(fileContent);
|
||||
return {
|
||||
const original = JSON.stringify(data);
|
||||
const migrated = ensureBuiltinSources({
|
||||
sources: data.sources || [],
|
||||
hiddenItems: data.hiddenItems || [],
|
||||
favorites: data.favorites || [],
|
||||
settings: data.settings || getDefaultSettings(),
|
||||
users: data.users || [],
|
||||
nextId: data.nextId || 1
|
||||
};
|
||||
});
|
||||
if (original !== JSON.stringify(migrated)) await saveDb(migrated);
|
||||
return migrated;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// File doesn't exist, return default
|
||||
return {
|
||||
return ensureBuiltinSources({
|
||||
sources: [],
|
||||
hiddenItems: [],
|
||||
favorites: [],
|
||||
settings: getDefaultSettings(),
|
||||
users: [],
|
||||
nextId: 1
|
||||
};
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@ -62,6 +67,8 @@ function getDefaultSettings() {
|
||||
rememberVolume: true,
|
||||
lastVolume: 80,
|
||||
autoPlayNextEpisode: false,
|
||||
liveChannelListMode: 'grouped',
|
||||
languageDetectionMode: 'algo',
|
||||
forceProxy: false,
|
||||
forceTranscode: false, // Force Audio Transcode
|
||||
forceVideoTranscode: false, // Force Video Transcode
|
||||
@ -76,6 +83,9 @@ function getDefaultSettings() {
|
||||
hwEncoder: 'auto', // auto | nvenc | amf | qsv | vaapi | software
|
||||
maxResolution: '1080p', // 4k | 1080p | 720p | 480p
|
||||
quality: 'medium', // high | medium | low
|
||||
maxDirectBitrateKbps: 0, // 0 = unlimited
|
||||
targetTranscodeBitrateKbps: 6000,
|
||||
allowHardwareFallback: true,
|
||||
audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough
|
||||
// Probe cache settings
|
||||
probeCacheTTL: 300, // 5 minutes for URL probe cache
|
||||
@ -131,7 +141,8 @@ async function saveDb(data) {
|
||||
const sources = {
|
||||
async getAll() {
|
||||
const db = await loadDb();
|
||||
return db.sources;
|
||||
const epg = sortEpgSources(db.sources);
|
||||
return [...db.sources.filter(source => source.type !== 'epg'), ...epg];
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
@ -141,7 +152,8 @@ const sources = {
|
||||
|
||||
async getByType(type) {
|
||||
const db = await loadDb();
|
||||
return db.sources.filter(s => s.type === type && s.enabled);
|
||||
const matching = db.sources.filter(s => s.type === type && s.enabled);
|
||||
return type === 'epg' ? sortEpgSources(matching) : matching;
|
||||
},
|
||||
|
||||
async create(source) {
|
||||
@ -149,6 +161,7 @@ const sources = {
|
||||
const newSource = {
|
||||
id: db.nextId++,
|
||||
...source,
|
||||
...(source.type === 'epg' ? { epgPriority: nextEpgPriority(db.sources) } : {}),
|
||||
enabled: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
@ -163,6 +176,11 @@ const sources = {
|
||||
const index = db.sources.findIndex(s => s.id === parseInt(id));
|
||||
if (index === -1) return null;
|
||||
|
||||
const existing = db.sources[index];
|
||||
if (existing.builtinKey) {
|
||||
const allowed = Object.keys(updates).every(key => key === 'enabled');
|
||||
if (!allowed) throw Object.assign(new Error('Built-in sources can only be enabled or disabled'), { code: 'BUILTIN_SOURCE_READONLY' });
|
||||
}
|
||||
db.sources[index] = {
|
||||
...db.sources[index],
|
||||
...updates,
|
||||
@ -174,6 +192,8 @@ const sources = {
|
||||
|
||||
async delete(id) {
|
||||
const db = await loadDb();
|
||||
const existing = db.sources.find(s => s.id === parseInt(id));
|
||||
if (existing?.builtinKey) throw Object.assign(new Error('Built-in sources cannot be removed'), { code: 'BUILTIN_SOURCE_PROTECTED' });
|
||||
db.sources = db.sources.filter(s => s.id !== parseInt(id));
|
||||
// Also delete related hidden items and favorites
|
||||
db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id));
|
||||
@ -190,6 +210,13 @@ const sources = {
|
||||
await saveDb(db);
|
||||
}
|
||||
return source;
|
||||
},
|
||||
|
||||
async reorderEpg(orderedIds) {
|
||||
const db = await loadDb();
|
||||
const ordered = applyEpgOrder(db.sources, orderedIds);
|
||||
await saveDb(db);
|
||||
return ordered;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
85
server/db/liveProjectionSchema.js
Normal file
85
server/db/liveProjectionSchema.js
Normal file
@ -0,0 +1,85 @@
|
||||
const LIVE_PROJECTION_COLUMNS = {
|
||||
logical_channel_id: 'TEXT',
|
||||
grouping_fingerprint: 'TEXT',
|
||||
grouping_input_hash: 'TEXT',
|
||||
quality_label: 'TEXT',
|
||||
quality_rank: 'INTEGER',
|
||||
variant_rank: 'INTEGER',
|
||||
language_code: 'TEXT',
|
||||
language_confidence: 'REAL',
|
||||
language_scores: 'JSON',
|
||||
language_evidence: 'JSON',
|
||||
language_detector: 'TEXT',
|
||||
language_input_hash: 'TEXT'
|
||||
};
|
||||
|
||||
const CATALOG_PROJECTION_STATE_COLUMNS = {
|
||||
phase: "TEXT NOT NULL DEFAULT 'idle'",
|
||||
processed_count: 'INTEGER NOT NULL DEFAULT 0',
|
||||
total_count: 'INTEGER NOT NULL DEFAULT 0',
|
||||
laya_processed_count: 'INTEGER NOT NULL DEFAULT 0',
|
||||
laya_total_count: 'INTEGER NOT NULL DEFAULT 0'
|
||||
};
|
||||
|
||||
const LIVE_PROJECTION_SCHEMA_SQL = `
|
||||
CREATE INDEX IF NOT EXISTS idx_items_logical_channel ON playlist_items(logical_channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_grouping_fingerprint ON playlist_items(grouping_fingerprint);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS logical_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
preferred_variant_id TEXT,
|
||||
variant_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_logical_channels_fingerprint ON logical_channels(fingerprint);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iptv_org_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
normalized_name TEXT NOT NULL,
|
||||
alt_names JSON NOT NULL DEFAULT '[]',
|
||||
country TEXT,
|
||||
languages JSON NOT NULL DEFAULT '[]',
|
||||
data JSON,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_iptv_org_channels_name ON iptv_org_channels(normalized_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iptv_org_cache_state (
|
||||
resource TEXT PRIMARY KEY,
|
||||
refreshed_at INTEGER NOT NULL,
|
||||
item_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS catalog_projection_state (
|
||||
projection TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
started_at INTEGER,
|
||||
completed_at INTEGER,
|
||||
input_hash TEXT,
|
||||
projected_count INTEGER NOT NULL DEFAULT 0,
|
||||
phase TEXT NOT NULL DEFAULT 'idle',
|
||||
processed_count INTEGER NOT NULL DEFAULT 0,
|
||||
total_count INTEGER NOT NULL DEFAULT 0,
|
||||
laya_processed_count INTEGER NOT NULL DEFAULT 0,
|
||||
laya_total_count INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT
|
||||
);
|
||||
`;
|
||||
|
||||
function ensureColumns(database, table, columns) {
|
||||
const existing = new Set(database.prepare(`PRAGMA table_info(${table})`).all().map(column => column.name));
|
||||
for (const [name, definition] of Object.entries(columns)) {
|
||||
if (!existing.has(name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CATALOG_PROJECTION_STATE_COLUMNS,
|
||||
ensureColumns,
|
||||
LIVE_PROJECTION_COLUMNS,
|
||||
LIVE_PROJECTION_SCHEMA_SQL
|
||||
};
|
||||
@ -1,6 +1,12 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const {
|
||||
CATALOG_PROJECTION_STATE_COLUMNS,
|
||||
ensureColumns,
|
||||
LIVE_PROJECTION_COLUMNS,
|
||||
LIVE_PROJECTION_SCHEMA_SQL
|
||||
} = require('./liveProjectionSchema');
|
||||
|
||||
const dataDir = path.join(__dirname, '..', '..', 'data');
|
||||
const dbPath = path.join(dataDir, 'content.db');
|
||||
@ -73,6 +79,10 @@ function initSchema() {
|
||||
CREATE INDEX IF NOT EXISTS idx_items_category ON playlist_items(source_id, category_id);
|
||||
`);
|
||||
|
||||
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||
ensureColumns(db, 'catalog_projection_state', CATALOG_PROJECTION_STATE_COLUMNS);
|
||||
|
||||
// EPG Programs
|
||||
// Optimized for range queries
|
||||
db.exec(`
|
||||
@ -135,6 +145,18 @@ function initSchema() {
|
||||
CREATE INDEX IF NOT EXISTS idx_history_user_item ON watch_history(user_id, item_id);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS laya_language_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
mode TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_laya_cache_mode_model
|
||||
ON laya_language_cache(mode, model);
|
||||
`);
|
||||
|
||||
// Migration: Add source_id column if missing (for existing databases)
|
||||
try {
|
||||
db.exec(`ALTER TABLE watch_history ADD COLUMN source_id INTEGER`);
|
||||
@ -209,8 +231,34 @@ const favorites = {
|
||||
}
|
||||
};
|
||||
|
||||
const layaLanguageCache = {
|
||||
get(cacheKey) {
|
||||
const row = getDb().prepare(
|
||||
'SELECT response FROM laya_language_cache WHERE cache_key = ?'
|
||||
).get(cacheKey);
|
||||
return row ? JSON.parse(row.response) : null;
|
||||
},
|
||||
|
||||
set(cacheKey, mode, model, response) {
|
||||
getDb().prepare(`
|
||||
INSERT INTO laya_language_cache (cache_key, mode, model, response, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
response = excluded.response,
|
||||
created_at = excluded.created_at
|
||||
`).run(cacheKey, mode, model, JSON.stringify(response), Date.now());
|
||||
},
|
||||
|
||||
clear() {
|
||||
getDb().prepare('DELETE FROM laya_language_cache').run();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getDb,
|
||||
initSchema,
|
||||
favorites
|
||||
ensureColumns,
|
||||
LIVE_PROJECTION_COLUMNS,
|
||||
favorites,
|
||||
layaLanguageCache
|
||||
};
|
||||
|
||||
@ -180,6 +180,8 @@ app.use('/api/probe', require('./routes/probe'));
|
||||
app.use('/api/subtitle', require('./routes/subtitle'));
|
||||
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'));
|
||||
|
||||
// Version endpoint
|
||||
app.get('/api/version', (req, res) => {
|
||||
|
||||
42
server/routes/laya.js
Normal file
42
server/routes/laya.js
Normal file
@ -0,0 +1,42 @@
|
||||
const express = require('express');
|
||||
const { requireAuth, requireAdmin } = require('../auth');
|
||||
const { LayaRequestError, LayaService } = require('../services/layaService');
|
||||
|
||||
const router = express.Router();
|
||||
const laya = new LayaService();
|
||||
|
||||
function sendError(res, error) {
|
||||
const status = error instanceof LayaRequestError ? error.statusCode : 500;
|
||||
const message = error instanceof LayaRequestError ? error.message : 'Internal server error';
|
||||
res.status(status).json({ runtimeAvailable: false, error: message });
|
||||
}
|
||||
|
||||
router.get('/status', requireAuth, async (req, res) => {
|
||||
res.json(await laya.status());
|
||||
});
|
||||
|
||||
router.post('/model/download', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
res.status(202).json(laya.startModelDownload(req.body?.model));
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/model', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
res.json(await laya.deleteModel());
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/language/batch', requireAuth, async (req, res) => {
|
||||
try {
|
||||
res.json(await laya.detectBatch(req.body));
|
||||
} catch (error) {
|
||||
sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
100
server/routes/live.js
Normal file
100
server/routes/live.js
Normal file
@ -0,0 +1,100 @@
|
||||
const express = require('express');
|
||||
const { requireAuth, requireAdmin } = require('../auth');
|
||||
const liveProjection = require('../services/liveProjectionService');
|
||||
const iptvOrgCatalog = require('../services/iptvOrgCatalogService');
|
||||
const { getDb } = require('../db/sqlite');
|
||||
const { sources } = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function serializeState(state) {
|
||||
const emptyProjectionWithLiveInput = state.total_count > 0 && state.projected_count === 0;
|
||||
return {
|
||||
status: state.status,
|
||||
stale: state.status !== 'success' || emptyProjectionWithLiveInput,
|
||||
startedAt: state.started_at,
|
||||
completedAt: state.completed_at,
|
||||
inputHash: state.input_hash,
|
||||
projectedCount: state.projected_count,
|
||||
phase: state.phase,
|
||||
processed: state.processed_count,
|
||||
total: state.total_count,
|
||||
layaProcessed: state.laya_processed_count,
|
||||
layaTotal: state.laya_total_count,
|
||||
error: state.error
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/channels', requireAuth, async (req, res) => {
|
||||
const sourceId = req.query.sourceId === undefined ? null : Number(req.query.sourceId);
|
||||
if (sourceId !== null && (!Number.isInteger(sourceId) || sourceId <= 0)) {
|
||||
return res.status(400).json({ error: 'sourceId must be a positive integer' });
|
||||
}
|
||||
if (req.query.includeHidden !== undefined && !['true', 'false'].includes(req.query.includeHidden)) {
|
||||
return res.status(400).json({ error: 'includeHidden must be true or false' });
|
||||
}
|
||||
const includeHidden = req.query.includeHidden === 'true';
|
||||
const q = String(req.query.q || '').slice(0, 200);
|
||||
const languages = String(req.query.languages || '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
|
||||
if (languages.some(code => !/^(?:[a-z]{2,3}|und)$/.test(code))) {
|
||||
return res.status(400).json({ error: 'languages must be comma-separated language codes' });
|
||||
}
|
||||
|
||||
try {
|
||||
let state = liveProjection.projectionState();
|
||||
if (state.status !== 'success' && state.status !== 'building') {
|
||||
state = await liveProjection.startRebuild().promise;
|
||||
}
|
||||
const epgSources = await sources.getByType('epg');
|
||||
const { channels, availableLanguages } = liveProjection.getChannelPage({
|
||||
sourceId, includeHidden, userId: req.user.id, epgSources, q, languages
|
||||
});
|
||||
return res.json({ projection: serializeState(state), filters: { availableLanguages }, channels });
|
||||
} catch (error) {
|
||||
console.error('[LiveProjection] Failed to serve projected channels:', error.message);
|
||||
const state = liveProjection.projectionState();
|
||||
return res.status(503).json({
|
||||
projection: serializeState(state),
|
||||
channels: [],
|
||||
error: 'Live channel projection is unavailable'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/projection/status', requireAuth, (req, res) => {
|
||||
try {
|
||||
return res.json(serializeState(liveProjection.projectionState()));
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Unable to read Live projection status' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/projection/rebuild', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const started = liveProjection.startRebuild();
|
||||
started.promise.catch(error => {
|
||||
console.error('[LiveProjection] Manual rebuild failed:', error.message);
|
||||
});
|
||||
return res.status(202).json({
|
||||
alreadyRunning: started.alreadyRunning,
|
||||
projection: serializeState(started.state)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[LiveProjection] Unable to start manual rebuild:', error.message);
|
||||
return res.status(500).json({ error: 'Unable to start Live projection rebuild' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/projection/standards/refresh', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const result = await iptvOrgCatalog.refresh(getDb());
|
||||
await liveProjection.queueRebuild();
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[LiveProjection] iptv-org refresh failed:', error.message);
|
||||
return res.status(502).json({ error: 'Unable to refresh the iptv-org catalog' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.serializeState = serializeState;
|
||||
@ -87,6 +87,22 @@ function analyzeProbeResult(probeResult, url) {
|
||||
const videoCodec = videoStream?.codec_name?.toLowerCase() || 'unknown';
|
||||
const audioCodec = audioStream?.codec_name?.toLowerCase() || 'unknown';
|
||||
const container = format.format_name?.toLowerCase() || 'unknown';
|
||||
const toPositiveNumber = (value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
const toKbps = (value) => {
|
||||
const bitrate = toPositiveNumber(value);
|
||||
return bitrate === null ? null : Math.round(bitrate / 1000);
|
||||
};
|
||||
const durationSeconds = toPositiveNumber(format.duration) ?? toPositiveNumber(videoStream?.duration) ?? toPositiveNumber(audioStream?.duration);
|
||||
const videoBitrateKbps = toKbps(videoStream?.bit_rate);
|
||||
const audioBitrateKbps = toKbps(audioStream?.bit_rate);
|
||||
const overallBitrateKbps = toKbps(format.bit_rate);
|
||||
const probeConfidence = durationSeconds !== null && videoBitrateKbps !== null &&
|
||||
(!audioStream || audioBitrateKbps !== null) && overallBitrateKbps !== null
|
||||
? 'complete'
|
||||
: 'partial';
|
||||
|
||||
// Check codec compatibility
|
||||
const videoOk = BROWSER_VIDEO_CODECS.some(c => videoCodec.includes(c));
|
||||
@ -131,6 +147,11 @@ function analyzeProbeResult(probeResult, url) {
|
||||
width: videoStream?.width || 0,
|
||||
height: videoStream?.height || 0,
|
||||
audioChannels: audioStream?.channels || 0, // For Smart Audio Copy
|
||||
durationSeconds,
|
||||
videoBitrateKbps,
|
||||
audioBitrateKbps,
|
||||
overallBitrateKbps,
|
||||
probeConfidence,
|
||||
container: container,
|
||||
compatible: compatible,
|
||||
needsRemux: needsRemux,
|
||||
@ -157,7 +178,12 @@ router.get('/', async (req, res) => {
|
||||
container: 'unknown',
|
||||
compatible: false,
|
||||
needsRemux: false,
|
||||
needsTranscode: true
|
||||
needsTranscode: true,
|
||||
durationSeconds: null,
|
||||
videoBitrateKbps: null,
|
||||
audioBitrateKbps: null,
|
||||
overallBitrateKbps: null,
|
||||
probeConfidence: 'partial'
|
||||
});
|
||||
}
|
||||
|
||||
@ -193,6 +219,11 @@ router.get('/', async (req, res) => {
|
||||
compatible: false,
|
||||
needsRemux: false,
|
||||
needsTranscode: true,
|
||||
durationSeconds: null,
|
||||
videoBitrateKbps: null,
|
||||
audioBitrateKbps: null,
|
||||
overallBitrateKbps: null,
|
||||
probeConfidence: 'partial',
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ const { getDb } = require('../db/sqlite');
|
||||
const xtreamApi = require('../services/xtreamApi');
|
||||
const syncService = require('../services/syncService');
|
||||
const m3uParser = require('../services/m3uParser');
|
||||
const liveProjection = require('../services/liveProjectionService');
|
||||
|
||||
// Get all sources
|
||||
router.get('/', async (req, res) => {
|
||||
@ -46,6 +47,20 @@ router.get('/type/:type', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/epg/order', async (req, res) => {
|
||||
try {
|
||||
const ordered = await sources.reorderEpg(req.body?.orderedIds);
|
||||
res.json(ordered.map(source => ({
|
||||
...source,
|
||||
password: source.password ? '••••••••' : null
|
||||
})));
|
||||
} catch (err) {
|
||||
if (err.code) return res.status(400).json({ error: err.message, code: err.code });
|
||||
console.error('Error reordering EPG sources:', err);
|
||||
res.status(500).json({ error: 'Failed to reorder EPG sources' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single source
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
@ -92,6 +107,9 @@ router.put('/:id', async (req, res) => {
|
||||
}
|
||||
|
||||
const { name, url, username, password } = req.body;
|
||||
if (existing.builtinKey) {
|
||||
return res.status(409).json({ error: 'Built-in sources can only be enabled or disabled', code: 'BUILTIN_SOURCE_READONLY' });
|
||||
}
|
||||
const updated = await sources.update(req.params.id, {
|
||||
name: name || existing.name,
|
||||
url: url || existing.url,
|
||||
@ -115,6 +133,9 @@ router.delete('/:id', async (req, res) => {
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Source not found' });
|
||||
}
|
||||
if (existing.builtinKey) {
|
||||
return res.status(409).json({ error: 'Built-in sources cannot be removed', code: 'BUILTIN_SOURCE_PROTECTED' });
|
||||
}
|
||||
|
||||
// Cascade delete: Clean up SQLite data for this source
|
||||
const db = getDb();
|
||||
@ -132,6 +153,7 @@ router.delete('/:id', async (req, res) => {
|
||||
|
||||
// Delete source config and related hidden items (favorites handled by db.js)
|
||||
await sources.delete(sourceId);
|
||||
await liveProjection.queueRebuild();
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
@ -278,4 +300,3 @@ router.post('/sync-all', async (req, res) => {
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const db = require('../db');
|
||||
const transcodeSession = require('../services/transcodeSession');
|
||||
const { QUALITY_MODES, resolveQualityMode } = require('../services/transcodeQualityProfiles');
|
||||
|
||||
/**
|
||||
* Transcode Routes
|
||||
@ -29,7 +30,11 @@ transcodeSession.startCleanupInterval();
|
||||
* Body: { url: string, seekOffset?: number }
|
||||
*/
|
||||
router.post('/session', async (req, res) => {
|
||||
const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels } = req.body;
|
||||
const {
|
||||
url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels,
|
||||
targetVideoBitrateKbps, targetAudioBitrateKbps, allowHardwareFallback,
|
||||
qualityMode
|
||||
} = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
@ -40,6 +45,7 @@ router.post('/session', async (req, res) => {
|
||||
const userAgent = db.getUserAgent(settings);
|
||||
|
||||
try {
|
||||
const qualitySelection = resolveQualityMode(qualityMode, settings);
|
||||
const session = await transcodeSession.createSession(url, {
|
||||
ffmpegPath,
|
||||
userAgent,
|
||||
@ -47,6 +53,9 @@ router.post('/session', async (req, res) => {
|
||||
hwEncoder: settings.hwEncoder || 'software',
|
||||
maxResolution: settings.maxResolution || '1080p',
|
||||
quality: settings.quality || 'medium',
|
||||
targetVideoBitrateKbps: targetVideoBitrateKbps ?? settings.targetTranscodeBitrateKbps ?? 6000,
|
||||
targetAudioBitrateKbps: targetAudioBitrateKbps ?? 192,
|
||||
allowHardwareFallback: allowHardwareFallback ?? settings.allowHardwareFallback ?? true,
|
||||
audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset
|
||||
// Upscaling options
|
||||
upscaleEnabled: settings.upscaleEnabled || false,
|
||||
@ -55,7 +64,8 @@ router.post('/session', async (req, res) => {
|
||||
videoMode: videoMode, // 'copy' or 'encode'
|
||||
videoCodec: videoCodec, // 'h264', 'hevc', etc.
|
||||
audioCodec: audioCodec, // 'aac', 'ac3', etc.
|
||||
audioChannels: audioChannels // number of channels (2=stereo)
|
||||
audioChannels: audioChannels, // number of channels (2=stereo)
|
||||
...(qualitySelection?.sessionOptions || {})
|
||||
});
|
||||
|
||||
await session.start();
|
||||
@ -71,10 +81,25 @@ router.post('/session', async (req, res) => {
|
||||
res.json({
|
||||
sessionId: session.id,
|
||||
playlistUrl: `/api/transcode/${session.id}/stream.m3u8`,
|
||||
status: session.status
|
||||
status: session.status,
|
||||
requestedEncoder: session.requestedEncoder,
|
||||
effectiveEncoder: session.effectiveEncoder,
|
||||
hardwareFallbackOccurred: session.hardwareFallbackOccurred,
|
||||
...(qualitySelection && {
|
||||
qualityMode: qualitySelection.qualityMode,
|
||||
mode: qualitySelection.mode,
|
||||
selectedProfile: qualitySelection.selectedProfile,
|
||||
availableProfiles: qualitySelection.availableProfiles
|
||||
})
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === 'INVALID_QUALITY_MODE') {
|
||||
return res.status(400).json({
|
||||
error: err.message,
|
||||
availableProfiles: QUALITY_MODES
|
||||
});
|
||||
}
|
||||
console.error('[Transcode] Session creation failed:', err);
|
||||
res.status(500).json({ error: 'Failed to create session', details: err.message });
|
||||
}
|
||||
@ -270,4 +295,3 @@ router.get('/', async (req, res) => {
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
23
server/services/builtinEpgRegistry.js
Normal file
23
server/services/builtinEpgRegistry.js
Normal file
@ -0,0 +1,23 @@
|
||||
const BUILTIN_EPG_SOURCES = Object.freeze([
|
||||
Object.freeze({ builtinKey: 'epgpw-fr', type: 'epg', name: 'EPG.PW France', url: 'https://epg.pw/xmltv/epg_FR.xml.gz', provider: 'EPG.PW', language: 'fr', country: 'FR' }),
|
||||
Object.freeze({ builtinKey: 'epgpw-us', type: 'epg', name: 'EPG.PW United States', url: 'https://epg.pw/xmltv/epg_US.xml.gz', provider: 'EPG.PW', language: 'en', country: 'US' })
|
||||
]);
|
||||
|
||||
function protectedFields(source) {
|
||||
return source?.builtinKey ? { readonly: true, removable: false } : { readonly: false, removable: true };
|
||||
}
|
||||
|
||||
function ensureBuiltinSources(database) {
|
||||
database.sources ||= [];
|
||||
const { migrateEpgPriorities, nextEpgPriority } = require('./epgSourceOrdering');
|
||||
migrateEpgPriorities(database);
|
||||
for (const definition of BUILTIN_EPG_SOURCES) {
|
||||
const existing = database.sources.find(source => source.builtinKey === definition.builtinKey);
|
||||
if (existing) Object.assign(existing, definition, protectedFields(definition));
|
||||
else database.sources.push({ id: database.nextId++, ...definition, epgPriority: nextEpgPriority(database.sources), ...protectedFields(definition), enabled: false, created_at: new Date().toISOString(), updated_at: new Date().toISOString() });
|
||||
}
|
||||
migrateEpgPriorities(database);
|
||||
return database;
|
||||
}
|
||||
|
||||
module.exports = { BUILTIN_EPG_SOURCES, ensureBuiltinSources, protectedFields };
|
||||
50
server/services/channelLanguage.js
Normal file
50
server/services/channelLanguage.js
Normal file
@ -0,0 +1,50 @@
|
||||
const LANGUAGES = Object.freeze({
|
||||
fr: { code: 'fr', short: 'FR', label: 'French' },
|
||||
en: { code: 'en', short: 'EN', label: 'English' },
|
||||
es: { code: 'es', short: 'ES', label: 'Spanish' },
|
||||
de: { code: 'de', short: 'DE', label: 'German' },
|
||||
it: { code: 'it', short: 'IT', label: 'Italian' },
|
||||
pt: { code: 'pt', short: 'PT', label: 'Portuguese' }
|
||||
});
|
||||
|
||||
const CODE_ALIASES = Object.freeze({
|
||||
fr: 'fr', fra: 'fr', fre: 'fr', french: 'fr', francais: 'fr', france: 'fr',
|
||||
en: 'en', eng: 'en', english: 'en', anglais: 'en', uk: 'en', us: 'en',
|
||||
es: 'es', spa: 'es', spanish: 'es', espanol: 'es', spain: 'es',
|
||||
de: 'de', deu: 'de', ger: 'de', german: 'de', deutsch: 'de',
|
||||
it: 'it', ita: 'it', italian: 'it', italiano: 'it',
|
||||
pt: 'pt', por: 'pt', portuguese: 'pt', portugues: 'pt'
|
||||
});
|
||||
|
||||
function normalize(value) {
|
||||
return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function detectLanguage(item) {
|
||||
const data = item.data && typeof item.data === 'object' ? item.data : {};
|
||||
const values = [item.name, item.category_name, data.groupTitle, data.tvgId, data.tvgName].filter(Boolean);
|
||||
const scores = {};
|
||||
const evidence = [];
|
||||
const prefix = String(item.name || '').trim().match(/^(?:\|\s*([a-z-]{2,8})\s*\||\[\s*([a-z-]{2,8})\s*\]|([a-z-]{2,8})\s*\|)/i);
|
||||
if (prefix) add(prefix[1] || prefix[2] || prefix[3], 70, 'name-prefix');
|
||||
values.forEach((value, index) => {
|
||||
const tokens = normalize(value).split(/[^a-z]+/).filter(Boolean);
|
||||
for (const token of tokens) add(token, index === 0 ? 45 : 25, index === 0 ? 'name-token' : 'metadata');
|
||||
});
|
||||
function add(token, weight, type) {
|
||||
const code = CODE_ALIASES[normalize(token)];
|
||||
if (!code) return;
|
||||
scores[code] = (scores[code] || 0) + weight;
|
||||
evidence.push({ code, weight, type, value: String(token) });
|
||||
}
|
||||
const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
||||
if (!ranked.length) return { code: 'und', short: 'UND', label: 'Unknown', confidence: 0, scores, evidence };
|
||||
const total = ranked.reduce((sum, [, score]) => sum + score, 0);
|
||||
const confidence = Math.round(ranked[0][1] / total * 100);
|
||||
if (confidence < 55 || (ranked[1] && ranked[0][1] - ranked[1][1] < 15)) {
|
||||
return { code: 'und', short: 'UND', label: 'Unknown', confidence: 0, scores, evidence };
|
||||
}
|
||||
return { ...LANGUAGES[ranked[0][0]], confidence, scores, evidence };
|
||||
}
|
||||
|
||||
module.exports = { CODE_ALIASES, LANGUAGES, detectLanguage, normalize };
|
||||
61
server/services/epgMatchingService.js
Normal file
61
server/services/epgMatchingService.js
Normal file
@ -0,0 +1,61 @@
|
||||
const { numericPriority, sortEpgSources } = require('./epgSourceOrdering');
|
||||
|
||||
function normalize(value) {
|
||||
return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function candidateScore(channel, epgChannel) {
|
||||
const variantIds = new Set((channel.variants || []).flatMap(variant => [variant.tvgId, variant.epg_channel_id]).filter(Boolean).map(String));
|
||||
if (variantIds.has(String(epgChannel.id))) return 100;
|
||||
const wantedNames = new Set([channel.standardName, channel.displayName, ...(channel.variants || []).map(variant => variant.name)].map(normalize).filter(Boolean));
|
||||
const candidateName = normalize(epgChannel.name);
|
||||
if (wantedNames.has(candidateName)) return 90;
|
||||
if (candidateName && [...wantedNames].some(name => name.includes(candidateName) || candidateName.includes(name))) return 50;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function chooseBestMatch(channel, candidates, epgSources) {
|
||||
const language = channel.language?.code;
|
||||
const eligible = sortEpgSources(epgSources, { enabledOnly: true }).filter(source =>
|
||||
!source.language || !language || language === 'und' || source.language === language
|
||||
);
|
||||
const priorities = new Map(eligible.map(source => [source.id, numericPriority(source)]));
|
||||
return candidates
|
||||
.filter(candidate => priorities.has(candidate.sourceId))
|
||||
.map(candidate => ({ ...candidate, matchConfidence: candidateScore(channel, candidate) }))
|
||||
.filter(candidate => candidate.matchConfidence > 0)
|
||||
.sort((left, right) => right.matchConfidence - left.matchConfidence ||
|
||||
priorities.get(left.sourceId) - priorities.get(right.sourceId) || left.sourceId - right.sourceId)[0] || null;
|
||||
}
|
||||
|
||||
function findBestMatch(database, channel, epgSources, now = Date.now()) {
|
||||
const language = channel.language?.code;
|
||||
const enabled = sortEpgSources(epgSources, { enabledOnly: true }).filter(source =>
|
||||
!source.language || !language || language === 'und' || source.language === language
|
||||
);
|
||||
if (!enabled.length) return null;
|
||||
const ids = enabled.map(source => source.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const epgChannels = database.prepare(`
|
||||
SELECT source_id AS sourceId, item_id AS id, name
|
||||
FROM playlist_items
|
||||
WHERE type = 'epg_channel' AND source_id IN (${placeholders})
|
||||
`).all(...ids);
|
||||
const best = chooseBestMatch(channel, epgChannels, enabled);
|
||||
if (!best) return null;
|
||||
const current = database.prepare(`
|
||||
SELECT title, description, start_time AS startTime, end_time AS endTime
|
||||
FROM epg_programs
|
||||
WHERE source_id = ? AND channel_id = ? AND start_time <= ? AND end_time > ?
|
||||
ORDER BY start_time DESC LIMIT 1
|
||||
`).get(best.sourceId, best.id, now, now);
|
||||
return {
|
||||
sourceId: best.sourceId,
|
||||
channelId: best.id,
|
||||
matchConfidence: best.matchConfidence,
|
||||
currentProgram: current || undefined
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { candidateScore, chooseBestMatch, findBestMatch, normalize };
|
||||
61
server/services/epgSourceOrdering.js
Normal file
61
server/services/epgSourceOrdering.js
Normal file
@ -0,0 +1,61 @@
|
||||
const PRIORITY_STEP = 10;
|
||||
|
||||
function numericPriority(source) {
|
||||
return Number.isInteger(source.epgPriority) && source.epgPriority >= 0
|
||||
? source.epgPriority
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function sortEpgSources(list, { enabledOnly = false } = {}) {
|
||||
return list
|
||||
.filter(source => source.type === 'epg' && (!enabledOnly || source.enabled))
|
||||
.slice()
|
||||
.sort((left, right) => numericPriority(left) - numericPriority(right) || left.id - right.id);
|
||||
}
|
||||
|
||||
function migrateEpgPriorities(database) {
|
||||
const epgSources = database.sources.filter(source => source.type === 'epg');
|
||||
let next = epgSources.reduce((maximum, source) => Number.isInteger(source.epgPriority)
|
||||
? Math.max(maximum, source.epgPriority + PRIORITY_STEP)
|
||||
: maximum, 0);
|
||||
for (const source of epgSources) {
|
||||
if (!Number.isInteger(source.epgPriority) || source.epgPriority < 0) {
|
||||
source.epgPriority = next;
|
||||
next += PRIORITY_STEP;
|
||||
}
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
function nextEpgPriority(sources) {
|
||||
return sortEpgSources(sources).reduce((maximum, source) => Math.max(maximum, numericPriority(source) + PRIORITY_STEP), 0);
|
||||
}
|
||||
|
||||
function validateCompleteOrder(allSources, orderedIds) {
|
||||
if (!Array.isArray(orderedIds)) return { code: 'INVALID_ORDER', message: 'orderedIds must be an array' };
|
||||
if (orderedIds.some(id => !Number.isInteger(id) || id <= 0)) return { code: 'INVALID_ORDER_ID', message: 'orderedIds must contain positive integer IDs' };
|
||||
if (new Set(orderedIds).size !== orderedIds.length) return { code: 'DUPLICATE_SOURCE_ID', message: 'orderedIds contains duplicate IDs' };
|
||||
const byId = new Map(allSources.map(source => [source.id, source]));
|
||||
const unknown = orderedIds.find(id => !byId.has(id));
|
||||
if (unknown !== undefined) return { code: 'UNKNOWN_SOURCE_ID', message: `Unknown source ID: ${unknown}` };
|
||||
const nonEpg = orderedIds.find(id => byId.get(id).type !== 'epg');
|
||||
if (nonEpg !== undefined) return { code: 'SOURCE_NOT_EPG', message: `Source ${nonEpg} is not an EPG source` };
|
||||
const expected = allSources.filter(source => source.type === 'epg').map(source => source.id);
|
||||
if (orderedIds.length !== expected.length || expected.some(id => !orderedIds.includes(id))) {
|
||||
return { code: 'INCOMPLETE_EPG_ORDER', message: 'orderedIds must contain every EPG source' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyEpgOrder(allSources, orderedIds, updatedAt = new Date().toISOString()) {
|
||||
const error = validateCompleteOrder(allSources, orderedIds);
|
||||
if (error) throw Object.assign(new Error(error.message), error);
|
||||
orderedIds.forEach((id, index) => {
|
||||
const source = allSources.find(item => item.id === id);
|
||||
source.epgPriority = index * PRIORITY_STEP;
|
||||
source.updated_at = updatedAt;
|
||||
});
|
||||
return sortEpgSources(allSources);
|
||||
}
|
||||
|
||||
module.exports = { applyEpgOrder, migrateEpgPriorities, nextEpgPriority, numericPriority, PRIORITY_STEP, sortEpgSources, validateCompleteOrder };
|
||||
25
server/services/flexibleChannelSearch.js
Normal file
25
server/services/flexibleChannelSearch.js
Normal file
@ -0,0 +1,25 @@
|
||||
function normalizeSearch(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/([a-z])([0-9])/g, '$1 $2')
|
||||
.replace(/([0-9])([a-z])/g, '$1 $2')
|
||||
.replace(/[^a-z0-9]+/g, ' ').trim()
|
||||
.split(/\s+/).filter(Boolean)
|
||||
.map(token => token.length > 3 && token.endsWith('s') ? token.slice(0, -1) : token);
|
||||
}
|
||||
|
||||
function matchesSearch(channel, query) {
|
||||
const wanted = normalizeSearch(query);
|
||||
if (!wanted.length) return true;
|
||||
const corpus = normalizeSearch([
|
||||
channel.displayName, channel.standardName, channel.fingerprint,
|
||||
...(channel.variants || []).flatMap(variant => [variant.name, variant.tvgId, variant.categoryName])
|
||||
].filter(Boolean).join(' '));
|
||||
return wanted.every(token => corpus.some(candidate => {
|
||||
if (/^\d+$/.test(token)) return /^\d+$/.test(candidate) && Number(candidate) === Number(token);
|
||||
return candidate.includes(token);
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = { matchesSearch, normalizeSearch };
|
||||
65
server/services/iptvOrgCatalogService.js
Normal file
65
server/services/iptvOrgCatalogService.js
Normal file
@ -0,0 +1,65 @@
|
||||
const API_ROOT = 'https://iptv-org.github.io/api';
|
||||
|
||||
function normalize(value) {
|
||||
return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function parseJson(value, fallback = []) {
|
||||
try { return JSON.parse(value); } catch { return fallback; }
|
||||
}
|
||||
|
||||
async function refresh(database, fetchImpl = globalThis.fetch) {
|
||||
if (typeof fetchImpl !== 'function') throw new Error('A fetch implementation is required');
|
||||
const [channelsResponse, feedsResponse] = await Promise.all([
|
||||
fetchImpl(`${API_ROOT}/channels.json`), fetchImpl(`${API_ROOT}/feeds.json`)
|
||||
]);
|
||||
if (!channelsResponse.ok || !feedsResponse.ok) throw new Error('iptv-org API refresh failed');
|
||||
const channels = await channelsResponse.json();
|
||||
const feeds = await feedsResponse.json();
|
||||
const feedLanguages = new Map();
|
||||
for (const feed of feeds) {
|
||||
const list = feedLanguages.get(feed.channel) || new Set();
|
||||
for (const language of feed.languages || []) list.add(String(language).toLowerCase());
|
||||
feedLanguages.set(feed.channel, list);
|
||||
}
|
||||
const write = database.transaction(() => {
|
||||
database.prepare('DELETE FROM iptv_org_channels').run();
|
||||
const insert = database.prepare(`
|
||||
INSERT INTO iptv_org_channels
|
||||
(id, name, normalized_name, alt_names, country, languages, data, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const now = Date.now();
|
||||
for (const channel of channels) {
|
||||
insert.run(channel.id, channel.name, normalize(channel.name), JSON.stringify(channel.alt_names || []),
|
||||
channel.country || null, JSON.stringify([...feedLanguages.get(channel.id) || []]), JSON.stringify(channel), now);
|
||||
}
|
||||
database.prepare(`
|
||||
INSERT INTO iptv_org_cache_state (resource, refreshed_at, item_count) VALUES ('catalog', ?, ?)
|
||||
ON CONFLICT(resource) DO UPDATE SET refreshed_at = excluded.refreshed_at, item_count = excluded.item_count
|
||||
`).run(now, channels.length);
|
||||
});
|
||||
write();
|
||||
return { refreshedAt: Date.now(), itemCount: channels.length };
|
||||
}
|
||||
|
||||
function findMatch(database, channel) {
|
||||
const names = new Set([channel.displayName, channel.fingerprint, ...(channel.variants || []).map(variant => variant.name)].map(normalize).filter(Boolean));
|
||||
if (!names.size) return null;
|
||||
const direct = database.prepare(`SELECT * FROM iptv_org_channels WHERE normalized_name IN (${[...names].map(() => '?').join(',')})`).all(...names);
|
||||
const candidates = direct.length ? direct : database.prepare('SELECT * FROM iptv_org_channels').all().filter(row =>
|
||||
parseJson(row.alt_names).some(name => names.has(normalize(name)))
|
||||
);
|
||||
const language = channel.language?.code;
|
||||
const compatible = candidates.find(row => language === 'und' || parseJson(row.languages).includes(language)) || candidates[0];
|
||||
if (!compatible) return null;
|
||||
return {
|
||||
channelId: compatible.id,
|
||||
name: compatible.name,
|
||||
country: compatible.country,
|
||||
confidence: names.has(compatible.normalized_name) ? 100 : 90
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { API_ROOT, findMatch, normalize, refresh };
|
||||
430
server/services/layaService.js
Normal file
430
server/services/layaService.js
Normal file
@ -0,0 +1,430 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const MODEL_DIR = path.resolve(__dirname, '..', '..', 'data', 'models', 'laya');
|
||||
const MAX_BATCH_ITEMS = 64;
|
||||
const MAX_BATCH_BYTES = 64 * 1024;
|
||||
const MAX_TEXT_LENGTH = 4096;
|
||||
const ALLOWED_MODELS = new Set(['english', 'multilingual', 'typed-decisions']);
|
||||
const DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const BATCH_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
const LANGUAGE_CACHE_VERSION = 'v2';
|
||||
const LANGUAGE_LABELS = new Map([
|
||||
['fr', 'fr'], ['fr-fr', 'fr'], ['french', 'fr'], ['francais', 'fr'], ['francaise', 'fr'],
|
||||
['en', 'en'], ['en-gb', 'en'], ['en-us', 'en'], ['english', 'en'], ['anglais', 'en'],
|
||||
['es', 'es'], ['es-es', 'es'], ['spanish', 'es'], ['espanol', 'es'], ['espagnol', 'es'], ['castellano', 'es'],
|
||||
['other', 'other'], ['another language', 'other'], ['unknown', 'unknown'], ['undetermined', 'unknown']
|
||||
]);
|
||||
|
||||
class LayaRequestError extends Error {
|
||||
constructor(message, statusCode = 503) {
|
||||
super(message);
|
||||
this.name = 'LayaRequestError';
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
function validateBatchPayload(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new LayaRequestError('Request body must be an object', 400);
|
||||
}
|
||||
if (!Array.isArray(payload.items) || payload.items.length === 0) {
|
||||
throw new LayaRequestError('items must be a non-empty array', 400);
|
||||
}
|
||||
if (Object.keys(payload).some(key => !['items', 'model'].includes(key))) {
|
||||
throw new LayaRequestError('Request body contains unsupported fields', 400);
|
||||
}
|
||||
if (payload.items.length > MAX_BATCH_ITEMS || Buffer.byteLength(JSON.stringify(payload)) > MAX_BATCH_BYTES) {
|
||||
throw new LayaRequestError('Batch payload is too large', 413);
|
||||
}
|
||||
payload.items.forEach((item, index) => {
|
||||
if (!item || typeof item !== 'object' || typeof item.text !== 'string' || !item.text.trim()) {
|
||||
throw new LayaRequestError(`items[${index}].text must be a non-empty string`, 400);
|
||||
}
|
||||
if (item.text.length > MAX_TEXT_LENGTH) {
|
||||
throw new LayaRequestError(`items[${index}].text is too long`, 413);
|
||||
}
|
||||
if (Object.keys(item).some(key => !['id', 'text'].includes(key))) {
|
||||
throw new LayaRequestError(`items[${index}] contains unsupported fields`, 400);
|
||||
}
|
||||
if (item.id !== undefined && !['string', 'number'].includes(typeof item.id)) {
|
||||
throw new LayaRequestError(`items[${index}].id must be a string or number`, 400);
|
||||
}
|
||||
});
|
||||
if (payload.model !== undefined && !ALLOWED_MODELS.has(payload.model)) {
|
||||
throw new LayaRequestError('Unsupported Laya model', 400);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function clearDirectoryContents(directory) {
|
||||
const root = path.resolve(directory);
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
const entries = await fs.readdir(root);
|
||||
for (const entry of entries) {
|
||||
const target = path.resolve(root, entry);
|
||||
if (path.dirname(target) !== root) {
|
||||
throw new Error('Refusing to delete outside the Laya model directory');
|
||||
}
|
||||
await fs.rm(target, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function directorySize(directory) {
|
||||
let total = 0;
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return 0;
|
||||
throw error;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) total += await directorySize(target);
|
||||
else if (entry.isFile()) total += (await fs.stat(target)).size;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function safeError(error) {
|
||||
const statusCode = error instanceof LayaRequestError ? error.statusCode : 500;
|
||||
const message = statusCode === 500 ? 'Model download failed' : error.message;
|
||||
return {
|
||||
message,
|
||||
detail: statusCode === 503
|
||||
? 'The optional Laya runtime is unavailable.'
|
||||
: `Laya model operation failed (HTTP ${statusCode}).`
|
||||
};
|
||||
}
|
||||
|
||||
function initialDownloadState() {
|
||||
return {
|
||||
phase: 'idle',
|
||||
downloading: false,
|
||||
progressPercent: null,
|
||||
bytesDownloaded: null,
|
||||
totalBytes: null,
|
||||
error: null,
|
||||
lastError: null
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeIdentityText(value, maxLength) {
|
||||
return String(value || '')
|
||||
.replace(/https?:\/\/\S+/gi, '[redacted]')
|
||||
.replace(/\b(?:password|passwd|token|username|user)\s*[=:]\s*\S+/gi, '[redacted]')
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function sanitizeIdentityDescriptor(value) {
|
||||
return {
|
||||
name: sanitizeIdentityText(value?.name, 300),
|
||||
group: sanitizeIdentityText(value?.group, 200),
|
||||
source: sanitizeIdentityText(value?.source, 120),
|
||||
tvgId: sanitizeIdentityText(value?.tvgId, 160)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLanguageLabel(value) {
|
||||
if (typeof value !== 'string' || !value.trim()) return 'unknown';
|
||||
const normalized = value
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/_/g, '-');
|
||||
return LANGUAGE_LABELS.get(normalized) || 'unknown';
|
||||
}
|
||||
|
||||
function parseLanguageAnswer(answer) {
|
||||
const choice = typeof answer === 'string'
|
||||
? answer
|
||||
: answer?.choice ?? answer?.value ?? answer?.label;
|
||||
if (typeof choice !== 'string' || !choice.trim()) {
|
||||
throw new LayaRequestError('Laya runtime returned an invalid language answer', 502);
|
||||
}
|
||||
const confidenceValue = typeof answer === 'object'
|
||||
? answer?.answer_confidence ?? answer?.confidence
|
||||
: 0;
|
||||
const confidence = Number(confidenceValue);
|
||||
return {
|
||||
language: normalizeLanguageLabel(choice),
|
||||
confidence: Number.isFinite(confidence) ? confidence : 0
|
||||
};
|
||||
}
|
||||
|
||||
class LayaService {
|
||||
constructor(options = {}) {
|
||||
this.baseUrl = options.baseUrl || process.env.LAYA_BASE_URL || 'http://laya:8000';
|
||||
this.modelDir = path.resolve(options.modelDir || MODEL_DIR);
|
||||
this.fetch = options.fetch || globalThis.fetch;
|
||||
this.cache = options.cache === undefined
|
||||
? require('../db/sqlite').layaLanguageCache
|
||||
: options.cache;
|
||||
this.timeoutMs = options.timeoutMs || 3000;
|
||||
this.batchTimeoutMs = options.batchTimeoutMs
|
||||
|| Number(process.env.LAYA_BATCH_TIMEOUT_MS)
|
||||
|| BATCH_TIMEOUT_MS;
|
||||
this.downloadTimeoutMs = options.downloadTimeoutMs || DOWNLOAD_TIMEOUT_MS;
|
||||
this.progressIntervalMs = options.progressIntervalMs || 500;
|
||||
this.downloadState = initialDownloadState();
|
||||
this.downloadPromise = null;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
if (typeof this.fetch !== 'function') throw new LayaRequestError('Laya runtime client is unavailable');
|
||||
const controller = new AbortController();
|
||||
const { timeoutMs = this.timeoutMs, ...fetchOptions } = options;
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const headers = { 'content-type': 'application/json' };
|
||||
if (process.env.LAYA_API_KEY) headers.authorization = `Bearer ${process.env.LAYA_API_KEY}`;
|
||||
try {
|
||||
const response = await this.fetch(`${this.baseUrl}${endpoint}`, {
|
||||
...fetchOptions,
|
||||
headers: { ...headers, ...fetchOptions.headers },
|
||||
signal: controller.signal
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new LayaRequestError(`Laya runtime returned HTTP ${response.status}`, 502);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof LayaRequestError) throw error;
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new LayaRequestError(`Laya runtime request timed out after ${timeoutMs} ms`, 504);
|
||||
}
|
||||
throw new LayaRequestError('Laya runtime is unavailable');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async status() {
|
||||
await fs.mkdir(this.modelDir, { recursive: true });
|
||||
const sizeBytes = await directorySize(this.modelDir);
|
||||
const modelPresent = sizeBytes > 0;
|
||||
if (this.downloadState.downloading) this.downloadState.bytesDownloaded = sizeBytes;
|
||||
try {
|
||||
const runtime = await this.request('/health', { method: 'GET' });
|
||||
if (!this.downloadState.downloading && this.downloadState.phase !== 'failed' && modelPresent) {
|
||||
this.downloadState.phase = 'ready';
|
||||
}
|
||||
return {
|
||||
...this.downloadState,
|
||||
runtimeAvailable: true,
|
||||
modelPresent,
|
||||
ready: modelPresent,
|
||||
sizeBytes,
|
||||
runtime,
|
||||
runtimeError: null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...this.downloadState,
|
||||
runtimeAvailable: false,
|
||||
modelPresent,
|
||||
ready: false,
|
||||
sizeBytes,
|
||||
runtimeError: {
|
||||
message: error.message,
|
||||
detail: 'Start the Laya sidecar and verify LAYA_BASE_URL.'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
startModelDownload(model = 'multilingual') {
|
||||
if (!ALLOWED_MODELS.has(model)) throw new LayaRequestError('Unsupported Laya model', 400);
|
||||
if (this.downloadState.downloading) throw new LayaRequestError('A Laya model download is already running', 409);
|
||||
this.downloadState = {
|
||||
...initialDownloadState(),
|
||||
phase: 'preparing',
|
||||
downloading: true,
|
||||
bytesDownloaded: 0
|
||||
};
|
||||
this.downloadPromise = this.downloadModel(model).catch(() => undefined);
|
||||
return { ...this.downloadState };
|
||||
}
|
||||
|
||||
async downloadModel(model = 'multilingual') {
|
||||
if (!ALLOWED_MODELS.has(model)) throw new LayaRequestError('Unsupported Laya model', 400);
|
||||
await fs.mkdir(this.modelDir, { recursive: true });
|
||||
this.downloadState.phase = 'downloading';
|
||||
const progressTimer = setInterval(() => {
|
||||
directorySize(this.modelDir)
|
||||
.then(size => { this.downloadState.bytesDownloaded = size; })
|
||||
.catch(() => {});
|
||||
}, this.progressIntervalMs);
|
||||
progressTimer.unref?.();
|
||||
try {
|
||||
await this.request('/v1/systemone', {
|
||||
method: 'POST',
|
||||
timeoutMs: this.downloadTimeoutMs,
|
||||
body: JSON.stringify({
|
||||
state: 'Bonjour',
|
||||
model,
|
||||
questions: {
|
||||
language: {
|
||||
type: 'choice',
|
||||
instructions: 'Identify the language of this text.',
|
||||
criteria: { fr: 'French', en: 'English', es: 'Spanish', other: 'Another language' }
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
this.downloadState.phase = 'verifying';
|
||||
this.downloadState.bytesDownloaded = await directorySize(this.modelDir);
|
||||
const status = await this.status();
|
||||
this.downloadState = {
|
||||
...this.downloadState,
|
||||
phase: 'ready',
|
||||
downloading: false,
|
||||
error: null,
|
||||
lastError: null
|
||||
};
|
||||
return { ...status, ...this.downloadState };
|
||||
} catch (error) {
|
||||
const visibleError = safeError(error);
|
||||
this.downloadState = {
|
||||
...this.downloadState,
|
||||
phase: 'failed',
|
||||
downloading: false,
|
||||
error: visibleError,
|
||||
lastError: visibleError,
|
||||
bytesDownloaded: await directorySize(this.modelDir)
|
||||
};
|
||||
throw error;
|
||||
} finally {
|
||||
clearInterval(progressTimer);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteModel() {
|
||||
if (this.downloadState.downloading) throw new LayaRequestError('Cannot delete a model while downloading', 409);
|
||||
this.downloadState = { ...initialDownloadState(), phase: 'deleting' };
|
||||
try {
|
||||
await clearDirectoryContents(this.modelDir);
|
||||
if (this.cache) this.cache.clear();
|
||||
this.downloadState = initialDownloadState();
|
||||
return {
|
||||
...this.downloadState,
|
||||
deleted: true,
|
||||
modelPresent: false,
|
||||
runtimeAvailable: false,
|
||||
sizeBytes: 0
|
||||
};
|
||||
} catch (error) {
|
||||
const visibleError = safeError(error);
|
||||
this.downloadState = {
|
||||
...this.downloadState,
|
||||
phase: 'failed',
|
||||
error: visibleError,
|
||||
lastError: visibleError
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
cacheKey(text, mode, model, version = '') {
|
||||
return crypto.createHash('sha256').update(`${version}\0${mode}\0${model}\0${text}`).digest('hex');
|
||||
}
|
||||
|
||||
async detectBatch(payload) {
|
||||
validateBatchPayload(payload);
|
||||
const model = payload.model || 'multilingual';
|
||||
const mode = 'laya';
|
||||
const results = new Array(payload.items.length);
|
||||
const misses = [];
|
||||
|
||||
payload.items.forEach((item, index) => {
|
||||
const key = this.cacheKey(item.text, mode, model, LANGUAGE_CACHE_VERSION);
|
||||
const cached = this.cache ? this.cache.get(key) : null;
|
||||
if (cached) results[index] = { ...cached, cached: true };
|
||||
else misses.push({ item, index, key });
|
||||
});
|
||||
|
||||
if (misses.length) {
|
||||
const response = await this.request('/v1/systemone/batch', {
|
||||
method: 'POST',
|
||||
timeoutMs: this.batchTimeoutMs,
|
||||
body: JSON.stringify({
|
||||
states: misses.map(({ item }) => item.text),
|
||||
model,
|
||||
questions: {
|
||||
language: {
|
||||
type: 'choice',
|
||||
instructions: 'Identify the language of this text.',
|
||||
criteria: { fr: 'French', en: 'English', es: 'Spanish', other: 'Another language' }
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
if (!response || !Array.isArray(response.results) || response.results.length !== misses.length) {
|
||||
throw new LayaRequestError('Laya runtime returned an invalid batch response', 502);
|
||||
}
|
||||
const parsed = response.results.map(result => parseLanguageAnswer(result?.answers?.language));
|
||||
misses.forEach(({ item, index, key }, responseIndex) => {
|
||||
const result = {
|
||||
id: item.id ?? index,
|
||||
...parsed[responseIndex],
|
||||
cached: false
|
||||
};
|
||||
results[index] = result;
|
||||
if (this.cache) this.cache.set(key, mode, model, { ...result, cached: undefined });
|
||||
});
|
||||
}
|
||||
return { runtimeAvailable: true, model, results };
|
||||
}
|
||||
|
||||
async identityHintBatch(pairs) {
|
||||
if (!Array.isArray(pairs) || pairs.length === 0 || pairs.length > MAX_BATCH_ITEMS) {
|
||||
throw new LayaRequestError(`Identity hint batch must contain 1 to ${MAX_BATCH_ITEMS} pairs`, 400);
|
||||
}
|
||||
const response = await this.request('/v1/systemone/batch', {
|
||||
method: 'POST',
|
||||
timeoutMs: this.batchTimeoutMs,
|
||||
body: JSON.stringify({
|
||||
states: pairs.map(pair => ({
|
||||
left: sanitizeIdentityDescriptor(pair.left),
|
||||
right: sanitizeIdentityDescriptor(pair.right)
|
||||
})),
|
||||
model: 'multilingual',
|
||||
questions: {
|
||||
sameIdentity: {
|
||||
type: 'noul',
|
||||
instructions: 'Do these two TV channel descriptors identify the same numbered channel?'
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
if (!response || !Array.isArray(response.results) || response.results.length !== pairs.length) {
|
||||
throw new LayaRequestError('Laya runtime returned an invalid identity batch response', 502);
|
||||
}
|
||||
return pairs.map((pair, index) => {
|
||||
const answer = response.results[index]?.answers?.sameIdentity || {};
|
||||
return {
|
||||
id: pair.id,
|
||||
sameIdentityProbability: Number(answer.noul) || 0,
|
||||
confidence: Number(answer.answer_confidence ?? answer.confidence) || 0,
|
||||
family: answer.family || null,
|
||||
number: answer.number || null
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ALLOWED_MODELS,
|
||||
BATCH_TIMEOUT_MS,
|
||||
LayaRequestError,
|
||||
LayaService,
|
||||
MODEL_DIR,
|
||||
clearDirectoryContents,
|
||||
directorySize,
|
||||
initialDownloadState,
|
||||
normalizeLanguageLabel,
|
||||
parseLanguageAnswer,
|
||||
sanitizeIdentityDescriptor,
|
||||
validateBatchPayload
|
||||
};
|
||||
105
server/services/liveGroupingScoring.js
Normal file
105
server/services/liveGroupingScoring.js
Normal file
@ -0,0 +1,105 @@
|
||||
const MODE_WEIGHTS = Object.freeze({
|
||||
algo: Object.freeze({ algorithm: 80, structure: 15, metadata: 5, laya: 0 }),
|
||||
hybrid: Object.freeze({ algorithm: 70, structure: 15, metadata: 5, laya: 10 }),
|
||||
laya: Object.freeze({ algorithm: 55, structure: 15, metadata: 5, laya: 25 })
|
||||
});
|
||||
const PROTECTED_TOKENS = new Set(['max', 'news', 'premium', 'extra', 'xtra', 'kids', 'movies']);
|
||||
const STRONG_FAMILIES = ['bein sports', 'sky sports', 'canal plus', 'eurosport', 'espn', 'dazn'];
|
||||
const LAYA_CONFIDENCE_FLOOR = 0.65;
|
||||
const MERGE_THRESHOLD = 70;
|
||||
|
||||
function tokens(value) {
|
||||
return String(value || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function strongFamily(value) {
|
||||
const text = tokens(value).join(' ');
|
||||
return STRONG_FAMILIES.find(family => text.includes(family)) || null;
|
||||
}
|
||||
|
||||
function identityNumber(value, family = strongFamily(value)) {
|
||||
const list = tokens(value);
|
||||
let start = 0;
|
||||
if (family) {
|
||||
const familyTokens = family.split(' ');
|
||||
for (let index = 0; index <= list.length - familyTokens.length; index++) {
|
||||
if (familyTokens.every((token, offset) => list[index + offset] === token)) {
|
||||
start = index + familyTokens.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return list.slice(start).find(token => /^\d+$/.test(token)) || null;
|
||||
}
|
||||
|
||||
function identity(value) {
|
||||
const list = tokens(value);
|
||||
const family = strongFamily(value);
|
||||
return {
|
||||
family,
|
||||
number: identityNumber(value, family),
|
||||
protectedTokens: new Set(list.filter(token => PROTECTED_TOKENS.has(token)))
|
||||
};
|
||||
}
|
||||
|
||||
function sameSet(left, right) {
|
||||
return left.size === right.size && [...left].every(value => right.has(value));
|
||||
}
|
||||
|
||||
function hardBlock(left, right) {
|
||||
const a = identity(left.fingerprint || left.name);
|
||||
const b = identity(right.fingerprint || right.name);
|
||||
if (!sameSet(a.protectedTokens, b.protectedTokens)) return 'protected_token_mismatch';
|
||||
if (a.number && b.number && a.number !== b.number) return 'number_mismatch';
|
||||
if (a.family && b.family && a.family !== b.family) return 'strong_family_mismatch';
|
||||
return null;
|
||||
}
|
||||
|
||||
function pairKey(left, right) {
|
||||
return [String(left.id), String(right.id)].sort().join('\0');
|
||||
}
|
||||
|
||||
function layaSignal(hint, leftIdentity, rightIdentity) {
|
||||
if (!hint || Number(hint.confidence) < LAYA_CONFIDENCE_FLOOR) return 0;
|
||||
if (hint.number && leftIdentity.number && hint.number !== leftIdentity.number) return 0;
|
||||
if (hint.number && rightIdentity.number && hint.number !== rightIdentity.number) return 0;
|
||||
if (hint.family && leftIdentity.family && hint.family !== leftIdentity.family) return 0;
|
||||
if (hint.family && rightIdentity.family && hint.family !== rightIdentity.family) return 0;
|
||||
return Number(hint.sameIdentityProbability) >= 0.5
|
||||
? Math.min(1, Math.max(0, Number(hint.confidence)))
|
||||
: 0;
|
||||
}
|
||||
|
||||
function scoreCandidate(left, right, options = {}) {
|
||||
const mode = MODE_WEIGHTS[options.mode] ? options.mode : 'algo';
|
||||
const weights = MODE_WEIGHTS[mode];
|
||||
const blocker = hardBlock(left, right);
|
||||
if (blocker) return { matched: false, blocker, total: 0, signals: { algorithm: 0, structure: 0, metadata: 0, laya: 0 } };
|
||||
|
||||
const leftIdentity = identity(left.fingerprint || left.name);
|
||||
const rightIdentity = identity(right.fingerprint || right.name);
|
||||
const exact = left.fingerprint === right.fingerprint;
|
||||
const sameStructure = Boolean(leftIdentity.family && leftIdentity.family === rightIdentity.family &&
|
||||
leftIdentity.number && leftIdentity.number === rightIdentity.number);
|
||||
const algorithm = exact ? 1 : (mode !== 'algo' && sameStructure ? 0.6 : 0);
|
||||
const structure = sameStructure ? 1 : 0;
|
||||
const metadata = Boolean(
|
||||
(left.category_id && left.category_id === right.category_id) ||
|
||||
(left.tvg_id && left.tvg_id === right.tvg_id)
|
||||
) ? 1 : 0;
|
||||
const laya = exact ? 0 : layaSignal(options.layaHint, leftIdentity, rightIdentity);
|
||||
const total = algorithm * weights.algorithm + structure * weights.structure +
|
||||
metadata * weights.metadata + laya * weights.laya;
|
||||
return { matched: exact || total >= MERGE_THRESHOLD, blocker: null, total, signals: { algorithm, structure, metadata, laya } };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LAYA_CONFIDENCE_FLOOR,
|
||||
MERGE_THRESHOLD,
|
||||
MODE_WEIGHTS,
|
||||
PROTECTED_TOKENS,
|
||||
hardBlock,
|
||||
identity,
|
||||
pairKey,
|
||||
scoreCandidate
|
||||
};
|
||||
499
server/services/liveProjectionService.js
Normal file
499
server/services/liveProjectionService.js
Normal file
@ -0,0 +1,499 @@
|
||||
const crypto = require('crypto');
|
||||
const { pairKey, scoreCandidate, identity } = require('./liveGroupingScoring');
|
||||
const { detectLanguage } = require('./channelLanguage');
|
||||
const { matchesSearch } = require('./flexibleChannelSearch');
|
||||
const iptvOrgCatalog = require('./iptvOrgCatalogService');
|
||||
const epgMatching = require('./epgMatchingService');
|
||||
|
||||
const MAX_SYNCHRONOUS_ENRICHMENTS = 250;
|
||||
|
||||
const QUALITY_TOKENS = new Set([
|
||||
'4k', 'uhd', 'fhd', 'fullhd', 'hd', 'sd', 'hevc', 'h265', 'h264',
|
||||
'1080p', '1080i', '720p', '576p', '480p'
|
||||
]);
|
||||
const REGION_PREFIX = /^(?:(?:\|\s*[a-z]{2,3}\s*\|)|(?:[a-z]{2,3}(?:-[a-z]{2,3})?\s*\|))\s*/i;
|
||||
const BEIN_SUFFIX_TOKENS = new Set(['fr', 'en', 'thai', 'tod', 'appel', 'app']);
|
||||
const BEIN_TIMESHIFT_TOKENS = new Set(['2h', '4h', '8h', '12h']);
|
||||
|
||||
function words(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(REGION_PREFIX, '')
|
||||
.replace(/[|()[\]{}_.,:;+/\\-]+/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeChannelName(name) {
|
||||
const tokens = words(name).filter(token => !QUALITY_TOKENS.has(token));
|
||||
const brandIndex = tokens.findIndex((token, index) => token === 'bein' && tokens[index + 1] === 'sports');
|
||||
if (brandIndex >= 0) {
|
||||
const branded = tokens.slice(brandIndex);
|
||||
const numberedBaseLength = branded[2] === 'max' && /^\d+$/.test(branded[3])
|
||||
? 4
|
||||
: (/^\d+$/.test(branded[2]) ? 3 : 0);
|
||||
if (numberedBaseLength) {
|
||||
const suffix = branded.slice(numberedBaseLength);
|
||||
const removable = suffix.every(token =>
|
||||
BEIN_SUFFIX_TOKENS.has(token) || BEIN_TIMESHIFT_TOKENS.has(token)
|
||||
);
|
||||
if (removable) return branded.slice(0, numberedBaseLength).join(' ');
|
||||
}
|
||||
return branded.join(' ').trim();
|
||||
}
|
||||
return tokens.join(' ').trim();
|
||||
}
|
||||
|
||||
function displayNameForFingerprint(fingerprint) {
|
||||
return fingerprint.split(' ').map(token => {
|
||||
if (token === 'bein') return 'beIN';
|
||||
if (/^\d+$/.test(token)) return token;
|
||||
return token.charAt(0).toUpperCase() + token.slice(1);
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
function qualityForName(name) {
|
||||
const tokens = words(name);
|
||||
if (tokens.includes('4k') || tokens.includes('uhd')) return { label: '4K', rank: 4 };
|
||||
if (tokens.includes('fhd') || tokens.includes('fullhd') || tokens.includes('1080p') || tokens.includes('1080i')) {
|
||||
return { label: 'Full HD', rank: 3 };
|
||||
}
|
||||
if (tokens.includes('hd') || tokens.includes('720p')) return { label: 'HD', rank: 2 };
|
||||
if (tokens.includes('sd') || tokens.includes('576p') || tokens.includes('480p')) return { label: 'SD', rank: 0 };
|
||||
return { label: 'Standard', rank: 1 };
|
||||
}
|
||||
|
||||
function stableHash(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function projectCatalog(items, options = {}) {
|
||||
const candidateBuckets = new Map();
|
||||
for (const item of items) {
|
||||
const fallback = `channel-${item.source_id}:${item.item_id}`;
|
||||
const fingerprint = normalizeChannelName(item.name) || fallback;
|
||||
const quality = qualityForName(item.name);
|
||||
const projected = {
|
||||
...item,
|
||||
fingerprint,
|
||||
groupingInputHash: stableHash(`${item.name || ''}\0${item.category_id || ''}`),
|
||||
qualityLabel: quality.label,
|
||||
qualityRank: quality.rank
|
||||
};
|
||||
const language = detectLanguage({ ...projected, data: parseData(projected.data) });
|
||||
projected.languageCode = language.code;
|
||||
projected.languageConfidence = language.confidence;
|
||||
projected.languageScores = language.scores;
|
||||
projected.languageEvidence = language.evidence;
|
||||
const itemIdentity = identity(fingerprint);
|
||||
const candidateKey = itemIdentity.family && itemIdentity.number
|
||||
? `${itemIdentity.family}:${itemIdentity.number}`
|
||||
: fingerprint;
|
||||
if (!candidateBuckets.has(candidateKey)) candidateBuckets.set(candidateKey, []);
|
||||
const groups = candidateBuckets.get(candidateKey);
|
||||
const matchingGroup = groups.find(group => group.some(member => scoreCandidate(member, projected, {
|
||||
mode: options.mode || 'algo',
|
||||
layaHint: options.identityHints?.get(pairKey(member, projected))
|
||||
}).matched));
|
||||
if (matchingGroup) matchingGroup.push(projected);
|
||||
else groups.push([projected]);
|
||||
}
|
||||
|
||||
return Array.from(candidateBuckets.values()).flat().map(variants => {
|
||||
const fingerprint = variants.map(variant => variant.fingerprint).sort((a, b) => a.length - b.length || a.localeCompare(b))[0];
|
||||
variants.sort((a, b) => b.qualityRank - a.qualityRank || a.source_id - b.source_id || String(a.item_id).localeCompare(String(b.item_id)));
|
||||
variants.forEach((variant, index) => { variant.variantRank = index; });
|
||||
return {
|
||||
id: `logical:${stableHash(fingerprint).slice(0, 24)}`,
|
||||
fingerprint,
|
||||
displayName: displayNameForFingerprint(fingerprint),
|
||||
preferredVariantId: variants[0].id,
|
||||
variants
|
||||
};
|
||||
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
}
|
||||
|
||||
function safeIdentityDescriptor(item) {
|
||||
return {
|
||||
name: String(item.name || '').slice(0, 300),
|
||||
group: String(item.category_name || item.groupTitle || '').slice(0, 200),
|
||||
source: String(item.source_name || '').slice(0, 120),
|
||||
tvgId: String(item.tvg_id || '').slice(0, 160)
|
||||
};
|
||||
}
|
||||
|
||||
function identityCandidatePairs(items) {
|
||||
const buckets = new Map();
|
||||
for (const item of items) {
|
||||
const fingerprint = normalizeChannelName(item.name);
|
||||
const itemIdentity = identity(fingerprint);
|
||||
if (!itemIdentity.family || !itemIdentity.number) continue;
|
||||
const key = `${itemIdentity.family}:${itemIdentity.number}`;
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key).push({ ...item, fingerprint });
|
||||
}
|
||||
const pairs = [];
|
||||
for (const bucket of buckets.values()) {
|
||||
for (let left = 0; left < bucket.length; left++) {
|
||||
for (let right = left + 1; right < bucket.length; right++) {
|
||||
if (bucket[left].fingerprint === bucket[right].fingerprint) continue;
|
||||
if (scoreCandidate(bucket[left], bucket[right], { mode: 'algo' }).blocker) continue;
|
||||
pairs.push({
|
||||
id: pairKey(bucket[left], bucket[right]),
|
||||
left: safeIdentityDescriptor(bucket[left]),
|
||||
right: safeIdentityDescriptor(bucket[right])
|
||||
});
|
||||
if (pairs.length >= 256) return pairs;
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
async function collectIdentityHints(items, mode, layaClient, options = {}) {
|
||||
if (mode === 'algo' || !layaClient) return new Map();
|
||||
const pairs = identityCandidatePairs(items);
|
||||
const total = Math.ceil(pairs.length / 64);
|
||||
let processed = 0;
|
||||
const hints = new Map();
|
||||
options.onProgress?.({ processed, total });
|
||||
for (let index = 0; index < pairs.length; index += 64) {
|
||||
const results = await layaClient.identityHintBatch(pairs.slice(index, index + 64));
|
||||
results.forEach(result => hints.set(result.id, result));
|
||||
processed += 1;
|
||||
options.onProgress?.({ processed, total });
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
async function collectOptionalIdentityHints(items, mode, layaClient, options = {}) {
|
||||
if (mode === 'algo') return new Map();
|
||||
try {
|
||||
return await collectIdentityHints(items, mode, layaClient, options);
|
||||
} catch (error) {
|
||||
console.warn('[LiveProjection] Laya identity hints unavailable; continuing deterministically');
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function catalogInputHash(items) {
|
||||
return stableHash(items
|
||||
.map(item => `${item.id}\0${item.name}\0${item.category_id || ''}`)
|
||||
.sort()
|
||||
.join('\n'));
|
||||
}
|
||||
|
||||
function defaultDatabase() {
|
||||
return require('../db/sqlite').getDb();
|
||||
}
|
||||
|
||||
function projectionState(database = null) {
|
||||
database ||= defaultDatabase();
|
||||
return database.prepare('SELECT * FROM catalog_projection_state WHERE projection = ?').get('live') || {
|
||||
projection: 'live', status: 'missing', started_at: null, completed_at: null,
|
||||
input_hash: null, projected_count: 0, phase: 'idle', processed_count: 0, total_count: 0,
|
||||
laya_processed_count: 0, laya_total_count: 0, error: null
|
||||
};
|
||||
}
|
||||
|
||||
function updateProjectionProgress(database, phase, progress = {}) {
|
||||
database.prepare(`
|
||||
UPDATE catalog_projection_state SET phase = ?,
|
||||
laya_processed_count = COALESCE(?, laya_processed_count),
|
||||
laya_total_count = COALESCE(?, laya_total_count)
|
||||
WHERE projection = 'live'
|
||||
`).run(phase, progress.layaProcessed ?? null, progress.layaTotal ?? null);
|
||||
}
|
||||
|
||||
function markProjectionBuilding(database = null) {
|
||||
database ||= defaultDatabase();
|
||||
const total = database.prepare("SELECT COUNT(*) AS count FROM playlist_items WHERE type = 'live'").get().count;
|
||||
const startedAt = Date.now();
|
||||
database.prepare(`
|
||||
INSERT INTO catalog_projection_state
|
||||
(projection, status, started_at, completed_at, input_hash, projected_count, phase,
|
||||
processed_count, total_count, laya_processed_count, laya_total_count, error)
|
||||
VALUES ('live', 'building', ?, NULL, NULL, 0, 'preparing', 0, ?, 0, 0, NULL)
|
||||
ON CONFLICT(projection) DO UPDATE SET status = 'building', started_at = excluded.started_at,
|
||||
completed_at = NULL, phase = 'preparing', processed_count = 0,
|
||||
total_count = excluded.total_count, laya_processed_count = 0,
|
||||
laya_total_count = 0, error = NULL
|
||||
`).run(startedAt, total);
|
||||
return projectionState(database);
|
||||
}
|
||||
|
||||
function loadProjectionItems(database) {
|
||||
return database.prepare(`
|
||||
SELECT p.id, p.source_id, p.item_id, p.name, p.category_id, p.data,
|
||||
c.name AS category_name
|
||||
FROM playlist_items p
|
||||
LEFT JOIN categories c ON c.source_id = p.source_id AND c.type = 'live' AND c.category_id = p.category_id
|
||||
WHERE p.type = 'live'
|
||||
ORDER BY p.id
|
||||
`).all().map(item => {
|
||||
const data = parseData(item.data);
|
||||
return { ...item, tvg_id: data.tvgId || data.tvg_id || null };
|
||||
});
|
||||
}
|
||||
|
||||
function rebuild(database = null, options = {}) {
|
||||
database ||= defaultDatabase();
|
||||
const items = options.items || loadProjectionItems(database);
|
||||
if (!options.stateAlreadyBuilding) markProjectionBuilding(database);
|
||||
updateProjectionProgress(database, 'projecting');
|
||||
const groups = projectCatalog(items, { mode: options.mode, identityHints: options.identityHints });
|
||||
const inputHash = catalogInputHash(items);
|
||||
updateProjectionProgress(database, 'writing');
|
||||
|
||||
try {
|
||||
const apply = database.transaction(() => {
|
||||
database.prepare('DELETE FROM logical_channels').run();
|
||||
database.prepare(`
|
||||
UPDATE playlist_items SET logical_channel_id = NULL, grouping_fingerprint = NULL,
|
||||
grouping_input_hash = NULL, quality_label = NULL, quality_rank = NULL, variant_rank = NULL,
|
||||
language_code = NULL, language_confidence = NULL, language_scores = NULL,
|
||||
language_evidence = NULL, language_detector = NULL, language_input_hash = NULL
|
||||
WHERE type = 'live'
|
||||
`).run();
|
||||
const insertLogical = database.prepare(`
|
||||
INSERT INTO logical_channels
|
||||
(id, fingerprint, display_name, preferred_variant_id, variant_count, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const updateVariant = database.prepare(`
|
||||
UPDATE playlist_items SET logical_channel_id = ?, grouping_fingerprint = ?,
|
||||
grouping_input_hash = ?, quality_label = ?, quality_rank = ?, variant_rank = ?,
|
||||
language_code = ?, language_confidence = ?, language_scores = ?,
|
||||
language_evidence = ?, language_detector = 'algorithm', language_input_hash = ?
|
||||
WHERE id = ? AND type = 'live'
|
||||
`);
|
||||
const now = Date.now();
|
||||
for (const group of groups) {
|
||||
insertLogical.run(group.id, group.fingerprint, group.displayName, group.preferredVariantId, group.variants.length, now, now);
|
||||
for (const variant of group.variants) {
|
||||
updateVariant.run(group.id, group.fingerprint, variant.groupingInputHash,
|
||||
variant.qualityLabel, variant.qualityRank, variant.variantRank,
|
||||
variant.languageCode, variant.languageConfidence,
|
||||
JSON.stringify(variant.languageScores), JSON.stringify(variant.languageEvidence),
|
||||
stableHash(`${variant.name || ''}\0${variant.category_name || ''}\0${variant.tvg_id || ''}`), variant.id);
|
||||
}
|
||||
}
|
||||
database.prepare(`
|
||||
UPDATE catalog_projection_state SET status = 'success', phase = 'complete', completed_at = ?,
|
||||
input_hash = ?, projected_count = ?, processed_count = ?, total_count = ?, error = NULL
|
||||
WHERE projection = 'live'
|
||||
`).run(Date.now(), inputHash, groups.length, items.length, items.length);
|
||||
});
|
||||
apply();
|
||||
return projectionState(database);
|
||||
} catch (error) {
|
||||
const diagnosticCode = /^SQLITE_[A-Z_]+$/.test(error.code || '') ? error.code : 'INTERNAL';
|
||||
console.error(`[LiveProjection] Rebuild transaction failed (${diagnosticCode}, ${error.name || 'Error'})`);
|
||||
database.prepare(`
|
||||
UPDATE catalog_projection_state SET status = 'error', phase = 'failed', completed_at = ?, error = ?
|
||||
WHERE projection = 'live'
|
||||
`).run(Date.now(), `Live catalog projection failed [${diagnosticCode}]`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildWithSignals(database = null, options = {}) {
|
||||
database ||= defaultDatabase();
|
||||
const items = loadProjectionItems(database);
|
||||
const configured = options.mode || (await require('../db').settings.get()).languageDetectionMode || 'algo';
|
||||
const mode = ['algo', 'hybrid', 'laya'].includes(configured) ? configured : 'algo';
|
||||
let identityHints = new Map();
|
||||
if (mode !== 'algo') {
|
||||
const layaClient = options.layaClient || new (require('./layaService').LayaService)();
|
||||
identityHints = await collectOptionalIdentityHints(items, mode, layaClient, {
|
||||
onProgress: ({ processed, total }) => updateProjectionProgress(database, 'laya_identity', {
|
||||
layaProcessed: processed,
|
||||
layaTotal: total
|
||||
})
|
||||
});
|
||||
}
|
||||
return rebuild(database, { ...options, items, mode, identityHints });
|
||||
}
|
||||
|
||||
let activeRebuild = null;
|
||||
let activeState = null;
|
||||
|
||||
function startRebuild(database = null, options = {}) {
|
||||
if (activeRebuild) return { alreadyRunning: true, state: activeState, promise: activeRebuild };
|
||||
database ||= defaultDatabase();
|
||||
const mark = options.mark || markProjectionBuilding;
|
||||
const runner = options.runner || rebuildWithSignals;
|
||||
const schedule = options.schedule || setImmediate;
|
||||
activeState = mark(database);
|
||||
activeRebuild = new Promise((resolve, reject) => {
|
||||
schedule(() => {
|
||||
Promise.resolve()
|
||||
.then(() => runner(database, { stateAlreadyBuilding: true }))
|
||||
.then(resolve, reject);
|
||||
});
|
||||
}).finally(() => {
|
||||
activeRebuild = null;
|
||||
activeState = null;
|
||||
});
|
||||
return { alreadyRunning: false, state: activeState, promise: activeRebuild };
|
||||
}
|
||||
|
||||
function queueRebuild(database = null) {
|
||||
return startRebuild(database).promise;
|
||||
}
|
||||
|
||||
function parseData(value) {
|
||||
try { return value ? JSON.parse(value) : {}; } catch { return {}; }
|
||||
}
|
||||
|
||||
function getChannelPage({ sourceId = null, includeHidden = false, userId = null, q = '', languages = [], epgSources = [] }, database = null, dependencies = {}) {
|
||||
database ||= defaultDatabase();
|
||||
const standardCatalog = dependencies.iptvOrgCatalog || iptvOrgCatalog;
|
||||
const epgMatcher = dependencies.epgMatching || epgMatching;
|
||||
const params = [];
|
||||
let sourceFilter = '';
|
||||
if (sourceId !== null) {
|
||||
sourceFilter = 'AND p.source_id = ?';
|
||||
params.push(sourceId);
|
||||
}
|
||||
const rows = database.prepare(`
|
||||
SELECT p.*, CASE WHEN f.id IS NULL THEN 0 ELSE 1 END AS user_favorite,
|
||||
l.display_name, l.preferred_variant_id
|
||||
FROM playlist_items p
|
||||
JOIN logical_channels l ON l.id = p.logical_channel_id
|
||||
LEFT JOIN favorites f ON f.user_id = ? AND f.source_id = p.source_id
|
||||
AND f.item_id = p.item_id AND f.item_type = 'channel'
|
||||
WHERE p.type = 'live' ${sourceFilter}
|
||||
ORDER BY l.display_name, p.variant_rank
|
||||
`).all(userId ?? -1, ...params);
|
||||
const categoryRows = sourceId === null
|
||||
? database.prepare("SELECT source_id, category_id, name, is_hidden FROM categories WHERE type = 'live'").all()
|
||||
: database.prepare("SELECT source_id, category_id, name, is_hidden FROM categories WHERE type = 'live' AND source_id = ?").all(sourceId);
|
||||
const categories = new Map(categoryRows.map(category => [`${category.source_id}\0${category.category_id}`, category]));
|
||||
for (const row of rows) {
|
||||
const category = categories.get(`${row.source_id}\0${row.category_id}`);
|
||||
row.category_name = category?.name || null;
|
||||
row.category_hidden = category?.is_hidden || 0;
|
||||
}
|
||||
const serialized = serializeChannels(rows, includeHidden);
|
||||
const availableLanguages = [...new Map(serialized.map(channel => [channel.language.code, channel.language])).values()]
|
||||
.map(language => ({ code: language.code, short: language.short, label: language.label }))
|
||||
.sort((left, right) => left.label.localeCompare(right.label));
|
||||
const filtered = serialized.filter(channel =>
|
||||
(!languages.length || languages.includes(channel.language.code)) && matchesSearch(channel, q)
|
||||
);
|
||||
// Catalog loading must stay bounded: both enrichers perform synchronous SQLite work,
|
||||
// and EPG matching is especially expensive against large XMLTV catalogs. Enrich only
|
||||
// a genuinely targeted, bounded search; base projection data remains usable otherwise.
|
||||
const shouldEnrich = Boolean(String(q).trim()) && filtered.length <= MAX_SYNCHRONOUS_ENRICHMENTS;
|
||||
const channels = !shouldEnrich ? filtered : filtered.map(channel => {
|
||||
const match = standardCatalog.findMatch(database, channel);
|
||||
if (match) {
|
||||
channel.standardName = match.name;
|
||||
channel.standard = { provider: 'iptv-org', channelId: match.channelId, country: match.country, confidence: match.confidence };
|
||||
}
|
||||
const epgMatch = epgMatcher.findBestMatch(database, channel, epgSources);
|
||||
if (epgMatch) channel.epg = { ...channel.epg, ...epgMatch, language: channel.language.code };
|
||||
return channel;
|
||||
});
|
||||
return { channels, availableLanguages };
|
||||
}
|
||||
|
||||
function getChannels(options, database = null) {
|
||||
return getChannelPage(options, database).channels;
|
||||
}
|
||||
|
||||
function serializeChannels(rows, includeHidden = false) {
|
||||
const groups = new Map();
|
||||
for (const row of rows) {
|
||||
const hidden = Boolean(row.is_hidden || row.category_hidden);
|
||||
if (hidden && !includeHidden) continue;
|
||||
const raw = parseData(row.data);
|
||||
const categoryName = raw.groupTitle || row.category_name || 'Uncategorized';
|
||||
const variant = {
|
||||
...raw,
|
||||
id: row.item_id,
|
||||
sourceId: row.source_id,
|
||||
name: row.name,
|
||||
categoryId: row.category_id,
|
||||
categoryName,
|
||||
groupTitle: categoryName,
|
||||
streamIcon: row.stream_icon,
|
||||
streamUrl: row.stream_url,
|
||||
qualityLabel: row.quality_label,
|
||||
qualityRank: row.quality_rank,
|
||||
variantRank: row.variant_rank,
|
||||
isHidden: hidden,
|
||||
isFavorite: Boolean(row.user_favorite)
|
||||
};
|
||||
const languageCode = row.language_code || 'und';
|
||||
const languageKey = `${row.logical_channel_id}:${languageCode}`;
|
||||
if (!groups.has(languageKey)) {
|
||||
groups.set(languageKey, {
|
||||
id: languageKey,
|
||||
logicalChannelId: row.logical_channel_id,
|
||||
fingerprint: row.grouping_fingerprint,
|
||||
displayName: row.display_name,
|
||||
standardName: row.display_name,
|
||||
standard: { provider: 'local', channelId: null, confidence: 0 },
|
||||
language: {
|
||||
code: languageCode,
|
||||
short: languageCode === 'und' ? 'UND' : languageCode.toUpperCase(),
|
||||
label: languageCode === 'und' ? 'Unknown' : new Intl.DisplayNames(['en'], { type: 'language' }).of(languageCode),
|
||||
confidence: Number(row.language_confidence || 0)
|
||||
},
|
||||
name: row.display_name,
|
||||
variants: [],
|
||||
isFavorite: false,
|
||||
isHidden: true
|
||||
});
|
||||
}
|
||||
const group = groups.get(languageKey);
|
||||
group.variants.push(variant);
|
||||
group.isFavorite ||= variant.isFavorite;
|
||||
group.isHidden &&= variant.isHidden;
|
||||
if (!group.preferredVariant || Number(variant.variantRank) < Number(group.preferredVariant.variantRank)) group.preferredVariant = variant;
|
||||
}
|
||||
return Array.from(groups.values()).map(group => {
|
||||
group.preferredVariant ||= group.variants[0];
|
||||
group.categoryId = group.preferredVariant?.categoryId || null;
|
||||
group.categoryName = group.preferredVariant?.categoryName || 'Uncategorized';
|
||||
group.groupTitle = group.preferredVariant?.groupTitle || group.categoryName;
|
||||
group.selectedVariantId = group.preferredVariant
|
||||
? `${group.preferredVariant.sourceId}:${group.preferredVariant.id}`
|
||||
: null;
|
||||
const epgChannelId = group.preferredVariant?.tvgId || group.preferredVariant?.epg_channel_id || null;
|
||||
group.epg = {
|
||||
sourceId: null,
|
||||
channelId: epgChannelId,
|
||||
language: group.language.code,
|
||||
matchConfidence: epgChannelId ? 100 : 0
|
||||
};
|
||||
return group;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
catalogInputHash,
|
||||
collectIdentityHints,
|
||||
collectOptionalIdentityHints,
|
||||
displayNameForFingerprint,
|
||||
getChannelPage,
|
||||
getChannels,
|
||||
identityCandidatePairs,
|
||||
normalizeChannelName,
|
||||
markProjectionBuilding,
|
||||
MAX_SYNCHRONOUS_ENRICHMENTS,
|
||||
matchesSearch,
|
||||
projectCatalog,
|
||||
projectionState,
|
||||
qualityForName,
|
||||
queueRebuild,
|
||||
rebuild,
|
||||
rebuildWithSignals,
|
||||
safeIdentityDescriptor,
|
||||
serializeChannels,
|
||||
startRebuild
|
||||
};
|
||||
@ -3,6 +3,7 @@ const { sources, settings } = require('../db'); // For source config and setting
|
||||
const xtreamApi = require('./xtreamApi');
|
||||
const m3uParser = require('./m3uParser');
|
||||
const epgParser = require('./epgParser');
|
||||
const liveProjection = require('./liveProjectionService');
|
||||
|
||||
// Sync tracking
|
||||
const activeSyncs = new Set(); // sourceId
|
||||
@ -183,6 +184,7 @@ class SyncService {
|
||||
console.log(`[Sync] Fetching Live Streams for ${source.name}`);
|
||||
const liveStreams = await api.getLiveStreams();
|
||||
await this.saveStreams(source.id, 'live', liveStreams);
|
||||
await liveProjection.queueRebuild();
|
||||
|
||||
// 3. VOD Categories
|
||||
console.log(`[Sync] Fetching VOD Categories for ${source.name}`);
|
||||
@ -559,6 +561,7 @@ class SyncService {
|
||||
}));
|
||||
|
||||
await this.saveCategories(source.id, 'live', categories);
|
||||
await liveProjection.queueRebuild();
|
||||
console.log(`[Sync] M3U sync complete for ${source.name}`);
|
||||
}
|
||||
|
||||
|
||||
69
server/services/transcodeQualityProfiles.js
Normal file
69
server/services/transcodeQualityProfiles.js
Normal file
@ -0,0 +1,69 @@
|
||||
const QUALITY_PROFILES = Object.freeze({
|
||||
source: Object.freeze({
|
||||
id: 'source',
|
||||
videoMode: 'copy'
|
||||
}),
|
||||
'1080p': Object.freeze({
|
||||
id: '1080p',
|
||||
videoMode: 'encode',
|
||||
maxResolution: '1080p',
|
||||
targetVideoBitrateKbps: 6000
|
||||
}),
|
||||
'720p': Object.freeze({
|
||||
id: '720p',
|
||||
videoMode: 'encode',
|
||||
maxResolution: '720p',
|
||||
targetVideoBitrateKbps: 3500
|
||||
}),
|
||||
'480p': Object.freeze({
|
||||
id: '480p',
|
||||
videoMode: 'encode',
|
||||
maxResolution: '480p',
|
||||
targetVideoBitrateKbps: 1500
|
||||
})
|
||||
});
|
||||
|
||||
const QUALITY_MODES = Object.freeze(['auto', ...Object.keys(QUALITY_PROFILES)]);
|
||||
|
||||
function getAutomaticProfile(settings = {}) {
|
||||
const configuredResolution = settings.maxResolution;
|
||||
return QUALITY_PROFILES[configuredResolution] || QUALITY_PROFILES['1080p'];
|
||||
}
|
||||
|
||||
function resolveQualityMode(qualityMode, settings = {}) {
|
||||
if (qualityMode === undefined || qualityMode === null || qualityMode === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!QUALITY_MODES.includes(qualityMode)) {
|
||||
const error = new Error(`Unsupported qualityMode: ${qualityMode}`);
|
||||
error.code = 'INVALID_QUALITY_MODE';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const profile = qualityMode === 'auto'
|
||||
? getAutomaticProfile(settings)
|
||||
: QUALITY_PROFILES[qualityMode];
|
||||
|
||||
return {
|
||||
qualityMode,
|
||||
mode: 'single',
|
||||
selectedProfile: profile.id,
|
||||
availableProfiles: QUALITY_MODES,
|
||||
sessionOptions: {
|
||||
videoMode: profile.videoMode,
|
||||
...(profile.maxResolution && { maxResolution: profile.maxResolution }),
|
||||
...(profile.targetVideoBitrateKbps && {
|
||||
targetVideoBitrateKbps: profile.targetVideoBitrateKbps
|
||||
}),
|
||||
// A manual downscale profile must not inherit a global upscaling setting.
|
||||
...(profile.videoMode === 'encode' && { upscaleEnabled: false })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
QUALITY_MODES,
|
||||
QUALITY_PROFILES,
|
||||
resolveQualityMode
|
||||
};
|
||||
@ -63,20 +63,28 @@ class TranscodeSession extends EventEmitter {
|
||||
this.segments = new Map(); // segment index -> { ready: boolean, path: string }
|
||||
this.status = 'pending'; // pending | starting | running | stopped | error
|
||||
this.error = null;
|
||||
this.requestedEncoder = options.hwEncoder || 'software';
|
||||
this.effectiveEncoder = null;
|
||||
this.hardwareFallbackOccurred = false;
|
||||
this.playlistReady = false;
|
||||
this.processGeneration = 0;
|
||||
this.startTime = Date.now();
|
||||
this.lastAccess = Date.now();
|
||||
this.options = {
|
||||
...options,
|
||||
ffmpegPath: options.ffmpegPath || 'ffmpeg',
|
||||
userAgent: options.userAgent || 'Mozilla/5.0',
|
||||
seekOffset: options.seekOffset || 0,
|
||||
hwEncoder: options.hwEncoder || 'software',
|
||||
maxResolution: options.maxResolution || '1080p',
|
||||
quality: options.quality || 'medium',
|
||||
targetVideoBitrateKbps: this.normalizeBitrate(options.targetVideoBitrateKbps, 6000),
|
||||
targetAudioBitrateKbps: this.normalizeBitrate(options.targetAudioBitrateKbps, 192),
|
||||
allowHardwareFallback: options.allowHardwareFallback !== false,
|
||||
// Upscaling options
|
||||
upscaleEnabled: options.upscaleEnabled || false,
|
||||
upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software'
|
||||
upscaleTarget: options.upscaleTarget || '1080p',
|
||||
...options
|
||||
upscaleTarget: options.upscaleTarget || '1080p'
|
||||
};
|
||||
}
|
||||
|
||||
@ -100,27 +108,62 @@ class TranscodeSession extends EventEmitter {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Build FFmpeg arguments for HLS output
|
||||
const args = this.buildFFmpegArgs();
|
||||
try {
|
||||
const encoder = this.resolveEncoder();
|
||||
this.requestedEncoder = this.options.hwEncoder || 'software';
|
||||
this.startProcess(encoder);
|
||||
|
||||
// Save session metadata
|
||||
await this.persist();
|
||||
|
||||
} catch (err) {
|
||||
this.status = 'error';
|
||||
this.error = err.message;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
normalizeBitrate(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
|
||||
}
|
||||
|
||||
resolveEncoder() {
|
||||
let encoder = this.options.hwEncoder || 'software';
|
||||
if (encoder === 'auto') {
|
||||
const hwCaps = hwDetect.getCapabilities();
|
||||
encoder = hwCaps?.recommended || 'software';
|
||||
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
|
||||
}
|
||||
return encoder;
|
||||
}
|
||||
|
||||
startProcess(encoder) {
|
||||
const args = this.buildFFmpegArgs(encoder);
|
||||
const generation = ++this.processGeneration;
|
||||
this.effectiveEncoder = encoder;
|
||||
this.status = 'starting';
|
||||
this.error = null;
|
||||
console.log(`[TranscodeSession ${this.id}] Starting with requested encoder=${this.requestedEncoder}, effective encoder=${encoder}`);
|
||||
console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`);
|
||||
|
||||
try {
|
||||
this.process = spawn(this.options.ffmpegPath, args, {
|
||||
const process = spawn(this.options.ffmpegPath, args, {
|
||||
cwd: this.dir,
|
||||
windowsHide: true
|
||||
});
|
||||
this.process = process;
|
||||
|
||||
this.status = 'running';
|
||||
|
||||
// Handle stdout (should be empty for file output)
|
||||
this.process.stdout.on('data', (data) => {
|
||||
process.stdout.on('data', (data) => {
|
||||
console.log(`[TranscodeSession ${this.id}] stdout: ${data}`);
|
||||
});
|
||||
|
||||
// Handle stderr (FFmpeg progress/errors)
|
||||
let stderrBuffer = '';
|
||||
this.process.stderr.on('data', (data) => {
|
||||
process.stderr.on('data', (data) => {
|
||||
stderrBuffer += data.toString();
|
||||
// Log periodically to avoid spam
|
||||
const lines = stderrBuffer.split('\n');
|
||||
@ -135,51 +178,62 @@ class TranscodeSession extends EventEmitter {
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
this.process.on('exit', (code) => {
|
||||
process.on('exit', (code) => {
|
||||
if (generation !== this.processGeneration) return;
|
||||
if (code === 0 || code === null) {
|
||||
console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`);
|
||||
this.status = 'stopped';
|
||||
} else if (code !== 255) { // 255 is often from SIGKILL
|
||||
console.error(`[TranscodeSession ${this.id}] FFmpeg exited with code ${code}`);
|
||||
const error = `FFmpeg exited with code ${code}`;
|
||||
if (this.tryHardwareFallback(error, encoder)) return;
|
||||
console.error(`[TranscodeSession ${this.id}] ${error}`);
|
||||
this.status = 'error';
|
||||
this.error = `FFmpeg exited with code ${code}`;
|
||||
this.error = error;
|
||||
}
|
||||
this.process = null;
|
||||
this.emit('exit', code);
|
||||
});
|
||||
|
||||
// Handle spawn errors
|
||||
this.process.on('error', (err) => {
|
||||
process.on('error', (err) => {
|
||||
if (generation !== this.processGeneration) return;
|
||||
if (this.tryHardwareFallback(err.message, encoder)) return;
|
||||
console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err);
|
||||
this.status = 'error';
|
||||
this.error = err.message;
|
||||
if (this.listenerCount('error') > 0) {
|
||||
this.emit('error', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Save session metadata
|
||||
await this.persist();
|
||||
|
||||
} catch (err) {
|
||||
this.status = 'error';
|
||||
this.error = err.message;
|
||||
if (this.tryHardwareFallback(err.message, encoder)) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
tryHardwareFallback(initialError, encoder) {
|
||||
const canFallback = this.options.videoMode !== 'copy' && encoder !== 'software' &&
|
||||
this.options.allowHardwareFallback && !this.hardwareFallbackOccurred && !this.playlistReady;
|
||||
if (!canFallback) return false;
|
||||
|
||||
this.hardwareFallbackOccurred = true;
|
||||
console.error(`[TranscodeSession ${this.id}] Hardware encoder ${encoder} failed during startup: ${initialError}`);
|
||||
console.log(`[TranscodeSession ${this.id}] Retrying once with software encoder`);
|
||||
this.startProcess('software');
|
||||
this.persist().catch(err => console.error(`[TranscodeSession ${this.id}] Failed to persist fallback state:`, err.message));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build FFmpeg arguments for HLS output with optional GPU encoding
|
||||
*/
|
||||
buildFFmpegArgs() {
|
||||
buildFFmpegArgs(resolvedEncoder = null) {
|
||||
const segmentPattern = path.join(this.dir, 'seg%04d.m4s');
|
||||
const videoMode = this.options.videoMode || 'encode';
|
||||
|
||||
// Resolve 'auto' encoder to detected hardware, fallback to software
|
||||
let encoder = this.options.hwEncoder || 'software';
|
||||
if (encoder === 'auto') {
|
||||
const hwCaps = hwDetect.getCapabilities();
|
||||
encoder = hwCaps?.recommended || 'software';
|
||||
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
|
||||
}
|
||||
const encoder = resolvedEncoder || this.resolveEncoder();
|
||||
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
@ -264,7 +318,7 @@ class TranscodeSession extends EventEmitter {
|
||||
args.push(
|
||||
'-c:a', 'aac',
|
||||
'-ar', '48000',
|
||||
'-b:a', '192k',
|
||||
'-b:a', `${this.options.targetAudioBitrateKbps}k`,
|
||||
'-af', `${panFilter},aresample=async=1`
|
||||
);
|
||||
}
|
||||
@ -336,6 +390,7 @@ class TranscodeSession extends EventEmitter {
|
||||
'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 }
|
||||
};
|
||||
const qp = qualityPresets[quality] || qualityPresets.medium;
|
||||
const bitrate = this.options.targetVideoBitrateKbps;
|
||||
|
||||
switch (encoder) {
|
||||
case 'nvenc':
|
||||
@ -356,6 +411,7 @@ class TranscodeSession extends EventEmitter {
|
||||
this.addSoftwareEncoderArgs(args, resolution, qp.software);
|
||||
break;
|
||||
}
|
||||
args.push('-b:v', `${bitrate}k`, '-maxrate', `${bitrate}k`, '-bufsize', `${bitrate * 2}k`);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -432,8 +488,8 @@ class TranscodeSession extends EventEmitter {
|
||||
args.push(
|
||||
'-c:v', 'h264_nvenc',
|
||||
'-preset', 'p4', // Balanced preset (p1=fastest, p7=best)
|
||||
'-rc', 'constqp', // Constant QP mode
|
||||
'-qp', String(qp),
|
||||
'-rc', 'vbr', // Bitrate-constrained variable bitrate
|
||||
'-cq', String(qp), // Quality target within bitrate constraint
|
||||
'-bf', '3' // B-frames for better compression
|
||||
);
|
||||
}
|
||||
@ -448,10 +504,7 @@ class TranscodeSession extends EventEmitter {
|
||||
args.push(
|
||||
'-c:v', 'h264_amf',
|
||||
'-quality', 'quality', // Quality preset
|
||||
'-rc', 'cqp', // Constant QP
|
||||
'-qp_i', String(qp),
|
||||
'-qp_p', String(qp + 2),
|
||||
'-qp_b', String(qp + 4),
|
||||
'-rc', 'vbr_peak', // Bitrate-constrained variable bitrate
|
||||
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
||||
);
|
||||
}
|
||||
@ -556,6 +609,7 @@ class TranscodeSession extends EventEmitter {
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
if (await this.isPlaylistReady()) {
|
||||
this.playlistReady = true;
|
||||
return true;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
@ -600,6 +654,9 @@ class TranscodeSession extends EventEmitter {
|
||||
startTime: this.startTime,
|
||||
lastAccess: this.lastAccess,
|
||||
options: this.options,
|
||||
requestedEncoder: this.requestedEncoder,
|
||||
effectiveEncoder: this.effectiveEncoder,
|
||||
hardwareFallbackOccurred: this.hardwareFallbackOccurred,
|
||||
seekOffset: this.options.seekOffset
|
||||
};
|
||||
const metaPath = path.join(this.dir, 'session.json');
|
||||
@ -621,6 +678,9 @@ class TranscodeSession extends EventEmitter {
|
||||
session.startTime = metadata.startTime;
|
||||
session.lastAccess = metadata.lastAccess;
|
||||
session.status = 'stopped'; // Not running after restart
|
||||
session.requestedEncoder = metadata.requestedEncoder || session.requestedEncoder;
|
||||
session.effectiveEncoder = metadata.effectiveEncoder || null;
|
||||
session.hardwareFallbackOccurred = metadata.hardwareFallbackOccurred || false;
|
||||
return session;
|
||||
} catch (err) {
|
||||
console.error(`Failed to restore session from ${sessionDir}:`, err.message);
|
||||
@ -753,7 +813,10 @@ function getAllSessions() {
|
||||
status: s.status,
|
||||
startTime: s.startTime,
|
||||
lastAccess: s.lastAccess,
|
||||
idleMs: Date.now() - s.lastAccess
|
||||
idleMs: Date.now() - s.lastAccess,
|
||||
requestedEncoder: s.requestedEncoder,
|
||||
effectiveEncoder: s.effectiveEncoder,
|
||||
hardwareFallbackOccurred: s.hardwareFallbackOccurred
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
66
test/channelLanguageDetection.test.js
Normal file
66
test/channelLanguageDetection.test.js
Normal file
@ -0,0 +1,66 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const detection = require('../public/js/services/channelLanguageDetection.js');
|
||||
const grouping = require('../public/js/services/channelVariantGrouping.js');
|
||||
|
||||
const channel = (id, name, extra = {}) => ({
|
||||
id, streamId: id, sourceId: 1, name, groupTitle: 'Sports', ...extra
|
||||
});
|
||||
|
||||
test('scores explicit French prefixes with strong confidence', () => {
|
||||
for (const name of ['|FR| BEIN SPORTS 1 UHD', 'FR-CAR| bein sports 1']) {
|
||||
const result = detection.detectLanguage(channel(name, name));
|
||||
assert.equal(result.language.code, 'fr');
|
||||
assert.match(result.language.status, /medium|high/);
|
||||
assert.ok(result.languageScores.fr >= 75);
|
||||
assert.ok(result.languageEvidence.some(item => item.type === 'name-prefix'));
|
||||
}
|
||||
});
|
||||
|
||||
test('maps UK group evidence to English with medium confidence', () => {
|
||||
const result = detection.detectLanguage(channel('1', 'Sports One', { groupTitle: 'UK Sports' }));
|
||||
assert.equal(result.language.code, 'en');
|
||||
assert.equal(result.language.status, 'medium');
|
||||
});
|
||||
|
||||
test('uses a France source as weak French evidence', () => {
|
||||
const result = detection.detectLanguage(channel('1', 'Sports One'), { sourceName: 'France IPTV' });
|
||||
assert.equal(result.language.code, 'fr');
|
||||
assert.equal(result.language.status, 'low');
|
||||
});
|
||||
|
||||
test('does not force multilingual BE to a language', () => {
|
||||
const result = detection.detectLanguage(channel('1', 'BE Sports 1'));
|
||||
assert.equal(result.language.code, 'unknown');
|
||||
});
|
||||
|
||||
test('classifies contradictory close evidence as unknown', () => {
|
||||
const result = detection.detectLanguage(channel('1', 'French English Sports'));
|
||||
assert.equal(result.language.code, 'unknown');
|
||||
assert.equal(result.language.status, 'unknown');
|
||||
});
|
||||
|
||||
test('global and per-channel language preferences resolve tied variants', () => {
|
||||
const variants = [channel('fr', '|FR| Sports One HD'), channel('en', '|UK| Sports One HD')];
|
||||
const globalEnglish = grouping.groupChannels(variants, { preferredLanguage: 'en' })[0];
|
||||
assert.equal(globalEnglish.preferredVariant.id, 'en');
|
||||
|
||||
const perChannel = grouping.groupChannels(variants, {
|
||||
preferredLanguage: 'en',
|
||||
languagePreferences: new Map([['sports one', 'fr']])
|
||||
})[0];
|
||||
assert.equal(perChannel.preferredVariant.id, 'fr');
|
||||
});
|
||||
|
||||
test('explicit language preference beats quality and hidden variants are ignored', () => {
|
||||
const frenchSd = channel('fr', '|FR| Sports One SD');
|
||||
const english4k = channel('en', '|UK| Sports One 4K');
|
||||
const preferredFrench = grouping.groupChannels([frenchSd, english4k], { preferredLanguage: 'fr' })[0];
|
||||
assert.equal(preferredFrench.preferredVariant.id, 'fr');
|
||||
|
||||
const hiddenFrench = grouping.groupChannels([frenchSd, english4k], {
|
||||
preferredLanguage: 'fr',
|
||||
isHidden: variant => variant.id === 'fr'
|
||||
})[0];
|
||||
assert.equal(hiddenFrench.preferredVariant.id, 'en');
|
||||
});
|
||||
47
test/channelLanguageFusion.test.js
Normal file
47
test/channelLanguageFusion.test.js
Normal file
@ -0,0 +1,47 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fusion = require('../public/js/services/channelLanguageFusion.js');
|
||||
|
||||
test('normalizes common Laya language labels to supported codes', () => {
|
||||
assert.equal(fusion.normalizeLanguageCode('French'), 'fr');
|
||||
assert.equal(fusion.normalizeLanguageCode('English'), 'en');
|
||||
assert.equal(fusion.normalizeLanguageCode('ESPAÑOL'), 'es');
|
||||
});
|
||||
|
||||
const algo = {
|
||||
language: { code: 'fr', label: 'French', confidence: 70, status: 'medium' },
|
||||
languageScores: { fr: 70, en: 30 },
|
||||
languageEvidence: [{ code: 'fr', weight: 60, type: 'name-prefix', value: 'FR' }]
|
||||
};
|
||||
|
||||
test('Laya-only mode maps backend confidence and language', () => {
|
||||
const result = fusion.applyLanguageMode(algo, { language: 'en', confidence: 0.88 }, 'laya');
|
||||
assert.deepEqual(result.language, { code: 'en', label: 'English', confidence: 88, status: 'high' });
|
||||
assert.equal(result.languageEvidence[0].type, 'laya');
|
||||
});
|
||||
|
||||
test('hybrid mode combines Laya and algorithm evidence', () => {
|
||||
const result = fusion.applyLanguageMode(algo, { language: 'en', confidence: 0.9 }, 'hybrid');
|
||||
assert.equal(result.language.code, 'en');
|
||||
assert.ok(result.languageScores.en > result.languageScores.fr);
|
||||
assert.deepEqual(result.languageEvidence.map(item => item.type), ['name-prefix', 'laya']);
|
||||
});
|
||||
|
||||
test('hybrid mode safely preserves algorithm output without Laya', () => {
|
||||
assert.equal(fusion.applyLanguageMode(algo, null, 'hybrid'), algo);
|
||||
});
|
||||
|
||||
test('batch descriptors contain only safe metadata in the backend id/text contract', () => {
|
||||
const descriptor = fusion.buildLayaDescriptor({
|
||||
sourceId: 4,
|
||||
name: '|FR| Sports',
|
||||
groupTitle: 'Sports',
|
||||
sourceName: 'Provider',
|
||||
tvgId: 'sports.fr',
|
||||
url: 'https://user:secret@example.test/live'
|
||||
}, '4:channel-1', algo);
|
||||
assert.deepEqual(Object.keys(descriptor), ['id', 'text']);
|
||||
assert.equal(descriptor.id, '4:channel-1');
|
||||
assert.match(descriptor.text, /sourceId: 4/);
|
||||
assert.doesNotMatch(descriptor.text, /secret|https:\/\//);
|
||||
});
|
||||
139
test/channelVariantGrouping.test.js
Normal file
139
test/channelVariantGrouping.test.js
Normal file
@ -0,0 +1,139 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const grouping = require('../public/js/services/channelVariantGrouping.js');
|
||||
|
||||
const channel = (id, name, extra = {}) => ({ id, streamId: id, sourceId: 1, name, groupTitle: 'Sports', ...extra });
|
||||
|
||||
test('normalizes regional prefixes and quality markers into one logical channel', () => {
|
||||
const names = ['|FR| BEIN SPORTS uhd 1', '|FR| BEIN SPORTS 1 4K', 'FR-CAR| bein sports 1'];
|
||||
assert.deepEqual(names.map(grouping.normalizeChannelName), ['bein sports 1', 'bein sports 1', 'bein sports 1']);
|
||||
const logical = grouping.groupChannels(names.map((name, index) => channel(String(index + 1), name)));
|
||||
assert.equal(logical.length, 1);
|
||||
assert.equal(logical[0].displayName, 'beIN Sports 1');
|
||||
assert.equal(logical[0].variants.length, 3);
|
||||
});
|
||||
|
||||
test('does not merge distinct numbered channels', () => {
|
||||
const logical = grouping.groupChannels([
|
||||
channel('1', 'beIN Sports 1 HD'),
|
||||
channel('2', 'beIN Sports 2 HD')
|
||||
]);
|
||||
assert.deepEqual(logical.map(item => item.fingerprint), ['bein sports 1', 'bein sports 2']);
|
||||
});
|
||||
|
||||
test('normalizes captured beIN Sports aliases like the server projection', () => {
|
||||
const aliases = [
|
||||
'C Car beIN Sports 1',
|
||||
'C Reu beIN Sports 1',
|
||||
'Thai beIN Sports 1 Thai',
|
||||
'beIN Sports 1',
|
||||
'beIN Sports 1 12h',
|
||||
'beIN Sports 1 2h',
|
||||
'beIN Sports 1 4h',
|
||||
'beIN Sports 1 8h',
|
||||
'beIN Sports 1 Appel',
|
||||
'beIN Sports 1 En',
|
||||
'beIN Sports 1 Fr',
|
||||
'beIN Sports 1 Tod'
|
||||
];
|
||||
assert.deepEqual(aliases.map(grouping.normalizeChannelName), Array(aliases.length).fill('bein sports 1'));
|
||||
const logical = grouping.groupChannels(aliases.map((name, index) => channel(String(index), name)));
|
||||
assert.equal(logical.length, 1);
|
||||
assert.equal(logical[0].variants.length, aliases.length);
|
||||
});
|
||||
|
||||
test('keeps distinct beIN numbers and unknown suffixes separate', () => {
|
||||
assert.equal(grouping.normalizeChannelName('beIN Sports 1'), 'bein sports 1');
|
||||
assert.equal(grouping.normalizeChannelName('beIN Sports 2'), 'bein sports 2');
|
||||
assert.equal(grouping.normalizeChannelName('beIN Sports Max 10'), 'bein sports max 10');
|
||||
assert.equal(grouping.normalizeChannelName('Partner beIN Sports 1 Event'), 'bein sports 1 event');
|
||||
assert.notEqual(
|
||||
grouping.normalizeChannelName('Partner beIN Sports 1 Event'),
|
||||
grouping.normalizeChannelName('beIN Sports 1')
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves precomputed projection boundaries and display names', () => {
|
||||
const logical = grouping.groupChannels([
|
||||
channel('1', 'Shared Name HD', { logicalId: 'logical:a', logicalFingerprint: 'a', logicalDisplayName: 'Backend A' }),
|
||||
channel('2', 'Shared Name 4K', { logicalId: 'logical:b', logicalFingerprint: 'b', logicalDisplayName: 'Backend B' })
|
||||
]);
|
||||
assert.deepEqual(logical.map(item => [item.id, item.displayName]), [
|
||||
['logical:a', 'Backend A'],
|
||||
['logical:b', 'Backend B']
|
||||
]);
|
||||
});
|
||||
|
||||
test('logical favorite and hidden state are derived from individual streams', () => {
|
||||
const variants = [channel('1', 'BEIN SPORTS 1 HD'), channel('2', 'BEIN SPORTS 1 4K')];
|
||||
const logical = grouping.groupChannels(variants, {
|
||||
isFavorite: item => item.id === '1',
|
||||
isHidden: item => item.id === '2'
|
||||
})[0];
|
||||
assert.equal(logical.isFavorite, true);
|
||||
assert.equal(logical.isHidden, false);
|
||||
assert.equal(logical.preferredVariant.id, '1');
|
||||
|
||||
assert.equal(grouping.groupChannels(variants, { isHidden: () => true }).length, 0);
|
||||
assert.equal(grouping.groupChannels(variants, { isHidden: () => true, showHidden: true })[0].isHidden, true);
|
||||
});
|
||||
|
||||
test('an explicit visible selection overrides the automatic quality preference', () => {
|
||||
const hd = channel('1', 'BEIN SPORTS 1 HD');
|
||||
const uhd = channel('2', 'BEIN SPORTS 1 UHD');
|
||||
const selectedVariants = new Map([['bein sports 1', grouping.getVariantKey(hd)]]);
|
||||
const logical = grouping.groupChannels([hd, uhd], { selectedVariants })[0];
|
||||
assert.equal(logical.preferredVariant.id, '1');
|
||||
});
|
||||
|
||||
test('flat list ordering promotes favorites without duplicating channels', () => {
|
||||
const channels = [
|
||||
{ id: 'one', isFavorite: false },
|
||||
{ id: 'two', isFavorite: true },
|
||||
{ id: 'three', isFavorite: false },
|
||||
{ id: 'four', isFavorite: true }
|
||||
];
|
||||
const ordered = grouping.orderFlatChannels(channels);
|
||||
assert.deepEqual(ordered.map(item => item.id), ['two', 'four', 'one', 'three']);
|
||||
assert.equal(new Set(ordered.map(item => item.id)).size, channels.length);
|
||||
});
|
||||
|
||||
test('favorites group updates are disabled for flat lists', () => {
|
||||
assert.equal(grouping.shouldUseFavoritesGroup('flat'), false);
|
||||
assert.equal(grouping.shouldUseFavoritesGroup('grouped'), true);
|
||||
});
|
||||
|
||||
test('Laya language results participate in variant selection', () => {
|
||||
const lowQualityFrench = channel('1', 'Sports One SD');
|
||||
const highQualityEnglish = channel('2', 'Sports One 4K');
|
||||
const languageResults = new Map([
|
||||
[grouping.getVariantKey(lowQualityFrench), { language: 'fr', confidence: 0.92 }],
|
||||
[grouping.getVariantKey(highQualityEnglish), { language: 'en', confidence: 0.94 }]
|
||||
]);
|
||||
const logical = grouping.groupChannels([lowQualityFrench, highQualityEnglish], {
|
||||
languageMode: 'laya',
|
||||
languageResults,
|
||||
preferredLanguage: 'fr'
|
||||
})[0];
|
||||
assert.equal(logical.preferredVariant.id, '1');
|
||||
assert.deepEqual(logical.availableLanguages.map(item => item.code), ['fr', 'en']);
|
||||
});
|
||||
|
||||
test('language selection immediately resolves the best visible matching variant', () => {
|
||||
const frenchSd = channel('fr-sd', 'Sports One SD');
|
||||
const frenchHd = channel('fr-hd', 'Sports One HD');
|
||||
const english4k = channel('en-4k', 'Sports One 4K');
|
||||
const languageResults = new Map([
|
||||
[grouping.getVariantKey(frenchSd), { language: 'fr', confidence: 0.9 }],
|
||||
[grouping.getVariantKey(frenchHd), { language: 'fr', confidence: 0.9 }],
|
||||
[grouping.getVariantKey(english4k), { language: 'en', confidence: 0.95 }]
|
||||
]);
|
||||
const logical = grouping.groupChannels([frenchSd, frenchHd, english4k], {
|
||||
languageMode: 'laya',
|
||||
languageResults
|
||||
})[0];
|
||||
const selected = grouping.selectVariantByLanguage(logical, 'fr', {
|
||||
isHidden: variant => variant.id === 'fr-hd'
|
||||
});
|
||||
assert.equal(selected.id, 'fr-sd');
|
||||
});
|
||||
63
test/dockerBuildPushScript.test.js
Normal file
63
test/dockerBuildPushScript.test.js
Normal file
@ -0,0 +1,63 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const script = path.join(root, 'scripts', 'docker-build-push.sh');
|
||||
|
||||
function runWithMockDocker(args = [], env = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-docker-build-push-'));
|
||||
const bin = path.join(dir, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
const log = path.join(dir, 'docker.log');
|
||||
const docker = path.join(bin, 'docker');
|
||||
fs.writeFileSync(docker, `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> "${log}"\n`);
|
||||
fs.chmodSync(docker, 0o755);
|
||||
const result = spawnSync('bash', [script, ...args], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
||||
...env
|
||||
},
|
||||
encoding: 'utf8'
|
||||
});
|
||||
const commands = fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean) : [];
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
return {
|
||||
result,
|
||||
commands
|
||||
};
|
||||
}
|
||||
|
||||
test('docker build/push script builds and pushes Nodecast and Laya images by default', () => {
|
||||
const { result, commands } = runWithMockDocker();
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.deepEqual(commands, [
|
||||
`build --file ${path.join(root, 'Dockerfile')} --tag gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest ${root}`,
|
||||
`build --file ${path.join(root, 'Dockerfile.laya')} --tag gitea.anthonybouteiller.ovh/blomios/nodecast-tv-laya:latest ${root}`,
|
||||
'push gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest',
|
||||
'push gitea.anthonybouteiller.ovh/blomios/nodecast-tv-laya:latest'
|
||||
]);
|
||||
});
|
||||
|
||||
test('--no-push builds both images and skips both pushes', () => {
|
||||
const { result, commands } = runWithMockDocker(['--no-push']);
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.deepEqual(commands, [
|
||||
`build --file ${path.join(root, 'Dockerfile')} --tag gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest ${root}`,
|
||||
`build --file ${path.join(root, 'Dockerfile.laya')} --tag gitea.anthonybouteiller.ovh/blomios/nodecast-tv-laya:latest ${root}`
|
||||
]);
|
||||
});
|
||||
|
||||
test('DRY_RUN=true builds both images and skips both pushes', () => {
|
||||
const { result, commands } = runWithMockDocker([], { DRY_RUN: 'true' });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.deepEqual(commands, [
|
||||
`build --file ${path.join(root, 'Dockerfile')} --tag gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest ${root}`,
|
||||
`build --file ${path.join(root, 'Dockerfile.laya')} --tag gitea.anthonybouteiller.ovh/blomios/nodecast-tv-laya:latest ${root}`
|
||||
]);
|
||||
});
|
||||
242
test/layaService.test.js
Normal file
242
test/layaService.test.js
Normal file
@ -0,0 +1,242 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs/promises');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
LayaRequestError,
|
||||
LayaService,
|
||||
clearDirectoryContents,
|
||||
validateBatchPayload
|
||||
} = require('../server/services/layaService');
|
||||
|
||||
test('status reports an unavailable runtime without throwing', async () => {
|
||||
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-status-'));
|
||||
const service = new LayaService({
|
||||
modelDir,
|
||||
cache: null,
|
||||
fetch: async () => { throw new Error('connection refused'); }
|
||||
});
|
||||
const status = await service.status();
|
||||
assert.equal(status.runtimeAvailable, false);
|
||||
assert.equal(status.ready, false);
|
||||
assert.equal(status.modelPresent, false);
|
||||
assert.equal(status.phase, 'idle');
|
||||
assert.equal(status.downloading, false);
|
||||
assert.equal(status.progressPercent, null);
|
||||
assert.equal(status.bytesDownloaded, null);
|
||||
assert.equal(status.totalBytes, null);
|
||||
assert.equal(status.sizeBytes, 0);
|
||||
assert.equal(status.error, null);
|
||||
assert.match(status.runtimeError.detail, /Start the Laya sidecar/);
|
||||
});
|
||||
|
||||
test('status does not report ready when a model exists but the runtime is unavailable', async () => {
|
||||
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-offline-model-'));
|
||||
await fs.writeFile(path.join(modelDir, 'model.bin'), 'model');
|
||||
const service = new LayaService({
|
||||
modelDir,
|
||||
cache: null,
|
||||
fetch: async () => { throw new Error('connection refused'); }
|
||||
});
|
||||
|
||||
const status = await service.status();
|
||||
|
||||
assert.equal(status.modelPresent, true);
|
||||
assert.equal(status.runtimeAvailable, false);
|
||||
assert.equal(status.ready, false);
|
||||
assert.equal(status.runtimeError.message, 'Laya runtime is unavailable');
|
||||
});
|
||||
|
||||
test('model download exposes an indeterminate transition and finishes ready', async () => {
|
||||
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-download-'));
|
||||
let finishDownload;
|
||||
const downloadResponse = new Promise(resolve => { finishDownload = resolve; });
|
||||
const service = new LayaService({
|
||||
modelDir,
|
||||
cache: null,
|
||||
progressIntervalMs: 5,
|
||||
fetch: async url => {
|
||||
if (url.endsWith('/health')) return { ok: true, json: async () => ({ status: 'ok' }) };
|
||||
await downloadResponse;
|
||||
return { ok: true, json: async () => ({ answers: {} }) };
|
||||
}
|
||||
});
|
||||
|
||||
const started = service.startModelDownload();
|
||||
assert.equal(started.phase, 'preparing');
|
||||
assert.equal(started.downloading, true);
|
||||
assert.equal(started.progressPercent, null);
|
||||
|
||||
await fs.writeFile(path.join(modelDir, 'partial.bin'), 'partial model');
|
||||
await new Promise(resolve => setTimeout(resolve, 15));
|
||||
const downloading = await service.status();
|
||||
assert.equal(downloading.phase, 'downloading');
|
||||
assert.equal(downloading.downloading, true);
|
||||
assert.ok(downloading.bytesDownloaded > 0);
|
||||
|
||||
finishDownload();
|
||||
await service.downloadPromise;
|
||||
const ready = await service.status();
|
||||
assert.equal(ready.phase, 'ready');
|
||||
assert.equal(ready.downloading, false);
|
||||
assert.equal(ready.modelPresent, true);
|
||||
assert.equal(ready.error, null);
|
||||
});
|
||||
|
||||
test('download failures remain visible in status and retry resets the error', async () => {
|
||||
const modelDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-failure-'));
|
||||
let shouldFail = true;
|
||||
const service = new LayaService({
|
||||
modelDir,
|
||||
cache: null,
|
||||
fetch: async url => {
|
||||
if (url.endsWith('/health')) throw new Error('connection refused at https://secret.invalid/token');
|
||||
if (shouldFail) throw new Error('download failed at https://secret.invalid/token');
|
||||
return { ok: true, json: async () => ({ answers: {} }) };
|
||||
}
|
||||
});
|
||||
|
||||
service.startModelDownload();
|
||||
await service.downloadPromise;
|
||||
const failed = await service.status();
|
||||
assert.equal(failed.phase, 'failed');
|
||||
assert.equal(failed.downloading, false);
|
||||
assert.equal(failed.error.message, 'Laya runtime is unavailable');
|
||||
assert.doesNotMatch(JSON.stringify(failed), /secret\.invalid|token/);
|
||||
|
||||
shouldFail = false;
|
||||
const retry = service.startModelDownload();
|
||||
assert.equal(retry.error, null);
|
||||
assert.equal(retry.lastError, null);
|
||||
await service.downloadPromise;
|
||||
});
|
||||
|
||||
test('model deletion remains limited to the configured model directory', async () => {
|
||||
const parent = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-laya-delete-'));
|
||||
const modelDir = path.join(parent, 'models');
|
||||
const sentinel = path.join(parent, 'keep.txt');
|
||||
await fs.mkdir(path.join(modelDir, 'nested'), { recursive: true });
|
||||
await fs.writeFile(path.join(modelDir, 'nested', 'model.bin'), 'model');
|
||||
await fs.writeFile(sentinel, 'keep');
|
||||
|
||||
const service = new LayaService({ modelDir, cache: null, fetch: async () => { throw new Error('offline'); } });
|
||||
service.downloadState.phase = 'failed';
|
||||
service.downloadState.error = { message: 'previous error', detail: 'safe' };
|
||||
const deleted = await service.deleteModel();
|
||||
|
||||
assert.deepEqual(await fs.readdir(modelDir), []);
|
||||
assert.equal(await fs.readFile(sentinel, 'utf8'), 'keep');
|
||||
assert.equal(deleted.phase, 'idle');
|
||||
assert.equal(deleted.error, null);
|
||||
assert.equal(deleted.sizeBytes, 0);
|
||||
});
|
||||
|
||||
test('batch validation rejects oversized item counts', () => {
|
||||
assert.throws(
|
||||
() => validateBatchPayload({ items: Array.from({ length: 65 }, (_, id) => ({ id, text: 'channel' })) }),
|
||||
error => error instanceof LayaRequestError && error.statusCode === 413
|
||||
);
|
||||
});
|
||||
|
||||
test('language batch sends the Laya contract and parses language confidence without fallback', async () => {
|
||||
let requestedUrl;
|
||||
let requestBody;
|
||||
const service = new LayaService({
|
||||
cache: null,
|
||||
batchTimeoutMs: 120000,
|
||||
fetch: async (url, options) => {
|
||||
requestedUrl = url;
|
||||
requestBody = JSON.parse(options.body);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
results: [
|
||||
{ answers: { language: { choice: 'French', answer_confidence: 0.93 } } },
|
||||
{ answers: { language: { choice: 'English', confidence: 0.81 } } }
|
||||
]
|
||||
})
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const response = await service.detectBatch({
|
||||
model: 'multilingual',
|
||||
items: [
|
||||
{ id: 'fr-channel', text: 'France 2 FR' },
|
||||
{ id: 'en-channel', text: 'BBC News UK' }
|
||||
]
|
||||
});
|
||||
|
||||
assert.equal(requestedUrl, 'http://laya:8000/v1/systemone/batch');
|
||||
assert.deepEqual(requestBody.states, ['France 2 FR', 'BBC News UK']);
|
||||
assert.equal(requestBody.model, 'multilingual');
|
||||
assert.deepEqual(response, {
|
||||
runtimeAvailable: true,
|
||||
model: 'multilingual',
|
||||
results: [
|
||||
{ id: 'fr-channel', language: 'fr', confidence: 0.93, cached: false },
|
||||
{ id: 'en-channel', language: 'en', confidence: 0.81, cached: false }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
test('language batch preserves explicit other and maps an unfamiliar label to unknown', async () => {
|
||||
const service = new LayaService({
|
||||
cache: null,
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
results: [
|
||||
{ answers: { language: { choice: 'Other', confidence: 0.72 } } },
|
||||
{ answers: { language: { choice: 'Klingon', confidence: 0.99 } } }
|
||||
]
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
const response = await service.detectBatch({
|
||||
items: [{ id: 'other', text: 'Autre' }, { id: 'strange', text: 'Qapla' }]
|
||||
});
|
||||
|
||||
assert.deepEqual(response.results, [
|
||||
{ id: 'other', language: 'other', confidence: 0.72, cached: false },
|
||||
{ id: 'strange', language: 'unknown', confidence: 0.99, cached: false }
|
||||
]);
|
||||
});
|
||||
|
||||
test('language batch rejects a successful HTTP response without a usable language answer', async () => {
|
||||
const service = new LayaService({
|
||||
cache: null,
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ results: [{ answers: { language: { confidence: 0.9 } } }] })
|
||||
})
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
service.detectBatch({ items: [{ id: 'missing', text: 'Missing language' }] }),
|
||||
error => error instanceof LayaRequestError && error.statusCode === 502
|
||||
);
|
||||
});
|
||||
|
||||
test('identity hints send only sanitized channel metadata to Laya', async () => {
|
||||
let requestBody;
|
||||
const service = new LayaService({
|
||||
cache: null,
|
||||
fetch: async (url, options) => {
|
||||
requestBody = JSON.parse(options.body);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ results: [{ answers: { sameIdentity: { noul: 0.9, confidence: 0.8 } } }] })
|
||||
};
|
||||
}
|
||||
});
|
||||
await service.identityHintBatch([{
|
||||
id: 'pair',
|
||||
left: { name: 'Sports http://user:pass@example.test/live', group: 'token=secret', source: 'Provider', tvgId: 'one', streamUrl: 'forbidden' },
|
||||
right: { name: 'Sports', group: 'Group', source: 'Provider', tvgId: 'two', password: 'forbidden' }
|
||||
}]);
|
||||
assert.deepEqual(Object.keys(requestBody.states[0].left), ['name', 'group', 'source', 'tvgId']);
|
||||
assert.doesNotMatch(JSON.stringify(requestBody), /example\.test|secret|forbidden|user:pass/);
|
||||
});
|
||||
112
test/layaStatusPresentation.test.js
Normal file
112
test/layaStatusPresentation.test.js
Normal file
@ -0,0 +1,112 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { channelListWarning, presentLayaStatus } = require('../public/js/services/layaStatusPresentation.js');
|
||||
|
||||
test('presents unknown-size downloads as indeterminate with downloaded bytes', () => {
|
||||
const view = presentLayaStatus({
|
||||
phase: 'downloading',
|
||||
downloading: true,
|
||||
progressPercent: null,
|
||||
bytesDownloaded: 12 * 1024 * 1024,
|
||||
totalBytes: null
|
||||
});
|
||||
assert.equal(view.progressIndeterminate, true);
|
||||
assert.equal(view.progressPercent, null);
|
||||
assert.equal(view.bytesDownloaded, 12 * 1024 * 1024);
|
||||
assert.equal(view.showDownload, false);
|
||||
assert.equal(view.showRemove, false);
|
||||
});
|
||||
|
||||
test('presents known progress as a bounded percentage', () => {
|
||||
const view = presentLayaStatus({ phase: 'downloading', downloading: true, progressPercent: 47.6 });
|
||||
assert.equal(view.progressIndeterminate, false);
|
||||
assert.equal(view.progressPercent, 48);
|
||||
});
|
||||
|
||||
test('failed downloads expose retry, safe details and Standard fallback', () => {
|
||||
const view = presentLayaStatus({
|
||||
phase: 'failed',
|
||||
modelPresent: false,
|
||||
error: { message: 'Model download failed', detail: 'The optional Laya runtime is unavailable.' }
|
||||
}, 'hybrid');
|
||||
assert.equal(view.downloadLabel, 'Retry download');
|
||||
assert.equal(view.showDownload, true);
|
||||
assert.equal(view.showUseStandard, true);
|
||||
assert.equal(view.errorMessage, 'Model download failed');
|
||||
assert.equal(view.errorDetail, 'The optional Laya runtime is unavailable.');
|
||||
});
|
||||
|
||||
test('failed Laya downloads explain how to start the unavailable runtime', () => {
|
||||
const view = presentLayaStatus({
|
||||
phase: 'failed',
|
||||
modelPresent: false,
|
||||
runtimeAvailable: false,
|
||||
error: { message: 'Model download failed', detail: 'The optional Laya runtime is unavailable.' }
|
||||
}, 'hybrid');
|
||||
assert.match(view.errorDetail, /Nodecast cannot reach the optional Laya runtime/);
|
||||
assert.match(view.errorDetail, /Standard matching remains available/);
|
||||
assert.match(view.errorDetail, /Start the Laya service, then try again:/);
|
||||
assert.match(view.errorDetail, /docker compose --profile laya up -d/);
|
||||
assert.equal(view.showStartCommand, true);
|
||||
});
|
||||
|
||||
test('generic Laya failures do not suggest Docker Compose', () => {
|
||||
const view = presentLayaStatus({
|
||||
phase: 'failed',
|
||||
modelPresent: false,
|
||||
runtimeAvailable: true,
|
||||
error: { message: 'Model download failed', detail: 'Laya model operation failed (HTTP 500).' }
|
||||
}, 'hybrid');
|
||||
assert.doesNotMatch(view.errorDetail, /docker compose/);
|
||||
assert.equal(view.showStartCommand, false);
|
||||
});
|
||||
|
||||
test('ready models expose removal without download actions', () => {
|
||||
const view = presentLayaStatus({ phase: 'idle', modelPresent: true, runtimeAvailable: true, sizeBytes: 678323814 });
|
||||
assert.equal(view.statusLabel, 'Ready');
|
||||
assert.equal(view.showRemove, true);
|
||||
assert.equal(view.showDownload, false);
|
||||
assert.equal(view.showCheckAgain, false);
|
||||
assert.equal(view.showStartCommand, false);
|
||||
assert.equal(view.availabilityMessage, '');
|
||||
});
|
||||
|
||||
test('ChannelList warning only appears for active Laya modes when unavailable or failed', () => {
|
||||
const ready = { modelPresent: true, runtimeAvailable: true };
|
||||
assert.equal(channelListWarning('hybrid', ready), '');
|
||||
assert.equal(channelListWarning('laya', ready), '');
|
||||
assert.equal(channelListWarning('algo', { modelPresent: false, runtimeAvailable: false }), '');
|
||||
assert.equal(
|
||||
channelListWarning('hybrid', { modelPresent: true, runtimeAvailable: false }),
|
||||
'Local AI service is unavailable. Standard channel matching is being used.'
|
||||
);
|
||||
assert.equal(
|
||||
channelListWarning('laya', ready, true),
|
||||
'Local AI language detection failed. Channels were grouped with Standard matching and remain available.'
|
||||
);
|
||||
});
|
||||
|
||||
test('installed model with unavailable service is not reported ready', () => {
|
||||
const view = presentLayaStatus({
|
||||
phase: 'idle',
|
||||
modelPresent: true,
|
||||
runtimeAvailable: false,
|
||||
sizeBytes: 10
|
||||
}, 'hybrid');
|
||||
assert.equal(view.ready, false);
|
||||
assert.equal(view.statusLabel, 'Local AI service unavailable');
|
||||
assert.equal(view.modelLabel, 'Model: Downloaded');
|
||||
assert.equal(view.serviceLabel, 'Local AI service: Unavailable');
|
||||
assert.equal(view.showDownload, false);
|
||||
assert.equal(view.showRemove, true);
|
||||
assert.equal(view.showCheckAgain, true);
|
||||
assert.equal(view.showStartCommand, true);
|
||||
assert.equal(view.availabilityMessage, 'The model is installed, but the Local AI service is not running. Standard channel matching is being used.');
|
||||
});
|
||||
|
||||
test('missing model keeps download available', () => {
|
||||
const view = presentLayaStatus({ phase: 'idle', modelPresent: false, runtimeAvailable: false });
|
||||
assert.equal(view.showDownload, true);
|
||||
assert.equal(view.showRemove, false);
|
||||
assert.equal(view.showCheckAgain, false);
|
||||
});
|
||||
78
test/liveAnalysisPresentation.test.js
Normal file
78
test/liveAnalysisPresentation.test.js
Normal file
@ -0,0 +1,78 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { layaFallbackMessage, present } = require('../public/js/services/liveAnalysisPresentation.js');
|
||||
|
||||
test('presents determinate channel analysis progress', () => {
|
||||
const view = present({ status: 'building', phase: 'projecting', processed: 25, total: 100 });
|
||||
assert.equal(view.busy, true);
|
||||
assert.equal(view.statusLabel, 'Building channel catalog…');
|
||||
assert.equal(view.progressIndeterminate, false);
|
||||
assert.equal(view.progressPercent, 25);
|
||||
assert.equal(view.progressText, '25 of 100 channels · 25%');
|
||||
});
|
||||
|
||||
test('presents Laya identity progress using AI batch counters', () => {
|
||||
const view = present({
|
||||
status: 'building',
|
||||
phase: 'laya_identity',
|
||||
layaProcessed: 3,
|
||||
layaTotal: 8,
|
||||
processed: 0,
|
||||
total: 39348
|
||||
});
|
||||
assert.equal(view.statusLabel, 'Analyzing with local AI…');
|
||||
assert.equal(view.progressIndeterminate, false);
|
||||
assert.equal(view.progressPercent, 38);
|
||||
assert.equal(view.progressText, 'AI batch 3 of 8 · 38%');
|
||||
});
|
||||
|
||||
test('presents local Laya language batch progress after projection', () => {
|
||||
const view = present({
|
||||
status: 'building',
|
||||
phase: 'laya_language',
|
||||
languageProcessed: 5,
|
||||
languageTotal: 10
|
||||
});
|
||||
assert.equal(view.statusLabel, 'Detecting stream languages…');
|
||||
assert.equal(view.progressIndeterminate, false);
|
||||
assert.equal(view.progressPercent, 50);
|
||||
assert.equal(view.progressText, 'Language batch 5 of 10 · 50%');
|
||||
});
|
||||
|
||||
test('presents indeterminate progress when totals are unavailable', () => {
|
||||
const view = present({ status: 'queued', processed: null, total: null });
|
||||
assert.equal(view.busy, true);
|
||||
assert.equal(view.progressIndeterminate, true);
|
||||
assert.equal(view.progressPercent, null);
|
||||
assert.equal(view.progressText, 'In progress…');
|
||||
assert.equal(view.buttonLabel, 'Analyze channels');
|
||||
assert.equal(present({ status: 'running', processed: null, total: 100 }).progressIndeterminate, true);
|
||||
});
|
||||
|
||||
test('presents writing as an indeterminate finalization phase', () => {
|
||||
const view = present({ status: 'building', phase: 'writing', processed: 39348, total: 39348 });
|
||||
assert.equal(view.statusLabel, 'Saving channel catalog…');
|
||||
assert.equal(view.progressIndeterminate, true);
|
||||
assert.equal(view.progressPercent, null);
|
||||
assert.equal(view.progressText, 'Finalizing…');
|
||||
});
|
||||
|
||||
test('presents success and retry states', () => {
|
||||
assert.deepEqual(
|
||||
{ label: present({ status: 'success', projectedCount: 12 }).statusLabel, button: present({ status: 'success' }).buttonLabel },
|
||||
{ label: 'Analysis complete', button: 'Analyze again' }
|
||||
);
|
||||
const failed = present({ status: 'error', error: 'Safe projection error' });
|
||||
assert.equal(failed.statusLabel, 'Analysis failed');
|
||||
assert.equal(failed.buttonLabel, 'Try again');
|
||||
assert.equal(failed.errorDetail, 'Safe projection error');
|
||||
});
|
||||
|
||||
test('mentions Standard fallback only for unavailable active Local AI modes', () => {
|
||||
assert.equal(layaFallbackMessage('algo', { modelPresent: false, runtimeAvailable: false }), '');
|
||||
assert.equal(layaFallbackMessage('hybrid', { modelPresent: true, runtimeAvailable: true }), '');
|
||||
assert.equal(
|
||||
layaFallbackMessage('laya', { modelPresent: true, runtimeAvailable: false }),
|
||||
'Local AI is unavailable. Standard channel matching will be used for this analysis.'
|
||||
);
|
||||
});
|
||||
547
test/liveAnalysisUserFlowDom.test.js
Normal file
547
test/liveAnalysisUserFlowDom.test.js
Normal file
@ -0,0 +1,547 @@
|
||||
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 readPublicScript(relativePath, exposeGlobal = '') {
|
||||
const source = fs.readFileSync(path.join(root, 'public', relativePath), 'utf8');
|
||||
return exposeGlobal ? `${source}\nwindow.${exposeGlobal} = ${exposeGlobal};` : source;
|
||||
}
|
||||
|
||||
function createDom() {
|
||||
const dom = new JSDOM(`<!doctype html>
|
||||
<input id="channel-search">
|
||||
<select id="source-select"><option value="">All Sources</option></select>
|
||||
<input id="show-hidden" type="checkbox">
|
||||
<button id="toggle-groups" type="button"></button>
|
||||
<div id="context-menu"></div>
|
||||
<div id="channel-list"></div>
|
||||
|
||||
<button type="button" id="live-analysis-button">Analyze channels</button>
|
||||
<div id="live-analysis-status" role="status"></div>
|
||||
<div id="live-analysis-progress" hidden>
|
||||
<div id="live-analysis-progress-track"><span id="live-analysis-progress-bar"></span></div>
|
||||
<div id="live-analysis-progress-text"></div>
|
||||
</div>
|
||||
<div id="live-analysis-fallback"></div>
|
||||
<button type="button" id="live-analysis-details-toggle" hidden></button>
|
||||
<pre id="live-analysis-error-details" hidden></pre>
|
||||
`, {
|
||||
url: 'http://nodecast.test/',
|
||||
runScripts: 'outside-only',
|
||||
pretendToBeVisual: true
|
||||
});
|
||||
const { window } = dom;
|
||||
window.clearInterval = window.clearInterval.bind(window);
|
||||
window.IntersectionObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
window.console = console;
|
||||
window.eval(readPublicScript('js/icons.js'));
|
||||
window.eval(readPublicScript('js/services/channelLanguageDetection.js'));
|
||||
window.eval(readPublicScript('js/services/channelLanguageFusion.js'));
|
||||
window.eval(readPublicScript('js/services/channelVariantGrouping.js'));
|
||||
window.eval(readPublicScript('js/services/liveChannelProjection.js'));
|
||||
window.eval(readPublicScript('js/services/layaStatusPresentation.js'));
|
||||
window.eval(readPublicScript('js/services/liveAnalysisPresentation.js'));
|
||||
window.eval(readPublicScript('js/components/ChannelList.js', 'ChannelList'));
|
||||
window.eval(readPublicScript('js/pages/Settings.js', 'SettingsPage'));
|
||||
return {
|
||||
window
|
||||
};
|
||||
}
|
||||
|
||||
function projectedChannels() {
|
||||
return {
|
||||
projection: { status: 'success', stale: false },
|
||||
channels: [{
|
||||
id: 'logical:sports-one',
|
||||
fingerprint: 'sports one',
|
||||
displayName: 'Sports One',
|
||||
variants: [
|
||||
{
|
||||
id: 'fr-sd',
|
||||
sourceId: 1,
|
||||
name: '|FR| Sports One SD',
|
||||
groupTitle: 'Sports France',
|
||||
categoryName: 'Sports France',
|
||||
streamIcon: 'fr.png',
|
||||
streamUrl: 'https://streams.test/fr-sd.m3u8',
|
||||
tvgId: 'sports.one.fr',
|
||||
isFavorite: true,
|
||||
isHidden: false
|
||||
},
|
||||
{
|
||||
id: 'en-4k',
|
||||
sourceId: 1,
|
||||
name: '|UK| Sports One 4K',
|
||||
groupTitle: 'UK Sports',
|
||||
categoryName: 'UK Sports',
|
||||
streamIcon: 'en.png',
|
||||
streamUrl: 'https://streams.test/en-4k.m3u8',
|
||||
tvgId: 'sports.one.uk',
|
||||
isFavorite: false,
|
||||
isHidden: false
|
||||
}
|
||||
]
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
function waitFor(condition, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const started = Date.now();
|
||||
const poll = () => {
|
||||
if (condition()) return resolve();
|
||||
if (Date.now() - started > 1500) return reject(new Error(message));
|
||||
setTimeout(poll, 5);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
test('Settings renders Laya batch progress and removes misleading values when its total is unknown', () => {
|
||||
const { window } = createDom();
|
||||
const settingsPage = Object.create(window.SettingsPage.prototype);
|
||||
settingsPage.app = { player: { settings: { languageDetectionMode: 'hybrid' } } };
|
||||
settingsPage.layaStatus = { modelPresent: true, runtimeAvailable: true };
|
||||
|
||||
settingsPage.renderLiveAnalysisStatus({
|
||||
status: 'building',
|
||||
phase: 'laya_identity',
|
||||
layaProcessed: 3,
|
||||
layaTotal: 8,
|
||||
processed: 0,
|
||||
total: 39348
|
||||
});
|
||||
|
||||
const track = window.document.getElementById('live-analysis-progress-track');
|
||||
const bar = window.document.getElementById('live-analysis-progress-bar');
|
||||
assert.equal(window.document.getElementById('live-analysis-status').textContent, 'Analyzing with local AI…');
|
||||
assert.equal(window.document.getElementById('live-analysis-progress-text').textContent, 'AI batch 3 of 8 · 38%');
|
||||
assert.equal(track.getAttribute('aria-valuenow'), '38');
|
||||
assert.equal(bar.style.width, '38%');
|
||||
|
||||
settingsPage.renderLiveAnalysisStatus({
|
||||
status: 'building',
|
||||
phase: 'laya_identity',
|
||||
layaProcessed: 0,
|
||||
layaTotal: null,
|
||||
processed: 0,
|
||||
total: 39348
|
||||
});
|
||||
|
||||
assert.equal(window.document.getElementById('live-analysis-progress-text').textContent, 'In progress…');
|
||||
assert.equal(track.classList.contains('indeterminate'), true);
|
||||
assert.equal(track.hasAttribute('aria-valuenow'), false);
|
||||
assert.equal(bar.style.width, '');
|
||||
});
|
||||
|
||||
test('analysis remains visibly busy while Laya language batches run after projection completes', async () => {
|
||||
const { window } = createDom();
|
||||
let rebuildClicked = false;
|
||||
let languageRefreshStarted = false;
|
||||
let releaseLanguageRefresh;
|
||||
const languageRefresh = new Promise(resolve => { releaseLanguageRefresh = resolve; });
|
||||
window.API = {
|
||||
live: {
|
||||
getProjectionStatus: async () => rebuildClicked
|
||||
? { status: 'success', phase: 'complete', processed: 128, total: 128, projectedCount: 64 }
|
||||
: { status: 'idle', processed: 0, total: 128 },
|
||||
rebuildProjection: async () => {
|
||||
rebuildClicked = true;
|
||||
return {
|
||||
projection: {
|
||||
status: 'building',
|
||||
phase: 'laya_identity',
|
||||
layaProcessed: 4,
|
||||
layaTotal: 4,
|
||||
processed: 0,
|
||||
total: 128
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
laya: {
|
||||
getStatus: async () => ({ modelPresent: true, runtimeAvailable: true })
|
||||
}
|
||||
};
|
||||
const app = {
|
||||
player: {
|
||||
settings: { languageDetectionMode: 'laya' },
|
||||
saveSettings() {}
|
||||
},
|
||||
channelList: {
|
||||
loadChannels: async () => {},
|
||||
refreshLayaLanguages: async () => {
|
||||
languageRefreshStarted = true;
|
||||
await languageRefresh;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.app = app;
|
||||
const settingsPage = Object.create(window.SettingsPage.prototype);
|
||||
settingsPage.app = app;
|
||||
settingsPage.layaStatus = { modelPresent: true, runtimeAvailable: true };
|
||||
|
||||
settingsPage.initLiveAnalysis();
|
||||
await Promise.resolve();
|
||||
window.document.getElementById('live-analysis-button').click();
|
||||
|
||||
try {
|
||||
await waitFor(() => languageRefreshStarted, 'Expected Laya language refresh to start after projection completion');
|
||||
assert.notEqual(window.document.getElementById('live-analysis-status').textContent, 'Analysis complete');
|
||||
assert.equal(window.document.getElementById('live-analysis-progress').hidden, false);
|
||||
} finally {
|
||||
releaseLanguageRefresh();
|
||||
window.clearInterval(settingsPage.liveAnalysisPollTimer);
|
||||
}
|
||||
});
|
||||
|
||||
test('user analysis click keeps visible channels after Local AI language failure', async () => {
|
||||
const { window } = createDom();
|
||||
let rebuildClicked = 0;
|
||||
let layaBatchCalls = 0;
|
||||
let channelLoads = 0;
|
||||
const consoleWarnings = [];
|
||||
window.console = {
|
||||
...console,
|
||||
warn: (...args) => {
|
||||
consoleWarnings.push(args.join(' '));
|
||||
console.warn(...args);
|
||||
}
|
||||
};
|
||||
window.API = {
|
||||
live: {
|
||||
getChannels: async () => {
|
||||
channelLoads += 1;
|
||||
return projectedChannels();
|
||||
},
|
||||
rebuildProjection: async () => {
|
||||
rebuildClicked += 1;
|
||||
return { projection: { status: 'building', processed: 0, total: 2 } };
|
||||
},
|
||||
getProjectionStatus: async () => ({ status: 'success', processed: 2, total: 2, projectedCount: 1 })
|
||||
},
|
||||
laya: {
|
||||
getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }),
|
||||
detectLanguages: async () => {
|
||||
layaBatchCalls += 1;
|
||||
throw new Error('simulated local AI language failure');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const app = {
|
||||
player: {
|
||||
settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'algo' },
|
||||
saveSettings() {}
|
||||
},
|
||||
epgGuide: null,
|
||||
navigateTo() {},
|
||||
pages: { settings: { switchTab() {} } }
|
||||
};
|
||||
window.app = app;
|
||||
const channelList = new window.ChannelList();
|
||||
channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }];
|
||||
app.channelList = channelList;
|
||||
|
||||
await channelList.loadChannels();
|
||||
channelList.expandAll();
|
||||
const beforeNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent);
|
||||
assert.deepEqual(beforeNames, ['Sports One', 'Sports One']);
|
||||
assert.equal(window.document.querySelector('.empty-state'), null);
|
||||
|
||||
app.player.settings.languageDetectionMode = 'hybrid';
|
||||
const settingsPage = Object.create(window.SettingsPage.prototype);
|
||||
settingsPage.app = app;
|
||||
settingsPage.initLiveAnalysis();
|
||||
await Promise.resolve();
|
||||
|
||||
window.document.getElementById('live-analysis-button').click();
|
||||
assert.equal(rebuildClicked, 1);
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
channelList.expandAll();
|
||||
window.clearInterval(channelList._epgRefreshTimer);
|
||||
window.clearInterval(settingsPage.liveAnalysisPollTimer);
|
||||
|
||||
const notice = window.document.querySelector('.laya-language-notice');
|
||||
const afterNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent);
|
||||
const languageBadges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent);
|
||||
const groupNames = [...window.document.querySelectorAll('.group-name')].map(item => item.textContent);
|
||||
|
||||
assert.equal(channelLoads, 2);
|
||||
assert.equal(layaBatchCalls, 1);
|
||||
assert.ok(notice);
|
||||
assert.match(notice.textContent, /Local AI language detection failed/);
|
||||
assert.deepEqual(afterNames, ['Sports One', 'Sports One']);
|
||||
assert.deepEqual([...new Set(languageBadges)].sort(), ['EN 100%', 'FR 100%']);
|
||||
assert.deepEqual(groupNames, ['Favorites', 'Sports France']);
|
||||
assert.equal(window.document.querySelector('.empty-state'), null);
|
||||
assert.equal(window.document.querySelector('#channel-list > .loading'), null);
|
||||
assert.equal(channelList.languageDetectionFallback, true);
|
||||
assert.equal(channelList.logicalChannels[0].variants.length, 2);
|
||||
assert.ok(consoleWarnings.some(line => line.includes('Local AI language detection unavailable')));
|
||||
});
|
||||
|
||||
test('successful Standard fallback after Local AI failure keeps channels and reports the AI failure', async () => {
|
||||
const { window } = createDom();
|
||||
let rebuildClicked = 0;
|
||||
let layaBatchCalls = 0;
|
||||
window.API = {
|
||||
live: {
|
||||
getChannels: async () => projectedChannels(),
|
||||
rebuildProjection: async () => {
|
||||
rebuildClicked += 1;
|
||||
return { projection: { status: 'building', processed: 0, total: 2 } };
|
||||
},
|
||||
getProjectionStatus: async () => ({ status: 'success', processed: 2, total: 2, projectedCount: 1 })
|
||||
},
|
||||
laya: {
|
||||
getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }),
|
||||
detectLanguages: async () => {
|
||||
layaBatchCalls += 1;
|
||||
throw new Error('simulated local AI language failure');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const app = {
|
||||
player: {
|
||||
settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'algo' },
|
||||
saveSettings() {}
|
||||
},
|
||||
epgGuide: null,
|
||||
navigateTo() {},
|
||||
pages: { settings: { switchTab() {} } }
|
||||
};
|
||||
window.app = app;
|
||||
const channelList = new window.ChannelList();
|
||||
channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }];
|
||||
app.channelList = channelList;
|
||||
|
||||
await channelList.loadChannels();
|
||||
channelList.expandAll();
|
||||
assert.deepEqual(
|
||||
[...window.document.querySelectorAll('.channel-name')].map(item => item.textContent),
|
||||
['Sports One', 'Sports One']
|
||||
);
|
||||
|
||||
app.player.settings.languageDetectionMode = 'hybrid';
|
||||
const settingsPage = Object.create(window.SettingsPage.prototype);
|
||||
settingsPage.app = app;
|
||||
settingsPage.initLiveAnalysis();
|
||||
await Promise.resolve();
|
||||
|
||||
window.document.getElementById('live-analysis-button').click();
|
||||
assert.equal(rebuildClicked, 1);
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
channelList.expandAll();
|
||||
window.clearInterval(channelList._epgRefreshTimer);
|
||||
window.clearInterval(settingsPage.liveAnalysisPollTimer);
|
||||
|
||||
const afterNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent);
|
||||
const notice = window.document.querySelector('.laya-language-notice');
|
||||
|
||||
assert.equal(layaBatchCalls, 1);
|
||||
assert.deepEqual(afterNames, ['Sports One', 'Sports One']);
|
||||
assert.equal(window.document.querySelector('.empty-state'), null);
|
||||
assert.equal(window.document.querySelector('#channel-list > .loading'), null);
|
||||
assert.ok(notice);
|
||||
assert.match(notice.textContent, /Local AI language detection failed/);
|
||||
assert.match(notice.textContent, /Standard matching/);
|
||||
assert.equal(channelList.languageDetectionFallback, true);
|
||||
});
|
||||
|
||||
test('successful Laya language batch updates visible variant languages without Standard fallback', async () => {
|
||||
const { window } = createDom();
|
||||
let layaBatchCalls = 0;
|
||||
let batchPayload = null;
|
||||
window.API = {
|
||||
live: {
|
||||
getChannels: async () => projectedChannels(),
|
||||
rebuildProjection: async () => ({ projection: { status: 'success', processed: 2, total: 2, projectedCount: 1 } }),
|
||||
getProjectionStatus: async () => ({ status: 'success', processed: 2, total: 2, projectedCount: 1 })
|
||||
},
|
||||
laya: {
|
||||
getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }),
|
||||
detectLanguages: async items => {
|
||||
layaBatchCalls += 1;
|
||||
batchPayload = items;
|
||||
return {
|
||||
runtimeAvailable: true,
|
||||
model: 'multilingual',
|
||||
results: items.map(item => ({
|
||||
id: item.id,
|
||||
language: item.id.endsWith('fr-sd') ? 'en' : 'fr',
|
||||
confidence: item.id.endsWith('fr-sd') ? 0.91 : 0.87
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const app = {
|
||||
player: {
|
||||
settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'algo' },
|
||||
saveSettings() {}
|
||||
},
|
||||
epgGuide: null,
|
||||
navigateTo() {},
|
||||
pages: { settings: { switchTab() {} } }
|
||||
};
|
||||
window.app = app;
|
||||
const channelList = new window.ChannelList();
|
||||
channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }];
|
||||
app.channelList = channelList;
|
||||
|
||||
await channelList.loadChannels();
|
||||
app.player.settings.languageDetectionMode = 'laya';
|
||||
await channelList.refreshLayaLanguages('laya');
|
||||
channelList.expandedVariants.add('sports one');
|
||||
channelList.expandAll();
|
||||
|
||||
const badges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent);
|
||||
const evidenceTypes = channelList.logicalChannels
|
||||
.flatMap(channel => channel.variants)
|
||||
.flatMap(variant => variant.languageEvidence.map(item => item.type));
|
||||
|
||||
window.clearInterval(channelList._epgRefreshTimer);
|
||||
|
||||
assert.equal(layaBatchCalls, 1);
|
||||
assert.equal(JSON.stringify(batchPayload.map(item => item.id).sort()), JSON.stringify(['1:en-4k', '1:fr-sd']));
|
||||
assert.deepEqual([...new Set(badges)].sort(), ['EN 91%', 'FR 87%']);
|
||||
assert.ok(evidenceTypes.every(type => type === 'laya'));
|
||||
assert.equal(channelList.languageDetectionFallback, false);
|
||||
assert.equal(channelList.languageDetectionNotice, '');
|
||||
assert.equal(window.document.querySelector('.laya-language-notice'), null);
|
||||
});
|
||||
|
||||
test('usable Laya language labels do not leave visible variants as unknown', async () => {
|
||||
const { window } = createDom();
|
||||
window.API = {
|
||||
live: {
|
||||
getChannels: async () => projectedChannels()
|
||||
},
|
||||
laya: {
|
||||
getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }),
|
||||
detectLanguages: async items => ({
|
||||
runtimeAvailable: true,
|
||||
model: 'multilingual',
|
||||
results: items.map(item => ({
|
||||
id: item.id,
|
||||
language: item.id.endsWith('fr-sd') ? 'French' : 'English',
|
||||
confidence: 0.91
|
||||
}))
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const app = {
|
||||
player: {
|
||||
settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'laya' },
|
||||
saveSettings() {}
|
||||
},
|
||||
epgGuide: null,
|
||||
navigateTo() {},
|
||||
pages: { settings: { switchTab() {} } }
|
||||
};
|
||||
window.app = app;
|
||||
const channelList = new window.ChannelList();
|
||||
channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider' }];
|
||||
app.channelList = channelList;
|
||||
|
||||
await channelList.loadChannels();
|
||||
await channelList.refreshLayaLanguages('laya');
|
||||
channelList.expandAll();
|
||||
|
||||
const badges = [...window.document.querySelectorAll('.variant-language')].map(item => item.textContent);
|
||||
|
||||
window.clearInterval(channelList._epgRefreshTimer);
|
||||
|
||||
assert.deepEqual([...new Set(badges)].sort(), ['EN 91%', 'FR 91%']);
|
||||
assert.equal(channelList.languageDetectionFallback, false);
|
||||
assert.equal(channelList.languageDetectionNotice, '');
|
||||
});
|
||||
|
||||
test('analysis click does not replace visible channels with an empty successful projection', async () => {
|
||||
const { window } = createDom();
|
||||
let rebuildClicked = 0;
|
||||
let channelLoads = 0;
|
||||
window.API = {
|
||||
live: {
|
||||
getChannels: async () => {
|
||||
channelLoads += 1;
|
||||
if (channelLoads === 1) return projectedChannels();
|
||||
return {
|
||||
projection: {
|
||||
status: 'success',
|
||||
stale: false,
|
||||
projectedCount: 0,
|
||||
processed: 3,
|
||||
total: 3
|
||||
},
|
||||
channels: []
|
||||
};
|
||||
},
|
||||
rebuildProjection: async () => {
|
||||
rebuildClicked += 1;
|
||||
return { projection: { status: 'building', processed: 0, total: 3 } };
|
||||
},
|
||||
getProjectionStatus: async () => ({ status: 'success', processed: 3, total: 3, projectedCount: 0 })
|
||||
},
|
||||
laya: {
|
||||
getStatus: async () => ({ modelPresent: true, runtimeAvailable: true }),
|
||||
detectLanguages: async () => {
|
||||
throw new Error('simulated local AI language failure');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const app = {
|
||||
player: {
|
||||
settings: { liveChannelListMode: 'grouped', languageDetectionMode: 'hybrid' },
|
||||
saveSettings() {}
|
||||
},
|
||||
epgGuide: null,
|
||||
navigateTo() {},
|
||||
pages: { settings: { switchTab() {} } }
|
||||
};
|
||||
window.app = app;
|
||||
const channelList = new window.ChannelList();
|
||||
channelList.sources = [{ id: 1, type: 'm3u', name: 'Fixture Provider', enabled: true }];
|
||||
app.channelList = channelList;
|
||||
|
||||
await channelList.loadChannels();
|
||||
channelList.expandAll();
|
||||
const beforeNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent);
|
||||
assert.deepEqual(beforeNames, ['Sports One', 'Sports One']);
|
||||
assert.equal(window.document.querySelector('.empty-state'), null);
|
||||
assert.equal(channelLoads, 1);
|
||||
|
||||
const settingsPage = Object.create(window.SettingsPage.prototype);
|
||||
settingsPage.app = app;
|
||||
settingsPage.initLiveAnalysis();
|
||||
await Promise.resolve();
|
||||
|
||||
window.document.getElementById('live-analysis-button').click();
|
||||
assert.equal(rebuildClicked, 1);
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
channelList.expandAll();
|
||||
window.clearInterval(channelList._epgRefreshTimer);
|
||||
window.clearInterval(settingsPage.liveAnalysisPollTimer);
|
||||
|
||||
const afterNames = [...window.document.querySelectorAll('.channel-name')].map(item => item.textContent);
|
||||
const emptyState = window.document.querySelector('.empty-state');
|
||||
const loading = window.document.querySelector('#channel-list > .loading');
|
||||
|
||||
assert.equal(channelLoads, 2);
|
||||
assert.deepEqual(afterNames, ['Sports One', 'Sports One']);
|
||||
assert.equal(emptyState, null);
|
||||
assert.equal(loading, null);
|
||||
assert.equal(channelList.channels.length, 2);
|
||||
});
|
||||
35
test/liveChannelProjection.test.js
Normal file
35
test/liveChannelProjection.test.js
Normal file
@ -0,0 +1,35 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const projection = require('../public/js/services/liveChannelProjection.js');
|
||||
|
||||
test('maps projected logical channels to the frontend variant format', () => {
|
||||
const response = {
|
||||
projection: { status: 'success', stale: false },
|
||||
channels: [{
|
||||
id: 'logical:one', fingerprint: 'news', displayName: 'News',
|
||||
variants: [{
|
||||
id: 42, sourceId: 7, name: 'News HD', categoryId: 'News',
|
||||
streamIcon: 'logo.png', streamUrl: 'https://stream.test/live',
|
||||
tvgId: 'news.fr', isFavorite: true, isHidden: true
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const mapped = projection.mapResponse(response, [{ id: 7, type: 'm3u', name: 'Local' }]);
|
||||
assert.deepEqual(mapped.channels[0], {
|
||||
id: '42', sourceId: 7, name: 'News HD', categoryId: 'News',
|
||||
streamIcon: 'logo.png', streamUrl: 'https://stream.test/live', tvgId: 'news.fr',
|
||||
isFavorite: true, isHidden: true, logicalId: 'logical:one',
|
||||
logicalFingerprint: 'news', logicalDisplayName: 'News',
|
||||
streamId: '42', tvgLogo: 'logo.png',
|
||||
url: 'https://stream.test/live', groupTitle: 'News', sourceType: 'm3u', sourceName: 'Local',
|
||||
language: { code: 'und', short: 'UND', label: 'Unknown', confidence: 0 }
|
||||
});
|
||||
assert.equal(mapped.favorites.has('7:42'), true);
|
||||
assert.equal(mapped.hiddenItems.has('channel:7:42'), true);
|
||||
});
|
||||
|
||||
test('rejects stale projections so ChannelList can use its legacy fallback', () => {
|
||||
assert.equal(projection.canUse({ projection: { stale: true }, channels: [] }), false);
|
||||
assert.equal(projection.canUse({ projection: { stale: false }, channels: [] }), true);
|
||||
assert.equal(projection.canUse(null), false);
|
||||
});
|
||||
166
test/liveDetectionFunctionalChain.test.js
Normal file
166
test/liveDetectionFunctionalChain.test.js
Normal file
@ -0,0 +1,166 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Database = require('better-sqlite3');
|
||||
const {
|
||||
ensureColumns,
|
||||
LIVE_PROJECTION_COLUMNS,
|
||||
LIVE_PROJECTION_SCHEMA_SQL
|
||||
} = require('../server/db/liveProjectionSchema');
|
||||
const liveProjection = require('../server/services/liveProjectionService');
|
||||
const frontendProjection = require('../public/js/services/liveChannelProjection.js');
|
||||
const grouping = require('../public/js/services/channelVariantGrouping.js');
|
||||
const { channelListWarning } = require('../public/js/services/layaStatusPresentation.js');
|
||||
|
||||
function createFunctionalDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE playlist_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category_id TEXT,
|
||||
parent_id TEXT,
|
||||
stream_icon TEXT,
|
||||
stream_url TEXT,
|
||||
container_extension TEXT,
|
||||
rating REAL,
|
||||
year TEXT,
|
||||
added_at TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, source_id, item_id, item_type)
|
||||
);
|
||||
`);
|
||||
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedCategory(db, categoryId, name, extra = {}) {
|
||||
db.prepare(`
|
||||
INSERT INTO categories (id, source_id, category_id, type, name, is_hidden, data)
|
||||
VALUES (?, 1, ?, 'live', ?, ?, '{}')
|
||||
`).run(`1:${categoryId}`, categoryId, name, extra.isHidden ? 1 : 0);
|
||||
}
|
||||
|
||||
function seedChannel(db, itemId, name, categoryId, extra = {}) {
|
||||
db.prepare(`
|
||||
INSERT INTO playlist_items
|
||||
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, is_hidden, data)
|
||||
VALUES (?, 1, ?, 'live', ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
`1:${itemId}`,
|
||||
itemId,
|
||||
name,
|
||||
categoryId,
|
||||
`${itemId}.png`,
|
||||
`https://streams.test/${itemId}.m3u8`,
|
||||
extra.isHidden ? 1 : 0,
|
||||
JSON.stringify({
|
||||
tvgId: extra.tvgId || null,
|
||||
groupTitle: extra.groupTitle || undefined
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test('functional live detection chain keeps categories and language selection correct after Laya fallback', async () => {
|
||||
const db = createFunctionalDb();
|
||||
seedCategory(db, 'sports-fr', 'Sports France');
|
||||
seedCategory(db, 'sports-uk', 'UK Sports');
|
||||
seedCategory(db, 'events', 'Sports Events');
|
||||
seedChannel(db, 'fr-sd', '|FR| Sports One SD', 'sports-fr', { tvgId: 'sports.one.fr' });
|
||||
seedChannel(db, 'fr-4k-hidden', '|FR| Sports One 4K', 'sports-fr', { isHidden: true, tvgId: 'sports.one.fr.4k' });
|
||||
seedChannel(db, 'en-4k', '|UK| Sports One 4K', 'sports-uk', { tvgId: 'sports.one.uk' });
|
||||
seedChannel(db, 'bein-main', 'beIN Sports 1 HD', 'events');
|
||||
seedChannel(db, 'bein-event', 'Partner beIN Sports 1 Event', 'events');
|
||||
db.prepare(`
|
||||
INSERT INTO favorites (user_id, source_id, item_id, item_type)
|
||||
VALUES (7, 1, 'fr-sd', 'channel')
|
||||
`).run();
|
||||
|
||||
let layaRequests = 0;
|
||||
const state = await liveProjection.rebuildWithSignals(db, {
|
||||
mode: 'hybrid',
|
||||
layaClient: {
|
||||
identityHintBatch: async () => {
|
||||
layaRequests += 1;
|
||||
throw new Error('simulated local AI failure');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(layaRequests, 1);
|
||||
assert.equal(state.status, 'success');
|
||||
assert.equal(state.error, null);
|
||||
|
||||
const rawRows = db.prepare(`
|
||||
SELECT item_id, logical_channel_id, grouping_fingerprint
|
||||
FROM playlist_items
|
||||
WHERE item_id IN ('fr-sd', 'fr-4k-hidden', 'en-4k')
|
||||
ORDER BY item_id
|
||||
`).all();
|
||||
assert.deepEqual(rawRows.map(row => row.item_id), ['en-4k', 'fr-4k-hidden', 'fr-sd']);
|
||||
assert.equal(new Set(rawRows.map(row => row.logical_channel_id)).size, 1);
|
||||
assert.deepEqual([...new Set(rawRows.map(row => row.grouping_fingerprint))], ['sports one']);
|
||||
|
||||
const apiChannels = liveProjection.getChannels({ includeHidden: true, userId: 7 }, db);
|
||||
const sportsOneViews = apiChannels.filter(channel => channel.fingerprint === 'sports one');
|
||||
assert.deepEqual(sportsOneViews.map(channel => channel.language.code).sort(), ['en', 'fr']);
|
||||
assert.deepEqual(
|
||||
sportsOneViews.flatMap(channel => channel.variants)
|
||||
.map(variant => [variant.id, variant.categoryName, variant.isHidden, variant.isFavorite])
|
||||
.sort((left, right) => left[0].localeCompare(right[0])),
|
||||
[
|
||||
['en-4k', 'UK Sports', false, false],
|
||||
['fr-4k-hidden', 'Sports France', true, false],
|
||||
['fr-sd', 'Sports France', false, true]
|
||||
]
|
||||
);
|
||||
|
||||
const mapped = frontendProjection.mapResponse({
|
||||
projection: { status: 'success', stale: false },
|
||||
channels: apiChannels
|
||||
}, [{ id: 1, type: 'm3u', name: 'Fixture Provider France' }]);
|
||||
const warning = channelListWarning('hybrid', { modelPresent: true, runtimeAvailable: true }, true);
|
||||
const logical = grouping.groupChannels(mapped.channels, {
|
||||
languageMode: warning ? 'algo' : 'hybrid',
|
||||
preferredLanguage: 'fr',
|
||||
isFavorite: variant => mapped.favorites.has(`${variant.sourceId}:${variant.id}`),
|
||||
isHidden: variant => mapped.hiddenItems.has(`channel:${variant.sourceId}:${variant.streamId || variant.id}`)
|
||||
});
|
||||
const frontendSportsOne = logical.find(channel => channel.fingerprint === 'sports one');
|
||||
|
||||
assert.equal(
|
||||
warning,
|
||||
'Local AI language detection failed. Channels were grouped with Standard matching and remain available.'
|
||||
);
|
||||
assert.ok(frontendSportsOne);
|
||||
assert.equal(frontendSportsOne.variants.length, 3);
|
||||
assert.deepEqual(frontendSportsOne.availableLanguages.map(language => language.code).sort(), ['en', 'fr']);
|
||||
assert.equal(frontendSportsOne.preferredVariant.id, 'fr-sd');
|
||||
assert.equal(frontendSportsOne.preferredVariant.groupTitle, 'Sports France');
|
||||
assert.equal(frontendSportsOne.isFavorite, true);
|
||||
assert.equal(frontendSportsOne.isHidden, false);
|
||||
assert.equal(grouping.orderFlatChannels(logical).length, logical.length);
|
||||
});
|
||||
148
test/liveGroupingScoring.test.js
Normal file
148
test/liveGroupingScoring.test.js
Normal file
@ -0,0 +1,148 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
MODE_WEIGHTS,
|
||||
hardBlock,
|
||||
scoreCandidate
|
||||
} = require('../server/services/liveGroupingScoring');
|
||||
const {
|
||||
collectIdentityHints,
|
||||
collectOptionalIdentityHints,
|
||||
normalizeChannelName,
|
||||
projectCatalog
|
||||
} = require('../server/services/liveProjectionService');
|
||||
|
||||
const channel = (id, name, extra = {}) => ({
|
||||
id,
|
||||
source_id: 1,
|
||||
item_id: id,
|
||||
name,
|
||||
category_id: 'sports',
|
||||
fingerprint: normalizeChannelName(name),
|
||||
...extra
|
||||
});
|
||||
|
||||
test('weights match the validated architecture and remain bounded', () => {
|
||||
assert.deepEqual(MODE_WEIGHTS.algo, { algorithm: 80, structure: 15, metadata: 5, laya: 0 });
|
||||
assert.deepEqual(MODE_WEIGHTS.hybrid, { algorithm: 70, structure: 15, metadata: 5, laya: 10 });
|
||||
assert.deepEqual(MODE_WEIGHTS.laya, { algorithm: 55, structure: 15, metadata: 5, laya: 25 });
|
||||
});
|
||||
|
||||
test('exact algorithm match merges without Laya', () => {
|
||||
const left = channel('a', 'beIN Sports 1 HD');
|
||||
const right = channel('b', 'beIN Sports 1 4K');
|
||||
assert.equal(scoreCandidate(left, right, { mode: 'algo' }).matched, true);
|
||||
});
|
||||
|
||||
test('exact algorithm match survives a contradictory Laya opinion', () => {
|
||||
const left = channel('a', 'beIN Sports 1 HD');
|
||||
const right = channel('b', 'beIN Sports 1 4K');
|
||||
const score = scoreCandidate(left, right, {
|
||||
mode: 'hybrid',
|
||||
layaHint: { sameIdentityProbability: 0, confidence: 1, number: '2' }
|
||||
});
|
||||
assert.equal(score.matched, true);
|
||||
assert.equal(score.signals.laya, 0);
|
||||
});
|
||||
|
||||
test('exact generic fingerprints merge in Laya-only mode without family metadata', () => {
|
||||
const left = channel('a', '1000 x Hora TV', { category_id: 'one' });
|
||||
const right = channel('b', '|UY| 1000 x Hora TV', { category_id: 'two' });
|
||||
const score = scoreCandidate(left, right, { mode: 'laya' });
|
||||
assert.equal(score.total, 55);
|
||||
assert.equal(score.matched, true);
|
||||
const groups = projectCatalog([left, right], { mode: 'laya' });
|
||||
assert.equal(groups.length, 1);
|
||||
});
|
||||
|
||||
test('different channel numbers block even when Laya proposes a merge', () => {
|
||||
const left = channel('a', 'beIN Sports 1');
|
||||
const right = channel('b', 'beIN Sports 2');
|
||||
const score = scoreCandidate(left, right, {
|
||||
mode: 'laya', layaHint: { sameIdentityProbability: 1, confidence: 1 }
|
||||
});
|
||||
assert.equal(score.matched, false);
|
||||
assert.equal(score.blocker, 'number_mismatch');
|
||||
});
|
||||
|
||||
test('protected max token blocks beIN Sports Max 10 from beIN Sports 1', () => {
|
||||
assert.equal(hardBlock(channel('a', 'beIN Sports Max 10'), channel('b', 'beIN Sports 1')), 'protected_token_mismatch');
|
||||
assert.equal(projectCatalog([
|
||||
channel('a', 'beIN Sports Max 10'), channel('b', 'beIN Sports 1')
|
||||
], { mode: 'laya' }).length, 2);
|
||||
});
|
||||
|
||||
test('different strong families are blocked before weighted scoring', () => {
|
||||
assert.equal(hardBlock(channel('a', 'beIN Sports 1'), channel('b', 'Sky Sports 1')), 'strong_family_mismatch');
|
||||
});
|
||||
|
||||
test('low-confidence Laya hint contributes zero', () => {
|
||||
const score = scoreCandidate(
|
||||
channel('a', 'beIN Sports 1 Event'),
|
||||
channel('b', 'beIN Sports 1'),
|
||||
{ mode: 'hybrid', layaHint: { sameIdentityProbability: 1, confidence: 0.64 } }
|
||||
);
|
||||
assert.equal(score.signals.laya, 0);
|
||||
assert.equal(score.matched, false);
|
||||
});
|
||||
|
||||
test('No Laya mode never calls the identity client', async () => {
|
||||
let calls = 0;
|
||||
const hints = await collectIdentityHints([
|
||||
channel('a', 'beIN Sports 1 Event'), channel('b', 'beIN Sports 1')
|
||||
], 'algo', { identityHintBatch: async () => { calls++; return []; } });
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(hints.size, 0);
|
||||
});
|
||||
|
||||
test('Laya only calls Laya but still respects blockers', async () => {
|
||||
let calls = 0;
|
||||
const items = [channel('a', 'beIN Sports 1 Event'), channel('b', 'beIN Sports 1')];
|
||||
const hints = await collectIdentityHints(items, 'laya', {
|
||||
identityHintBatch: async pairs => {
|
||||
calls++;
|
||||
return pairs.map(pair => ({ id: pair.id, sameIdentityProbability: 1, confidence: 1 }));
|
||||
}
|
||||
});
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(projectCatalog(items, { mode: 'laya', identityHints: hints }).length, 1);
|
||||
assert.equal(projectCatalog([
|
||||
channel('1', 'beIN Sports 1'), channel('2', 'beIN Sports 2')
|
||||
], { mode: 'laya', identityHints: hints }).length, 2);
|
||||
});
|
||||
|
||||
test('hybrid mode caps the positive Laya contribution at weight 10', () => {
|
||||
const score = scoreCandidate(
|
||||
channel('a', 'beIN Sports 1 Event'),
|
||||
channel('b', 'beIN Sports 1'),
|
||||
{ mode: 'hybrid', layaHint: { sameIdentityProbability: 1, confidence: 5 } }
|
||||
);
|
||||
assert.equal(score.signals.laya, 1);
|
||||
assert.equal(score.total, 72);
|
||||
assert.equal(score.matched, true);
|
||||
});
|
||||
|
||||
test('Laya bridge followed by an exact alias stays in one unique fingerprint group', () => {
|
||||
const items = [
|
||||
channel('a', 'beIN Sports 1 Event'),
|
||||
channel('b', 'beIN Sports 1'),
|
||||
channel('c', 'beIN Sports 1')
|
||||
];
|
||||
const hints = new Map([[
|
||||
['a', 'b'].sort().join('\0'),
|
||||
{ sameIdentityProbability: 1, confidence: 1 }
|
||||
]]);
|
||||
const groups = projectCatalog(items, { mode: 'hybrid', identityHints: hints });
|
||||
assert.equal(groups.length, 1);
|
||||
assert.equal(groups[0].fingerprint, 'bein sports 1');
|
||||
assert.equal(groups[0].variants.length, 3);
|
||||
});
|
||||
|
||||
test('optional Laya failure degrades to an empty signal set', async () => {
|
||||
const hints = await collectOptionalIdentityHints([
|
||||
channel('a', 'beIN Sports 1 Event'), channel('b', 'beIN Sports 1')
|
||||
], 'hybrid', {
|
||||
identityHintBatch: async () => { throw new Error('runtime unavailable at https://private.invalid'); }
|
||||
});
|
||||
assert.equal(hints.size, 0);
|
||||
});
|
||||
60
test/liveProjectionRoutes.test.js
Normal file
60
test/liveProjectionRoutes.test.js
Normal file
@ -0,0 +1,60 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { isAdmin } = require('../server/authz');
|
||||
const { serializeState } = require('../server/routes/live');
|
||||
|
||||
const routeSource = fs.readFileSync(path.join(__dirname, '..', 'server', 'routes', 'live.js'), 'utf8');
|
||||
|
||||
test('projection status route requires authentication', () => {
|
||||
assert.match(routeSource, /router\.get\('\/projection\/status', requireAuth,/);
|
||||
});
|
||||
|
||||
test('projection rebuild route requires authentication and admin authorization', () => {
|
||||
assert.match(routeSource, /router\.post\('\/projection\/rebuild', requireAuth, requireAdmin,/);
|
||||
});
|
||||
|
||||
test('projection authorization distinguishes viewer and admin roles', () => {
|
||||
assert.equal(isAdmin({ role: 'viewer' }), false);
|
||||
assert.equal(isAdmin({ role: 'admin' }), true);
|
||||
assert.equal(isAdmin(null), false);
|
||||
});
|
||||
|
||||
test('projection success with live input but zero projected channels is exposed as stale', () => {
|
||||
const state = serializeState({
|
||||
status: 'success',
|
||||
started_at: 100,
|
||||
completed_at: 200,
|
||||
input_hash: 'hash',
|
||||
projected_count: 0,
|
||||
processed_count: 3,
|
||||
total_count: 3,
|
||||
error: null
|
||||
});
|
||||
assert.equal(state.stale, true);
|
||||
assert.equal(state.projectedCount, 0);
|
||||
assert.equal(state.total, 3);
|
||||
});
|
||||
|
||||
test('projection status exposes phase-specific Laya progress separately from catalog progress', () => {
|
||||
const state = serializeState({
|
||||
status: 'building',
|
||||
phase: 'laya_identity',
|
||||
started_at: 100,
|
||||
completed_at: null,
|
||||
input_hash: null,
|
||||
projected_count: 0,
|
||||
processed_count: 0,
|
||||
total_count: 12,
|
||||
laya_processed_count: 1,
|
||||
laya_total_count: 2,
|
||||
error: null
|
||||
});
|
||||
|
||||
assert.equal(state.phase, 'laya_identity');
|
||||
assert.equal(state.processed, 0);
|
||||
assert.equal(state.total, 12);
|
||||
assert.equal(state.layaProcessed, 1);
|
||||
assert.equal(state.layaTotal, 2);
|
||||
});
|
||||
325
test/liveProjectionService.test.js
Normal file
325
test/liveProjectionService.test.js
Normal file
@ -0,0 +1,325 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Database = require('better-sqlite3');
|
||||
const {
|
||||
normalizeChannelName,
|
||||
getChannelPage,
|
||||
projectCatalog,
|
||||
markProjectionBuilding,
|
||||
projectionState,
|
||||
rebuildWithSignals,
|
||||
serializeChannels,
|
||||
startRebuild
|
||||
} = require('../server/services/liveProjectionService');
|
||||
const {
|
||||
LIVE_PROJECTION_COLUMNS,
|
||||
LIVE_PROJECTION_SCHEMA_SQL
|
||||
} = require('../server/db/liveProjectionSchema');
|
||||
|
||||
const item = (id, name, extra = {}) => ({
|
||||
id: `1:${id}`,
|
||||
source_id: 1,
|
||||
item_id: String(id),
|
||||
name,
|
||||
category_id: 'sports',
|
||||
...extra
|
||||
});
|
||||
|
||||
function createProjectionDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE playlist_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category_id TEXT,
|
||||
parent_id TEXT,
|
||||
stream_icon TEXT,
|
||||
stream_url TEXT,
|
||||
container_extension TEXT,
|
||||
rating REAL,
|
||||
year TEXT,
|
||||
added_at TEXT,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
data JSON
|
||||
);
|
||||
CREATE TABLE favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
const { ensureColumns } = require('../server/db/liveProjectionSchema');
|
||||
ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS);
|
||||
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||
return db;
|
||||
}
|
||||
|
||||
function seedProjectionChannel(db, itemId, name) {
|
||||
db.prepare(`
|
||||
INSERT INTO playlist_items
|
||||
(id, source_id, item_id, type, name, category_id, stream_icon, stream_url, data)
|
||||
VALUES (?, 1, ?, 'live', ?, 'sports', '', ?, '{}')
|
||||
`).run(`1:${itemId}`, itemId, name, `https://streams.test/${itemId}.m3u8`);
|
||||
}
|
||||
|
||||
function waitFor(condition, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const started = Date.now();
|
||||
const poll = () => {
|
||||
if (condition()) return resolve();
|
||||
if (Date.now() - started > 1000) return reject(new Error(message));
|
||||
setTimeout(poll, 5);
|
||||
};
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
test('projection migration declares grouping and language columns', () => {
|
||||
assert.deepEqual(Object.keys(LIVE_PROJECTION_COLUMNS), [
|
||||
'logical_channel_id', 'grouping_fingerprint', 'grouping_input_hash',
|
||||
'quality_label', 'quality_rank', 'variant_rank', 'language_code',
|
||||
'language_confidence', 'language_scores', 'language_evidence',
|
||||
'language_detector', 'language_input_hash'
|
||||
]);
|
||||
assert.match(LIVE_PROJECTION_SCHEMA_SQL, /CREATE TABLE IF NOT EXISTS logical_channels/);
|
||||
assert.match(LIVE_PROJECTION_SCHEMA_SQL, /CREATE TABLE IF NOT EXISTS catalog_projection_state/);
|
||||
assert.match(LIVE_PROJECTION_SCHEMA_SQL, /idx_items_logical_channel/);
|
||||
});
|
||||
|
||||
test('manual rebuild scheduling coalesces concurrent requests', async () => {
|
||||
let scheduled;
|
||||
let runs = 0;
|
||||
const state = { status: 'building', processed_count: 0, total_count: 2 };
|
||||
const options = {
|
||||
mark: () => state,
|
||||
runner: async () => { runs++; return { status: 'success' }; },
|
||||
schedule: callback => { scheduled = callback; }
|
||||
};
|
||||
const first = startRebuild({}, options);
|
||||
const second = startRebuild({}, options);
|
||||
assert.equal(first.alreadyRunning, false);
|
||||
assert.equal(second.alreadyRunning, true);
|
||||
assert.equal(second.promise, first.promise);
|
||||
scheduled();
|
||||
await first.promise;
|
||||
assert.equal(runs, 1);
|
||||
});
|
||||
|
||||
test('Laya rebuild progress advances after a successful identity batch while analysis is still running', async () => {
|
||||
const db = createProjectionDb();
|
||||
for (let index = 0; index < 12; index++) {
|
||||
seedProjectionChannel(db, `bein-${index}`, `beIN Sports 1 regional ${index}`);
|
||||
}
|
||||
|
||||
let calls = 0;
|
||||
let secondBatchStarted = false;
|
||||
let releaseSecondBatch;
|
||||
const secondBatchGate = new Promise(resolve => { releaseSecondBatch = resolve; });
|
||||
const layaClient = {
|
||||
identityHintBatch: async pairs => {
|
||||
calls += 1;
|
||||
if (calls === 2) {
|
||||
secondBatchStarted = true;
|
||||
await secondBatchGate;
|
||||
}
|
||||
return pairs.map(pair => ({
|
||||
id: pair.id,
|
||||
sameIdentityProbability: 0.9,
|
||||
confidence: 0.9,
|
||||
family: 'bein sports',
|
||||
number: '1'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
markProjectionBuilding(db);
|
||||
const rebuild = rebuildWithSignals(db, {
|
||||
mode: 'laya',
|
||||
layaClient,
|
||||
stateAlreadyBuilding: true
|
||||
});
|
||||
|
||||
try {
|
||||
await waitFor(() => secondBatchStarted, 'Expected the second Laya identity batch to start');
|
||||
const state = projectionState(db);
|
||||
assert.equal(state.status, 'building');
|
||||
assert.equal(state.phase, 'laya_identity');
|
||||
assert.equal(state.total_count, 12);
|
||||
assert.equal(state.processed_count, 0);
|
||||
assert.equal(state.laya_processed_count, 1);
|
||||
assert.equal(state.laya_total_count, 2);
|
||||
} finally {
|
||||
releaseSecondBatch();
|
||||
await rebuild;
|
||||
}
|
||||
});
|
||||
|
||||
test('projects beIN quality variants into one logical channel', () => {
|
||||
const groups = projectCatalog([
|
||||
item('uhd', '|FR| BEIN SPORTS 1 UHD'),
|
||||
item('4k', '|FR| BEIN SPORTS 1 4K'),
|
||||
item('car', 'FR-CAR| bein sports 1')
|
||||
]);
|
||||
assert.equal(groups.length, 1);
|
||||
assert.equal(groups[0].fingerprint, 'bein sports 1');
|
||||
assert.equal(groups[0].displayName, 'beIN Sports 1');
|
||||
assert.equal(groups[0].variants.length, 3);
|
||||
assert.deepEqual(groups[0].variants.map(variant => variant.variantRank), [0, 1, 2]);
|
||||
});
|
||||
|
||||
test('normalizes the captured beIN Sports 1 aliases to one exact fingerprint', () => {
|
||||
const aliases = [
|
||||
'C Car beIN Sports 1',
|
||||
'C Reu beIN Sports 1',
|
||||
'Thai beIN Sports 1 Thai',
|
||||
'beIN Sports 1',
|
||||
'beIN Sports 1 12h',
|
||||
'beIN Sports 1 2h',
|
||||
'beIN Sports 1 4h',
|
||||
'beIN Sports 1 8h',
|
||||
'beIN Sports 1 Appel',
|
||||
'beIN Sports 1 En',
|
||||
'beIN Sports 1 Fr',
|
||||
'beIN Sports 1 Tod'
|
||||
];
|
||||
assert.deepEqual(aliases.map(normalizeChannelName), Array(aliases.length).fill('bein sports 1'));
|
||||
const groups = projectCatalog(aliases.map((name, index) => item(index, name)));
|
||||
assert.equal(groups.length, 1);
|
||||
assert.equal(groups[0].fingerprint, 'bein sports 1');
|
||||
assert.equal(groups[0].variants.length, aliases.length);
|
||||
});
|
||||
|
||||
test('does not merge distinct numbered beIN channels', () => {
|
||||
const groups = projectCatalog([
|
||||
item('1', 'beIN Sports 1 HD'),
|
||||
item('2', 'beIN Sports 2 HD')
|
||||
]);
|
||||
assert.deepEqual(groups.map(group => group.fingerprint), ['bein sports 1', 'bein sports 2']);
|
||||
assert.equal(normalizeChannelName('beIN Sports Max 10'), 'bein sports max 10');
|
||||
});
|
||||
|
||||
test('keeps unknown beIN suffixes distinct instead of applying fuzzy matching', () => {
|
||||
assert.equal(normalizeChannelName('Partner beIN Sports 1 Event'), 'bein sports 1 event');
|
||||
assert.notEqual(normalizeChannelName('Partner beIN Sports 1 Event'), normalizeChannelName('beIN Sports 1'));
|
||||
});
|
||||
|
||||
test('renaming a stream changes its projection membership and input hash', () => {
|
||||
const before = projectCatalog([item('1', 'beIN Sports 1 HD'), item('2', 'beIN Sports 1 4K')]);
|
||||
const after = projectCatalog([item('1', 'beIN Sports 2 HD'), item('2', 'beIN Sports 1 4K')]);
|
||||
assert.equal(before.length, 1);
|
||||
assert.equal(after.length, 2);
|
||||
const beforeRename = before.flatMap(group => group.variants).find(variant => variant.id === '1:1');
|
||||
const afterRename = after.flatMap(group => group.variants).find(variant => variant.id === '1:1');
|
||||
assert.notEqual(beforeRename.groupingInputHash, afterRename.groupingInputHash);
|
||||
});
|
||||
|
||||
test('API serialization filters source rows upstream and hidden variants by option', () => {
|
||||
const rows = [
|
||||
{
|
||||
...item('1', 'beIN Sports 1 HD'), logical_channel_id: 'logical:1',
|
||||
grouping_fingerprint: 'bein sports 1', display_name: 'beIN Sports 1',
|
||||
preferred_variant_id: '1:1', quality_label: 'HD', quality_rank: 2,
|
||||
variant_rank: 0, is_hidden: 0, category_hidden: 0, category_name: 'Sports Premium',
|
||||
user_favorite: 1, data: '{}'
|
||||
},
|
||||
{
|
||||
...item('2', 'beIN Sports 1 4K'), logical_channel_id: 'logical:1',
|
||||
grouping_fingerprint: 'bein sports 1', display_name: 'beIN Sports 1',
|
||||
preferred_variant_id: '1:1', quality_label: '4K', quality_rank: 4,
|
||||
variant_rank: 1, is_hidden: 1, category_hidden: 0, category_name: 'Sports Premium',
|
||||
user_favorite: 0, data: '{}'
|
||||
}
|
||||
];
|
||||
const visible = serializeChannels(rows, false);
|
||||
assert.equal(visible[0].variants.length, 1);
|
||||
assert.equal(visible[0].isFavorite, true);
|
||||
assert.equal(visible[0].groupTitle, 'Sports Premium');
|
||||
assert.equal(visible[0].categoryName, 'Sports Premium');
|
||||
assert.equal(visible[0].variants[0].groupTitle, 'Sports Premium');
|
||||
assert.equal(serializeChannels(rows, true)[0].variants.length, 2);
|
||||
});
|
||||
|
||||
test('API serialization preserves an M3U groupTitle over the stored category fallback', () => {
|
||||
const rows = [{
|
||||
...item('m3u', 'M3U Sports HD'), logical_channel_id: 'logical:m3u',
|
||||
grouping_fingerprint: 'm3u sports', display_name: 'M3U Sports',
|
||||
preferred_variant_id: '1:m3u', quality_label: 'HD', quality_rank: 2,
|
||||
variant_rank: 0, is_hidden: 0, category_hidden: 0,
|
||||
category_name: 'Fallback category', user_favorite: 0,
|
||||
data: JSON.stringify({ groupTitle: 'M3U Sports France' })
|
||||
}];
|
||||
const [channel] = serializeChannels(rows, false);
|
||||
assert.equal(channel.groupTitle, 'M3U Sports France');
|
||||
assert.equal(channel.variants[0].categoryName, 'M3U Sports France');
|
||||
});
|
||||
|
||||
test('search and language filters run before expensive standard and EPG enrichment', () => {
|
||||
const db = createProjectionDb();
|
||||
seedProjectionChannel(db, 'bein-fr', '|FR| beIN Sports 1 HD');
|
||||
seedProjectionChannel(db, 'bein-en', '|UK| beIN Sports 1 4K');
|
||||
seedProjectionChannel(db, 'euro-fr', '|FR| Eurosport HD');
|
||||
require('../server/services/liveProjectionService').rebuild(db);
|
||||
|
||||
let standardCalls = 0;
|
||||
let epgCalls = 0;
|
||||
const page = getChannelPage({ q: 'Bein Sport 1', languages: ['fr'], epgSources: [] }, db, {
|
||||
iptvOrgCatalog: { findMatch: () => { standardCalls += 1; return null; } },
|
||||
epgMatching: { findBestMatch: () => { epgCalls += 1; return null; } }
|
||||
});
|
||||
|
||||
assert.equal(page.channels.length, 1);
|
||||
assert.equal(page.channels[0].language.code, 'fr');
|
||||
assert.deepEqual(page.availableLanguages.map(language => language.code).sort(), ['en', 'fr']);
|
||||
assert.equal(standardCalls, 1);
|
||||
assert.equal(epgCalls, 1);
|
||||
});
|
||||
|
||||
test('full catalog listing skips synchronous per-channel enrichment', () => {
|
||||
const db = createProjectionDb();
|
||||
for (let index = 0; index < 500; index++) {
|
||||
seedProjectionChannel(db, `channel-${index}`, `Channel ${index} HD`);
|
||||
}
|
||||
require('../server/services/liveProjectionService').rebuild(db);
|
||||
|
||||
let standardCalls = 0;
|
||||
let epgCalls = 0;
|
||||
const started = Date.now();
|
||||
const page = getChannelPage({ languages: [], q: '', epgSources: [{ id: 99, type: 'epg', enabled: true }] }, db, {
|
||||
iptvOrgCatalog: { findMatch: () => { standardCalls += 1; return null; } },
|
||||
epgMatching: { findBestMatch: () => { epgCalls += 1; return null; } }
|
||||
});
|
||||
|
||||
assert.equal(page.channels.length, 500);
|
||||
assert.equal(standardCalls, 0);
|
||||
assert.equal(epgCalls, 0);
|
||||
assert.ok(Date.now() - started < 1000);
|
||||
});
|
||||
|
||||
test('broad searches above the enrichment guard remain bounded', () => {
|
||||
const db = createProjectionDb();
|
||||
for (let index = 0; index < 300; index++) seedProjectionChannel(db, `news-${index}`, `News ${index}`);
|
||||
require('../server/services/liveProjectionService').rebuild(db);
|
||||
let calls = 0;
|
||||
const page = getChannelPage({ q: 'news', epgSources: [] }, db, {
|
||||
iptvOrgCatalog: { findMatch: () => { calls += 1; return null; } },
|
||||
epgMatching: { findBestMatch: () => { calls += 1; return null; } }
|
||||
});
|
||||
assert.equal(page.channels.length, 300);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
11
test/settingsDefaults.test.js
Normal file
11
test/settingsDefaults.test.js
Normal file
@ -0,0 +1,11 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { getDefaultSettings } = require('../server/db');
|
||||
|
||||
test('defaults Live TV channel lists to grouped mode', () => {
|
||||
assert.equal(getDefaultSettings().liveChannelListMode, 'grouped');
|
||||
});
|
||||
|
||||
test('defaults language detection to the safe algorithmic mode', () => {
|
||||
assert.equal(getDefaultSettings().languageDetectionMode, 'algo');
|
||||
});
|
||||
42
test/t23Backend.test.js
Normal file
42
test/t23Backend.test.js
Normal file
@ -0,0 +1,42 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Database = require('better-sqlite3');
|
||||
const { detectLanguage } = require('../server/services/channelLanguage');
|
||||
const { matchesSearch, normalizeSearch } = require('../server/services/flexibleChannelSearch');
|
||||
const { ensureBuiltinSources } = require('../server/services/builtinEpgRegistry');
|
||||
const iptvOrg = require('../server/services/iptvOrgCatalogService');
|
||||
const { LIVE_PROJECTION_SCHEMA_SQL } = require('../server/db/liveProjectionSchema');
|
||||
|
||||
test('flexible search ignores accents, punctuation, case and simple plurals', () => {
|
||||
const channel = { displayName: 'beIN Sports 1', variants: [{ name: 'BéIN SPORTS 1 HD' }] };
|
||||
for (const query of ['Bein Sport 1', 'bein sports 1', 'beIN Sport 1']) assert.equal(matchesSearch(channel, query), true);
|
||||
assert.deepEqual(normalizeSearch('Chaînes-Sports'), ['chaine', 'sport']);
|
||||
});
|
||||
|
||||
test('backend language detection uses und and separates explicit languages', () => {
|
||||
assert.equal(detectLanguage({ name: 'Generic Sports' }).code, 'und');
|
||||
assert.equal(detectLanguage({ name: '|FR| Sports One' }).code, 'fr');
|
||||
assert.equal(detectLanguage({ name: '[UK] Sports One' }).code, 'en');
|
||||
});
|
||||
|
||||
test('built-in EPG registry is idempotent and protected while remaining disabled by default', () => {
|
||||
const data = ensureBuiltinSources({ sources: [], nextId: 1 });
|
||||
ensureBuiltinSources(data);
|
||||
assert.equal(data.sources.length, 2);
|
||||
assert.ok(data.sources.every(source => source.type === 'epg' && source.readonly && !source.removable && !source.enabled));
|
||||
});
|
||||
|
||||
test('iptv-org adapter refreshes a persistent cache and matches aliases by language', async () => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE playlist_items (id TEXT PRIMARY KEY, logical_channel_id TEXT, grouping_fingerprint TEXT);`);
|
||||
db.exec(LIVE_PROJECTION_SCHEMA_SQL);
|
||||
const payloads = {
|
||||
'channels.json': [{ id: 'BeInSports1.fr', name: 'beIN Sports 1', alt_names: ['Bein Sport 1'], country: 'FR' }],
|
||||
'feeds.json': [{ channel: 'BeInSports1.fr', languages: ['fr'] }]
|
||||
};
|
||||
await iptvOrg.refresh(db, async url => ({ ok: true, json: async () => payloads[url.split('/').pop()] }));
|
||||
const match = iptvOrg.findMatch(db, { displayName: 'Bein Sport 1', language: { code: 'fr' }, variants: [] });
|
||||
assert.equal(match.channelId, 'BeInSports1.fr');
|
||||
assert.equal(match.name, 'beIN Sports 1');
|
||||
assert.equal(db.prepare("SELECT item_count FROM iptv_org_cache_state WHERE resource = 'catalog'").get().item_count, 1);
|
||||
});
|
||||
71
test/t23Frontend.test.js
Normal file
71
test/t23Frontend.test.js
Normal file
@ -0,0 +1,71 @@
|
||||
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, '..');
|
||||
const projection = require('../public/js/services/liveChannelProjection.js');
|
||||
|
||||
function script(relativePath) {
|
||||
return fs.readFileSync(path.join(root, 'public', relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('projection adapter preserves backend channel-language views and their variants', () => {
|
||||
const mapped = projection.mapResponse({
|
||||
projection: { stale: false },
|
||||
filters: { availableLanguages: [{ code: 'fr', short: 'FR', label: 'French' }] },
|
||||
channels: [{
|
||||
id: 'logical-1:fr', logicalChannelId: 'logical-1', displayName: 'beIN Sports 1',
|
||||
language: { code: 'fr', short: 'FR', label: 'French', confidence: 95 },
|
||||
selectedVariantId: '7:hd', epg: { channelId: 'BeInSports1.fr', language: 'fr' },
|
||||
variants: [
|
||||
{ id: 'sd', sourceId: 7, name: 'beIN 1 SD', categoryName: 'Sports', isHidden: false },
|
||||
{ id: 'hd', sourceId: 7, name: 'beIN 1 HD', categoryName: 'Sports', isHidden: false }
|
||||
]
|
||||
}]
|
||||
}, [{ id: 7, type: 'm3u', name: 'Provider' }]);
|
||||
|
||||
assert.equal(mapped.logicalChannels.length, 1);
|
||||
assert.equal(mapped.logicalChannels[0].fingerprint, 'logical-1:fr');
|
||||
assert.equal(mapped.logicalChannels[0].language.code, 'fr');
|
||||
assert.equal(mapped.logicalChannels[0].preferredVariant.id, 'hd');
|
||||
assert.equal(mapped.logicalChannels[0].variants.length, 2);
|
||||
assert.equal(mapped.logicalChannels[0].tvgId, 'BeInSports1.fr');
|
||||
assert.deepEqual(mapped.availableLanguages.map(language => language.code), ['fr']);
|
||||
});
|
||||
|
||||
test('live API sends flexible search and selected language filters', async () => {
|
||||
const dom = new JSDOM('', { url: 'http://nodecast.test/', runScripts: 'outside-only' });
|
||||
let requestedUrl = '';
|
||||
dom.window.fetch = async url => {
|
||||
requestedUrl = url;
|
||||
return { ok: true, headers: { get: () => 'application/json' }, json: async () => ({ channels: [] }) };
|
||||
};
|
||||
dom.window.eval(script('js/api.js'));
|
||||
await dom.window.API.live.getChannels({ q: 'Bein Sport 1', languages: ['fr', 'en'] });
|
||||
const params = new URL(requestedUrl, 'http://nodecast.test').searchParams;
|
||||
assert.equal(params.get('q'), 'Bein Sport 1');
|
||||
assert.equal(params.get('languages'), 'fr,en');
|
||||
});
|
||||
|
||||
test('built-in EPG sources expose only refresh and enable controls', () => {
|
||||
const dom = new JSDOM('<div id="builtin-epg-list"></div>', { runScripts: 'outside-only' });
|
||||
dom.window.Icons = { series: '<i></i>', refresh: '<i></i>' };
|
||||
dom.window.API = { sources: {} };
|
||||
dom.window.eval(`${script('js/components/SourceManager.js')}\nwindow.SourceManager = SourceManager;`);
|
||||
const manager = Object.create(dom.window.SourceManager.prototype);
|
||||
manager.builtinEpgList = dom.window.document.getElementById('builtin-epg-list');
|
||||
manager.refreshSource = () => {};
|
||||
manager.toggleSource = () => {};
|
||||
manager.renderBuiltinEpgSources([{
|
||||
id: 4, name: 'EPG.PW France', builtinKey: 'epgpw-fr', enabled: false,
|
||||
language: 'fr', country: 'FR', readonly: true, removable: false
|
||||
}]);
|
||||
|
||||
assert.match(manager.builtinEpgList.textContent, /Built-in/);
|
||||
assert.match(manager.builtinEpgList.textContent, /Free EPG data/);
|
||||
assert.equal(manager.builtinEpgList.querySelector('[data-action="delete"]'), null);
|
||||
assert.equal(manager.builtinEpgList.querySelector('[data-action="edit"]'), null);
|
||||
assert.equal(manager.builtinEpgList.querySelector('[data-action="toggle"]').getAttribute('aria-pressed'), 'false');
|
||||
});
|
||||
88
test/t24EpgSourcePriority.test.js
Normal file
88
test/t24EpgSourcePriority.test.js
Normal file
@ -0,0 +1,88 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Database = require('better-sqlite3');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {
|
||||
applyEpgOrder,
|
||||
migrateEpgPriorities,
|
||||
nextEpgPriority,
|
||||
sortEpgSources,
|
||||
validateCompleteOrder
|
||||
} = require('../server/services/epgSourceOrdering');
|
||||
const { chooseBestMatch, findBestMatch } = require('../server/services/epgMatchingService');
|
||||
const { ensureBuiltinSources } = require('../server/services/builtinEpgRegistry');
|
||||
|
||||
const source = (id, type = 'epg', extra = {}) => ({ id, type, enabled: true, ...extra });
|
||||
|
||||
test('priority migration is idempotent and appends built-ins after existing custom EPG sources', () => {
|
||||
const data = { sources: [source(1, 'epg', { name: 'Custom' })], nextId: 2 };
|
||||
ensureBuiltinSources(data);
|
||||
const first = data.sources.map(item => [item.id, item.epgPriority]);
|
||||
ensureBuiltinSources(data);
|
||||
assert.deepEqual(data.sources.map(item => [item.id, item.epgPriority]), first);
|
||||
assert.equal(data.sources[0].epgPriority, 0);
|
||||
assert.ok(data.sources.filter(item => item.builtinKey).every(item => item.epgPriority > 0));
|
||||
assert.equal(nextEpgPriority(data.sources), 30);
|
||||
migrateEpgPriorities(data);
|
||||
});
|
||||
|
||||
test('complete EPG order normalizes priorities for enabled and disabled built-in/custom sources', () => {
|
||||
const all = [source(1), source(2, 'm3u'), source(3, 'epg', { enabled: false, builtinKey: 'built-in' })];
|
||||
assert.equal(validateCompleteOrder(all, [3, 1]), null);
|
||||
const ordered = applyEpgOrder(all, [3, 1], 'now');
|
||||
assert.deepEqual(ordered.map(item => [item.id, item.epgPriority]), [[3, 0], [1, 10]]);
|
||||
assert.equal(all.find(item => item.id === 3).updated_at, 'now');
|
||||
});
|
||||
|
||||
test('EPG order validation rejects duplicates, unknown, non-EPG and incomplete lists', () => {
|
||||
const all = [source(1), source(2), source(3, 'm3u')];
|
||||
assert.equal(validateCompleteOrder(all, [1, 1]).code, 'DUPLICATE_SOURCE_ID');
|
||||
assert.equal(validateCompleteOrder(all, [1, 9]).code, 'UNKNOWN_SOURCE_ID');
|
||||
assert.equal(validateCompleteOrder(all, [1, 3]).code, 'SOURCE_NOT_EPG');
|
||||
assert.equal(validateCompleteOrder(all, [1]).code, 'INCOMPLETE_EPG_ORDER');
|
||||
});
|
||||
|
||||
test('enabled EPG sources are sorted ascending and disabled sources are excluded from reads', () => {
|
||||
const all = [source(1, 'epg', { epgPriority: 20 }), source(2, 'epg', { epgPriority: 0, enabled: false }), source(3, 'epg', { epgPriority: 10 })];
|
||||
assert.deepEqual(sortEpgSources(all).map(item => item.id), [2, 3, 1]);
|
||||
assert.deepEqual(sortEpgSources(all, { enabledOnly: true }).map(item => item.id), [3, 1]);
|
||||
});
|
||||
|
||||
test('match score wins before source priority and priority breaks equal scores', () => {
|
||||
const channel = { displayName: 'News France', variants: [{ tvgId: 'news.fr', name: 'News France' }] };
|
||||
const sources = [source(1, 'epg', { epgPriority: 0 }), source(2, 'epg', { epgPriority: 10 })];
|
||||
const strongLater = chooseBestMatch(channel, [
|
||||
{ sourceId: 1, id: 'other', name: 'News' },
|
||||
{ sourceId: 2, id: 'news.fr', name: 'Different' }
|
||||
], sources);
|
||||
assert.equal(strongLater.sourceId, 2);
|
||||
const tied = chooseBestMatch(channel, [
|
||||
{ sourceId: 2, id: 'news.fr', name: 'News France' },
|
||||
{ sourceId: 1, id: 'news.fr', name: 'News France' }
|
||||
], sources);
|
||||
assert.equal(tied.sourceId, 1);
|
||||
});
|
||||
|
||||
test('EPG matching ignores disabled sources and returns the current programme from the selected source', () => {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE playlist_items (source_id INTEGER, item_id TEXT, type TEXT, name TEXT);
|
||||
CREATE TABLE epg_programs (source_id INTEGER, channel_id TEXT, start_time INTEGER, end_time INTEGER, title TEXT, description TEXT);
|
||||
INSERT INTO playlist_items VALUES (1, 'news.fr', 'epg_channel', 'News France');
|
||||
INSERT INTO playlist_items VALUES (2, 'news.fr', 'epg_channel', 'News France');
|
||||
INSERT INTO epg_programs VALUES (2, 'news.fr', 900, 1100, 'Current news', 'Headlines');
|
||||
`);
|
||||
const match = findBestMatch(db, { displayName: 'News France', variants: [{ tvgId: 'news.fr' }] }, [
|
||||
source(1, 'epg', { epgPriority: 0, enabled: false }),
|
||||
source(2, 'epg', { epgPriority: 10 })
|
||||
], 1000);
|
||||
assert.equal(match.sourceId, 2);
|
||||
assert.equal(match.currentProgram.title, 'Current news');
|
||||
});
|
||||
|
||||
test('sources router exposes the complete-order endpoint', () => {
|
||||
const text = fs.readFileSync(path.join(__dirname, '..', 'server', 'routes', 'sources.js'), 'utf8');
|
||||
assert.match(text, /router\.put\('\/epg\/order'/);
|
||||
assert.match(text, /sources\.reorderEpg\(req\.body\?\.orderedIds\)/);
|
||||
});
|
||||
120
test/t24Frontend.test.js
Normal file
120
test/t24Frontend.test.js
Normal file
@ -0,0 +1,120 @@
|
||||
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, '..');
|
||||
const readScript = file => fs.readFileSync(path.join(root, 'public', file), 'utf8');
|
||||
|
||||
function createManager() {
|
||||
const dom = new JSDOM(`
|
||||
<div id="epg-order-status"></div>
|
||||
<div id="epg-active-list"></div>
|
||||
<div id="epg-inactive-list"></div>
|
||||
`, { url: 'http://nodecast.test/', runScripts: 'outside-only' });
|
||||
dom.window.Icons = {
|
||||
series: '<i>guide</i>', refresh: '<i>refresh</i>', settings: '<i>edit</i>', close: '<i>delete</i>'
|
||||
};
|
||||
dom.window.console.error = () => {};
|
||||
dom.window.API = { sources: {} };
|
||||
dom.window.eval(`${readScript('js/components/SourceManager.js')}\nwindow.SourceManager = SourceManager;`);
|
||||
const manager = Object.create(dom.window.SourceManager.prototype);
|
||||
manager.epgActiveList = dom.window.document.getElementById('epg-active-list');
|
||||
manager.epgInactiveList = dom.window.document.getElementById('epg-inactive-list');
|
||||
manager.epgOrderStatus = dom.window.document.getElementById('epg-order-status');
|
||||
manager.isSavingEpgOrder = false;
|
||||
manager.refreshSource = () => {};
|
||||
manager.toggleEpgSource = () => {};
|
||||
manager.showEditModal = () => {};
|
||||
manager.deleteSource = () => {};
|
||||
return { dom, manager };
|
||||
}
|
||||
|
||||
const sources = () => [
|
||||
{ id: 1, type: 'epg', name: 'Custom France', enabled: true, epgPriority: 0, language: 'fr', country: 'FR' },
|
||||
{ id: 2, type: 'epg', name: 'EPG.PW US', enabled: true, epgPriority: 10, builtinKey: 'epgpw-us', language: 'en', country: 'US' },
|
||||
{ id: 3, type: 'epg', name: 'Inactive custom', enabled: false, epgPriority: 20 }
|
||||
];
|
||||
|
||||
test('EPG priority view renders ranked active sources and protected inactive/custom actions', () => {
|
||||
const { manager } = createManager();
|
||||
manager.renderEpgSources(sources());
|
||||
|
||||
const activeRows = manager.epgActiveList.querySelectorAll('.epg-source-item');
|
||||
assert.equal(activeRows.length, 2);
|
||||
assert.match(activeRows[0].textContent, /Priority 1/);
|
||||
assert.match(activeRows[1].textContent, /Built-in/);
|
||||
assert.equal(activeRows[1].querySelector('[data-action="edit"]'), null);
|
||||
assert.equal(activeRows[1].querySelector('[data-action="delete"]'), null);
|
||||
assert.equal(activeRows[0].querySelector('[data-action="move-up"]').disabled, true);
|
||||
assert.equal(activeRows[0].querySelector('[data-action="move-down"]').getAttribute('aria-label'), 'Move Custom France down');
|
||||
|
||||
const inactive = manager.epgInactiveList.querySelector('.epg-source-item');
|
||||
assert.doesNotMatch(inactive.textContent, /Priority/);
|
||||
assert.ok(inactive.querySelector('[data-action="edit"]'));
|
||||
assert.ok(inactive.querySelector('[data-action="delete"]'));
|
||||
});
|
||||
|
||||
test('moving an active EPG source saves a complete order including inactive sources', async () => {
|
||||
const { dom, manager } = createManager();
|
||||
let orderedIds;
|
||||
dom.window.API.sources.updateEpgOrder = async ids => {
|
||||
orderedIds = [...ids];
|
||||
return [sources()[1], sources()[0], sources()[2]];
|
||||
};
|
||||
manager.renderEpgSources(sources());
|
||||
await manager.moveEpgSource(2, -1, 'move-up');
|
||||
|
||||
assert.deepEqual(orderedIds, [2, 1, 3]);
|
||||
assert.match(manager.epgActiveList.querySelector('.epg-source-item').textContent, /EPG.PW US/);
|
||||
assert.equal(manager.epgOrderStatus.textContent, 'EPG priority saved');
|
||||
});
|
||||
|
||||
test('failed EPG priority save rolls the visual order back and announces the error', async () => {
|
||||
const { dom, manager } = createManager();
|
||||
dom.window.API.sources.updateEpgOrder = async () => { throw new Error('network'); };
|
||||
manager.renderEpgSources(sources());
|
||||
await manager.moveEpgSource(2, -1, 'move-up');
|
||||
|
||||
assert.match(manager.epgActiveList.querySelector('.epg-source-item').textContent, /Custom France/);
|
||||
assert.equal(manager.epgOrderStatus.textContent, 'EPG priority couldn’t be saved. Try again.');
|
||||
assert.equal(manager.epgOrderStatus.dataset.state, 'error');
|
||||
});
|
||||
|
||||
test('enabling an EPG source appends it to the active priority list', async () => {
|
||||
const { dom, manager } = createManager();
|
||||
let orderedIds;
|
||||
dom.window.API.sources.toggle = async () => ({ enabled: true });
|
||||
dom.window.API.sources.getAll = async () => sources().map(source =>
|
||||
source.id === 3 ? { ...source, enabled: true } : source
|
||||
);
|
||||
dom.window.API.sources.updateEpgOrder = async ids => {
|
||||
orderedIds = [...ids];
|
||||
return sources().map(source => source.id === 3 ? { ...source, enabled: true } : source);
|
||||
};
|
||||
await dom.window.SourceManager.prototype.toggleEpgSource.call(manager, 3);
|
||||
assert.deepEqual(orderedIds, [1, 2, 3]);
|
||||
assert.match(manager.epgActiveList.lastElementChild.textContent, /Inactive custom/);
|
||||
});
|
||||
|
||||
test('empty active EPG state gives an actionable explanation', () => {
|
||||
const { manager } = createManager();
|
||||
manager.renderEpgSources(sources().map(source => ({ ...source, enabled: false })));
|
||||
assert.match(manager.epgActiveList.textContent, /No active EPG sources/);
|
||||
assert.match(manager.epgActiveList.textContent, /Enable a source to load program information/);
|
||||
});
|
||||
|
||||
test('API client sends the complete EPG order to the dedicated endpoint', async () => {
|
||||
const dom = new JSDOM('', { url: 'http://nodecast.test/', runScripts: 'outside-only' });
|
||||
let request;
|
||||
dom.window.fetch = async (url, options) => {
|
||||
request = { url, options };
|
||||
return { ok: true, headers: { get: () => 'application/json' }, json: async () => [] };
|
||||
};
|
||||
dom.window.eval(readScript('js/api.js'));
|
||||
await dom.window.API.sources.updateEpgOrder([2, 1, 3]);
|
||||
assert.equal(request.url, '/api/sources/epg/order');
|
||||
assert.equal(request.options.method, 'PUT');
|
||||
assert.deepEqual(JSON.parse(request.options.body), { orderedIds: [2, 1, 3] });
|
||||
});
|
||||
107
test/t25Frontend.test.js
Normal file
107
test/t25Frontend.test.js
Normal file
@ -0,0 +1,107 @@
|
||||
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, '..');
|
||||
const script = relativePath => fs.readFileSync(path.join(root, 'public', relativePath), 'utf8');
|
||||
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
|
||||
function createChannelList() {
|
||||
const dom = new JSDOM(`<!doctype html>
|
||||
<input id="channel-search">
|
||||
<select id="source-select"><option value="">All Sources</option></select>
|
||||
<input id="show-hidden" type="checkbox">
|
||||
<button id="toggle-groups"></button>
|
||||
<div id="channel-language-filter"></div>
|
||||
<div id="channel-list-status"></div>
|
||||
<div id="context-menu"></div>
|
||||
<div id="channel-list"></div>
|
||||
`, { url: 'http://nodecast.test/', runScripts: 'outside-only', pretendToBeVisual: true });
|
||||
const { window } = dom;
|
||||
window.IntersectionObserver = class { observe() {} disconnect() {} };
|
||||
window.eval(script('js/icons.js'));
|
||||
window.eval(script('js/services/channelLanguageDetection.js'));
|
||||
window.eval(script('js/services/channelLanguageFusion.js'));
|
||||
window.eval(script('js/services/channelVariantGrouping.js'));
|
||||
window.eval(script('js/services/liveChannelProjection.js'));
|
||||
window.eval(`${script('js/components/ChannelList.js')}\nwindow.ChannelList = ChannelList;`);
|
||||
window.app = {
|
||||
player: { settings: { liveChannelListMode: 'flat', languageDetectionMode: 'algo' } },
|
||||
epgGuide: { getCurrentProgram: () => null, loadError: null }
|
||||
};
|
||||
return { dom, window, channelList: new window.ChannelList() };
|
||||
}
|
||||
|
||||
function projectedResponse() {
|
||||
const channel = (id, name, language) => ({
|
||||
id: `${id}:${language}`, logicalChannelId: id, displayName: name,
|
||||
language: { code: language, short: language.toUpperCase(), label: language === 'fr' ? 'French' : 'English', confidence: 95 },
|
||||
selectedVariantId: `1:${id}-${language}`,
|
||||
variants: [{ id: `${id}-${language}`, sourceId: 1, name: `${name} ${language.toUpperCase()}`, categoryName: 'Sports', isHidden: false }]
|
||||
});
|
||||
return {
|
||||
projection: { stale: false },
|
||||
filters: { availableLanguages: [
|
||||
{ code: 'en', short: 'EN', label: 'English' },
|
||||
{ code: 'fr', short: 'FR', label: 'French' }
|
||||
] },
|
||||
channels: [
|
||||
channel('bein-1', 'beIN Sports 1', 'fr'),
|
||||
channel('bein-1', 'beIN Sports 1', 'en'),
|
||||
channel('news', 'World News', 'en')
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
test('typing filters a loaded projection locally with flexible matching and no API request', async () => {
|
||||
const { dom, window, channelList } = createChannelList();
|
||||
let calls = 0;
|
||||
window.API = { live: { getChannels: async options => {
|
||||
calls++;
|
||||
assert.equal(options.q, '');
|
||||
assert.deepEqual([...options.languages], []);
|
||||
return projectedResponse();
|
||||
} } };
|
||||
await channelList.loadProjectedChannels();
|
||||
channelList.render();
|
||||
calls = 0;
|
||||
|
||||
channelList.searchInput.value = 'Bein Sport 1';
|
||||
channelList.searchInput.dispatchEvent(new window.Event('input', { bubbles: true }));
|
||||
await wait(100);
|
||||
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(channelList.filteredChannels.length, 2);
|
||||
assert.ok(channelList.filteredChannels.every(channel => channel.displayName === 'beIN Sports 1'));
|
||||
assert.equal(channelList.listStatus.textContent, '2 channel entries shown');
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test('language selection filters the complete loaded catalog immediately without API', async () => {
|
||||
const { dom, window, channelList } = createChannelList();
|
||||
let calls = 0;
|
||||
window.API = { live: { getChannels: async () => { calls++; return projectedResponse(); } } };
|
||||
await channelList.loadProjectedChannels();
|
||||
channelList.render();
|
||||
calls = 0;
|
||||
|
||||
channelList.setLanguageSelection('fr');
|
||||
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(channelList.filteredChannels.length, 1);
|
||||
assert.equal(channelList.filteredChannels[0].language.code, 'fr');
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test('Escape clears channel search while preserving language-panel Escape behavior', async () => {
|
||||
const { dom, window, channelList } = createChannelList();
|
||||
window.API = { live: { getChannels: async () => projectedResponse() } };
|
||||
await channelList.loadProjectedChannels();
|
||||
channelList.searchInput.value = 'news';
|
||||
window.document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
assert.equal(channelList.searchInput.value, '');
|
||||
assert.equal(channelList.filteredChannels.length, 3);
|
||||
dom.window.close();
|
||||
});
|
||||
69
test/t26Frontend.test.js
Normal file
69
test/t26Frontend.test.js
Normal file
@ -0,0 +1,69 @@
|
||||
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, '..');
|
||||
const projection = require('../public/js/services/liveChannelProjection.js');
|
||||
const script = relativePath => fs.readFileSync(path.join(root, 'public', relativePath), 'utf8');
|
||||
|
||||
test('minimal non-enriched live projection payload maps and renders without an exception', () => {
|
||||
const mapped = projection.mapResponse({
|
||||
projection: { stale: false },
|
||||
filters: { availableLanguages: [null, {}] },
|
||||
channels: [
|
||||
{
|
||||
id: 'logical-news:und', logicalChannelId: 'logical-news', displayName: 'News',
|
||||
standard: null, language: {}, epg: null, preferredVariant: null,
|
||||
variants: [{
|
||||
id: 'news-raw', sourceId: 1, name: 'News Raw', tvgId: 'news.raw',
|
||||
categoryName: 'News', language: null, isHidden: false
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: 'logical-sports:und', logicalChannelId: 'logical-sports', displayName: 'Sports',
|
||||
language: null, variants: null,
|
||||
preferredVariant: {
|
||||
id: 'sports-raw', sourceId: 1, name: 'Sports Raw', tvgId: 'sports.raw',
|
||||
categoryName: 'Sports', isHidden: false
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'empty', logicalChannelId: 'empty', displayName: 'Empty',
|
||||
language: null, variants: null, preferredVariant: null
|
||||
}
|
||||
]
|
||||
}, [{ id: 1, type: 'm3u', name: 'Provider' }]);
|
||||
|
||||
assert.equal(mapped.logicalChannels.length, 2);
|
||||
assert.ok(mapped.logicalChannels.every(channel => channel.language.code === 'und'));
|
||||
assert.equal(mapped.logicalChannels[0].tvgId, 'news.raw');
|
||||
assert.deepEqual(mapped.availableLanguages.map(language => language.code), ['und']);
|
||||
|
||||
const dom = new JSDOM('<div id="result"></div>', { url: 'http://nodecast.test/', runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
window.eval(script('js/icons.js'));
|
||||
window.eval(script('js/services/channelLanguageDetection.js'));
|
||||
window.eval(script('js/services/channelLanguageFusion.js'));
|
||||
window.eval(script('js/services/channelVariantGrouping.js'));
|
||||
window.eval(`${script('js/components/ChannelList.js')}\nwindow.ChannelList = ChannelList;`);
|
||||
window.app = { epgGuide: { getCurrentProgram: () => null, loadError: null } };
|
||||
const channelList = Object.create(window.ChannelList.prototype);
|
||||
channelList.expandedVariants = new Set();
|
||||
channelList.sources = [{ id: 1, type: 'm3u', name: 'Provider' }];
|
||||
channelList.showHidden = false;
|
||||
channelList.isFavorite = () => false;
|
||||
channelList.isHidden = () => false;
|
||||
|
||||
const result = window.document.getElementById('result');
|
||||
assert.doesNotThrow(() => {
|
||||
result.innerHTML = mapped.logicalChannels.map(channel =>
|
||||
channelList.renderLogicalChannel(channel, 'render-id', 'News')
|
||||
).join('');
|
||||
});
|
||||
assert.equal(result.querySelectorAll('.logical-channel').length, 2);
|
||||
assert.equal(result.querySelector('.channel-language-badge').getAttribute('aria-label'), 'Unknown language');
|
||||
assert.match(result.textContent, /No program information/);
|
||||
dom.window.close();
|
||||
});
|
||||
39
test/t27BeinSearchRegression.test.js
Normal file
39
test/t27BeinSearchRegression.test.js
Normal file
@ -0,0 +1,39 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const backendSearch = require('../server/services/flexibleChannelSearch');
|
||||
const frontendProjection = require('../public/js/services/liveChannelProjection.js');
|
||||
|
||||
function channel(displayName) {
|
||||
return {
|
||||
displayName,
|
||||
standardName: displayName,
|
||||
fingerprint: displayName.toLowerCase(),
|
||||
variants: [{
|
||||
name: `${displayName} HD`,
|
||||
tvgId: displayName.replace(/\s+/g, '.').toLowerCase(),
|
||||
categoryName: 'Sports',
|
||||
groupTitle: 'Sports'
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
test('backend beIN search keeps singular/plural tolerance without matching Max channels', () => {
|
||||
assert.equal(backendSearch.matchesSearch(channel('beIN Sports 1'), 'bein sports 1'), true);
|
||||
assert.equal(backendSearch.matchesSearch(channel('beIN Sports 1'), 'Bein Sport 1'), true);
|
||||
assert.equal(backendSearch.matchesSearch(channel('beIN Sports Max 10'), 'bein sports 1'), false);
|
||||
assert.equal(backendSearch.matchesSearch(channel('beIN Sports Max 4'), 'bein sports 1'), false);
|
||||
assert.equal(backendSearch.matchesSearch(channel('beIN Sports 2'), 'bein sports 1'), false);
|
||||
assert.equal(backendSearch.matchesSearch(channel('beINSports1'), 'bein sports 1'), true);
|
||||
assert.equal(backendSearch.matchesSearch(channel('beIN Sports 01'), 'bein sports 1'), true);
|
||||
});
|
||||
|
||||
test('frontend local beIN search mirrors backend Max-channel exclusion', () => {
|
||||
assert.equal(frontendProjection.matchesSearch(channel('beIN Sports 1'), 'bein sports 1'), true);
|
||||
assert.equal(frontendProjection.matchesSearch(channel('beIN Sports 1'), 'Bein Sport 1'), true);
|
||||
assert.equal(frontendProjection.matchesSearch(channel('beIN Sports Max 10'), 'bein sports 1'), false);
|
||||
assert.equal(frontendProjection.matchesSearch(channel('beIN Sports Max 4'), 'bein sports 1'), false);
|
||||
assert.equal(frontendProjection.matchesSearch(channel('beIN Sports 2'), 'bein sports 1'), false);
|
||||
assert.equal(frontendProjection.matchesSearch(channel('beINSports1'), 'bein sports 1'), true);
|
||||
assert.equal(frontendProjection.matchesSearch(channel('beIN Sports 01'), 'bein sports 1'), true);
|
||||
});
|
||||
82
test/transcodeQualityProfiles.test.js
Normal file
82
test/transcodeQualityProfiles.test.js
Normal file
@ -0,0 +1,82 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
QUALITY_MODES,
|
||||
resolveQualityMode
|
||||
} = require('../server/services/transcodeQualityProfiles');
|
||||
const { TranscodeSession } = require('../server/services/transcodeSession');
|
||||
|
||||
test('legacy payloads do not receive profile overrides', () => {
|
||||
assert.equal(resolveQualityMode(undefined, { maxResolution: '720p' }), null);
|
||||
});
|
||||
|
||||
test('manual quality profiles map to encoding height and bitrate', () => {
|
||||
const expected = {
|
||||
'1080p': { maxResolution: '1080p', targetVideoBitrateKbps: 6000 },
|
||||
'720p': { maxResolution: '720p', targetVideoBitrateKbps: 3500 },
|
||||
'480p': { maxResolution: '480p', targetVideoBitrateKbps: 1500 }
|
||||
};
|
||||
|
||||
for (const [qualityMode, profile] of Object.entries(expected)) {
|
||||
const selection = resolveQualityMode(qualityMode);
|
||||
assert.equal(selection.mode, 'single');
|
||||
assert.equal(selection.selectedProfile, qualityMode);
|
||||
assert.deepEqual(selection.availableProfiles, QUALITY_MODES);
|
||||
assert.deepEqual(selection.sessionOptions, {
|
||||
videoMode: 'encode',
|
||||
...profile,
|
||||
upscaleEnabled: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('profile options produce the expected FFmpeg scale and bitrate arguments', () => {
|
||||
for (const qualityMode of ['1080p', '720p', '480p']) {
|
||||
const selection = resolveQualityMode(qualityMode);
|
||||
const session = new TranscodeSession('https://example.test/live.ts', {
|
||||
hwEncoder: 'software',
|
||||
audioMixPreset: 'passthrough',
|
||||
...selection.sessionOptions
|
||||
});
|
||||
const args = session.buildFFmpegArgs('software');
|
||||
const bitrate = selection.sessionOptions.targetVideoBitrateKbps;
|
||||
|
||||
assert.equal(args[args.indexOf('-vf') + 1], `scale=-2:${qualityMode.replace('p', '')}`);
|
||||
assert.equal(args[args.indexOf('-b:v') + 1], `${bitrate}k`);
|
||||
assert.equal(args[args.indexOf('-maxrate') + 1], `${bitrate}k`);
|
||||
assert.equal(args[args.indexOf('-bufsize') + 1], `${bitrate * 2}k`);
|
||||
}
|
||||
});
|
||||
|
||||
test('source profile preserves the original video stream', () => {
|
||||
const selection = resolveQualityMode('source');
|
||||
|
||||
assert.equal(selection.selectedProfile, 'source');
|
||||
assert.deepEqual(selection.sessionOptions, { videoMode: 'copy' });
|
||||
});
|
||||
|
||||
test('auto selects one configured rendition and reports single-rendition mode', () => {
|
||||
const selection = resolveQualityMode('auto', { maxResolution: '720p' });
|
||||
|
||||
assert.equal(selection.qualityMode, 'auto');
|
||||
assert.equal(selection.mode, 'single');
|
||||
assert.equal(selection.selectedProfile, '720p');
|
||||
assert.deepEqual(selection.sessionOptions, {
|
||||
videoMode: 'encode',
|
||||
maxResolution: '720p',
|
||||
targetVideoBitrateKbps: 3500,
|
||||
upscaleEnabled: false
|
||||
});
|
||||
});
|
||||
|
||||
test('auto falls back to 1080p for an unsupported global resolution', () => {
|
||||
assert.equal(resolveQualityMode('auto', { maxResolution: '4k' }).selectedProfile, '1080p');
|
||||
});
|
||||
|
||||
test('unsupported quality modes are rejected', () => {
|
||||
assert.throws(
|
||||
() => resolveQualityMode('adaptive'),
|
||||
error => error.code === 'INVALID_QUALITY_MODE'
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user