Merge branch 'develop' into main for v1.0.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
blomios
2026-10-06 21:41:30 +02:00
68 changed files with 8132 additions and 289 deletions

18
.dockerignore Normal file
View 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

View File

@ -40,8 +40,9 @@ WORKDIR /app
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./
# Install dependencies (better-sqlite3 will build from source using g++ installed above) # Install dependencies (better-sqlite3 will build for the image's Node.js ABI).
RUN npm ci --only=production # node_modules is excluded by .dockerignore so COPY below cannot overwrite it.
RUN npm ci --omit=dev
# Copy application files # Copy application files
COPY . . COPY . .

23
Dockerfile.laya Normal file
View 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
View File

@ -64,31 +64,60 @@ nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (V
### Docker Deployment ### 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 Start nodecast-tv without the optional Laya runtime:
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
```
2. Run the container: ```bash
```bash docker compose up -d
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 ### Hardware Acceleration Setup
@ -211,6 +240,45 @@ All transcoding and stream processing settings are found in **Settings → Trans
## Troubleshooting ## 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) ### Video Won't Play (Black Screen or Loading Forever)
| Symptom | Likely Cause | Solution | | Symptom | Likely Cause | Solution |

View File

@ -1,7 +1,6 @@
services: services:
nodecast-tv: nodecast-tv:
image: ghcr.io/technomancer702/nodecast-tv:latest image: ${NODECAST_IMAGE:-gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest}
build: https://github.com/technomancer702/nodecast-tv.git#main
container_name: nodecast-tv container_name: nodecast-tv
ports: ports:
- "3000:3000" - "3000:3000"
@ -11,3 +10,25 @@ services:
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 # Internal container port - 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
View File

@ -23,6 +23,9 @@
"sax": "^1.4.3", "sax": "^1.4.3",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
}, },
"devDependencies": {
"jsdom": "^22.1.0"
},
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@ -175,6 +178,16 @@
"win32" "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": { "node_modules/@types/node": {
"version": "10.17.60", "version": "10.17.60",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
@ -182,6 +195,14 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -199,8 +220,8 @@
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"debug": "4" "debug": "4"
}, },
@ -212,8 +233,8 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
}, },
@ -230,8 +251,8 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT", "devOptional": true,
"optional": true "license": "MIT"
}, },
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
@ -239,6 +260,13 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -414,6 +442,19 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC" "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": { "node_modules/concat-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@ -466,6 +507,34 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "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": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -475,6 +544,13 @@
"ms": "2.0.0" "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": { "node_modules/decompress-response": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@ -499,6 +575,16 @@
"node": ">=4.0.0" "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -527,6 +613,20 @@
"node": ">=8" "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": { "node_modules/dotenv": {
"version": "17.2.3", "version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
@ -586,6 +686,19 @@
"once": "^1.4.0" "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": { "node_modules/env-paths": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@ -626,6 +739,22 @@
"node": ">= 0.4" "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": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -756,6 +885,23 @@
"node": ">= 0.8" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -856,10 +1002,26 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@ -868,6 +1030,19 @@
"node": ">= 0.4" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -888,6 +1063,46 @@
"url": "https://opencollective.com/express" "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": { "node_modules/http-response-object": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
@ -902,8 +1117,8 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"agent-base": "6", "agent-base": "6",
"debug": "4" "debug": "4"
@ -916,8 +1131,8 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
}, },
@ -934,8 +1149,8 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT", "devOptional": true,
"optional": true "license": "MIT"
}, },
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
@ -990,6 +1205,56 @@
"node": ">= 0.10" "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": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@ -1210,6 +1475,13 @@
"node": ">=10" "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": { "node_modules/oauth": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
@ -1264,6 +1536,19 @@
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==", "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==",
"optional": true "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -1397,6 +1682,19 @@
"node": ">= 0.10" "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": { "node_modules/pump": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
@ -1407,6 +1705,16 @@
"once": "^1.3.1" "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": { "node_modules/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@ -1422,6 +1730,13 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/random-bytes": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
@ -1484,6 +1799,20 @@
"node": ">= 6" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -1516,6 +1845,19 @@
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0" "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": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@ -1723,6 +2065,13 @@
"node": ">=0.10.0" "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": { "node_modules/tar-fs": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@ -1760,6 +2109,35 @@
"node": ">=0.6" "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": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@ -1804,6 +2182,16 @@
"node": ">= 0.8" "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": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -1813,6 +2201,17 @@
"node": ">= 0.8" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -1837,12 +2236,118 @@
"node": ">= 0.8" "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": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "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": { "node_modules/xml2js": {
"version": "0.6.2", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
@ -1864,6 +2369,13 @@
"engines": { "engines": {
"node": ">=4.0" "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"
} }
} }
} }

View File

@ -6,7 +6,8 @@
"main": "server/index.js", "main": "server/index.js",
"scripts": { "scripts": {
"start": "node server/index.js", "start": "node server/index.js",
"dev": "node --watch server/index.js" "dev": "node --watch server/index.js",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"@ffprobe-installer/ffprobe": "^2.1.2", "@ffprobe-installer/ffprobe": "^2.1.2",
@ -28,5 +29,8 @@
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
},
"devDependencies": {
"jsdom": "^22.1.0"
} }
} }

View File

@ -537,6 +537,82 @@ h4 {
margin-top: var(--space-sm); 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 { .source-select {
flex: 1; flex: 1;
padding: var(--space-xs) var(--space-sm); padding: var(--space-xs) var(--space-sm);
@ -647,6 +723,28 @@ h4 {
object-fit: contain; 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 { .channel-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@ -667,6 +765,242 @@ h4 {
text-overflow: ellipsis; 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 Video Player Section
===================================================== */ ===================================================== */
@ -1284,11 +1618,124 @@ video::cue {
word-break: break-all; 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 { .source-actions {
display: flex; display: flex;
flex-wrap: wrap;
gap: var(--space-xs); 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 Items */
.hidden-list { .hidden-list {
display: flex; display: flex;
@ -1702,6 +2149,44 @@ kbd {
border-color: var(--color-accent); 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 Context Menu
===================================================== */ ===================================================== */
@ -4819,4 +5304,4 @@ kbd {
font-size: 0.7rem; font-size: 0.7rem;
max-width: 70px; max-width: 70px;
} }
} }

View File

@ -10,7 +10,7 @@
<title>nodecast-tv | IPTV Player</title> <title>nodecast-tv | IPTV Player</title>
<meta name="description" content="Stream live TV, movies, and series with EPG support"> <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="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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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" <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">
<div class="sidebar-header-row"> <div class="sidebar-header-row">
<div class="search-wrapper"> <div class="search-wrapper">
<input type="text" id="channel-search" placeholder="Search channels..." class="search-input"> <input type="text" id="channel-search" placeholder="Search channels…" class="search-input">
<button type="button" class="search-clear" title="Clear search">&times;</button> <button type="button" class="search-clear" title="Clear search" aria-label="Clear channel search">&times;</button>
</div> </div>
<button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn" <button id="sidebar-collapse-btn" class="btn btn-sm btn-ghost sidebar-collapse-btn"
title="Collapse sidebar"> title="Collapse sidebar">
@ -148,6 +148,8 @@
</button> </button>
</div> </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>
<div id="channel-list" class="channel-list"> <div id="channel-list" class="channel-list">
<div class="empty-state"> <div class="empty-state">
@ -419,12 +421,18 @@
<div id="m3u-list" class="source-list"></div> <div id="m3u-list" class="source-list"></div>
</div> </div>
<div class="source-section"> <div class="source-section epg-sources-section">
<div class="section-header"> <div class="section-header">
<h3>EPG Sources</h3> <h3>EPG Sources</h3>
<button class="btn btn-primary" id="add-epg">+ Add EPG</button> <button class="btn btn-primary" id="add-epg">+ Add EPG</button>
</div> </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>
<div class="source-section"> <div class="source-section">
@ -454,6 +462,83 @@
<!-- Player Tab --> <!-- Player Tab -->
<div id="tab-player" class="tab-content"> <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"> <div class="settings-section">
<h3>Keyboard Controls</h3> <h3>Keyboard Controls</h3>
<div class="setting-item"> <div class="setting-item">
@ -539,7 +624,7 @@
<h3>Encoder Settings</h3> <h3>Encoder Settings</h3>
<div class="setting-item"> <div class="setting-item">
<div class="setting-info" style="flex: 1;"> <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 <span class="setting-hint">Select which GPU encoder to use for transcoding. Auto will use the best
available.</span> available.</span>
</div> </div>
@ -554,7 +639,7 @@
</div> </div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-info" style="flex: 1;"> <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 <span class="setting-hint">Limit transcoded output resolution. Lower = faster, more
compatible.</span> compatible.</span>
</div> </div>
@ -567,7 +652,7 @@
</div> </div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-info" style="flex: 1;"> <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> <span class="setting-hint">Higher quality uses more bandwidth and CPU/GPU.</span>
</div> </div>
<select id="setting-quality" class="source-select" style="width: auto; min-width: 200px;"> <select id="setting-quality" class="source-select" style="width: auto; min-width: 200px;">
@ -578,7 +663,7 @@
</div> </div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-info" style="flex: 1;"> <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 <span class="setting-hint">How multi-channel audio (5.1/7.1) is mixed down to stereo for browser
playback.</span> playback.</span>
</div> </div>
@ -600,14 +685,14 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<div class="setting-info"> <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 <span class="setting-hint">Upscale lower-resolution content to a higher resolution during
transcoding.</span> transcoding.</span>
</div> </div>
</div> </div>
<div class="setting-item" id="upscale-method-container" style="display: none;"> <div class="setting-item" id="upscale-method-container" style="display: none;">
<div class="setting-info" style="flex: 1;"> <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> <span class="setting-hint">Hardware is faster; Software (Lanczos) is higher quality.</span>
</div> </div>
<select id="setting-upscale-method" class="source-select" style="width: auto; min-width: 200px;"> <select id="setting-upscale-method" class="source-select" style="width: auto; min-width: 200px;">
@ -617,7 +702,7 @@
</div> </div>
<div class="setting-item" id="upscale-target-container" style="display: none;"> <div class="setting-item" id="upscale-target-container" style="display: none;">
<div class="setting-info" style="flex: 1;"> <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> <span class="setting-hint">Target resolution for upscaled output.</span>
</div> </div>
<select id="setting-upscale-target" class="source-select" style="width: auto; min-width: 200px;"> <select id="setting-upscale-target" class="source-select" style="width: auto; min-width: 200px;">
@ -629,25 +714,43 @@
</div> </div>
<div class="settings-section"> <div class="settings-section">
<h3>Stream Processing</h3> <h3>Automatic Playback</h3>
<div class="setting-item"> <div class="setting-item">
<label class="setting-toggle"> <label class="setting-toggle">
<input type="checkbox" id="setting-auto-transcode-tc"> <input type="checkbox" id="setting-auto-transcode-tc">
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<div class="setting-info"> <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. <span class="setting-hint">Automatically detect stream codecs and only transcode/remux when needed.
Adds ~1-3s probe delay on first play.</span> Adds ~1-3s probe delay on first play.</span>
</div> </div>
</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"> <div class="setting-item">
<label class="setting-toggle"> <label class="setting-toggle">
<input type="checkbox" id="setting-force-transcode-tc"> <input type="checkbox" id="setting-force-transcode-tc">
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<div class="setting-info"> <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 <span class="setting-hint">Transcode audio to AAC for browser compatibility. Enable if you get video
but no audio (fixes Dolby/AC3/EAC3).</span> but no audio (fixes Dolby/AC3/EAC3).</span>
</div> </div>
@ -658,7 +761,7 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<div class="setting-info"> <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). <span class="setting-hint">Force full video transcoding (burn-in subtitles, incompatible video).
Uses Uses
HW encoder.</span> HW encoder.</span>
@ -670,14 +773,14 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
<div class="setting-info"> <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 <span class="setting-hint">Remux streams to MP4 container. Enable for raw .ts streams from IPTV
middleware like m3u-editor or dispatcharr.</span> middleware like m3u-editor or dispatcharr.</span>
</div> </div>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-info" style="flex: 1;"> <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 <span class="setting-hint">Container format for Xtream streams. Try TS if you experience buffering
issues.</span> issues.</span>
</div> </div>
@ -686,6 +789,15 @@
<option value="ts">MPEG-TS (ts) - Better buffer</option> <option value="ts">MPEG-TS (ts) - Better buffer</option>
</select> </select>
</div> </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>
<div class="settings-section"> <div class="settings-section">
@ -1096,17 +1208,23 @@
<!-- JavaScript --> <!-- JavaScript -->
<script src="/js/icons.js"></script> <script src="/js/icons.js"></script>
<script src="/js/api.js?v=2"></script> <script src="/js/api.js?v=6"></script>
<script src="/js/components/VideoPlayer.js?v=2"></script> <script src="/js/components/VideoPlayer.js?v=5"></script>
<script src="/js/components/ChannelList.js?v=3"></script> <script src="/js/services/channelLanguageDetection.js?v=1"></script>
<script src="/js/components/SourceManager.js?v=2"></script> <script src="/js/services/channelLanguageFusion.js?v=1"></script>
<script src="/js/components/EpgGuide.js?v=2"></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/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/Guide.js?v=2"></script>
<script src="/js/pages/MoviesPage.js?v=4"></script> <script src="/js/pages/MoviesPage.js?v=4"></script>
<script src="/js/pages/SeriesPage.js?v=1"></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/pages/WatchPage.js?v=1"></script>
<script src="/js/app.js?v=4"></script> <script src="/js/app.js?v=4"></script>
<script> <script>
@ -1125,4 +1243,4 @@
</script> </script>
</body> </body>
</html> </html>

View File

@ -59,6 +59,7 @@ const API = {
toggle: (id) => API.request('POST', `/sources/${id}/toggle`), toggle: (id) => API.request('POST', `/sources/${id}/toggle`),
test: (id) => API.request('POST', `/sources/${id}/test`), test: (id) => API.request('POST', `/sources/${id}/test`),
sync: (id) => API.request('POST', `/sources/${id}/sync`), // Manual sync 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 getStatus: () => API.request('GET', '/sources/status'), // Get all statuses
estimate: (id) => API.request('GET', `/sources/${id}/estimate`), // Estimate M3U size 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) 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 }) 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
favorites: { favorites: {
getAll: (sourceId = null, itemType = null) => { getAll: (sourceId = null, itemType = null) => {
@ -95,6 +109,13 @@ const API = {
API.request('GET', `/favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`) 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
proxy: { proxy: {
// Xtream // Xtream

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@ class EpgGuide {
this.timeOffset = 0; // Hours offset from now this.timeOffset = 0; // Hours offset from now
this.pixelsPerMinute = 6.67; // Width scaling (30min = 200px) this.pixelsPerMinute = 6.67; // Width scaling (30min = 200px)
this.favorites = new Set(); // Set<"sourceId:channelId"> this.favorites = new Set(); // Set<"sourceId:channelId">
this.loadError = null;
this.selectedGroup = 'Favorites'; // Default to Favorites this.selectedGroup = 'Favorites'; // Default to Favorites
// Virtual scrolling properties // Virtual scrolling properties
@ -175,6 +176,7 @@ class EpgGuide {
try { try {
this.container.innerHTML = '<div class="loading"></div>'; this.container.innerHTML = '<div class="loading"></div>';
await this.fetchEpgData(forceRefresh); await this.fetchEpgData(forceRefresh);
this.loadError = null;
this.lastRefreshTime = new Date(); this.lastRefreshTime = new Date();
this.render(); this.render();
@ -182,6 +184,7 @@ class EpgGuide {
// This ensures EPG data stays fresh while the app is open // This ensures EPG data stays fresh while the app is open
this.startBackgroundRefresh(); this.startBackgroundRefresh();
} catch (err) { } catch (err) {
this.loadError = err;
console.error('Error loading EPG:', err); console.error('Error loading EPG:', err);
this.container.innerHTML = ` this.container.innerHTML = `
<div class="empty-state"> <div class="empty-state">

View File

@ -8,6 +8,12 @@ class SourceManager {
this.xtreamList = document.getElementById('xtream-list'); this.xtreamList = document.getElementById('xtream-list');
this.m3uList = document.getElementById('m3u-list'); this.m3uList = document.getElementById('m3u-list');
this.epgList = document.getElementById('epg-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 // Content browser state
this.contentType = 'channels'; // 'channels' or 'movies' 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.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream');
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u'); 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) { } catch (err) {
console.error('Error loading sources:', 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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 * Show add source modal
*/ */

View File

@ -28,6 +28,14 @@ class VideoPlayer {
this.isUsingProxy = false; this.isUsingProxy = false;
this.currentUrl = null; this.currentUrl = null;
this.settingsLoaded = false; 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 // Settings - start with defaults, load from server async
this.settings = this.getDefaultSettings(); this.settings = this.getDefaultSettings();
@ -49,15 +57,37 @@ class VideoPlayer {
rememberVolume: true, rememberVolume: true,
lastVolume: 80, lastVolume: 80,
autoPlayNextEpisode: false, autoPlayNextEpisode: false,
liveChannelListMode: 'grouped',
languageDetectionMode: 'algo',
forceProxy: false, forceProxy: false,
forceTranscode: false, forceTranscode: false,
forceRemux: false, forceRemux: false,
autoTranscode: true, autoTranscode: true,
maxDirectBitrateKbps: 0,
targetTranscodeBitrateKbps: 6000,
allowHardwareFallback: true,
streamFormat: 'm3u8', streamFormat: 'm3u8',
epgRefreshInterval: '24' 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 * Load settings from server API
*/ */
@ -66,6 +96,7 @@ class VideoPlayer {
const serverSettings = await API.settings.get(); const serverSettings = await API.settings.get();
this.settings = { ...this.getDefaultSettings(), ...serverSettings }; this.settings = { ...this.getDefaultSettings(), ...serverSettings };
this.settingsLoaded = true; this.settingsLoaded = true;
window.app?.channelList?.setListMode(this.settings.liveChannelListMode);
console.log('[Player] Settings loaded from server'); console.log('[Player] Settings loaded from server');
} catch (err) { } catch (err) {
console.warn('[Player] Failed to load settings from server, using defaults:', err.message); 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 btnOverflow = document.getElementById('btn-overflow');
const overflowMenu = document.getElementById('player-overflow-menu'); const overflowMenu = document.getElementById('player-overflow-menu');
this.initQualityControl(overflowMenu);
this.initLanguageControl(overflowMenu);
btnOverflow?.addEventListener('click', (e) => { btnOverflow?.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
overflowMenu?.classList.toggle('hidden'); overflowMenu?.classList.toggle('hidden');
@ -412,6 +446,120 @@ class VideoPlayer {
updateVolumeUI(); 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) * Toggle fullscreen mode (cross-browser including Safari)
*/ */
@ -620,7 +768,9 @@ class VideoPlayer {
width: this.video.videoWidth, width: this.video.videoWidth,
height: this.video.videoHeight height: this.video.videoHeight
}; };
this.activeQuality = this.getQualityLabel(this.video.videoHeight);
this.updateQualityBadge(); this.updateQualityBadge();
this.renderQualityControl();
} }
}); });
@ -812,7 +962,8 @@ class VideoPlayer {
/** /**
* Start a HLS transcode session * Start a HLS transcode session
*/ */
async startTranscodeSession(url, options = {}) { async startTranscodeSession(url, options = {}, allowLegacyFallback = true) {
this.currentTranscodeSession = null;
try { try {
console.log('[Player] Starting HLS transcode session...', options); console.log('[Player] Starting HLS transcode session...', options);
const res = await fetch('/api/transcode/session', { const res = await fetch('/api/transcode/session', {
@ -820,14 +971,24 @@ class VideoPlayer {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, ...options }) 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(); const session = await res.json();
this.currentSessionId = session.sessionId; 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; return session.playlistUrl;
} catch (err) { } catch (err) {
console.error('[Player] Session start failed:', err); console.error('[Player] Session start failed:', err);
// Fallback to direct transcode if session fails if (allowLegacyFallback) {
return `/api/transcode?url=${encodeURIComponent(url)}`; return `/api/transcode?url=${encodeURIComponent(url)}`;
}
throw err;
} }
} }
@ -835,15 +996,18 @@ class VideoPlayer {
* Stop and cleanup current transcode session * Stop and cleanup current transcode session
*/ */
async stopTranscodeSession() { async stopTranscodeSession() {
if (this.currentSessionId) { const sessionId = this.currentSessionId;
console.log('[Player] Stopping transcode session:', this.currentSessionId); if (sessionId) {
console.log('[Player] Stopping transcode session:', sessionId);
try { try {
// Fire and forget cleanup await fetch(`/api/transcode/${sessionId}`, { method: 'DELETE' });
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
} catch (err) { } catch (err) {
console.error('Failed to stop session:', err); console.error('Failed to stop session:', err);
} }
this.currentSessionId = null; // A new session may have started while the DELETE was in flight.
if (this.currentSessionId === sessionId) {
this.currentSessionId = null;
}
} }
} }
@ -852,6 +1016,7 @@ class VideoPlayer {
*/ */
async play(channel, streamUrl) { async play(channel, streamUrl) {
this.currentChannel = channel; this.currentChannel = channel;
this.originalStreamUrl = streamUrl;
try { try {
// Stop any WatchPage playback (movies/series) before starting Live TV // Stop any WatchPage playback (movies/series) before starting Live TV
@ -870,10 +1035,22 @@ class VideoPlayer {
// Determine if HLS or direct stream // Determine if HLS or direct stream
this.currentUrl = streamUrl; this.currentUrl = streamUrl;
this.sourceStreamInfo = null;
// CHECK: Auto Transcode (Smart) - probe first, then decide // A persisted manual preference always creates a dedicated transcode session.
if (this.settings.autoTranscode) { 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...'); console.log('[Player] Auto Transcode enabled. Probing stream...');
this.updateTranscodeStatus('direct', 'Analyzing…');
try { try {
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
const info = await probeRes.json(); const info = await probeRes.json();
@ -881,6 +1058,8 @@ class VideoPlayer {
// Store probe result for quality badge display // Store probe result for quality badge display
this.currentStreamInfo = info; this.currentStreamInfo = info;
this.sourceStreamInfo = info;
this.updateAvailableQualityProfiles(null, info.height);
this.updateQualityBadge(); this.updateQualityBadge();
// Handle subtitles from probe result // 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 // 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'})`); 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) // Heuristic: If video is h264, it's likely compatible, so only copy video (audio transcode only)
// BUT: If upscaling is enabled, we MUST encode. // BUT: If upscaling is enabled, we MUST encode.
const videoMode = (info.video && info.video.includes('h264') && !this.settings.upscaleEnabled) ? 'copy' : 'encode'; const videoMode = (info.video && info.video.includes('h264') &&
const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : (this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)'); !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'; const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
this.updateTranscodeStatus(statusMode, statusText); this.updateTranscodeStatus(statusMode, statusText);
@ -920,8 +1108,18 @@ class VideoPlayer {
videoMode, videoMode,
videoCodec: info.video, videoCodec: info.video,
audioCodec: info.audio, 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.currentUrl = playlistUrl; // Update currentUrl for HLS reload
this.playHls(playlistUrl); this.playHls(playlistUrl);
@ -934,7 +1132,7 @@ class VideoPlayer {
} else if (info.needsRemux) { } else if (info.needsRemux) {
// Raw .ts container - use remux // Raw .ts container - use remux
console.log('[Player] Auto: Using remux (.ts container)'); console.log('[Player] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); this.updateTranscodeStatus('remuxing', 'Remux');
const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`; const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
this.currentUrl = remuxUrl; this.currentUrl = remuxUrl;
this.video.src = remuxUrl; this.video.src = remuxUrl;
@ -949,6 +1147,7 @@ class VideoPlayer {
} }
// Compatible - fall through to normal HLS.js path // Compatible - fall through to normal HLS.js path
console.log('[Player] Auto: Using HLS.js (compatible)'); console.log('[Player] Auto: Using HLS.js (compatible)');
this.updateTranscodeStatus('direct', 'Direct Play');
} catch (err) { } catch (err) {
console.warn('[Player] Probe failed, using normal playback:', err.message); console.warn('[Player] Probe failed, using normal playback:', err.message);
// Continue with normal playback on probe failure // Continue with normal playback on probe failure
@ -957,11 +1156,19 @@ class VideoPlayer {
// CHECK: Force Video Transcode (Full) or Upscaling // CHECK: Force Video Transcode (Full) or Upscaling
if (this.settings.forceVideoTranscode || this.settings.upscaleEnabled) { 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'; const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
console.log(`[Player] ${statusText} enabled. Starting session (encode)...`); console.log(`[Player] ${statusText} enabled. Starting session (encode)...`);
this.updateTranscodeStatus(statusMode, statusText); 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; this.currentUrl = playlistUrl;
// Load HLS // Load HLS
@ -1002,7 +1209,7 @@ class VideoPlayer {
// CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting // CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting
if (this.settings.forceTranscode) { if (this.settings.forceTranscode) {
console.log('[Player] Force Audio Transcode enabled. Starting session (copy)...'); 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 // Probe to get video codec for HEVC tag handling
let videoCodec = 'unknown'; let videoCodec = 'unknown';
@ -1012,7 +1219,11 @@ class VideoPlayer {
videoCodec = info.video; videoCodec = info.video;
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); } } 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; this.currentUrl = playlistUrl;
console.log('[Player] Playing transcoded HLS stream:', playlistUrl); console.log('[Player] Playing transcoded HLS stream:', playlistUrl);
@ -1053,7 +1264,7 @@ class VideoPlayer {
if (this.settings.forceRemux && (isRawTs || isExtensionless)) { if (this.settings.forceRemux && (isRawTs || isExtensionless)) {
console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...'); console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...');
console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)'); 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); const remuxUrl = this.getRemuxUrl(streamUrl);
this.video.src = remuxUrl; this.video.src = remuxUrl;
this.video.play().catch(e => { this.video.play().catch(e => {
@ -1082,7 +1293,7 @@ class VideoPlayer {
// Priority 1: Use HLS.js for HLS streams on browsers that support it // Priority 1: Use HLS.js for HLS streams on browsers that support it
if (looksLikeHls && Hls.isSupported()) { if (looksLikeHls && Hls.isSupported()) {
this.updateTranscodeStatus('direct', 'Direct HLS'); this.updateTranscodeStatus('direct', 'Direct Play');
// Use playHls helper logic here (or extract it) // Use playHls helper logic here (or extract it)
// For now, let's just use existing logic but wrapped/modularized if possible? // 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.loadSource(finalUrl);
this.hls.attachMedia(this.video); 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 => { this.video.play().catch(e => {
if (e.name !== 'AbortError') console.log('Autoplay prevented:', 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 // Re-attach error handler for the new Hls instance
this.hls.on(Hls.Events.ERROR, (event, data) => { this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) { if (data.fatal) {
@ -1146,7 +1369,7 @@ class VideoPlayer {
} else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' || } else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') { this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') {
// Priority 2: Native HLS support (Safari on iOS/macOS where HLS.js may not work) // 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.src = finalUrl;
this.video.play().catch(e => { this.video.play().catch(e => {
if (e.name === 'AbortError') return; // Ignore interruption by new load if (e.name === 'AbortError') return; // Ignore interruption by new load
@ -1198,12 +1421,27 @@ class VideoPlayer {
this.hls.loadSource(url); this.hls.loadSource(url);
this.hls.attachMedia(this.video); 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 => { this.video.play().catch(e => {
if (e.name !== 'AbortError') console.log('Autoplay prevented:', 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) => { this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) { if (data.fatal) {
// Simple error handling for forced HLS/transcode modes // 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) { async updateTranscodeStatus(mode, text) {
const el = document.getElementById('player-transcode-status'); const el = document.getElementById('player-transcode-status');
if (!el) return; if (!el) return;
@ -1247,11 +1571,20 @@ class VideoPlayer {
/** /**
* Update quality badge display * Update quality badge display
*/ */
updateQualityBadge() { updateQualityBadge(targetBitrateKbps = null) {
const badge = document.getElementById('player-quality-badge'); const badge = document.getElementById('player-quality-badge');
if (!badge) return; 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.textContent = this.getQualityLabel(this.currentStreamInfo.height);
badge.classList.remove('hidden'); badge.classList.remove('hidden');
} else { } else {
@ -1407,6 +1740,9 @@ class VideoPlayer {
// Hide quality badge // Hide quality badge
this.currentStreamInfo = null; this.currentStreamInfo = null;
this.activeQuality = null;
this.autoIsAdaptive = false;
this.renderQualityControl();
const badge = document.getElementById('player-quality-badge'); const badge = document.getElementById('player-quality-badge');
if (badge) badge.classList.add('hidden'); if (badge) badge.classList.add('hidden');
} }
@ -1531,11 +1867,18 @@ class VideoPlayer {
if (channels.length === 0) return; if (channels.length === 0) return;
const currentIdx = this.currentChannel 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; : -1;
const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 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; if (channels.length === 0) return;
const currentIdx = this.currentChannel 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; : -1;
const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 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
});
} }
/** /**

View File

@ -23,6 +23,7 @@ class LivePage {
// Update program info in existing DOM elements without re-rendering // Update program info in existing DOM elements without re-rendering
this.updateProgramInfo(); this.updateProgramInfo();
} catch (err) { } catch (err) {
this.app.epgGuide.loadError = err;
console.warn('Background EPG fetch failed:', err); console.warn('Background EPG fetch failed:', err);
} }
} }
@ -36,7 +37,7 @@ class LivePage {
// Build a map for O(1) channel lookups // Build a map for O(1) channel lookups
const channelMap = new Map(); 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 // Process in small batches to avoid blocking UI
const BATCH_SIZE = 50; const BATCH_SIZE = 50;
@ -47,14 +48,12 @@ class LivePage {
for (let i = index; i < end; i++) { for (let i = index; i < end; i++) {
const item = channelItems[i]; const item = channelItems[i];
const channelId = item.dataset.channelId; const channel = channelMap.get(item.dataset.logicalId);
const channel = channelMap.get(channelId);
if (channel) { if (channel) {
const programDiv = item.querySelector('.channel-program'); const programDiv = item.querySelector('.channel-program');
if (programDiv) { if (programDiv) {
const programTitle = this.app.channelList.getProgramInfo(channel); programDiv.textContent = this.app.channelList.getProgramStatusText(channel);
programDiv.textContent = programTitle || '';
} }
} }
} }

View File

@ -34,6 +34,7 @@ class SettingsPage {
const volumeValueDisplay = document.getElementById('volume-value'); const volumeValueDisplay = document.getElementById('volume-value');
const rememberVolumeToggle = document.getElementById('setting-remember-volume'); const rememberVolumeToggle = document.getElementById('setting-remember-volume');
const autoPlayNextToggle = document.getElementById('setting-autoplay-next'); const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
const groupLiveChannelsToggle = document.getElementById('setting-group-live-channels');
// Load current settings // Load current settings
if (this.app.player?.settings) { if (this.app.player?.settings) {
@ -43,6 +44,7 @@ class SettingsPage {
volumeValueDisplay.textContent = this.app.player.settings.defaultVolume + '%'; volumeValueDisplay.textContent = this.app.player.settings.defaultVolume + '%';
rememberVolumeToggle.checked = this.app.player.settings.rememberVolume; rememberVolumeToggle.checked = this.app.player.settings.rememberVolume;
autoPlayNextToggle.checked = this.app.player.settings.autoPlayNextEpisode; autoPlayNextToggle.checked = this.app.player.settings.autoPlayNextEpisode;
groupLiveChannelsToggle.checked = this.app.player.settings.liveChannelListMode !== 'flat';
} }
// Arrow keys toggle // Arrow keys toggle
@ -77,6 +79,16 @@ class SettingsPage {
this.app.player.saveSettings(); 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 // EPG refresh interval
const epgRefreshSelect = document.getElementById('epg-refresh-interval'); const epgRefreshSelect = document.getElementById('epg-refresh-interval');
if (epgRefreshSelect && this.app.player?.settings) { if (epgRefreshSelect && this.app.player?.settings) {
@ -94,6 +106,330 @@ class SettingsPage {
this.updateEpgLastRefreshed(); 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() { async initTranscodingSettings() {
// Encoder settings // Encoder settings
const hwEncoderSelect = document.getElementById('setting-hw-encoder'); const hwEncoderSelect = document.getElementById('setting-hw-encoder');
@ -107,6 +443,8 @@ class SettingsPage {
const forceVideoTranscodeToggle = document.getElementById('setting-force-video-transcode-tc'); const forceVideoTranscodeToggle = document.getElementById('setting-force-video-transcode-tc');
const forceRemuxToggle = document.getElementById('setting-force-remux-tc'); const forceRemuxToggle = document.getElementById('setting-force-remux-tc');
const streamFormatSelect = document.getElementById('setting-stream-format-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) // User-Agent (Transcoding tab versions)
const userAgentSelect = document.getElementById('setting-user-agent-tc'); const userAgentSelect = document.getElementById('setting-user-agent-tc');
@ -131,6 +469,8 @@ class SettingsPage {
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true; if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false; if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8'; 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 (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || ''; if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
if (customUaContainer) { if (customUaContainer) {
@ -232,6 +572,16 @@ class SettingsPage {
this.app.player.saveSettings(); 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 // User-Agent handlers
const toggleCustomInput = () => { const toggleCustomInput = () => {
if (customUaContainer) { if (customUaContainer) {
@ -557,6 +907,7 @@ class SettingsPage {
const volumeValueDisplay = document.getElementById('volume-value'); const volumeValueDisplay = document.getElementById('volume-value');
const rememberVolumeToggle = document.getElementById('setting-remember-volume'); const rememberVolumeToggle = document.getElementById('setting-remember-volume');
const autoPlayNextToggle = document.getElementById('setting-autoplay-next'); const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
const groupLiveChannelsToggle = document.getElementById('setting-group-live-channels');
const forceProxyToggle = document.getElementById('setting-force-proxy'); const forceProxyToggle = document.getElementById('setting-force-proxy');
const forceTranscodeToggle = document.getElementById('setting-force-transcode'); const forceTranscodeToggle = document.getElementById('setting-force-transcode');
const forceRemuxToggle = document.getElementById('setting-force-remux'); const forceRemuxToggle = document.getElementById('setting-force-remux');
@ -570,6 +921,10 @@ class SettingsPage {
if (volumeValueDisplay) volumeValueDisplay.textContent = s.defaultVolume + '%'; if (volumeValueDisplay) volumeValueDisplay.textContent = s.defaultVolume + '%';
if (rememberVolumeToggle) rememberVolumeToggle.checked = s.rememberVolume; if (rememberVolumeToggle) rememberVolumeToggle.checked = s.rememberVolume;
if (autoPlayNextToggle) autoPlayNextToggle.checked = s.autoPlayNextEpisode; 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 (forceProxyToggle) forceProxyToggle.checked = s.forceProxy || false;
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false; if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false; if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
@ -592,6 +947,7 @@ class SettingsPage {
// Update EPG last refreshed display // Update EPG last refreshed display
this.updateEpgLastRefreshed(); this.updateEpgLastRefreshed();
void this.loadLayaStatus();
} }
/** /**

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

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

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

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

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

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

View File

@ -1,6 +1,7 @@
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const passport = require('passport'); const passport = require('passport');
const { isAdmin } = require('./authz');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { Strategy: LocalStrategy } = require('passport-local'); const { Strategy: LocalStrategy } = require('passport-local');
@ -234,7 +235,7 @@ const requireAuth = passport.authenticate('jwt', { session: false });
* Middleware: Require admin role * Middleware: Require admin role
*/ */
function requireAdmin(req, res, next) { 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' }); return res.status(403).json({ error: 'Forbidden - Admin access required' });
} }
next(); next();

5
server/authz.js Normal file
View File

@ -0,0 +1,5 @@
function isAdmin(user) {
return user?.role === 'admin';
}
module.exports = { isAdmin };

View File

@ -1,6 +1,8 @@
const fs = require('fs/promises'); const fs = require('fs/promises');
const path = require('path'); const path = require('path');
const { existsSync, mkdirSync } = require('fs'); 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) // Ensure data directory exists (sync is fine for startup)
const dataDir = path.join(__dirname, '..', 'data'); const dataDir = path.join(__dirname, '..', 'data');
@ -17,25 +19,28 @@ async function loadDb() {
try { try {
const fileContent = await fs.readFile(dbPath, 'utf-8'); const fileContent = await fs.readFile(dbPath, 'utf-8');
const data = JSON.parse(fileContent); const data = JSON.parse(fileContent);
return { const original = JSON.stringify(data);
const migrated = ensureBuiltinSources({
sources: data.sources || [], sources: data.sources || [],
hiddenItems: data.hiddenItems || [], hiddenItems: data.hiddenItems || [],
favorites: data.favorites || [], favorites: data.favorites || [],
settings: data.settings || getDefaultSettings(), settings: data.settings || getDefaultSettings(),
users: data.users || [], users: data.users || [],
nextId: data.nextId || 1 nextId: data.nextId || 1
}; });
if (original !== JSON.stringify(migrated)) await saveDb(migrated);
return migrated;
} catch (error) { } catch (error) {
if (error.code === 'ENOENT') { if (error.code === 'ENOENT') {
// File doesn't exist, return default // File doesn't exist, return default
return { return ensureBuiltinSources({
sources: [], sources: [],
hiddenItems: [], hiddenItems: [],
favorites: [], favorites: [],
settings: getDefaultSettings(), settings: getDefaultSettings(),
users: [], users: [],
nextId: 1 nextId: 1
}; });
} }
throw error; throw error;
} }
@ -62,6 +67,8 @@ function getDefaultSettings() {
rememberVolume: true, rememberVolume: true,
lastVolume: 80, lastVolume: 80,
autoPlayNextEpisode: false, autoPlayNextEpisode: false,
liveChannelListMode: 'grouped',
languageDetectionMode: 'algo',
forceProxy: false, forceProxy: false,
forceTranscode: false, // Force Audio Transcode forceTranscode: false, // Force Audio Transcode
forceVideoTranscode: false, // Force Video Transcode forceVideoTranscode: false, // Force Video Transcode
@ -76,6 +83,9 @@ function getDefaultSettings() {
hwEncoder: 'auto', // auto | nvenc | amf | qsv | vaapi | software hwEncoder: 'auto', // auto | nvenc | amf | qsv | vaapi | software
maxResolution: '1080p', // 4k | 1080p | 720p | 480p maxResolution: '1080p', // 4k | 1080p | 720p | 480p
quality: 'medium', // high | medium | low quality: 'medium', // high | medium | low
maxDirectBitrateKbps: 0, // 0 = unlimited
targetTranscodeBitrateKbps: 6000,
allowHardwareFallback: true,
audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough
// Probe cache settings // Probe cache settings
probeCacheTTL: 300, // 5 minutes for URL probe cache probeCacheTTL: 300, // 5 minutes for URL probe cache
@ -131,7 +141,8 @@ async function saveDb(data) {
const sources = { const sources = {
async getAll() { async getAll() {
const db = await loadDb(); const db = await loadDb();
return db.sources; const epg = sortEpgSources(db.sources);
return [...db.sources.filter(source => source.type !== 'epg'), ...epg];
}, },
async getById(id) { async getById(id) {
@ -141,7 +152,8 @@ const sources = {
async getByType(type) { async getByType(type) {
const db = await loadDb(); 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) { async create(source) {
@ -149,6 +161,7 @@ const sources = {
const newSource = { const newSource = {
id: db.nextId++, id: db.nextId++,
...source, ...source,
...(source.type === 'epg' ? { epgPriority: nextEpgPriority(db.sources) } : {}),
enabled: true, enabled: true,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_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)); const index = db.sources.findIndex(s => s.id === parseInt(id));
if (index === -1) return null; 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] = {
...db.sources[index], ...db.sources[index],
...updates, ...updates,
@ -174,6 +192,8 @@ const sources = {
async delete(id) { async delete(id) {
const db = await loadDb(); 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)); db.sources = db.sources.filter(s => s.id !== parseInt(id));
// Also delete related hidden items and favorites // Also delete related hidden items and favorites
db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id)); db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id));
@ -190,6 +210,13 @@ const sources = {
await saveDb(db); await saveDb(db);
} }
return source; return source;
},
async reorderEpg(orderedIds) {
const db = await loadDb();
const ordered = applyEpgOrder(db.sources, orderedIds);
await saveDb(db);
return ordered;
} }
}; };

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

View File

@ -1,6 +1,12 @@
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const path = require('path'); const path = require('path');
const fs = require('fs'); 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 dataDir = path.join(__dirname, '..', '..', 'data');
const dbPath = path.join(dataDir, 'content.db'); 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); 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 // EPG Programs
// Optimized for range queries // Optimized for range queries
db.exec(` db.exec(`
@ -135,6 +145,18 @@ function initSchema() {
CREATE INDEX IF NOT EXISTS idx_history_user_item ON watch_history(user_id, item_id); 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) // Migration: Add source_id column if missing (for existing databases)
try { try {
db.exec(`ALTER TABLE watch_history ADD COLUMN source_id INTEGER`); 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 = { module.exports = {
getDb, getDb,
initSchema, initSchema,
favorites ensureColumns,
LIVE_PROJECTION_COLUMNS,
favorites,
layaLanguageCache
}; };

View File

@ -180,6 +180,8 @@ app.use('/api/probe', require('./routes/probe'));
app.use('/api/subtitle', require('./routes/subtitle')); app.use('/api/subtitle', require('./routes/subtitle'));
app.use('/api/settings', require('./routes/settings')); app.use('/api/settings', require('./routes/settings'));
app.use('/api/history', require('./routes/history')); app.use('/api/history', require('./routes/history'));
app.use('/api/laya', require('./routes/laya'));
app.use('/api/live', require('./routes/live'));
// Version endpoint // Version endpoint
app.get('/api/version', (req, res) => { app.get('/api/version', (req, res) => {

42
server/routes/laya.js Normal file
View 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
View 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;

View File

@ -87,6 +87,22 @@ function analyzeProbeResult(probeResult, url) {
const videoCodec = videoStream?.codec_name?.toLowerCase() || 'unknown'; const videoCodec = videoStream?.codec_name?.toLowerCase() || 'unknown';
const audioCodec = audioStream?.codec_name?.toLowerCase() || 'unknown'; const audioCodec = audioStream?.codec_name?.toLowerCase() || 'unknown';
const container = format.format_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 // Check codec compatibility
const videoOk = BROWSER_VIDEO_CODECS.some(c => videoCodec.includes(c)); const videoOk = BROWSER_VIDEO_CODECS.some(c => videoCodec.includes(c));
@ -131,6 +147,11 @@ function analyzeProbeResult(probeResult, url) {
width: videoStream?.width || 0, width: videoStream?.width || 0,
height: videoStream?.height || 0, height: videoStream?.height || 0,
audioChannels: audioStream?.channels || 0, // For Smart Audio Copy audioChannels: audioStream?.channels || 0, // For Smart Audio Copy
durationSeconds,
videoBitrateKbps,
audioBitrateKbps,
overallBitrateKbps,
probeConfidence,
container: container, container: container,
compatible: compatible, compatible: compatible,
needsRemux: needsRemux, needsRemux: needsRemux,
@ -157,7 +178,12 @@ router.get('/', async (req, res) => {
container: 'unknown', container: 'unknown',
compatible: false, compatible: false,
needsRemux: 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, compatible: false,
needsRemux: false, needsRemux: false,
needsTranscode: true, needsTranscode: true,
durationSeconds: null,
videoBitrateKbps: null,
audioBitrateKbps: null,
overallBitrateKbps: null,
probeConfidence: 'partial',
error: err.message error: err.message
}); });
} }

View File

@ -5,6 +5,7 @@ const { getDb } = require('../db/sqlite');
const xtreamApi = require('../services/xtreamApi'); const xtreamApi = require('../services/xtreamApi');
const syncService = require('../services/syncService'); const syncService = require('../services/syncService');
const m3uParser = require('../services/m3uParser'); const m3uParser = require('../services/m3uParser');
const liveProjection = require('../services/liveProjectionService');
// Get all sources // Get all sources
router.get('/', async (req, res) => { 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 // Get single source
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
try { try {
@ -92,6 +107,9 @@ router.put('/:id', async (req, res) => {
} }
const { name, url, username, password } = req.body; 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, { const updated = await sources.update(req.params.id, {
name: name || existing.name, name: name || existing.name,
url: url || existing.url, url: url || existing.url,
@ -115,6 +133,9 @@ router.delete('/:id', async (req, res) => {
if (!existing) { if (!existing) {
return res.status(404).json({ error: 'Source not found' }); 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 // Cascade delete: Clean up SQLite data for this source
const db = getDb(); 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) // Delete source config and related hidden items (favorites handled by db.js)
await sources.delete(sourceId); await sources.delete(sourceId);
await liveProjection.queueRebuild();
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
@ -278,4 +300,3 @@ router.post('/sync-all', async (req, res) => {
}); });
module.exports = router; module.exports = router;

View File

@ -5,6 +5,7 @@ const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const db = require('../db'); const db = require('../db');
const transcodeSession = require('../services/transcodeSession'); const transcodeSession = require('../services/transcodeSession');
const { QUALITY_MODES, resolveQualityMode } = require('../services/transcodeQualityProfiles');
/** /**
* Transcode Routes * Transcode Routes
@ -29,7 +30,11 @@ transcodeSession.startCleanupInterval();
* Body: { url: string, seekOffset?: number } * Body: { url: string, seekOffset?: number }
*/ */
router.post('/session', async (req, res) => { 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) { if (!url) {
return res.status(400).json({ error: 'URL is required' }); return res.status(400).json({ error: 'URL is required' });
@ -40,6 +45,7 @@ router.post('/session', async (req, res) => {
const userAgent = db.getUserAgent(settings); const userAgent = db.getUserAgent(settings);
try { try {
const qualitySelection = resolveQualityMode(qualityMode, settings);
const session = await transcodeSession.createSession(url, { const session = await transcodeSession.createSession(url, {
ffmpegPath, ffmpegPath,
userAgent, userAgent,
@ -47,6 +53,9 @@ router.post('/session', async (req, res) => {
hwEncoder: settings.hwEncoder || 'software', hwEncoder: settings.hwEncoder || 'software',
maxResolution: settings.maxResolution || '1080p', maxResolution: settings.maxResolution || '1080p',
quality: settings.quality || 'medium', quality: settings.quality || 'medium',
targetVideoBitrateKbps: targetVideoBitrateKbps ?? settings.targetTranscodeBitrateKbps ?? 6000,
targetAudioBitrateKbps: targetAudioBitrateKbps ?? 192,
allowHardwareFallback: allowHardwareFallback ?? settings.allowHardwareFallback ?? true,
audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset
// Upscaling options // Upscaling options
upscaleEnabled: settings.upscaleEnabled || false, upscaleEnabled: settings.upscaleEnabled || false,
@ -55,7 +64,8 @@ router.post('/session', async (req, res) => {
videoMode: videoMode, // 'copy' or 'encode' videoMode: videoMode, // 'copy' or 'encode'
videoCodec: videoCodec, // 'h264', 'hevc', etc. videoCodec: videoCodec, // 'h264', 'hevc', etc.
audioCodec: audioCodec, // 'aac', 'ac3', 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(); await session.start();
@ -71,10 +81,25 @@ router.post('/session', async (req, res) => {
res.json({ res.json({
sessionId: session.id, sessionId: session.id,
playlistUrl: `/api/transcode/${session.id}/stream.m3u8`, 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) { } 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); console.error('[Transcode] Session creation failed:', err);
res.status(500).json({ error: 'Failed to create session', details: err.message }); res.status(500).json({ error: 'Failed to create session', details: err.message });
} }
@ -270,4 +295,3 @@ router.get('/', async (req, res) => {
}); });
module.exports = router; module.exports = router;

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

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

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

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

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

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

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

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

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

View File

@ -3,6 +3,7 @@ const { sources, settings } = require('../db'); // For source config and setting
const xtreamApi = require('./xtreamApi'); const xtreamApi = require('./xtreamApi');
const m3uParser = require('./m3uParser'); const m3uParser = require('./m3uParser');
const epgParser = require('./epgParser'); const epgParser = require('./epgParser');
const liveProjection = require('./liveProjectionService');
// Sync tracking // Sync tracking
const activeSyncs = new Set(); // sourceId const activeSyncs = new Set(); // sourceId
@ -183,6 +184,7 @@ class SyncService {
console.log(`[Sync] Fetching Live Streams for ${source.name}`); console.log(`[Sync] Fetching Live Streams for ${source.name}`);
const liveStreams = await api.getLiveStreams(); const liveStreams = await api.getLiveStreams();
await this.saveStreams(source.id, 'live', liveStreams); await this.saveStreams(source.id, 'live', liveStreams);
await liveProjection.queueRebuild();
// 3. VOD Categories // 3. VOD Categories
console.log(`[Sync] Fetching VOD Categories for ${source.name}`); console.log(`[Sync] Fetching VOD Categories for ${source.name}`);
@ -559,6 +561,7 @@ class SyncService {
})); }));
await this.saveCategories(source.id, 'live', categories); await this.saveCategories(source.id, 'live', categories);
await liveProjection.queueRebuild();
console.log(`[Sync] M3U sync complete for ${source.name}`); console.log(`[Sync] M3U sync complete for ${source.name}`);
} }

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

View File

@ -63,20 +63,28 @@ class TranscodeSession extends EventEmitter {
this.segments = new Map(); // segment index -> { ready: boolean, path: string } this.segments = new Map(); // segment index -> { ready: boolean, path: string }
this.status = 'pending'; // pending | starting | running | stopped | error this.status = 'pending'; // pending | starting | running | stopped | error
this.error = null; 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.startTime = Date.now();
this.lastAccess = Date.now(); this.lastAccess = Date.now();
this.options = { this.options = {
...options,
ffmpegPath: options.ffmpegPath || 'ffmpeg', ffmpegPath: options.ffmpegPath || 'ffmpeg',
userAgent: options.userAgent || 'Mozilla/5.0', userAgent: options.userAgent || 'Mozilla/5.0',
seekOffset: options.seekOffset || 0, seekOffset: options.seekOffset || 0,
hwEncoder: options.hwEncoder || 'software', hwEncoder: options.hwEncoder || 'software',
maxResolution: options.maxResolution || '1080p', maxResolution: options.maxResolution || '1080p',
quality: options.quality || 'medium', quality: options.quality || 'medium',
targetVideoBitrateKbps: this.normalizeBitrate(options.targetVideoBitrateKbps, 6000),
targetAudioBitrateKbps: this.normalizeBitrate(options.targetAudioBitrateKbps, 192),
allowHardwareFallback: options.allowHardwareFallback !== false,
// Upscaling options // Upscaling options
upscaleEnabled: options.upscaleEnabled || false, upscaleEnabled: options.upscaleEnabled || false,
upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software' upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software'
upscaleTarget: options.upscaleTarget || '1080p', upscaleTarget: options.upscaleTarget || '1080p'
...options
}; };
} }
@ -100,27 +108,62 @@ class TranscodeSession extends EventEmitter {
throw err; throw err;
} }
// Build FFmpeg arguments for HLS output try {
const args = this.buildFFmpegArgs(); 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(' ')}`); console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`);
try { try {
this.process = spawn(this.options.ffmpegPath, args, { const process = spawn(this.options.ffmpegPath, args, {
cwd: this.dir, cwd: this.dir,
windowsHide: true windowsHide: true
}); });
this.process = process;
this.status = 'running'; this.status = 'running';
// Handle stdout (should be empty for file output) // 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}`); console.log(`[TranscodeSession ${this.id}] stdout: ${data}`);
}); });
// Handle stderr (FFmpeg progress/errors) // Handle stderr (FFmpeg progress/errors)
let stderrBuffer = ''; let stderrBuffer = '';
this.process.stderr.on('data', (data) => { process.stderr.on('data', (data) => {
stderrBuffer += data.toString(); stderrBuffer += data.toString();
// Log periodically to avoid spam // Log periodically to avoid spam
const lines = stderrBuffer.split('\n'); const lines = stderrBuffer.split('\n');
@ -135,51 +178,62 @@ class TranscodeSession extends EventEmitter {
}); });
// Handle process exit // Handle process exit
this.process.on('exit', (code) => { process.on('exit', (code) => {
if (generation !== this.processGeneration) return;
if (code === 0 || code === null) { if (code === 0 || code === null) {
console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`); console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`);
this.status = 'stopped'; this.status = 'stopped';
} else if (code !== 255) { // 255 is often from SIGKILL } 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.status = 'error';
this.error = `FFmpeg exited with code ${code}`; this.error = error;
} }
this.process = null; this.process = null;
this.emit('exit', code); this.emit('exit', code);
}); });
// Handle spawn errors // 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); console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err);
this.status = 'error'; this.status = 'error';
this.error = err.message; this.error = err.message;
this.emit('error', err); if (this.listenerCount('error') > 0) {
this.emit('error', err);
}
}); });
// Save session metadata
await this.persist();
} catch (err) { } catch (err) {
this.status = 'error'; if (this.tryHardwareFallback(err.message, encoder)) return;
this.error = err.message;
throw err; 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 * Build FFmpeg arguments for HLS output with optional GPU encoding
*/ */
buildFFmpegArgs() { buildFFmpegArgs(resolvedEncoder = null) {
const segmentPattern = path.join(this.dir, 'seg%04d.m4s'); const segmentPattern = path.join(this.dir, 'seg%04d.m4s');
const videoMode = this.options.videoMode || 'encode'; const videoMode = this.options.videoMode || 'encode';
// Resolve 'auto' encoder to detected hardware, fallback to software // Resolve 'auto' encoder to detected hardware, fallback to software
let encoder = this.options.hwEncoder || 'software'; const encoder = resolvedEncoder || this.resolveEncoder();
if (encoder === 'auto') {
const hwCaps = hwDetect.getCapabilities();
encoder = hwCaps?.recommended || 'software';
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
}
const args = [ const args = [
'-hide_banner', '-hide_banner',
@ -264,7 +318,7 @@ class TranscodeSession extends EventEmitter {
args.push( args.push(
'-c:a', 'aac', '-c:a', 'aac',
'-ar', '48000', '-ar', '48000',
'-b:a', '192k', '-b:a', `${this.options.targetAudioBitrateKbps}k`,
'-af', `${panFilter},aresample=async=1` '-af', `${panFilter},aresample=async=1`
); );
} }
@ -336,6 +390,7 @@ class TranscodeSession extends EventEmitter {
'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 } 'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 }
}; };
const qp = qualityPresets[quality] || qualityPresets.medium; const qp = qualityPresets[quality] || qualityPresets.medium;
const bitrate = this.options.targetVideoBitrateKbps;
switch (encoder) { switch (encoder) {
case 'nvenc': case 'nvenc':
@ -356,6 +411,7 @@ class TranscodeSession extends EventEmitter {
this.addSoftwareEncoderArgs(args, resolution, qp.software); this.addSoftwareEncoderArgs(args, resolution, qp.software);
break; break;
} }
args.push('-b:v', `${bitrate}k`, '-maxrate', `${bitrate}k`, '-bufsize', `${bitrate * 2}k`);
} }
/** /**
@ -432,8 +488,8 @@ class TranscodeSession extends EventEmitter {
args.push( args.push(
'-c:v', 'h264_nvenc', '-c:v', 'h264_nvenc',
'-preset', 'p4', // Balanced preset (p1=fastest, p7=best) '-preset', 'p4', // Balanced preset (p1=fastest, p7=best)
'-rc', 'constqp', // Constant QP mode '-rc', 'vbr', // Bitrate-constrained variable bitrate
'-qp', String(qp), '-cq', String(qp), // Quality target within bitrate constraint
'-bf', '3' // B-frames for better compression '-bf', '3' // B-frames for better compression
); );
} }
@ -448,10 +504,7 @@ class TranscodeSession extends EventEmitter {
args.push( args.push(
'-c:v', 'h264_amf', '-c:v', 'h264_amf',
'-quality', 'quality', // Quality preset '-quality', 'quality', // Quality preset
'-rc', 'cqp', // Constant QP '-rc', 'vbr_peak', // Bitrate-constrained variable bitrate
'-qp_i', String(qp),
'-qp_p', String(qp + 2),
'-qp_b', String(qp + 4),
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility '-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
); );
} }
@ -556,6 +609,7 @@ class TranscodeSession extends EventEmitter {
const startTime = Date.now(); const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) { while (Date.now() - startTime < timeoutMs) {
if (await this.isPlaylistReady()) { if (await this.isPlaylistReady()) {
this.playlistReady = true;
return true; return true;
} }
await new Promise(resolve => setTimeout(resolve, 200)); await new Promise(resolve => setTimeout(resolve, 200));
@ -600,6 +654,9 @@ class TranscodeSession extends EventEmitter {
startTime: this.startTime, startTime: this.startTime,
lastAccess: this.lastAccess, lastAccess: this.lastAccess,
options: this.options, options: this.options,
requestedEncoder: this.requestedEncoder,
effectiveEncoder: this.effectiveEncoder,
hardwareFallbackOccurred: this.hardwareFallbackOccurred,
seekOffset: this.options.seekOffset seekOffset: this.options.seekOffset
}; };
const metaPath = path.join(this.dir, 'session.json'); const metaPath = path.join(this.dir, 'session.json');
@ -621,6 +678,9 @@ class TranscodeSession extends EventEmitter {
session.startTime = metadata.startTime; session.startTime = metadata.startTime;
session.lastAccess = metadata.lastAccess; session.lastAccess = metadata.lastAccess;
session.status = 'stopped'; // Not running after restart 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; return session;
} catch (err) { } catch (err) {
console.error(`Failed to restore session from ${sessionDir}:`, err.message); console.error(`Failed to restore session from ${sessionDir}:`, err.message);
@ -753,7 +813,10 @@ function getAllSessions() {
status: s.status, status: s.status,
startTime: s.startTime, startTime: s.startTime,
lastAccess: s.lastAccess, lastAccess: s.lastAccess,
idleMs: Date.now() - s.lastAccess idleMs: Date.now() - s.lastAccess,
requestedEncoder: s.requestedEncoder,
effectiveEncoder: s.effectiveEncoder,
hardwareFallbackOccurred: s.hardwareFallbackOccurred
})); }));
} }

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

View 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:\/\//);
});

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

View 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
View 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/);
});

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

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

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

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

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

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

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

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

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

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

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

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