diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7b554b8 --- /dev/null +++ b/.dockerignore @@ -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 + diff --git a/Dockerfile b/Dockerfile index 73316e7..084f2c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,8 +40,9 @@ WORKDIR /app # Copy package files COPY package*.json ./ -# Install dependencies (better-sqlite3 will build from source using g++ installed above) -RUN npm ci --only=production +# Install dependencies (better-sqlite3 will build for the image's Node.js ABI). +# node_modules is excluded by .dockerignore so COPY below cannot overwrite it. +RUN npm ci --omit=dev # Copy application files COPY . . diff --git a/Dockerfile.laya b/Dockerfile.laya new file mode 100644 index 0000000..d1e380b --- /dev/null +++ b/Dockerfile.laya @@ -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"] diff --git a/README.md b/README.md index 19dfcdf..817ee1e 100644 --- a/README.md +++ b/README.md @@ -64,31 +64,60 @@ nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (V ### Docker Deployment -You can run nodecast-tv easily using Docker. +The provided `docker-compose.yml` is standalone: copy that file to an empty deployment directory and run Compose there. You do not need to clone the repository or keep any Dockerfile for a normal deployment. Application data is stored in the `./data` directory next to the Compose file. -1. Create a `docker-compose.yml` file (or copy the one from this repo): +#### Standard deployment - ```yaml - services: - nodecast-tv: - build: https://github.com/technomancer702/nodecast-tv.git#main - container_name: nodecast-tv - ports: - - "3000:3000" # Host:Container - volumes: - - ./data:/app/data - restart: unless-stopped - environment: - - NODE_ENV=production - - PORT=3000 # Optional: Internal container port - ``` +Start nodecast-tv without the optional Laya runtime: -2. Run the container: - ```bash - docker-compose up -d - ``` +```bash +docker compose up -d +``` -The application will be available at `http://localhost:3000`. +Compose pulls `${NODECAST_IMAGE}` when set, or `gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest` by default. The application will be available at `http://localhost:3000`. Live channel analysis continues to work in **Standard** mode when Laya is not running. + +#### Optional Laya runtime + +Laya provides optional local-AI assistance for channel grouping and language detection. It is behind the Compose profile named `laya`, so a plain `docker compose up -d` does not start it. + +Start nodecast-tv and Laya together: + +```bash +docker compose --profile laya up -d +``` + +Pull newer images and recreate both services after an update: + +```bash +docker compose --profile laya pull +docker compose --profile laya up -d --force-recreate +``` + +Recreate only Laya: + +```bash +docker compose --profile laya pull laya +docker compose --profile laya up -d --force-recreate laya +``` + +The optional service uses `${LAYA_IMAGE}` when set, or the project image `gitea.anthonybouteiller.ovh/blomios/nodecast-tv-laya:latest` by default. That image must be published and accessible in your environment. If it is hosted under another registry or tag, create a `.env` file next to `docker-compose.yml`: + +```env +NODECAST_IMAGE=gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest +LAYA_IMAGE=registry.example.com/team/nodecast-tv-laya:latest +``` + +No third-party Laya image is selected implicitly. If no suitable Laya image is available, omit the `laya` profile and continue using Standard mode. The Laya model cache is persisted under `./data/models/laya`; its first analysis can take longer while the runtime downloads or loads the model. + +Inside Compose, nodecast-tv reaches Laya at `http://laya:8000`. When running nodecast-tv directly on the host with a separately exposed Laya runtime, use: + +```bash +LAYA_BASE_URL=http://localhost:8000 npm start +``` + +`http://laya:8000` is a Compose-internal hostname and does not resolve from a host process. + +Repository maintainers can build and publish both the nodecast-tv and Laya images with `./scripts/docker-build-push.sh`; this script is not needed by Compose users. `REGISTRY`, `OWNER`, `IMAGE_NAME`, `LAYA_IMAGE_NAME`, and `TAG` can override the publication targets. Use `--no-push` to build both images locally without publishing them. ### Hardware Acceleration Setup @@ -211,6 +240,45 @@ All transcoding and stream processing settings are found in **Settings → Trans ## Troubleshooting +### Docker Compose and optional Laya + +Check that you are in the repository directory and that Compose sees both services: + +```bash +docker compose config --services +docker compose --profile laya config --services +docker compose config --profiles +``` + +The standard configuration should list `nodecast-tv`; with the `laya` profile enabled it should list both `nodecast-tv` and `laya`. The available profiles should include `laya`. + +If `docker compose ... laya` reports `no such service: laya`, you are usually using an old or different Compose file, or running the command from the wrong directory. Replace it with the current standalone `docker-compose.yml`, change to the directory containing that file, and run `docker compose config --services` again. + +If Compose cannot pull the Laya image, verify that `LAYA_IMAGE` names a published image and authenticate to its registry if necessary. This does not prevent the standard nodecast-tv service from running without the `laya` profile. + +If Settings reports **“The optional Laya runtime is unavailable”**, nodecast-tv remains usable and falls back to Standard analysis. To diagnose Laya, run: + +```bash +docker compose --profile laya ps +docker compose --profile laya logs nodecast-tv +docker compose --profile laya logs laya +docker compose --profile laya config +``` + +After configuration or image changes, pull and perform a clean recreation: + +```bash +docker compose --profile laya pull +docker compose --profile laya up -d --force-recreate +``` + +To recreate only the optional runtime: + +```bash +docker compose --profile laya pull laya +docker compose --profile laya up -d --force-recreate laya +``` + ### Video Won't Play (Black Screen or Loading Forever) | Symptom | Likely Cause | Solution | diff --git a/docker-compose.yml b/docker-compose.yml index 6de58db..ad95e73 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,6 @@ services: nodecast-tv: - image: ghcr.io/technomancer702/nodecast-tv:latest - build: https://github.com/technomancer702/nodecast-tv.git#main + image: ${NODECAST_IMAGE:-gitea.anthonybouteiller.ovh/blomios/nodecast-tv:latest} container_name: nodecast-tv ports: - "3000:3000" @@ -11,3 +10,25 @@ services: environment: - NODE_ENV=production - PORT=3000 # Internal container port + - LAYA_BASE_URL=http://laya:8000 + - LAYA_BATCH_TIMEOUT_MS=120000 + devices: + - /dev/dri:/dev/dri # Required for VAAPI/QuickSync/AMF (Linux) + + laya: + profiles: ["laya"] + image: ${LAYA_IMAGE:-gitea.anthonybouteiller.ovh/blomios/nodecast-tv-laya:latest} + volumes: + - ./data/models/laya:/models + environment: + - HF_HOME=/models + - LAYA_PRELOAD=0 + - LAYA_HOST=0.0.0.0 + - LAYA_PORT=8000 + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s + restart: unless-stopped diff --git a/package-lock.json b/package-lock.json index 3113d49..7595c62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,9 @@ "sax": "^1.4.3", "xml2js": "^0.6.2" }, + "devDependencies": { + "jsdom": "^22.1.0" + }, "engines": { "node": ">=18.0.0" }, @@ -175,6 +178,16 @@ "win32" ] }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/node": { "version": "10.17.60", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", @@ -182,6 +195,14 @@ "license": "MIT", "optional": true }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -199,8 +220,8 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "debug": "4" }, @@ -212,8 +233,8 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -230,8 +251,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/array-flatten": { "version": "1.1.1", @@ -239,6 +260,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -414,6 +442,19 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -466,6 +507,34 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cssstyle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", + "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/data-urls": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", + "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -475,6 +544,13 @@ "ms": "2.0.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -499,6 +575,16 @@ "node": ">=4.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -527,6 +613,20 @@ "node": ">=8" } }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -586,6 +686,19 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -626,6 +739,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -756,6 +885,23 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -856,10 +1002,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -868,6 +1030,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -888,6 +1063,46 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/http-response-object": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", @@ -902,8 +1117,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -916,8 +1131,8 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -934,8 +1149,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/iconv-lite": { "version": "0.4.24", @@ -990,6 +1205,56 @@ "node": ">= 0.10" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", + "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "cssstyle": "^3.0.0", + "data-urls": "^4.0.0", + "decimal.js": "^10.4.3", + "domexception": "^4.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.4", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.1", + "ws": "^8.13.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -1210,6 +1475,13 @@ "node": ">=10" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/oauth": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", @@ -1264,6 +1536,19 @@ "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==", "optional": true }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1397,6 +1682,19 @@ "node": ">= 0.10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -1407,6 +1705,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -1422,6 +1730,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -1484,6 +1799,20 @@ "node": ">= 6" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1516,6 +1845,19 @@ "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", "license": "BlueOak-1.0.0" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -1723,6 +2065,13 @@ "node": ">=0.10.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -1760,6 +2109,35 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -1804,6 +2182,16 @@ "node": ">= 0.8" } }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1813,6 +2201,17 @@ "node": ">= 0.8" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -1837,12 +2236,118 @@ "node": ">= 0.8" } }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", @@ -1864,6 +2369,13 @@ "engines": { "node": ">=4.0" } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 6bef281..b5bd8f5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "main": "server/index.js", "scripts": { "start": "node server/index.js", - "dev": "node --watch server/index.js" + "dev": "node --watch server/index.js", + "test": "node --test" }, "dependencies": { "@ffprobe-installer/ffprobe": "^2.1.2", @@ -28,5 +29,8 @@ }, "engines": { "node": ">=18.0.0" + }, + "devDependencies": { + "jsdom": "^22.1.0" } -} \ No newline at end of file +} diff --git a/public/css/main.css b/public/css/main.css index dedba28..ae672d0 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -537,6 +537,82 @@ h4 { margin-top: var(--space-sm); } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.language-filter { + position: relative; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs); + margin-top: var(--space-sm); +} + +.language-filter-label { + width: 100%; + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.language-chip { + min-height: 32px; + padding: 4px 9px; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-bg-tertiary); + color: var(--color-text-secondary); + cursor: pointer; +} + +.language-chip[aria-pressed="true"] { + border-color: var(--color-accent); + background: var(--color-accent-dim); + color: var(--color-text-primary); +} + +.language-more-panel { + position: absolute; + z-index: 20; + top: 100%; + left: 0; + right: 0; + padding: var(--space-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-secondary); + box-shadow: var(--shadow-lg); +} + +.language-search { + box-sizing: border-box; + width: 100%; + min-height: 40px; + margin-bottom: var(--space-sm); + padding: var(--space-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.language-options { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); + max-height: 180px; + overflow: auto; +} + .source-select { flex: 1; padding: var(--space-xs) var(--space-sm); @@ -647,6 +723,28 @@ h4 { object-fit: contain; } +.channel-logo-wrap { + position: relative; + flex: 0 0 40px; + height: 40px; +} + +.channel-language-badge { + position: absolute; + top: -4px; + left: -4px; + min-width: 20px; + padding: 1px 4px; + border-radius: 999px; + background: var(--color-accent); + color: #fff; + font-size: 0.625rem; + font-weight: 700; + line-height: 18px; + text-align: center; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45); +} + .channel-info { flex: 1; min-width: 0; @@ -667,6 +765,242 @@ h4 { text-overflow: ellipsis; } +.variant-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + border: 0; + background: transparent; + color: var(--color-text-muted); + font-size: 0.72rem; + white-space: nowrap; + cursor: pointer; +} + +.logical-channel.expanded .variant-chevron { + transform: rotate(180deg); +} + +.channel-variants { + margin: 0 var(--space-md) var(--space-xs) 56px; + border-left: 1px solid var(--color-border); +} + +.channel-variant { + width: 100%; + display: grid; + grid-template-columns: minmax(52px, auto) minmax(64px, auto) minmax(64px, auto) 1fr; + gap: var(--space-sm); + padding: 6px var(--space-sm); + border: 0; + background: transparent; + color: var(--color-text-muted); + text-align: left; + cursor: pointer; +} + +.channel-variant:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.channel-variant.is-hidden { + opacity: 0.5; +} + +.variant-quality { + color: var(--color-accent); + font-weight: 600; +} + +.variant-language { + justify-self: start; + padding: 1px 5px; + border-radius: 999px; + background: var(--color-bg-tertiary); + color: var(--color-text-muted); + font-size: 0.68rem; + white-space: nowrap; +} + +.channel-matching-settings { + margin-top: var(--space-lg); + padding-top: var(--space-md); + border-top: 1px solid var(--color-border); +} + +.matching-mode-options { + display: grid; + gap: var(--space-sm); + margin: var(--space-md) 0; +} + +.matching-mode-card { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: var(--space-md); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; +} + +.matching-mode-card:has(input:checked) { + border-color: var(--color-accent); + background: var(--color-accent-dim); +} + +.matching-mode-card span, +.matching-mode-card small { + display: block; +} + +.matching-mode-card small { + margin-top: 4px; + color: var(--color-text-muted); +} + +.laya-model-panel { + padding: var(--space-md); + border-radius: var(--radius-sm); + background: var(--color-bg-tertiary); +} + +.live-analysis-panel { + margin-bottom: var(--space-md); + padding: var(--space-md); + border-radius: var(--radius-sm); + background: var(--color-bg-tertiary); +} + +.live-analysis-panel [hidden] { + display: none !important; +} + +.live-analysis-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-md); +} + +@media (max-width: 600px) { + .live-analysis-heading { + flex-direction: column; + } +} + +.laya-model-panel [hidden] { + display: none !important; +} + +.laya-model-heading, +.laya-model-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); +} + +.laya-component-status { + display: flex; + align-items: baseline; + gap: var(--space-xs); + margin-top: var(--space-sm); +} + +#laya-service-state { + margin-top: 4px; +} + +.laya-start-command { + padding: var(--space-sm); + border-radius: var(--radius-sm); + background: var(--color-bg-secondary); + overflow-x: auto; + user-select: all; +} + +.laya-notice-action { + margin-left: var(--space-sm); + padding: 2px var(--space-xs); +} + +.laya-progress { + margin-top: var(--space-md); +} + +.laya-progress-track { + position: relative; + height: 8px; + overflow: hidden; + border-radius: 999px; + background: var(--color-bg-secondary); +} + +.laya-progress-bar { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: var(--color-accent); + transition: width var(--transition-fast); +} + +.laya-progress-track.indeterminate .laya-progress-bar { + width: 35%; + animation: laya-progress-indeterminate 1.2s ease-in-out infinite; +} + +@keyframes laya-progress-indeterminate { + from { transform: translateX(-120%); } + to { transform: translateX(360%); } +} + +@media (prefers-reduced-motion: reduce) { + .laya-progress-track.indeterminate .laya-progress-bar { + animation: none; + width: 100%; + opacity: .45; + } +} + +.laya-details-toggle { + margin-top: var(--space-sm); + padding-left: 0; +} + +.laya-error-details { + margin-top: var(--space-xs); + padding: var(--space-sm); + border-radius: var(--radius-sm); + background: var(--color-bg-secondary); + color: var(--color-text-muted); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.laya-model-actions { + justify-content: flex-start; + margin: var(--space-md) 0; +} + +.laya-language-notice, +.live-projection-notice { + margin: var(--space-sm) var(--space-md); + padding: var(--space-sm); + border-radius: var(--radius-sm); + background: var(--color-bg-tertiary); + color: var(--color-text-muted); + font-size: .75rem; +} + +.variant-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ===================================================== Video Player Section ===================================================== */ @@ -1284,11 +1618,124 @@ video::cue { word-break: break-all; } +.source-meta { + margin-top: 2px; + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.source-badge { + display: inline-block; + margin-left: var(--space-xs); + padding: 2px 6px; + border-radius: 999px; + background: var(--color-accent-dim); + color: var(--color-accent); + font-size: 0.6875rem; + font-weight: 600; +} + +@media (hover: none) and (pointer: coarse) { + .language-chip, + .variant-toggle, + .source-actions .btn { + min-height: 44px; + min-width: 44px; + } +} + .source-actions { display: flex; + flex-wrap: wrap; gap: var(--space-xs); } +.epg-sources-section h4 { + margin: var(--space-lg) 0 var(--space-xs); +} + +.epg-sources-section .setting-hint + .setting-hint { + margin-top: 2px; +} + +.epg-order-status { + min-height: 1.25rem; + margin: var(--space-sm) 0; + color: var(--color-text-muted); + font-size: 0.8125rem; +} + +.epg-order-status[data-state="success"] { + color: var(--color-success); +} + +.epg-order-status[data-state="error"] { + color: var(--color-error); +} + +.epg-priority-controls { + display: flex; + align-items: center; + gap: var(--space-sm); + flex: 0 0 auto; +} + +.epg-priority-rank { + display: grid; + min-width: 68px; + color: var(--color-text-muted); + font-size: 0.6875rem; + text-align: center; +} + +.epg-priority-rank strong { + color: var(--color-text-primary); + font-size: 1.125rem; + line-height: 1.1; +} + +.epg-move-actions { + display: grid; + grid-template-columns: repeat(2, 44px); + gap: var(--space-xs); +} + +.epg-move-actions .btn, +.epg-source-item .source-actions .btn { + min-width: 44px; + min-height: 44px; +} + +.epg-empty-state { + padding: var(--space-lg); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); +} + +.inactive-sources-heading { + margin-top: var(--space-xl) !important; +} + +@media (max-width: 768px) { + .epg-source-item { + align-items: flex-start; + flex-wrap: wrap; + } + + .epg-priority-controls { + width: 100%; + justify-content: space-between; + } + + .epg-source-item .source-info { + min-width: calc(100% - 64px); + } + + .epg-source-item .source-actions { + width: 100%; + } +} + /* Hidden Items */ .hidden-list { display: flex; @@ -1702,6 +2149,44 @@ kbd { border-color: var(--color-accent); } +/* Native select popups do not consistently inherit the application theme. */ +select.source-select, +select.form-input { + color-scheme: dark; + background-color: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +select.source-select:focus, +select.form-input:focus { + border-color: var(--color-accent); +} + +select.source-select option, +select.source-select optgroup, +select.form-input option, +select.form-input optgroup { + background-color: #1a1a25; + color: #f1f1f5; +} + +select.source-select option:hover, +select.source-select option:focus, +select.source-select option:checked, +select.form-input option:hover, +select.form-input option:focus, +select.form-input option:checked { + background-color: #2a2a3a; + color: #f1f1f5; +} + +select.source-select option:disabled, +select.source-select optgroup:disabled, +select.form-input option:disabled, +select.form-input optgroup:disabled { + color: #a1a1aa; +} + /* ===================================================== Context Menu ===================================================== */ @@ -4819,4 +5304,4 @@ kbd { font-size: 0.7rem; max-width: 70px; } -} \ No newline at end of file +} diff --git a/public/index.html b/public/index.html index dd74211..5562954 100644 --- a/public/index.html +++ b/public/index.html @@ -10,7 +10,7 @@ nodecast-tv | IPTV Player - + +
+
@@ -419,12 +421,18 @@
-
+

EPG Sources

-
+

Active sources

+

Sources are checked from top to bottom.

+

Higher sources take priority; lower sources fill missing program data.

+
+
+

Inactive sources

+
@@ -454,6 +462,83 @@
+
+

Live TV

+
+ +
+ Group channels by category + Turn off to show all channels in a single list. Stream variants remain grouped by channel. +
+
+
+

Channel matching

+

Choose how Nodecast TV identifies matching streams and languages.

+
+
+
+ Channel analysis +
Group matching streams into channels and update detected languages.
+
+ +
+

Use this to analyze channels that were added before automatic analysis was enabled.

+
Checking status…
+ +
+ + +
+
+ + + +
+
+
+
Local AI · Laya
Checking status…
+
+
Model: Checking…
+
Local AI service: Checking…
+
+ + + +
+ + + + + +
+ +

The model runs on this server. Channel names and metadata are not sent to an AI service.

+
+
+
+

Keyboard Controls

@@ -539,7 +624,7 @@

Encoder Settings

- Hardware Encoder + Transcoding Device Select which GPU encoder to use for transcoding. Auto will use the best available.
@@ -554,7 +639,7 @@
- Max Resolution + Maximum Transcoded Resolution Limit transcoded output resolution. Lower = faster, more compatible.
@@ -567,7 +652,7 @@
- Quality Preset + Encoding Quality Higher quality uses more bandwidth and CPU/GPU.
@@ -617,7 +702,7 @@
+
+
+ Maximum Streaming Bandwidth + The bandwidth limit is evaluated when playback starts. Quality does not change automatically during playback. +
+ +
+
+ +
+

Stream Processing

- Force Audio Transcode + Always Convert Audio Transcode audio to AAC for browser compatibility. Enable if you get video but no audio (fixes Dolby/AC3/EAC3).
@@ -658,7 +761,7 @@
- Force Video Transcode + Always Convert Video Force full video transcoding (burn-in subtitles, incompatible video). Uses HW encoder. @@ -670,14 +773,14 @@
- Force Remux + Always Remux Streams Remux streams to MP4 container. Enable for raw .ts streams from IPTV middleware like m3u-editor or dispatcharr.
- Stream Output Format + Stream Container Container format for Xtream streams. Try TS if you experience buffering issues.
@@ -686,6 +789,15 @@
+
+ +
+ Use CPU if hardware transcoding fails +
+
@@ -1096,17 +1208,23 @@ - - - - - + + + + + + + + + + + - + - + - \ No newline at end of file + diff --git a/public/js/api.js b/public/js/api.js index 77b1306..5ef235f 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -59,6 +59,7 @@ const API = { toggle: (id) => API.request('POST', `/sources/${id}/toggle`), test: (id) => API.request('POST', `/sources/${id}/test`), sync: (id) => API.request('POST', `/sources/${id}/sync`), // Manual sync + updateEpgOrder: (orderedIds) => API.request('PUT', '/sources/epg/order', { orderedIds }), getStatus: () => API.request('GET', '/sources/status'), // Get all statuses estimate: (id) => API.request('GET', `/sources/${id}/estimate`), // Estimate M3U size estimateByUrl: (url, type) => API.request('POST', '/sources/estimate', { url, type }), // Estimate by URL (before creation) @@ -77,6 +78,19 @@ const API = { hideAll: (sourceId, contentType) => API.request('POST', '/channels/hide/all', { sourceId, contentType }) }, + live: { + getChannels: ({ sourceId = null, includeHidden = false, q = '', languages = [] } = {}) => { + const params = new URLSearchParams(); + if (sourceId) params.set('sourceId', sourceId); + params.set('includeHidden', String(Boolean(includeHidden))); + if (q) params.set('q', q); + if (languages.length) params.set('languages', languages.join(',')); + return API.request('GET', `/live/channels?${params.toString()}`); + }, + rebuildProjection: () => API.request('POST', '/live/projection/rebuild'), + getProjectionStatus: () => API.request('GET', '/live/projection/status') + }, + // Favorites favorites: { getAll: (sourceId = null, itemType = null) => { @@ -95,6 +109,13 @@ const API = { API.request('GET', `/favorites/check?sourceId=${sourceId}&itemId=${itemId}&itemType=${itemType}`) }, + laya: { + getStatus: () => API.request('GET', '/laya/status'), + downloadModel: (model = 'multilingual') => API.request('POST', '/laya/model/download', { model }), + removeModel: () => API.request('DELETE', '/laya/model'), + detectLanguages: (items, model = 'multilingual') => API.request('POST', '/laya/language/batch', { items, model }) + }, + // Proxy proxy: { // Xtream diff --git a/public/js/components/ChannelList.js b/public/js/components/ChannelList.js index 5147892..1b541f4 100644 --- a/public/js/components/ChannelList.js +++ b/public/js/components/ChannelList.js @@ -11,6 +11,8 @@ class ChannelList { this.showHiddenCheckbox = document.getElementById('show-hidden'); this.toggleGroupsBtn = document.getElementById('toggle-groups'); this.contextMenu = document.getElementById('context-menu'); + this.languageFilter = document.getElementById('channel-language-filter'); + this.listStatus = document.getElementById('channel-list-status'); this.channels = []; this.groups = []; @@ -23,8 +25,25 @@ class ChannelList { this.sources = []; this.isLoading = false; this.renderedChannels = []; + this.listMode = 'grouped'; + this.logicalChannels = []; + this.projectedLogicalChannels = null; + this.availableLanguages = []; + this.selectedLanguages = new Set(); + this.selectedVariants = new Map(); + this.expandedVariants = new Set(); + this.preferredLanguageKey = 'nodecast_tv_preferred_language'; + this.channelLanguagePreferencesKey = 'nodecast_tv_channel_language_preferences'; + this.preferredLanguage = this.loadPreferredLanguage(); + this.channelLanguagePreferences = this.loadChannelLanguagePreferences(); + this.layaLanguageResults = new Map(); + this.languageDetectionNotice = ''; + this.languageDetectionFallback = false; + this._languageDetectionRequest = 0; + this.projectionNotice = ''; this.loadCollapsedState(); + this.restoreLanguageSelection(); this.init(); } @@ -59,6 +78,32 @@ class ChannelList { } } + loadPreferredLanguage() { + try { + return localStorage.getItem(this.preferredLanguageKey) || null; + } catch { + return null; + } + } + + loadChannelLanguagePreferences() { + try { + return new Map(Object.entries(JSON.parse(localStorage.getItem(this.channelLanguagePreferencesKey) || '{}'))); + } catch { + return new Map(); + } + } + + saveLanguagePreferences() { + try { + if (this.preferredLanguage) localStorage.setItem(this.preferredLanguageKey, this.preferredLanguage); + else localStorage.removeItem(this.preferredLanguageKey); + localStorage.setItem(this.channelLanguagePreferencesKey, JSON.stringify(Object.fromEntries(this.channelLanguagePreferences))); + } catch (err) { + console.warn('[ChannelList] Unable to persist language preferences:', err.message); + } + } + /** * Save collapsed state to localStorage */ @@ -144,17 +189,40 @@ class ChannelList { } init() { - // Search handler (debounced) + // Search locally whenever a catalog is already available. let searchTimeout; this.searchInput.addEventListener('input', () => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { - this.render(); - }, 300); + if (this.projectedLogicalChannels || this.channels.length) this.render(); + else this.loadChannels({ refreshLanguages: false }); + }, this.projectedLogicalChannels || this.channels.length ? 75 : 250); }); // Source filter handler - this.sourceSelect.addEventListener('change', () => this.loadChannels()); + this.sourceSelect.addEventListener('change', () => { + this.restoreLanguageSelection(); + this.loadChannels(); + }); + + document.addEventListener('keydown', event => { + if (event.key !== 'Escape') return; + const panel = this.languageFilter?.querySelector('.language-more-panel'); + if (panel && !panel.hidden) { + this.closeLanguagePanel(); + return; + } + if (this.searchInput?.value) { + this.searchInput.value = ''; + clearTimeout(searchTimeout); + this.render(); + this.searchInput.focus(); + } + }); + + this.toggleGroupsBtn?.addEventListener('click', () => { + if (this.listMode === 'grouped') this.toggleAllGroups(); + }); // Show hidden toggle if (this.showHiddenCheckbox) { @@ -179,7 +247,8 @@ class ChannelList { // Intersection Observer for lazy loading this.observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) { - this.renderNextBatch(); + if (this.listMode === 'flat') this.renderNextFlatBatch(); + else this.renderNextBatch(); } }, { rootMargin: '100px' }); @@ -216,20 +285,12 @@ class ChannelList { // Find all visible channel items and update their program info const channelItems = this.container.querySelectorAll('.channel-item'); channelItems.forEach(item => { - const channelId = item.dataset.channelId; - const sourceId = item.dataset.sourceId; - - // Find the channel data - const channel = this.channels.find(c => - String(c.id) === String(channelId) && - String(c.sourceId) === String(sourceId) - ); + const channel = this.logicalChannels.find(c => c.fingerprint === item.dataset.logicalId); if (channel) { - const programInfo = this.getProgramInfo(channel); const programElement = item.querySelector('.channel-program'); if (programElement) { - programElement.textContent = programInfo || ''; + programElement.textContent = this.getProgramStatusText(channel); } } }); @@ -246,7 +307,8 @@ class ChannelList { // Cache key: channel_id + current_minute (invalidate every minute) const currentMinute = Math.floor(Date.now() / 60000); - const cacheKey = `${channel.tvgId || channel.name}:${currentMinute}`; + const variant = channel.preferredVariant || channel; + const cacheKey = `${variant.tvgId || variant.name}:${currentMinute}`; if (this._programInfoCache && this._programInfoCache.has(cacheKey)) { return this._programInfoCache.get(cacheKey); @@ -258,7 +320,13 @@ class ChannelList { this._lastCacheMinute = currentMinute; } - const program = window.app.epgGuide.getCurrentProgram(channel.tvgId, channel.name); + let program = window.app.epgGuide.getCurrentProgram(variant.tvgId, variant.name); + if (!program && channel.variants) { + for (const fallback of channel.variants) { + program = window.app.epgGuide.getCurrentProgram(fallback.tvgId, fallback.name); + if (program) break; + } + } const result = program ? program.title : null; this._programInfoCache.set(cacheKey, result); @@ -269,6 +337,14 @@ class ChannelList { } } + getProgramStatusText(channel) { + const title = this.getProgramInfo(channel); + if (title) return `Live: ${title}`; + return window.app?.epgGuide?.loadError + ? 'Program information unavailable' + : 'No program information'; + } + /** * Clear program info cache * Useful when EPG data has been updated @@ -279,6 +355,90 @@ class ChannelList { } } + setListMode(mode) { + this.listMode = mode === 'flat' ? 'flat' : 'grouped'; + if (this.toggleGroupsBtn) { + this.toggleGroupsBtn.hidden = this.listMode === 'flat'; + this.toggleGroupsBtn.disabled = this.listMode === 'flat'; + this.toggleGroupsBtn.classList.toggle('hidden', this.listMode === 'flat'); + } + if (this.channels.length > 0) this.render(); + } + + setLanguageDetectionMode(mode) { + const safeMode = ['algo', 'laya', 'hybrid'].includes(mode) ? mode : 'algo'; + this.layaLanguageResults.clear(); + this.languageDetectionNotice = ''; + this.languageDetectionFallback = false; + if (this.channels.length > 0) { + this.render(); + void this.refreshLayaLanguages(safeMode); + } + } + + async refreshLayaLanguages(mode = window.app?.player?.settings?.languageDetectionMode || 'algo', options = {}) { + const requestId = ++this._languageDetectionRequest; + if (mode === 'algo') { + const hadWarning = Boolean(this.languageDetectionNotice || this.languageDetectionFallback); + this.layaLanguageResults.clear(); + this.languageDetectionNotice = ''; + this.languageDetectionFallback = false; + if (hadWarning) this.render(); + return; + } + + const descriptors = this.channels.map(variant => { + const algo = ChannelLanguageDetection.detectLanguage(variant, { sourceName: variant.sourceName }); + return ChannelLanguageFusion.buildLayaDescriptor( + variant, + ChannelVariantGrouping.getVariantKey(variant), + algo + ); + }); + const results = new Map(); + const totalBatches = Math.ceil(descriptors.length / 64); + try { + const status = await API.laya.getStatus(); + const statusWarning = LayaStatusPresentation.channelListWarning(mode, status); + if (statusWarning) { + if (requestId !== this._languageDetectionRequest) return; + this.layaLanguageResults.clear(); + this.languageDetectionFallback = true; + this.languageDetectionNotice = statusWarning; + this.render(); + return; + } + if (requestId !== this._languageDetectionRequest) return; + this.languageDetectionNotice = ''; + this.languageDetectionFallback = false; + this.render(); + options.onProgress?.({ processed: 0, total: totalBatches }); + for (let index = 0; index < descriptors.length; index += 64) { + const response = await API.laya.detectLanguages(descriptors.slice(index, index + 64)); + (response.results || []).forEach(result => results.set(String(result.id), result)); + if (requestId !== this._languageDetectionRequest) return; + options.onProgress?.({ + processed: Math.floor(index / 64) + 1, + total: totalBatches + }); + } + if (requestId !== this._languageDetectionRequest) return; + if (descriptors.length > 0 && results.size === 0) { + throw new Error('Local AI language detection returned no results'); + } + this.layaLanguageResults = results; + this.languageDetectionNotice = ''; + this.languageDetectionFallback = false; + } catch (err) { + if (requestId !== this._languageDetectionRequest) return; + this.layaLanguageResults.clear(); + this.languageDetectionFallback = true; + this.languageDetectionNotice = LayaStatusPresentation.channelListWarning(mode, {}, true); + console.warn('[ChannelList] Local AI language detection unavailable:', err.message); + } + this.render(); + } + escapeHtml(text) { if (!text) return ''; return text @@ -295,25 +455,72 @@ class ChannelList { render() { const searchTerm = this.searchInput.value.toLowerCase(); const showHidden = this.showHiddenCheckbox ? this.showHiddenCheckbox.checked : false; + this.listMode = window.app?.player?.settings?.liveChannelListMode === 'flat' ? 'flat' : 'grouped'; + if (this.toggleGroupsBtn) { + this.toggleGroupsBtn.hidden = this.listMode === 'flat'; + this.toggleGroupsBtn.disabled = this.listMode === 'flat'; + this.toggleGroupsBtn.classList.toggle('hidden', this.listMode === 'flat'); + } + + this.logicalChannels = this.projectedLogicalChannels || ChannelVariantGrouping.groupChannels(this.channels, { + showHidden, + selectedVariants: this.selectedVariants, + preferredLanguage: this.preferredLanguage, + languagePreferences: this.channelLanguagePreferences, + languageMode: this.languageDetectionFallback + ? 'algo' + : (window.app?.player?.settings?.languageDetectionMode || 'algo'), + languageResults: this.layaLanguageResults, + isFavorite: channel => this.isFavorite(channel.sourceId, channel.id), + isHidden: channel => this.isHidden('channel', channel.sourceId, channel.streamId || channel.id) + }); // Reset batching this.currentBatch = 0; this.batchSize = 100; // Number of groups to render per batch (increased to handle many hidden groups) this.container.innerHTML = ''; // Clear container + if (this.projectionNotice) { + const notice = document.createElement('div'); + notice.className = 'live-projection-notice'; + notice.setAttribute('role', 'status'); + notice.textContent = this.projectionNotice; + this.container.appendChild(notice); + } + if (this.languageDetectionNotice) { + const notice = document.createElement('div'); + notice.className = 'laya-language-notice'; + notice.setAttribute('role', 'status'); + notice.textContent = this.languageDetectionNotice; + const settingsButton = document.createElement('button'); + settingsButton.type = 'button'; + settingsButton.className = 'btn btn-ghost laya-notice-action'; + settingsButton.textContent = 'Open settings'; + settingsButton.addEventListener('click', () => { + window.app?.navigateTo?.('settings'); + window.app?.pages?.settings?.switchTab?.('player'); + }); + notice.appendChild(document.createTextNode(' ')); + notice.appendChild(settingsButton); + this.container.appendChild(notice); + } // Filter and Group channels const groupedChannels = {}; // 1. Filter - this.filteredChannels = this.channels; - if (searchTerm) { - this.filteredChannels = this.channels.filter(ch => - String(ch.name ?? "").toLowerCase().includes(searchTerm) || - String(ch.groupTitle ?? "").toLowerCase().includes(searchTerm) - ); - } + this.filteredChannels = this.logicalChannels.filter(channel => { + const languageMatches = !this.selectedLanguages.size || this.selectedLanguages.has(channel.language?.code); + return languageMatches && LiveChannelProjection.matchesSearch(channel, searchTerm); + }); let filteredChannels = this.filteredChannels; + if (this.listStatus) this.listStatus.textContent = `${filteredChannels.length} channel entries shown`; + + this.showHidden = showHidden; + if (this.listMode === 'flat') { + this.renderFlatList(filteredChannels, searchTerm); + return; + } // 2. Group filteredChannels.forEach(ch => { @@ -325,7 +532,7 @@ class ChannelList { }); // 3. Add Favorites - const favoritedChannels = this.channels.filter(ch => this.isFavorite(ch.sourceId, ch.id)); + const favoritedChannels = this.filteredChannels.filter(ch => ch.isFavorite); if (favoritedChannels.length > 0) { favoritedChannels.sort((a, b) => a.name.localeCompare(b.name)); groupedChannels['Favorites'] = favoritedChannels; @@ -344,9 +551,7 @@ class ChannelList { const channels = groupedChannels[groupName]; // Check if any channel in this group is visible return channels.some(channel => { - const rawChannelId = channel.streamId || channel.id; - const isHidden = this.isHidden('channel', channel.sourceId, rawChannelId); - return !isHidden || showHidden; + return !channel.isHidden || showHidden; }); }); @@ -373,9 +578,7 @@ class ChannelList { const visibleChannels = channels.filter(channel => { if (isFavoritesGroup) return true; - const rawChannelId = channel.streamId || channel.id; - const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId); - return !channelHidden || this.showHidden; + return !channel.isHidden || this.showHidden; }); // Assign unique render IDs for linear navigation @@ -426,6 +629,57 @@ class ChannelList { this.observer.observe(this.loader); } + renderFlatList(channels, searchTerm) { + const orderedChannels = ChannelVariantGrouping.orderFlatChannels(channels); + this.renderedChannels = orderedChannels.map(channel => ({ + ...channel, + _renderId: `rid_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`, + _renderGroup: 'flat' + })); + if (this.renderedChannels.length === 0) { + this.container.innerHTML = ` +
+

${searchTerm ? 'No channels match your search' : 'No channels loaded'}

+

${searchTerm ? 'Try a different search term' : 'Add a source in Settings to get started'}

+
`; + return; + } + + this.currentBatch = 0; + this.batchSize = 100; + this.listContainer = document.createElement('div'); + this.listContainer.className = 'channel-list-content flat-channel-list'; + this.container.appendChild(this.listContainer); + this.loader = document.createElement('div'); + this.loader.className = 'batch-loader'; + this.loader.innerHTML = '
'; + this.container.appendChild(this.loader); + this.renderNextFlatBatch(); + this.observer.observe(this.loader); + } + + renderNextFlatBatch() { + if (!this.loader || !this.listContainer) return; + const start = this.currentBatch * this.batchSize; + const channels = this.renderedChannels.slice(start, start + this.batchSize); + if (channels.length === 0) { + this.loader.style.display = 'none'; + return; + } + const temp = document.createElement('div'); + temp.innerHTML = channels.map(channel => this.renderLogicalChannel( + channel, + channel._renderId, + 'flat', + this.currentChannel?.id === channel.id, + this.currentRenderId === channel._renderId + )).join(''); + this.attachChannelListeners(temp); + while (temp.firstElementChild) this.listContainer.appendChild(temp.firstElementChild); + this.currentBatch++; + if (start + channels.length >= this.renderedChannels.length) this.loader.style.display = 'none'; + } + /** * Render next batch of groups */ @@ -454,9 +708,7 @@ class ChannelList { // Pre-filter visible channels for this group const visibleChannels = channels.filter(channel => { if (isFavoritesGroup) return true; - const rawChannelId = channel.streamId || channel.id; - const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId); - return !channelHidden || this.showHidden; + return !channel.isHidden || this.showHidden; }); // Skip group if no visible channels (derived visibility) @@ -487,39 +739,14 @@ class ChannelList { for (const channel of visibleChannels) { - // Check hidden again for styling (showHidden mode) - const rawChannelId = channel.streamId || channel.id; - const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId); - const isActive = this.currentChannel?.id === channel.id; // Check if this specific instance is the "active" one for navigation purposes const isRenderActive = this.currentRenderId && this.renderedChannels[renderIndex]?._renderId === this.currentRenderId; - const isFavorite = this.isFavorite(channel.sourceId, channel.id); const renderId = this.renderedChannels[renderIndex]?._renderId || ''; const renderGroup = this.renderedChannels[renderIndex]?._renderGroup || groupName; renderIndex++; - - html += ` -
- -
-
${this.escapeHtml(channel.name)}
-
${this.escapeHtml(this.getProgramInfo(channel) || '')}
-
- -
- `; + html += this.renderLogicalChannel(channel, renderId, renderGroup, isActive, isRenderActive); } html += '
'; } @@ -565,9 +792,13 @@ class ChannelList { header.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'group', header.dataset)); } + this.attachChannelListeners(groupEl); + } + + attachChannelListeners(groupEl) { groupEl.querySelectorAll('.channel-item').forEach(item => { item.addEventListener('click', (e) => { - if (e.target.closest('.favorite-btn')) return; + if (e.target.closest('.favorite-btn') || e.target.closest('.variant-toggle')) return; this.selectChannel(item.dataset); }); item.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', item.dataset)); @@ -576,10 +807,65 @@ class ChannelList { if (favBtn) { favBtn.addEventListener('click', (e) => { e.stopPropagation(); - this.toggleFavorite(parseInt(item.dataset.sourceId), item.dataset.channelId); + this.toggleFavorite(parseInt(item.dataset.sourceId), item.dataset.variantId); }); } }); + groupEl.querySelectorAll('.variant-toggle').forEach(button => button.addEventListener('click', (e) => { + e.stopPropagation(); + const fingerprint = button.closest('.channel-item').dataset.logicalId; + this.expandedVariants.has(fingerprint) ? this.expandedVariants.delete(fingerprint) : this.expandedVariants.add(fingerprint); + this.render(); + })); + groupEl.querySelectorAll('.channel-variant').forEach(item => item.addEventListener('click', (e) => { + e.stopPropagation(); + this.selectChannel(item.dataset); + })); + } + + renderLogicalChannel(channel, renderId, renderGroup, isActive = false, isRenderActive = false) { + const selected = channel.preferredVariant; + const selectedIsFavorite = this.isFavorite(selected.sourceId, selected.id); + const expanded = this.expandedVariants.has(channel.fingerprint); + const variants = channel.variants.map(variant => { + const hidden = this.isHidden('channel', variant.sourceId, variant.streamId || variant.id); + if (hidden && !this.showHidden) return ''; + const source = this.sources.find(item => String(item.id) === String(variant.sourceId)); + const language = variant.language; + const languageLabel = language.code === 'unknown' + ? 'Unknown' + : `${language.code.toUpperCase()} ${language.confidence}%`; + const languageDescription = language.code === 'unknown' + ? 'Unknown language' + : `${language.label} — ${language.confidence}% confidence`; + return ``; + }).join(''); + const channelLanguage = channel.language || { code: 'und', short: 'UND', label: 'Unknown' }; + const isUnknownLanguage = ['und', 'unknown'].includes(channelLanguage.code); + const languageBadge = isUnknownLanguage ? '?' : (channelLanguage.short || channelLanguage.code.toUpperCase()); + const languageAccessibleName = isUnknownLanguage ? 'Unknown language' : channelLanguage.label; + const programLabel = this.getProgramStatusText(channel); + return `
+
+
+ ${this.escapeHtml(languageBadge)}
+
${this.escapeHtml(channel.displayName)}
+
${this.escapeHtml(programLabel)}
+ ${channel.variants.length > 1 ? `` : ''} + +
+ ${channel.variants.length > 1 ? `
${variants}
` : ''} +
`; } /** @@ -594,17 +880,12 @@ class ChannelList { // Filter visible channels const visibleChannels = channels.filter(channel => { if (isFavoritesGroup) return true; - const rawChannelId = channel.streamId || channel.id; - const channelHidden = this.isHidden('channel', channel.sourceId, rawChannelId); - return !channelHidden || this.showHidden; + return !channel.isHidden || this.showHidden; }); let html = ''; for (const channel of visibleChannels) { - const rawChannelId = channel.streamId || channel.id; - const channelHidden = !isFavoritesGroup && this.isHidden('channel', channel.sourceId, rawChannelId); const isActive = this.currentChannel?.id === channel.id; - const isFavorite = this.isFavorite(channel.sourceId, channel.id); // Find the matching rendered channel to get its unique IDs const renderedChannel = this.renderedChannels.find(rc => @@ -613,46 +894,12 @@ class ChannelList { const renderId = renderedChannel?._renderId || ''; const renderGroup = renderedChannel?._renderGroup || groupName; - html += ` -
- -
-
${this.escapeHtml(channel.name)}
-
${this.escapeHtml(this.getProgramInfo(channel) || '')}
-
- -
- `; + html += this.renderLogicalChannel(channel, renderId, renderGroup, isActive); } container.innerHTML = html; - // Attach listeners to the new channel items - container.querySelectorAll('.channel-item').forEach(item => { - item.addEventListener('click', (e) => { - if (e.target.closest('.favorite-btn')) return; - this.selectChannel(item.dataset); - }); - item.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'channel', item.dataset)); - - const favBtn = item.querySelector('.favorite-btn'); - if (favBtn) { - favBtn.addEventListener('click', (e) => { - e.stopPropagation(); - this.toggleFavorite(parseInt(item.dataset.sourceId), item.dataset.channelId); - }); - } - }); + this.attachChannelListeners(container); } /** @@ -698,17 +945,24 @@ class ChannelList { /** * Load channels from selected source */ - async loadChannels() { + async loadChannels({ refreshLanguages = true } = {}) { if (this.isLoading) return; this.isLoading = true; this.currentRenderId = null; // Reset render tracking + const previousState = { + channels: this.channels, + groups: this.groups, + visibleFavorites: this.visibleFavorites, + hiddenItems: this.hiddenItems + }; + const sourceValue = this.sourceSelect.value; const self = this; if (!sourceValue) { // Load from all sources - await this.loadAllChannels(); + await this.loadAllChannels({ refreshLanguages }); this.isLoading = false; return; } @@ -718,6 +972,12 @@ class ChannelList { try { this.container.innerHTML = '
'; + if (await this.loadProjectedChannels(parseInt(id, 10))) { + this.render(); + if (refreshLanguages) void this.refreshLayaLanguages(); + return; + } + if (type === 'xtream') { await this.loadXtreamChannels(parseInt(id)); } else if (type === 'm3u') { @@ -731,9 +991,18 @@ class ChannelList { ]); this.render(); + if (refreshLanguages) void this.refreshLayaLanguages(); } catch (err) { console.error('Error loading channels:', err); - this.container.innerHTML = `

Error loading channels

${err.message}

`; + if (previousState.channels.length > 0) { + this.channels = previousState.channels; + this.groups = previousState.groups; + this.visibleFavorites = previousState.visibleFavorites; + this.hiddenItems = previousState.hiddenItems; + this.render(); + } else { + this.container.innerHTML = `

Error loading channels

${err.message}

`; + } } finally { this.isLoading = false; } @@ -742,13 +1011,25 @@ class ChannelList { /** * Load channels from all enabled sources */ - async loadAllChannels() { + async loadAllChannels({ refreshLanguages = true } = {}) { + const previousState = { + channels: this.channels, + groups: this.groups, + visibleFavorites: this.visibleFavorites, + hiddenItems: this.hiddenItems + }; this.channels = []; this.groups = []; try { this.container.innerHTML = '
'; + if (await this.loadProjectedChannels()) { + this.render(); + if (refreshLanguages) void this.refreshLayaLanguages(); + return; + } + const xtreamSources = this.sources.filter(s => s.type === 'xtream' && s.enabled); const m3uSources = this.sources.filter(s => s.type === 'm3u' && s.enabled); console.log('[ChannelList] loadAllChannels: xtream=', xtreamSources.length, 'm3u=', m3uSources.length); @@ -766,11 +1047,131 @@ class ChannelList { this.loadFavorites() ]); this.render(); + if (refreshLanguages) void this.refreshLayaLanguages(); } catch (err) { console.error('Error loading all channels:', err); + if (previousState.channels.length > 0) { + this.channels = previousState.channels; + this.groups = previousState.groups; + this.visibleFavorites = previousState.visibleFavorites; + this.hiddenItems = previousState.hiddenItems; + this.render(); + } else { + this.container.innerHTML = `

Error loading channels

${err.message}

`; + } } } + async loadProjectedChannels(sourceId = null) { + try { + const response = await API.live.getChannels({ + sourceId, + includeHidden: true, + q: '', + languages: [] + }); + if (!LiveChannelProjection.canUse(response)) { + this.projectedLogicalChannels = null; + this.projectionNotice = 'Channel list is updating. Using the standard channel list for now.'; + return false; + } + const mapped = LiveChannelProjection.mapResponse(response, this.sources); + this.channels = mapped.channels; + this.projectedLogicalChannels = mapped.logicalChannels; + this.availableLanguages = mapped.availableLanguages; + this.reconcileLanguageSelection(); + this.renderLanguageFilter(); + this.visibleFavorites = mapped.favorites; + this.hiddenItems = mapped.hiddenItems; + this.projectionNotice = ''; + return true; + } catch (err) { + this.projectedLogicalChannels = null; + console.warn('[ChannelList] Live projection unavailable; using legacy loading:', err.message); + this.projectionNotice = ''; + return false; + } + } + + languageStorageKey() { + return `nodecast_tv_live_languages:${this.sourceSelect?.value || 'all'}`; + } + + restoreLanguageSelection() { + try { + this.selectedLanguages = new Set(JSON.parse(sessionStorage.getItem(this.languageStorageKey()) || '[]')); + } catch { + this.selectedLanguages = new Set(); + } + } + + reconcileLanguageSelection() { + const previous = [...this.selectedLanguages]; + const available = new Set(this.availableLanguages.map(language => language.code)); + this.selectedLanguages = new Set([...this.selectedLanguages].filter(code => available.has(code))); + try { + sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages])); + } catch {} + return previous.length !== this.selectedLanguages.size; + } + + setLanguageSelection(code) { + if (code === 'all') this.selectedLanguages.clear(); + else if (this.selectedLanguages.has(code)) this.selectedLanguages.delete(code); + else this.selectedLanguages.add(code); + try { + sessionStorage.setItem(this.languageStorageKey(), JSON.stringify([...this.selectedLanguages])); + } catch {} + this.renderLanguageFilter(); + if (this.projectedLogicalChannels || this.channels.length) this.render(); + else this.loadChannels({ refreshLanguages: false }); + } + + closeLanguagePanel() { + const panel = this.languageFilter?.querySelector('.language-more-panel'); + const toggle = this.languageFilter?.querySelector('.language-more-toggle'); + const wasOpen = panel && !panel.hidden; + if (panel) panel.hidden = true; + toggle?.setAttribute('aria-expanded', 'false'); + if (wasOpen) toggle?.focus(); + } + + renderLanguageFilter() { + if (!this.languageFilter) return; + const quick = this.availableLanguages.slice(0, 3); + const selected = this.selectedLanguages; + const button = language => { + const unknown = language.code === 'und'; + const label = unknown ? 'Unknown' : language.label; + const text = unknown ? '?' : (language.short || language.code.toUpperCase()); + return ``; + }; + this.languageFilter.innerHTML = ` + Languages + + ${quick.map(button).join('')} + ${this.availableLanguages.length > 3 ? ` + ` : ''}`; + this.languageFilter.querySelectorAll('[data-language]').forEach(element => { + element.addEventListener('click', () => this.setLanguageSelection(element.dataset.language)); + }); + const toggle = this.languageFilter.querySelector('.language-more-toggle'); + const panel = this.languageFilter.querySelector('.language-more-panel'); + toggle?.addEventListener('click', () => { + panel.hidden = !panel.hidden; + toggle.setAttribute('aria-expanded', String(!panel.hidden)); + if (!panel.hidden) panel.querySelector('input')?.focus(); + }); + panel?.querySelector('.language-search')?.addEventListener('input', event => { + const query = event.target.value.toLowerCase(); + panel.querySelectorAll('[data-language]').forEach(element => { + const language = this.availableLanguages.find(item => item.code === element.dataset.language); + element.hidden = !`${language?.code} ${language?.label}`.toLowerCase().includes(query); + }); + }); + } + /** * Load Xtream channels */ @@ -788,7 +1189,8 @@ class ChannelList { id: `xtream_${sourceId}_${cat.category_id}`, name: cat.category_name, sourceId, - sourceType: 'xtream' + sourceType: 'xtream', + sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name })); this.groups = this.groups.concat(categoryGroups); @@ -804,7 +1206,8 @@ class ChannelList { // Use string comparison to handle type mismatches (number vs string category_id) groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized', sourceId, - sourceType: 'xtream' + sourceType: 'xtream', + sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name })); this.channels = this.channels.concat(channelList); @@ -829,7 +1232,8 @@ class ChannelList { id: `m3u_${sourceId}_${cat.category_id}`, name: cat.category_name, sourceId, - sourceType: 'm3u' + sourceType: 'm3u', + sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name })); this.groups = this.groups.concat(m3uGroups); @@ -845,7 +1249,8 @@ class ChannelList { groupId: `m3u_${sourceId}_${stream.category_id}`, groupTitle: categories.find(c => String(c.category_id) === String(stream.category_id))?.category_name || 'Uncategorized', sourceId, - sourceType: 'm3u' + sourceType: 'm3u', + sourceName: this.sources.find(source => String(source.id) === String(sourceId))?.name })); this.channels = this.channels.concat(channelList); @@ -927,6 +1332,7 @@ class ChannelList { if (channel) { this.updateFavoritesGroup(channel, !wasFavorite); } + this.render(); // Do NOT call this.render() - it causes lag // Perform API call @@ -962,6 +1368,7 @@ class ChannelList { const channel = this.channels.find(c => c.sourceId == sourceId && c.id == channelId); if (channel) this.updateFavoritesGroup(channel, false); } + this.render(); } } @@ -969,6 +1376,8 @@ class ChannelList { * Update Favorites group in DOM and data */ updateFavoritesGroup(channel, isAdded) { + if (!ChannelVariantGrouping.shouldUseFavoritesGroup(this.listMode) || !this.groupedChannels) return; + // 1. Update Data if (!this.groupedChannels['Favorites']) { this.groupedChannels['Favorites'] = []; @@ -1074,10 +1483,21 @@ class ChannelList { * Select and play a channel */ async selectChannel(dataset) { - const channel = this.channels.find(c => c.id === dataset.channelId); - if (!channel) return; + const logicalChannel = this.logicalChannels.find(c => c.fingerprint === dataset.logicalId) || + this.renderedChannels.find(c => c.fingerprint === dataset.logicalId) || + this.logicalChannels.find(c => c.variants.some(variant => + String(variant.id) === String(dataset.channelId) && + (!dataset.sourceId || String(variant.sourceId) === String(dataset.sourceId)) + )); + if (!logicalChannel) return; + const channel = logicalChannel.variants.find(variant => + String(variant.id) === String(dataset.variantId || dataset.channelId) && + (!dataset.sourceId || String(variant.sourceId) === String(dataset.sourceId)) + ) || logicalChannel.preferredVariant; + this.selectedVariants.set(logicalChannel.fingerprint, ChannelVariantGrouping.getVariantKey(channel)); + logicalChannel.preferredVariant = channel; - this.currentChannel = channel; + this.currentChannel = logicalChannel; this.currentRenderId = dataset.renderId; // Track which visual instance is active this.currentRenderGroup = dataset.renderGroup; // Track which group the selection came from @@ -1106,7 +1526,8 @@ class ChannelList { // Fallback checks if still not found if (!activeItem) { - activeItem = this.container.querySelector(`[data-channel-id="${channel.id}"]`); + activeItem = Array.from(this.container.querySelectorAll('.channel-item')) + .find(item => item.dataset.logicalId === logicalChannel.fingerprint); // If we fell back to channel ID, update currentRenderId to match what we found if (activeItem && activeItem.dataset.renderId) { this.currentRenderId = activeItem.dataset.renderId; @@ -1168,19 +1589,56 @@ class ChannelList { // Play channel if (window.app?.player) { + window.app.player.updateLanguageControl?.(logicalChannel); window.app.player.play(channel, streamUrl); } } + async selectLanguageForCurrent(languageCode) { + if (!this.currentChannel) return; + const fingerprint = this.currentChannel.fingerprint; + if (!languageCode || languageCode === 'auto') { + this.channelLanguagePreferences.delete(fingerprint); + this.preferredLanguage = null; + } else { + this.channelLanguagePreferences.set(fingerprint, languageCode); + this.preferredLanguage = languageCode; + } + this.selectedVariants.delete(fingerprint); + this.saveLanguagePreferences(); + this.render(); + const logicalChannel = this.logicalChannels.find(channel => channel.fingerprint === fingerprint); + if (!logicalChannel) return; + const variant = ChannelVariantGrouping.selectVariantByLanguage(logicalChannel, languageCode, { + isFavorite: item => this.isFavorite(item.sourceId, item.id), + isHidden: item => this.isHidden('channel', item.sourceId, item.streamId || item.id) + }); + if (!variant) return; + await this.selectChannel({ + logicalId: fingerprint, + variantId: variant.id, + sourceId: variant.sourceId, + renderId: this.currentRenderId, + renderGroup: this.currentRenderGroup + }); + } + /** * Show context menu */ showContextMenu(e, type, data) { e.preventDefault(); + const logicalChannel = type === 'channel' + ? this.logicalChannels.find(channel => channel.fingerprint === data.logicalId) + : null; + const variant = logicalChannel?.variants.find(item => + String(item.id) === String(data.variantId) && String(item.sourceId) === String(data.sourceId) + ) || logicalChannel?.preferredVariant; this.contextMenu.dataset.type = type; - this.contextMenu.dataset.sourceId = data.sourceId; - this.contextMenu.dataset.itemId = type === 'group' ? data.group : data.channelId; - this.contextMenu.dataset.streamId = data.streamId || ''; + this.contextMenu.dataset.sourceId = variant?.sourceId ?? data.sourceId; + this.contextMenu.dataset.itemId = type === 'group' ? data.group : (variant?.id || data.variantId); + this.contextMenu.dataset.streamId = variant?.streamId || data.streamId || ''; + this.contextMenu.dataset.logicalId = data.logicalId || ''; this.contextMenu.style.left = `${e.clientX}px`; this.contextMenu.style.top = `${e.clientY}px`; @@ -1199,15 +1657,12 @@ class ChannelList { */ async handleContextAction(e) { const action = e.target.dataset.action; - const { type, sourceId, itemId, streamId } = this.contextMenu.dataset; + const { type, sourceId, itemId, streamId, logicalId } = this.contextMenu.dataset; switch (action) { case 'play': if (type === 'channel') { - const channel = this.channels.find(c => c.id === itemId); - if (channel) { - await this.selectChannel({ channelId: channel.id }); - } + await this.selectChannel({ logicalId, variantId: itemId, sourceId }); } break; case 'hide': @@ -1345,6 +1800,7 @@ class ChannelList { if (channel) { this.updateFavoritesGroup(channel, isFavorite); } + this.render(); } /** @@ -1382,11 +1838,9 @@ class ChannelList { const nextChannel = this.renderedChannels[nextIndex]; this.selectChannel({ - channelId: nextChannel.id, - sourceId: nextChannel.sourceId, - sourceType: nextChannel.sourceType, - streamId: nextChannel.streamId, - url: nextChannel.url, + logicalId: nextChannel.fingerprint, + variantId: nextChannel.preferredVariant.id, + sourceId: nextChannel.preferredVariant.sourceId, renderId: nextChannel._renderId // Pass the unique render ID }); } @@ -1425,11 +1879,9 @@ class ChannelList { const prevChannel = this.renderedChannels[prevIndex]; this.selectChannel({ - channelId: prevChannel.id, - sourceId: prevChannel.sourceId, - sourceType: prevChannel.sourceType, - streamId: prevChannel.streamId, - url: prevChannel.url, + logicalId: prevChannel.fingerprint, + variantId: prevChannel.preferredVariant.id, + sourceId: prevChannel.preferredVariant.sourceId, renderId: prevChannel._renderId }); } @@ -1449,13 +1901,7 @@ class ChannelList { * Get list of visible (non-hidden) channels in display order */ getVisibleChannels() { - const showHidden = this.showHiddenCheckbox?.checked ?? false; - return this.channels.filter(ch => { - if (showHidden) return true; - const channelHidden = this.isHidden('channel', ch.sourceId, ch.id); - const groupHidden = this.isHidden('group', ch.sourceId, ch.groupTitle); - return !channelHidden && !groupHidden; - }); + return this.listMode === 'flat' ? this.renderedChannels : this.logicalChannels; } } diff --git a/public/js/components/EpgGuide.js b/public/js/components/EpgGuide.js index ce4a01d..11e0527 100644 --- a/public/js/components/EpgGuide.js +++ b/public/js/components/EpgGuide.js @@ -19,6 +19,7 @@ class EpgGuide { this.timeOffset = 0; // Hours offset from now this.pixelsPerMinute = 6.67; // Width scaling (30min = 200px) this.favorites = new Set(); // Set<"sourceId:channelId"> + this.loadError = null; this.selectedGroup = 'Favorites'; // Default to Favorites // Virtual scrolling properties @@ -175,6 +176,7 @@ class EpgGuide { try { this.container.innerHTML = '
'; await this.fetchEpgData(forceRefresh); + this.loadError = null; this.lastRefreshTime = new Date(); this.render(); @@ -182,6 +184,7 @@ class EpgGuide { // This ensures EPG data stays fresh while the app is open this.startBackgroundRefresh(); } catch (err) { + this.loadError = err; console.error('Error loading EPG:', err); this.container.innerHTML = `
diff --git a/public/js/components/SourceManager.js b/public/js/components/SourceManager.js index e5e00f9..59cae82 100644 --- a/public/js/components/SourceManager.js +++ b/public/js/components/SourceManager.js @@ -8,6 +8,12 @@ class SourceManager { this.xtreamList = document.getElementById('xtream-list'); this.m3uList = document.getElementById('m3u-list'); this.epgList = document.getElementById('epg-list'); + this.builtinEpgList = document.getElementById('builtin-epg-list'); + this.epgActiveList = document.getElementById('epg-active-list'); + this.epgInactiveList = document.getElementById('epg-inactive-list'); + this.epgOrderStatus = document.getElementById('epg-order-status'); + this.epgSources = []; + this.isSavingEpgOrder = false; // Content browser state this.contentType = 'channels'; // 'channels' or 'movies' @@ -114,7 +120,8 @@ class SourceManager { this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream'); this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u'); - this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg'); + const epgSources = sources.filter(s => s.type === 'epg'); + this.renderEpgSources(epgSources); } catch (err) { console.error('Error loading sources:', err); } @@ -162,6 +169,170 @@ class SourceManager { }); } + renderBuiltinEpgSources(sources) { + if (!this.builtinEpgList) return; + if (!sources.length) { + this.builtinEpgList.innerHTML = '

No built-in EPG sources available

'; + return; + } + this.builtinEpgList.innerHTML = sources.map(source => { + const coverage = [source.language?.toUpperCase(), source.country].filter(Boolean).join(' · '); + return `
+ ${Icons.series} +
+
${source.name} Built-in
+
Free EPG data${coverage ? ` · ${coverage}` : ''}
+
+
+ + +
+
`; + }).join(''); + this.builtinEpgList.querySelectorAll('.source-item').forEach(item => { + const id = Number(item.dataset.id); + item.querySelector('[data-action="refresh"]')?.addEventListener('click', () => this.refreshSource(id, 'epg')); + item.querySelector('[data-action="toggle"]')?.addEventListener('click', () => this.toggleSource(id)); + }); + } + + escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + 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 = `
+

No active EPG sources

+

Enable a source to load program information.

+
`; + } 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('') + : '

No inactive EPG sources

'; + + 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 `
+ ${active ? `
+ ${rank}Priority ${rank} +
+ + +
+
` : ''} + ${Icons.series} +
+
${name} ${kind}
+
${source.builtinKey ? 'Free EPG data' : 'Custom EPG source'}${coverage ? ` · ${this.escapeHtml(coverage)}` : ''} · ${active ? 'Active' : 'Inactive'}
+
+
+ + + ${source.builtinKey ? '' : ` + `} +
+
`; + } + + 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 */ diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index 5e81fce..470be18 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -28,6 +28,14 @@ class VideoPlayer { this.isUsingProxy = false; this.currentUrl = null; this.settingsLoaded = false; + this.liveQualityPreferenceKey = 'nodecast_tv_live_quality'; + this.requestedQuality = this.loadLiveQualityPreference(); + this.activeQuality = null; + this.availableQualityProfiles = ['auto', 'source', '1080p', '720p', '480p']; + this.qualitySwitchInProgress = false; + this.originalStreamUrl = null; + this.sourceStreamInfo = null; + this.currentLogicalChannel = null; // Settings - start with defaults, load from server async this.settings = this.getDefaultSettings(); @@ -49,15 +57,37 @@ class VideoPlayer { rememberVolume: true, lastVolume: 80, autoPlayNextEpisode: false, + liveChannelListMode: 'grouped', + languageDetectionMode: 'algo', forceProxy: false, forceTranscode: false, forceRemux: false, autoTranscode: true, + maxDirectBitrateKbps: 0, + targetTranscodeBitrateKbps: 6000, + allowHardwareFallback: true, streamFormat: 'm3u8', epgRefreshInterval: '24' }; } + loadLiveQualityPreference() { + try { + const saved = localStorage.getItem('nodecast_tv_live_quality'); + return ['auto', 'source', '1080p', '720p', '480p'].includes(saved) ? saved : 'auto'; + } catch { + return 'auto'; + } + } + + saveLiveQualityPreference(qualityMode) { + try { + localStorage.setItem(this.liveQualityPreferenceKey, qualityMode); + } catch (err) { + console.warn('[Player] Unable to persist live quality preference:', err.message); + } + } + /** * Load settings from server API */ @@ -66,6 +96,7 @@ class VideoPlayer { const serverSettings = await API.settings.get(); this.settings = { ...this.getDefaultSettings(), ...serverSettings }; this.settingsLoaded = true; + window.app?.channelList?.setListMode(this.settings.liveChannelListMode); console.log('[Player] Settings loaded from server'); } catch (err) { console.warn('[Player] Failed to load settings from server, using defaults:', err.message); @@ -337,6 +368,9 @@ class VideoPlayer { const btnOverflow = document.getElementById('btn-overflow'); const overflowMenu = document.getElementById('player-overflow-menu'); + this.initQualityControl(overflowMenu); + this.initLanguageControl(overflowMenu); + btnOverflow?.addEventListener('click', (e) => { e.stopPropagation(); overflowMenu?.classList.toggle('hidden'); @@ -412,6 +446,120 @@ class VideoPlayer { updateVolumeUI(); } + /** + * Add the live quality control to the existing player overflow menu. + */ + initQualityControl(overflowMenu) { + if (!overflowMenu || document.getElementById('player-quality-control')) return; + + const control = document.createElement('div'); + control.id = 'player-quality-control'; + control.setAttribute('role', 'group'); + control.setAttribute('aria-label', 'Qualité vidéo'); + control.style.borderTop = '1px solid var(--glass-border)'; + overflowMenu.style.minWidth = 'min(280px, calc(100vw - 24px))'; + overflowMenu.style.maxHeight = 'min(70vh, 420px)'; + overflowMenu.style.overflowY = 'auto'; + control.innerHTML = ` +
+ Qualité vidéo +
+
+ `; + 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 = ` +
Language
+ `; + 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 = `${language.label}`; + 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 = `${this.qualityLabel(mode)}`; + button.addEventListener('click', (event) => { + event.stopPropagation(); + this.changeLiveQuality(mode); + }); + list.appendChild(button); + }); + + if (this.qualitySwitchInProgress) { + summary.textContent = `Passage en ${this.qualityLabel(this.pendingQuality)}…`; + } else if (this.requestedQuality === 'auto') { + const autoKind = this.autoIsAdaptive ? 'adaptatif' : 'choix automatique au démarrage'; + summary.textContent = `Demandée : Auto (${autoKind})${this.activeQuality ? ` · Active : ${this.qualityLabel(this.activeQuality)}` : ''}`; + } else { + summary.textContent = `Demandée : ${this.qualityLabel(this.requestedQuality)}${this.activeQuality ? ` · Active : ${this.qualityLabel(this.activeQuality)}` : ''}`; + } + } + + updateAvailableQualityProfiles(profiles, sourceHeight) { + const ordered = ['auto', 'source', '1080p', '720p', '480p']; + const backendProfiles = Array.isArray(profiles) ? profiles : ordered; + const maxHeights = { '1080p': 1080, '720p': 720, '480p': 480 }; + this.availableQualityProfiles = ordered.filter(mode => backendProfiles.includes(mode) && + (!maxHeights[mode] || !sourceHeight || sourceHeight >= maxHeights[mode])); + this.renderQualityControl(); + } + /** * Toggle fullscreen mode (cross-browser including Safari) */ @@ -620,7 +768,9 @@ class VideoPlayer { width: this.video.videoWidth, height: this.video.videoHeight }; + this.activeQuality = this.getQualityLabel(this.video.videoHeight); this.updateQualityBadge(); + this.renderQualityControl(); } }); @@ -812,7 +962,8 @@ class VideoPlayer { /** * Start a HLS transcode session */ - async startTranscodeSession(url, options = {}) { + async startTranscodeSession(url, options = {}, allowLegacyFallback = true) { + this.currentTranscodeSession = null; try { console.log('[Player] Starting HLS transcode session...', options); const res = await fetch('/api/transcode/session', { @@ -820,14 +971,24 @@ class VideoPlayer { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, ...options }) }); - if (!res.ok) throw new Error('Failed to start session'); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Failed to start session'); + } const session = await res.json(); this.currentSessionId = session.sessionId; + this.currentTranscodeSession = session; + this.activeQuality = session.selectedProfile || options.qualityMode || null; + this.autoIsAdaptive = session.mode === 'adaptive'; + this.updateAvailableQualityProfiles(session.availableProfiles, this.sourceStreamInfo?.height); + this.renderQualityControl(); return session.playlistUrl; } catch (err) { console.error('[Player] Session start failed:', err); - // Fallback to direct transcode if session fails - return `/api/transcode?url=${encodeURIComponent(url)}`; + if (allowLegacyFallback) { + return `/api/transcode?url=${encodeURIComponent(url)}`; + } + throw err; } } @@ -835,15 +996,18 @@ class VideoPlayer { * Stop and cleanup current transcode session */ async stopTranscodeSession() { - if (this.currentSessionId) { - console.log('[Player] Stopping transcode session:', this.currentSessionId); + const sessionId = this.currentSessionId; + if (sessionId) { + console.log('[Player] Stopping transcode session:', sessionId); try { - // Fire and forget cleanup - fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' }); + await fetch(`/api/transcode/${sessionId}`, { method: 'DELETE' }); } catch (err) { console.error('Failed to stop session:', err); } - 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) { this.currentChannel = channel; + this.originalStreamUrl = streamUrl; try { // Stop any WatchPage playback (movies/series) before starting Live TV @@ -870,10 +1035,22 @@ class VideoPlayer { // Determine if HLS or direct stream this.currentUrl = streamUrl; + this.sourceStreamInfo = null; - // CHECK: Auto Transcode (Smart) - probe first, then decide - if (this.settings.autoTranscode) { + // A persisted manual preference always creates a dedicated transcode session. + if (this.requestedQuality !== 'auto') { + await this.playManualQuality(channel, streamUrl, this.requestedQuality); + return; + } + + // Forced playback modes take priority over automatic selection below. + const hasForcedPlaybackMode = this.settings.forceVideoTranscode || + this.settings.forceTranscode || this.settings.forceRemux; + + // Automatic playback: probe once, then choose transcode, remux, or direct play. + if (this.settings.autoTranscode && !hasForcedPlaybackMode) { console.log('[Player] Auto Transcode enabled. Probing stream...'); + this.updateTranscodeStatus('direct', 'Analyzing…'); try { const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); const info = await probeRes.json(); @@ -881,6 +1058,8 @@ class VideoPlayer { // Store probe result for quality badge display this.currentStreamInfo = info; + this.sourceStreamInfo = info; + this.updateAvailableQualityProfiles(null, info.height); this.updateQualityBadge(); // Handle subtitles from probe result @@ -905,14 +1084,23 @@ class VideoPlayer { } } - if (info.needsTranscode || this.settings.upscaleEnabled) { + const maxDirectBitrateKbps = Number(this.settings.maxDirectBitrateKbps) || 0; + const overallBitrateKbps = Number(info.overallBitrateKbps) || 0; + const exceedsBandwidthLimit = maxDirectBitrateKbps > 0 && + overallBitrateKbps > maxDirectBitrateKbps; + const requiresTranscode = info.needsTranscode || this.settings.upscaleEnabled || + (!info.needsRemux && exceedsBandwidthLimit); + + if (requiresTranscode) { // Incompatible audio (AC3/EAC3/DTS) or Upscaling enabled - use transcode session console.log(`[Player] Auto: Using HLS transcode session (${this.settings.upscaleEnabled ? 'Upscaling' : 'Incompatible audio/video'})`); // Heuristic: If video is h264, it's likely compatible, so only copy video (audio transcode only) // BUT: If upscaling is enabled, we MUST encode. - const videoMode = (info.video && info.video.includes('h264') && !this.settings.upscaleEnabled) ? 'copy' : 'encode'; - const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : (this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)'); + const videoMode = (info.video && info.video.includes('h264') && + !this.settings.upscaleEnabled && !exceedsBandwidthLimit) ? 'copy' : 'encode'; + const statusText = videoMode === 'copy' ? 'Audio Transcode' : + (this.settings.upscaleEnabled ? 'Upscaling' : 'Video Transcode'); const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding'; this.updateTranscodeStatus(statusMode, statusText); @@ -920,8 +1108,18 @@ class VideoPlayer { videoMode, videoCodec: info.video, audioCodec: info.audio, - audioChannels: info.audioChannels + audioChannels: info.audioChannels, + targetVideoBitrateKbps: this.settings.targetTranscodeBitrateKbps || + this.settings.maxDirectBitrateKbps || 6000, + allowHardwareFallback: this.settings.allowHardwareFallback !== false }); + if (this.currentTranscodeSession?.hardwareFallbackOccurred) { + this.updateTranscodeStatus('transcoding', 'CPU Fallback'); + } + if (exceedsBandwidthLimit) { + this.updateQualityBadge(this.settings.targetTranscodeBitrateKbps || + this.settings.maxDirectBitrateKbps || 6000); + } this.currentUrl = playlistUrl; // Update currentUrl for HLS reload this.playHls(playlistUrl); @@ -934,7 +1132,7 @@ class VideoPlayer { } else if (info.needsRemux) { // Raw .ts container - use remux console.log('[Player] Auto: Using remux (.ts container)'); - this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); + this.updateTranscodeStatus('remuxing', 'Remux'); const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`; this.currentUrl = remuxUrl; this.video.src = remuxUrl; @@ -949,6 +1147,7 @@ class VideoPlayer { } // Compatible - fall through to normal HLS.js path console.log('[Player] Auto: Using HLS.js (compatible)'); + this.updateTranscodeStatus('direct', 'Direct Play'); } catch (err) { console.warn('[Player] Probe failed, using normal playback:', err.message); // Continue with normal playback on probe failure @@ -957,11 +1156,19 @@ class VideoPlayer { // CHECK: Force Video Transcode (Full) or Upscaling if (this.settings.forceVideoTranscode || this.settings.upscaleEnabled) { - const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)'; + const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Video Transcode'; const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding'; console.log(`[Player] ${statusText} enabled. Starting session (encode)...`); this.updateTranscodeStatus(statusMode, statusText); - const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' }); + const playlistUrl = await this.startTranscodeSession(streamUrl, { + videoMode: 'encode', + targetVideoBitrateKbps: this.settings.targetTranscodeBitrateKbps || + this.settings.maxDirectBitrateKbps || 6000, + allowHardwareFallback: this.settings.allowHardwareFallback !== false + }); + if (this.currentTranscodeSession?.hardwareFallbackOccurred) { + this.updateTranscodeStatus('transcoding', 'CPU Fallback'); + } this.currentUrl = playlistUrl; // Load HLS @@ -1002,7 +1209,7 @@ class VideoPlayer { // CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting if (this.settings.forceTranscode) { console.log('[Player] Force Audio Transcode enabled. Starting session (copy)...'); - this.updateTranscodeStatus('transcoding', 'Transcoding (Audio)'); + this.updateTranscodeStatus('transcoding', 'Audio Transcode'); // Probe to get video codec for HEVC tag handling let videoCodec = 'unknown'; @@ -1012,7 +1219,11 @@ class VideoPlayer { videoCodec = info.video; } catch (e) { console.warn('Probe failed for force audio, assuming h264'); } - const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'copy', videoCodec }); + const playlistUrl = await this.startTranscodeSession(streamUrl, { + videoMode: 'copy', + videoCodec, + allowHardwareFallback: this.settings.allowHardwareFallback !== false + }); this.currentUrl = playlistUrl; console.log('[Player] Playing transcoded HLS stream:', playlistUrl); @@ -1053,7 +1264,7 @@ class VideoPlayer { if (this.settings.forceRemux && (isRawTs || isExtensionless)) { console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...'); console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)'); - this.updateTranscodeStatus('remuxing', 'Remux (Force)'); + this.updateTranscodeStatus('remuxing', 'Remux'); const remuxUrl = this.getRemuxUrl(streamUrl); this.video.src = remuxUrl; this.video.play().catch(e => { @@ -1082,7 +1293,7 @@ class VideoPlayer { // Priority 1: Use HLS.js for HLS streams on browsers that support it if (looksLikeHls && Hls.isSupported()) { - this.updateTranscodeStatus('direct', 'Direct HLS'); + this.updateTranscodeStatus('direct', 'Direct Play'); // Use playHls helper logic here (or extract it) // For now, let's just use existing logic but wrapped/modularized if possible? @@ -1093,12 +1304,24 @@ class VideoPlayer { this.hls.loadSource(finalUrl); this.hls.attachMedia(this.video); - this.hls.on(Hls.Events.MANIFEST_PARSED, () => { + this.hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => { + const levels = data?.levels || this.hls.levels || []; + this.autoIsAdaptive = this.requestedQuality === 'auto' && levels.length > 1; + this.renderQualityControl(); this.video.play().catch(e => { if (e.name !== 'AbortError') console.log('Autoplay prevented:', e); }); }); + this.hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => { + const level = this.hls?.levels?.[data.level]; + if (level?.height) { + this.activeQuality = this.getQualityLabel(level.height); + this.updateQualityBadge(); + this.renderQualityControl(); + } + }); + // Re-attach error handler for the new Hls instance this.hls.on(Hls.Events.ERROR, (event, data) => { if (data.fatal) { @@ -1146,7 +1369,7 @@ class VideoPlayer { } else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' || this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') { // Priority 2: Native HLS support (Safari on iOS/macOS where HLS.js may not work) - this.updateTranscodeStatus('direct', 'Direct Native'); + this.updateTranscodeStatus('direct', 'Direct Play'); this.video.src = finalUrl; this.video.play().catch(e => { if (e.name === 'AbortError') return; // Ignore interruption by new load @@ -1198,12 +1421,27 @@ class VideoPlayer { this.hls.loadSource(url); this.hls.attachMedia(this.video); - this.hls.on(Hls.Events.MANIFEST_PARSED, () => { + this.hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => { + const levels = data?.levels || this.hls.levels || []; + this.autoIsAdaptive = this.requestedQuality === 'auto' && levels.length > 1; + if (this.autoIsAdaptive && this.hls.currentLevel >= 0) { + this.activeQuality = this.getQualityLabel(levels[this.hls.currentLevel]?.height); + } + this.renderQualityControl(); this.video.play().catch(e => { if (e.name !== 'AbortError') console.log('Autoplay prevented:', e); }); }); + this.hls.on(Hls.Events.LEVEL_SWITCHED, (event, data) => { + const level = this.hls?.levels?.[data.level]; + if (level?.height) { + this.activeQuality = this.getQualityLabel(level.height); + this.updateQualityBadge(); + this.renderQualityControl(); + } + }); + this.hls.on(Hls.Events.ERROR, (event, data) => { if (data.fatal) { // Simple error handling for forced HLS/transcode modes @@ -1213,6 +1451,92 @@ class VideoPlayer { }); } + async playManualQuality(channel, streamUrl, qualityMode, restorePaused = false) { + this.updateTranscodeStatus('transcoding', `Passage en ${this.qualityLabel(qualityMode)}…`); + let effectiveQuality = qualityMode; + if (!this.sourceStreamInfo?.height) { + try { + const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); + if (probeRes.ok) this.sourceStreamInfo = await probeRes.json(); + } catch (err) { + console.warn('[Player] Could not probe source quality:', err.message); + } + } + if (this.sourceStreamInfo?.height) { + this.updateAvailableQualityProfiles(null, this.sourceStreamInfo.height); + const requestedHeight = Number.parseInt(qualityMode, 10); + if (requestedHeight > this.sourceStreamInfo.height) { + effectiveQuality = ['1080p', '720p', '480p'] + .find(mode => Number.parseInt(mode, 10) <= this.sourceStreamInfo.height) || 'source'; + } + } + const playlistUrl = await this.startTranscodeSession(streamUrl, { + qualityMode: effectiveQuality, + videoMode: effectiveQuality === 'source' ? undefined : 'encode', + allowHardwareFallback: this.settings.allowHardwareFallback !== false + }, false); + this.currentUrl = playlistUrl; + this.playHls(playlistUrl); + if (restorePaused) { + this.video.addEventListener('playing', () => this.video.pause(), { once: true }); + } + this.video.addEventListener('canplay', () => { + this.updateTranscodeStatus('transcoding', this.currentTranscodeSession?.hardwareFallbackOccurred ? 'CPU Fallback' : 'Transcodage'); + }, { once: true }); + this.updateQualityBadge(); + this.updateNowPlaying(channel); + this.showNowPlayingOverlay(); + this.fetchEpgData(channel); + window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel })); + } + + async changeLiveQuality(qualityMode) { + if (!this.currentChannel || !this.originalStreamUrl || this.qualitySwitchInProgress || qualityMode === this.requestedQuality) return; + + const previousQuality = this.requestedQuality; + const wasPaused = this.video.paused; + this.pendingQuality = qualityMode; + this.qualitySwitchInProgress = true; + this.renderQualityControl(); + this.loadingSpinner?.classList.add('show'); + + try { + await this.stopTranscodeSession(); + if (this.hls) { + this.hls.destroy(); + this.hls = null; + } + this.requestedQuality = qualityMode; + this.saveLiveQualityPreference(qualityMode); + + if (qualityMode === 'auto') { + await this.play(this.currentChannel, this.originalStreamUrl); + if (wasPaused) this.video.addEventListener('playing', () => this.video.pause(), { once: true }); + } else { + await this.playManualQuality(this.currentChannel, this.originalStreamUrl, qualityMode, wasPaused); + } + } catch (err) { + console.error('[Player] Quality switch failed, restoring previous quality:', err); + this.requestedQuality = previousQuality; + this.saveLiveQualityPreference(previousQuality); + try { + if (previousQuality === 'auto') { + await this.play(this.currentChannel, this.originalStreamUrl); + } else { + await this.playManualQuality(this.currentChannel, this.originalStreamUrl, previousQuality, wasPaused); + } + } catch (restoreError) { + console.error('[Player] Failed to restore previous quality:', restoreError); + this.showError('Impossible de changer la qualité vidéo. Réessayez.'); + } + } finally { + this.pendingQuality = null; + this.qualitySwitchInProgress = false; + this.loadingSpinner?.classList.remove('show'); + this.renderQualityControl(); + } + } + async updateTranscodeStatus(mode, text) { const el = document.getElementById('player-transcode-status'); if (!el) return; @@ -1247,11 +1571,20 @@ class VideoPlayer { /** * Update quality badge display */ - updateQualityBadge() { + updateQualityBadge(targetBitrateKbps = null) { const badge = document.getElementById('player-quality-badge'); if (!badge) return; - if (this.currentStreamInfo?.height > 0) { + if (this.requestedQuality && this.activeQuality) { + badge.textContent = this.requestedQuality === 'auto' + ? `Auto · ${this.qualityLabel(this.activeQuality)}` + : `${this.qualityLabel(this.requestedQuality)} · actif ${this.qualityLabel(this.activeQuality)}`; + badge.classList.remove('hidden'); + } else if (targetBitrateKbps) { + const targetMbps = targetBitrateKbps / 1000; + badge.textContent = `${Number.isInteger(targetMbps) ? targetMbps : targetMbps.toFixed(1)} Mbps`; + badge.classList.remove('hidden'); + } else if (this.currentStreamInfo?.height > 0) { badge.textContent = this.getQualityLabel(this.currentStreamInfo.height); badge.classList.remove('hidden'); } else { @@ -1407,6 +1740,9 @@ class VideoPlayer { // Hide quality badge this.currentStreamInfo = null; + this.activeQuality = null; + this.autoIsAdaptive = false; + this.renderQualityControl(); const badge = document.getElementById('player-quality-badge'); if (badge) badge.classList.add('hidden'); } @@ -1531,11 +1867,18 @@ class VideoPlayer { if (channels.length === 0) return; const currentIdx = this.currentChannel - ? channels.findIndex(c => c.id === this.currentChannel.id) + ? channels.findIndex(c => c.variants.some(variant => + variant.id === this.currentChannel.id && variant.sourceId === this.currentChannel.sourceId + )) : -1; const prevIdx = currentIdx <= 0 ? channels.length - 1 : currentIdx - 1; - window.app.channelList.selectChannel({ channelId: channels[prevIdx].id }); + const previous = channels[prevIdx]; + window.app.channelList.selectChannel({ + logicalId: previous.fingerprint, + variantId: previous.preferredVariant.id, + sourceId: previous.preferredVariant.sourceId + }); } /** @@ -1547,11 +1890,18 @@ class VideoPlayer { if (channels.length === 0) return; const currentIdx = this.currentChannel - ? channels.findIndex(c => c.id === this.currentChannel.id) + ? channels.findIndex(c => c.variants.some(variant => + variant.id === this.currentChannel.id && variant.sourceId === this.currentChannel.sourceId + )) : -1; const nextIdx = currentIdx >= channels.length - 1 ? 0 : currentIdx + 1; - window.app.channelList.selectChannel({ channelId: channels[nextIdx].id }); + const next = channels[nextIdx]; + window.app.channelList.selectChannel({ + logicalId: next.fingerprint, + variantId: next.preferredVariant.id, + sourceId: next.preferredVariant.sourceId + }); } /** diff --git a/public/js/pages/LivePage.js b/public/js/pages/LivePage.js index 908840c..19e1934 100644 --- a/public/js/pages/LivePage.js +++ b/public/js/pages/LivePage.js @@ -23,6 +23,7 @@ class LivePage { // Update program info in existing DOM elements without re-rendering this.updateProgramInfo(); } catch (err) { + this.app.epgGuide.loadError = err; console.warn('Background EPG fetch failed:', err); } } @@ -36,7 +37,7 @@ class LivePage { // Build a map for O(1) channel lookups const channelMap = new Map(); - this.app.channelList.channels.forEach(c => channelMap.set(c.id, c)); + this.app.channelList.logicalChannels.forEach(c => channelMap.set(c.fingerprint, c)); // Process in small batches to avoid blocking UI const BATCH_SIZE = 50; @@ -47,14 +48,12 @@ class LivePage { for (let i = index; i < end; i++) { const item = channelItems[i]; - const channelId = item.dataset.channelId; - const channel = channelMap.get(channelId); + const channel = channelMap.get(item.dataset.logicalId); if (channel) { const programDiv = item.querySelector('.channel-program'); if (programDiv) { - const programTitle = this.app.channelList.getProgramInfo(channel); - programDiv.textContent = programTitle || ''; + programDiv.textContent = this.app.channelList.getProgramStatusText(channel); } } } diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js index a47b5d7..9dfba4d 100644 --- a/public/js/pages/Settings.js +++ b/public/js/pages/Settings.js @@ -34,6 +34,7 @@ class SettingsPage { const volumeValueDisplay = document.getElementById('volume-value'); const rememberVolumeToggle = document.getElementById('setting-remember-volume'); const autoPlayNextToggle = document.getElementById('setting-autoplay-next'); + const groupLiveChannelsToggle = document.getElementById('setting-group-live-channels'); // Load current settings if (this.app.player?.settings) { @@ -43,6 +44,7 @@ class SettingsPage { volumeValueDisplay.textContent = this.app.player.settings.defaultVolume + '%'; rememberVolumeToggle.checked = this.app.player.settings.rememberVolume; autoPlayNextToggle.checked = this.app.player.settings.autoPlayNextEpisode; + groupLiveChannelsToggle.checked = this.app.player.settings.liveChannelListMode !== 'flat'; } // Arrow keys toggle @@ -77,6 +79,16 @@ class SettingsPage { this.app.player.saveSettings(); }); + groupLiveChannelsToggle.addEventListener('change', () => { + const mode = groupLiveChannelsToggle.checked ? 'grouped' : 'flat'; + this.app.player.settings.liveChannelListMode = mode; + this.app.channelList.setListMode(mode); + this.app.player.saveSettings(); + }); + + this.initLayaSettings(); + this.initLiveAnalysis(); + // EPG refresh interval const epgRefreshSelect = document.getElementById('epg-refresh-interval'); if (epgRefreshSelect && this.app.player?.settings) { @@ -94,6 +106,330 @@ class SettingsPage { this.updateEpgLastRefreshed(); } + initLiveAnalysis() { + document.getElementById('live-analysis-button')?.addEventListener('click', () => void this.runLiveAnalysis()); + document.getElementById('live-analysis-details-toggle')?.addEventListener('click', () => { + const button = document.getElementById('live-analysis-details-toggle'); + const details = document.getElementById('live-analysis-error-details'); + const expanded = button.getAttribute('aria-expanded') === 'true'; + button.setAttribute('aria-expanded', String(!expanded)); + details.hidden = expanded; + }); + void this.loadLiveAnalysisStatus(); + } + + async loadLiveAnalysisStatus() { + try { + this.liveAnalysisStatus = await API.live.getProjectionStatus(); + } catch (err) { + this.liveAnalysisStatus = { status: 'error', error: err.message }; + } + this.renderLiveAnalysisStatus(this.liveAnalysisStatus); + const presentation = LiveAnalysisPresentation.present(this.liveAnalysisStatus); + if (presentation.busy) this.startLiveAnalysisPolling(); + return this.liveAnalysisStatus; + } + + renderLiveAnalysisStatus(status = {}) { + const button = document.getElementById('live-analysis-button'); + const statusElement = document.getElementById('live-analysis-status'); + const progress = document.getElementById('live-analysis-progress'); + const track = document.getElementById('live-analysis-progress-track'); + const bar = document.getElementById('live-analysis-progress-bar'); + const progressText = document.getElementById('live-analysis-progress-text'); + const fallback = document.getElementById('live-analysis-fallback'); + const detailsButton = document.getElementById('live-analysis-details-toggle'); + const details = document.getElementById('live-analysis-error-details'); + if (!button || !statusElement) return; + + const presentation = LiveAnalysisPresentation.present(status); + button.textContent = presentation.buttonLabel; + button.disabled = presentation.busy; + statusElement.textContent = presentation.statusLabel; + progress.hidden = !presentation.busy; + track.classList.toggle('indeterminate', presentation.progressIndeterminate); + if (presentation.progressPercent === null) { + track.removeAttribute('aria-valuemin'); + track.removeAttribute('aria-valuemax'); + track.removeAttribute('aria-valuenow'); + bar.style.width = ''; + } else { + track.setAttribute('aria-valuemin', '0'); + track.setAttribute('aria-valuemax', '100'); + track.setAttribute('aria-valuenow', String(presentation.progressPercent)); + bar.style.width = `${presentation.progressPercent}%`; + } + progressText.textContent = presentation.progressText; + track.setAttribute('aria-valuetext', presentation.progressText); + const mode = this.app.player?.settings?.languageDetectionMode || 'algo'; + fallback.textContent = LiveAnalysisPresentation.layaFallbackMessage(mode, this.layaStatus); + detailsButton.hidden = !presentation.errorDetail; + detailsButton.setAttribute('aria-expanded', 'false'); + details.hidden = true; + details.textContent = presentation.errorDetail; + } + + async runLiveAnalysis() { + const button = document.getElementById('live-analysis-button'); + if (button?.disabled) return; + this.liveAnalysisStatus = { ...(this.liveAnalysisStatus || {}), status: 'queued', processed: 0 }; + this.renderLiveAnalysisStatus(this.liveAnalysisStatus); + try { + const response = await API.live.rebuildProjection(); + this.liveAnalysisStatus = response.projection || response; + this.renderLiveAnalysisStatus(this.liveAnalysisStatus); + this.startLiveAnalysisPolling(); + } catch (err) { + this.liveAnalysisStatus = { status: 'error', error: err.message }; + this.renderLiveAnalysisStatus(this.liveAnalysisStatus); + } + } + + startLiveAnalysisPolling() { + if (this.liveAnalysisPollTimer) return; + this.liveAnalysisPollTimer = setInterval(async () => { + if (this.liveAnalysisPollInFlight) return; + this.liveAnalysisPollInFlight = true; + try { + const status = await this.loadLiveAnalysisStatus(); + const presentation = LiveAnalysisPresentation.present(status); + if (!presentation.busy) { + clearInterval(this.liveAnalysisPollTimer); + this.liveAnalysisPollTimer = null; + if (presentation.complete) { + const mode = this.app.player?.settings?.languageDetectionMode || 'algo'; + await this.app.channelList?.loadChannels({ + refreshLanguages: mode !== 'laya' && mode !== 'hybrid' + }); + if (mode === 'laya' || mode === 'hybrid') { + this.liveAnalysisStatus = { + status: 'building', + phase: 'laya_language', + languageProcessed: null, + languageTotal: null + }; + this.renderLiveAnalysisStatus(this.liveAnalysisStatus); + await this.app.channelList?.refreshLayaLanguages(mode, { + onProgress: ({ processed, total }) => { + this.liveAnalysisStatus = { + status: 'building', + phase: 'laya_language', + languageProcessed: processed, + languageTotal: total + }; + this.renderLiveAnalysisStatus(this.liveAnalysisStatus); + } + }); + } + this.liveAnalysisStatus = status; + this.renderLiveAnalysisStatus(status); + } + } + } finally { + this.liveAnalysisPollInFlight = false; + } + }, 1000); + } + + initLayaSettings() { + const radios = document.querySelectorAll('input[name="language-detection-mode"]'); + const currentMode = this.app.player?.settings?.languageDetectionMode || 'algo'; + radios.forEach(radio => { + radio.checked = radio.value === currentMode; + radio.addEventListener('change', () => { + if (!radio.checked) return; + this.app.player.settings.languageDetectionMode = radio.value; + this.app.player.saveSettings(); + this.app.channelList.setLanguageDetectionMode(radio.value); + this.renderLayaStatus(this.layaStatus); + this.renderLiveAnalysisStatus(this.liveAnalysisStatus); + }); + }); + + document.getElementById('laya-download-model')?.addEventListener('click', () => this.runLayaModelAction('download')); + document.getElementById('laya-remove-model')?.addEventListener('click', () => { + if (confirm('Remove local AI model?\n\nYour sources, favorites, and hidden channels will not be deleted.')) { + this.runLayaModelAction('remove'); + } + }); + document.getElementById('laya-use-standard')?.addEventListener('click', () => this.useStandardLanguageDetection()); + document.getElementById('laya-check-again')?.addEventListener('click', () => void this.loadLayaStatus()); + document.getElementById('laya-copy-command')?.addEventListener('click', () => void this.copyLayaStartCommand()); + document.getElementById('laya-show-details')?.addEventListener('click', () => { + const details = document.getElementById('laya-error-details'); + const button = document.getElementById('laya-show-details'); + const expanded = button.getAttribute('aria-expanded') === 'true'; + button.setAttribute('aria-expanded', String(!expanded)); + details.hidden = expanded; + }); + void this.loadLayaStatus(); + } + + async loadLayaStatus() { + try { + this.layaStatus = await API.laya.getStatus(); + } catch (err) { + this.layaStatus = { runtimeAvailable: false, modelPresent: false, error: err.message }; + } + this.renderLayaStatus(this.layaStatus); + const mode = this.app.player?.settings?.languageDetectionMode || 'algo'; + if (mode !== 'algo' && this.layaStatus?.modelPresent && this.layaStatus?.runtimeAvailable && + this.app.channelList?.languageDetectionNotice) { + void this.app.channelList.refreshLayaLanguages(mode); + } + if (this.layaStatus?.downloading) this.startLayaStatusPolling(); + return this.layaStatus; + } + + renderLayaStatus(status = {}) { + const statusElement = document.getElementById('laya-model-status'); + const modelStateElement = document.getElementById('laya-model-state'); + const serviceStateElement = document.getElementById('laya-service-state'); + const sizeElement = document.getElementById('laya-model-size'); + const messageElement = document.getElementById('laya-model-message'); + const downloadButton = document.getElementById('laya-download-model'); + const removeButton = document.getElementById('laya-remove-model'); + const useStandardButton = document.getElementById('laya-use-standard'); + const checkAgainButton = document.getElementById('laya-check-again'); + const copyCommandButton = document.getElementById('laya-copy-command'); + const startCommand = document.getElementById('laya-start-command'); + const progress = document.getElementById('laya-download-progress'); + const progressTrack = document.getElementById('laya-progress-track'); + const progressBar = document.getElementById('laya-progress-bar'); + const progressText = document.getElementById('laya-progress-text'); + const detailsButton = document.getElementById('laya-show-details'); + const details = document.getElementById('laya-error-details'); + if (!statusElement) return; + + const mode = this.app.player?.settings?.languageDetectionMode || 'algo'; + const presentation = LayaStatusPresentation.presentLayaStatus(status, mode); + statusElement.textContent = presentation.statusLabel; + modelStateElement.textContent = presentation.modelLabel; + serviceStateElement.textContent = presentation.serviceLabel; + const size = status.sizeBytes || status.runtime?.modelSizeBytes; + sizeElement.textContent = size ? `· ${this.formatBytes(size)}` : ''; + downloadButton.hidden = !presentation.showDownload; + downloadButton.textContent = presentation.downloadLabel; + removeButton.hidden = !presentation.showRemove; + useStandardButton.hidden = !presentation.showUseStandard; + checkAgainButton.hidden = !presentation.showCheckAgain; + copyCommandButton.hidden = !presentation.showStartCommand; + startCommand.hidden = !presentation.showStartCommand; + downloadButton.disabled = presentation.busy || Boolean(this.layaOperationPending); + removeButton.disabled = presentation.busy || Boolean(this.layaOperationPending); + + progress.hidden = !presentation.showProgress; + progressTrack.classList.toggle('indeterminate', presentation.progressIndeterminate); + if (presentation.progressPercent !== null) { + progressTrack.setAttribute('aria-valuemin', '0'); + progressTrack.setAttribute('aria-valuemax', '100'); + progressTrack.setAttribute('aria-valuenow', String(presentation.progressPercent)); + progressBar.style.width = `${presentation.progressPercent}%`; + progressText.textContent = `${presentation.progressPercent}%${presentation.bytesDownloaded !== null ? ` · ${this.formatBytes(presentation.bytesDownloaded)} downloaded` : ''}`; + progressTrack.setAttribute('aria-valuetext', progressText.textContent); + } else { + progressTrack.removeAttribute('aria-valuemin'); + progressTrack.removeAttribute('aria-valuemax'); + progressTrack.removeAttribute('aria-valuenow'); + progressBar.style.width = ''; + progressText.textContent = presentation.bytesDownloaded !== null + ? `${this.formatBytes(presentation.bytesDownloaded)} downloaded` + : 'Downloading…'; + progressTrack.setAttribute('aria-valuetext', progressText.textContent); + } + + detailsButton.hidden = !presentation.errorDetail; + detailsButton.setAttribute('aria-expanded', 'false'); + details.hidden = true; + details.textContent = presentation.errorDetail; + if (presentation.errorMessage) messageElement.textContent = presentation.errorMessage; + else if (presentation.availabilityMessage) messageElement.textContent = presentation.availabilityMessage; + else if (this.layaOperationPending) messageElement.textContent = this.layaOperationPending; + else if (mode !== 'algo' && (!status.modelPresent || !status.runtimeAvailable)) { + messageElement.textContent = 'Local AI is not ready. Download the model and ensure the runtime is running. Live TV will remain available.'; + } else messageElement.textContent = ''; + if (this.liveAnalysisStatus) this.renderLiveAnalysisStatus(this.liveAnalysisStatus); + } + + formatBytes(bytes) { + const value = Number(bytes); + if (!Number.isFinite(value) || value <= 0) return ''; + const units = ['B', 'KB', 'MB', 'GB']; + const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); + return `${(value / (1024 ** index)).toFixed(index > 1 ? 1 : 0)} ${units[index]}`; + } + + async runLayaModelAction(action) { + if (this.layaOperationPending) return; + this.layaOperationPending = action === 'download' ? 'Downloading model…' : 'Removing model…'; + this.layaStatus = { + ...(this.layaStatus || {}), + phase: action === 'download' ? 'preparing' : 'deleting', + downloading: action === 'download' + }; + this.renderLayaStatus(this.layaStatus); + try { + this.layaStatus = action === 'download' + ? await API.laya.downloadModel('multilingual') + : await API.laya.removeModel(); + this.layaOperationPending = ''; + this.renderLayaStatus(this.layaStatus); + if (this.layaStatus?.downloading) this.startLayaStatusPolling(); + else await this.loadLayaStatus(); + } catch (err) { + this.layaStatus = { ...(this.layaStatus || {}), error: err.message }; + this.layaOperationPending = ''; + await this.loadLayaStatus(); + } + } + + startLayaStatusPolling() { + if (this.layaPollTimer) return; + this.layaPollTimer = setInterval(async () => { + if (this.layaPollInFlight) return; + this.layaPollInFlight = true; + try { + const status = await this.loadLayaStatus(); + if (!status?.downloading && ['ready', 'failed', 'idle'].includes(status?.phase)) { + clearInterval(this.layaPollTimer); + this.layaPollTimer = null; + if (status.phase === 'ready' && this.app.player.settings.languageDetectionMode !== 'algo') { + this.app.channelList.setLanguageDetectionMode(this.app.player.settings.languageDetectionMode); + } + } + } finally { + this.layaPollInFlight = false; + } + }, 1000); + } + + useStandardLanguageDetection() { + const radio = document.querySelector('input[name="language-detection-mode"][value="algo"]'); + if (radio) radio.checked = true; + this.app.player.settings.languageDetectionMode = 'algo'; + this.app.player.saveSettings(); + this.app.channelList.setLanguageDetectionMode('algo'); + this.renderLayaStatus(this.layaStatus); + } + + async copyLayaStartCommand() { + const command = 'docker compose --profile laya up -d'; + const button = document.getElementById('laya-copy-command'); + try { + await navigator.clipboard.writeText(command); + button.textContent = 'Copied'; + } catch { + const code = document.querySelector('#laya-start-command code'); + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(code); + selection.removeAllRanges(); + selection.addRange(range); + button.textContent = 'Command selected'; + } + setTimeout(() => { button.textContent = 'Copy start command'; }, 2000); + } + async initTranscodingSettings() { // Encoder settings const hwEncoderSelect = document.getElementById('setting-hw-encoder'); @@ -107,6 +443,8 @@ class SettingsPage { const forceVideoTranscodeToggle = document.getElementById('setting-force-video-transcode-tc'); const forceRemuxToggle = document.getElementById('setting-force-remux-tc'); const streamFormatSelect = document.getElementById('setting-stream-format-tc'); + const maxDirectBitrateSelect = document.getElementById('setting-max-direct-bitrate'); + const allowHardwareFallbackToggle = document.getElementById('setting-allow-hardware-fallback'); // User-Agent (Transcoding tab versions) const userAgentSelect = document.getElementById('setting-user-agent-tc'); @@ -131,6 +469,8 @@ class SettingsPage { if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true; if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false; if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8'; + if (maxDirectBitrateSelect) maxDirectBitrateSelect.value = String(s.maxDirectBitrateKbps || 0); + if (allowHardwareFallbackToggle) allowHardwareFallbackToggle.checked = s.allowHardwareFallback !== false; if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome'; if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || ''; if (customUaContainer) { @@ -232,6 +572,16 @@ class SettingsPage { this.app.player.saveSettings(); }); + maxDirectBitrateSelect?.addEventListener('change', () => { + this.app.player.settings.maxDirectBitrateKbps = parseInt(maxDirectBitrateSelect.value, 10) || 0; + this.app.player.saveSettings(); + }); + + allowHardwareFallbackToggle?.addEventListener('change', () => { + this.app.player.settings.allowHardwareFallback = allowHardwareFallbackToggle.checked; + this.app.player.saveSettings(); + }); + // User-Agent handlers const toggleCustomInput = () => { if (customUaContainer) { @@ -557,6 +907,7 @@ class SettingsPage { const volumeValueDisplay = document.getElementById('volume-value'); const rememberVolumeToggle = document.getElementById('setting-remember-volume'); const autoPlayNextToggle = document.getElementById('setting-autoplay-next'); + const groupLiveChannelsToggle = document.getElementById('setting-group-live-channels'); const forceProxyToggle = document.getElementById('setting-force-proxy'); const forceTranscodeToggle = document.getElementById('setting-force-transcode'); const forceRemuxToggle = document.getElementById('setting-force-remux'); @@ -570,6 +921,10 @@ class SettingsPage { if (volumeValueDisplay) volumeValueDisplay.textContent = s.defaultVolume + '%'; if (rememberVolumeToggle) rememberVolumeToggle.checked = s.rememberVolume; if (autoPlayNextToggle) autoPlayNextToggle.checked = s.autoPlayNextEpisode; + if (groupLiveChannelsToggle) groupLiveChannelsToggle.checked = s.liveChannelListMode !== 'flat'; + document.querySelectorAll('input[name="language-detection-mode"]').forEach(radio => { + radio.checked = radio.value === (s.languageDetectionMode || 'algo'); + }); if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy || false; if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode || false; if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false; @@ -592,6 +947,7 @@ class SettingsPage { // Update EPG last refreshed display this.updateEpgLastRefreshed(); + void this.loadLayaStatus(); } /** diff --git a/public/js/services/channelLanguageDetection.js b/public/js/services/channelLanguageDetection.js new file mode 100644 index 0000000..5d3f462 --- /dev/null +++ b/public/js/services/channelLanguageDetection.js @@ -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 }; +}); diff --git a/public/js/services/channelLanguageFusion.js b/public/js/services/channelLanguageFusion.js new file mode 100644 index 0000000..f09fd8d --- /dev/null +++ b/public/js/services/channelLanguageFusion.js @@ -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 }; +}); diff --git a/public/js/services/channelVariantGrouping.js b/public/js/services/channelVariantGrouping.js new file mode 100644 index 0000000..2067b78 --- /dev/null +++ b/public/js/services/channelVariantGrouping.js @@ -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 + }; +}); diff --git a/public/js/services/layaStatusPresentation.js b/public/js/services/layaStatusPresentation.js new file mode 100644 index 0000000..3da9859 --- /dev/null +++ b/public/js/services/layaStatusPresentation.js @@ -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 }; +}); diff --git a/public/js/services/liveAnalysisPresentation.js b/public/js/services/liveAnalysisPresentation.js new file mode 100644 index 0000000..fd6e2d7 --- /dev/null +++ b/public/js/services/liveAnalysisPresentation.js @@ -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 }; +}); diff --git a/public/js/services/liveChannelProjection.js b/public/js/services/liveChannelProjection.js new file mode 100644 index 0000000..c55feaa --- /dev/null +++ b/public/js/services/liveChannelProjection.js @@ -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 }; +}); diff --git a/scripts/docker-build-push.sh b/scripts/docker-build-push.sh new file mode 100755 index 0000000..437a0a7 --- /dev/null +++ b/scripts/docker-build-push.sh @@ -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}" diff --git a/server/auth.js b/server/auth.js index ac76206..19d8510 100644 --- a/server/auth.js +++ b/server/auth.js @@ -1,6 +1,7 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const passport = require('passport'); +const { isAdmin } = require('./authz'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); const { Strategy: LocalStrategy } = require('passport-local'); @@ -234,7 +235,7 @@ const requireAuth = passport.authenticate('jwt', { session: false }); * Middleware: Require admin role */ function requireAdmin(req, res, next) { - if (!req.user || req.user.role !== 'admin') { + if (!isAdmin(req.user)) { return res.status(403).json({ error: 'Forbidden - Admin access required' }); } next(); diff --git a/server/authz.js b/server/authz.js new file mode 100644 index 0000000..bd666d8 --- /dev/null +++ b/server/authz.js @@ -0,0 +1,5 @@ +function isAdmin(user) { + return user?.role === 'admin'; +} + +module.exports = { isAdmin }; diff --git a/server/db.js b/server/db.js index 127d9b8..35191ec 100644 --- a/server/db.js +++ b/server/db.js @@ -1,6 +1,8 @@ const fs = require('fs/promises'); const path = require('path'); const { existsSync, mkdirSync } = require('fs'); +const { ensureBuiltinSources } = require('./services/builtinEpgRegistry'); +const { applyEpgOrder, nextEpgPriority, sortEpgSources } = require('./services/epgSourceOrdering'); // Ensure data directory exists (sync is fine for startup) const dataDir = path.join(__dirname, '..', 'data'); @@ -17,25 +19,28 @@ async function loadDb() { try { const fileContent = await fs.readFile(dbPath, 'utf-8'); const data = JSON.parse(fileContent); - return { + const original = JSON.stringify(data); + const migrated = ensureBuiltinSources({ sources: data.sources || [], hiddenItems: data.hiddenItems || [], favorites: data.favorites || [], settings: data.settings || getDefaultSettings(), users: data.users || [], nextId: data.nextId || 1 - }; + }); + if (original !== JSON.stringify(migrated)) await saveDb(migrated); + return migrated; } catch (error) { if (error.code === 'ENOENT') { // File doesn't exist, return default - return { + return ensureBuiltinSources({ sources: [], hiddenItems: [], favorites: [], settings: getDefaultSettings(), users: [], nextId: 1 - }; + }); } throw error; } @@ -62,6 +67,8 @@ function getDefaultSettings() { rememberVolume: true, lastVolume: 80, autoPlayNextEpisode: false, + liveChannelListMode: 'grouped', + languageDetectionMode: 'algo', forceProxy: false, forceTranscode: false, // Force Audio Transcode forceVideoTranscode: false, // Force Video Transcode @@ -76,6 +83,9 @@ function getDefaultSettings() { hwEncoder: 'auto', // auto | nvenc | amf | qsv | vaapi | software maxResolution: '1080p', // 4k | 1080p | 720p | 480p quality: 'medium', // high | medium | low + maxDirectBitrateKbps: 0, // 0 = unlimited + targetTranscodeBitrateKbps: 6000, + allowHardwareFallback: true, audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough // Probe cache settings probeCacheTTL: 300, // 5 minutes for URL probe cache @@ -131,7 +141,8 @@ async function saveDb(data) { const sources = { async getAll() { const db = await loadDb(); - return db.sources; + const epg = sortEpgSources(db.sources); + return [...db.sources.filter(source => source.type !== 'epg'), ...epg]; }, async getById(id) { @@ -141,7 +152,8 @@ const sources = { async getByType(type) { const db = await loadDb(); - return db.sources.filter(s => s.type === type && s.enabled); + const matching = db.sources.filter(s => s.type === type && s.enabled); + return type === 'epg' ? sortEpgSources(matching) : matching; }, async create(source) { @@ -149,6 +161,7 @@ const sources = { const newSource = { id: db.nextId++, ...source, + ...(source.type === 'epg' ? { epgPriority: nextEpgPriority(db.sources) } : {}), enabled: true, created_at: new Date().toISOString(), updated_at: new Date().toISOString() @@ -163,6 +176,11 @@ const sources = { const index = db.sources.findIndex(s => s.id === parseInt(id)); if (index === -1) return null; + const existing = db.sources[index]; + if (existing.builtinKey) { + const allowed = Object.keys(updates).every(key => key === 'enabled'); + if (!allowed) throw Object.assign(new Error('Built-in sources can only be enabled or disabled'), { code: 'BUILTIN_SOURCE_READONLY' }); + } db.sources[index] = { ...db.sources[index], ...updates, @@ -174,6 +192,8 @@ const sources = { async delete(id) { const db = await loadDb(); + const existing = db.sources.find(s => s.id === parseInt(id)); + if (existing?.builtinKey) throw Object.assign(new Error('Built-in sources cannot be removed'), { code: 'BUILTIN_SOURCE_PROTECTED' }); db.sources = db.sources.filter(s => s.id !== parseInt(id)); // Also delete related hidden items and favorites db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id)); @@ -190,6 +210,13 @@ const sources = { await saveDb(db); } return source; + }, + + async reorderEpg(orderedIds) { + const db = await loadDb(); + const ordered = applyEpgOrder(db.sources, orderedIds); + await saveDb(db); + return ordered; } }; diff --git a/server/db/liveProjectionSchema.js b/server/db/liveProjectionSchema.js new file mode 100644 index 0000000..1d5f52c --- /dev/null +++ b/server/db/liveProjectionSchema.js @@ -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 +}; diff --git a/server/db/sqlite.js b/server/db/sqlite.js index c3a76e4..ef75a6e 100644 --- a/server/db/sqlite.js +++ b/server/db/sqlite.js @@ -1,6 +1,12 @@ const Database = require('better-sqlite3'); const path = require('path'); const fs = require('fs'); +const { + CATALOG_PROJECTION_STATE_COLUMNS, + ensureColumns, + LIVE_PROJECTION_COLUMNS, + LIVE_PROJECTION_SCHEMA_SQL +} = require('./liveProjectionSchema'); const dataDir = path.join(__dirname, '..', '..', 'data'); const dbPath = path.join(dataDir, 'content.db'); @@ -73,6 +79,10 @@ function initSchema() { CREATE INDEX IF NOT EXISTS idx_items_category ON playlist_items(source_id, category_id); `); + ensureColumns(db, 'playlist_items', LIVE_PROJECTION_COLUMNS); + db.exec(LIVE_PROJECTION_SCHEMA_SQL); + ensureColumns(db, 'catalog_projection_state', CATALOG_PROJECTION_STATE_COLUMNS); + // EPG Programs // Optimized for range queries db.exec(` @@ -135,6 +145,18 @@ function initSchema() { CREATE INDEX IF NOT EXISTS idx_history_user_item ON watch_history(user_id, item_id); `); + db.exec(` + CREATE TABLE IF NOT EXISTS laya_language_cache ( + cache_key TEXT PRIMARY KEY, + mode TEXT NOT NULL, + model TEXT NOT NULL, + response TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_laya_cache_mode_model + ON laya_language_cache(mode, model); + `); + // Migration: Add source_id column if missing (for existing databases) try { db.exec(`ALTER TABLE watch_history ADD COLUMN source_id INTEGER`); @@ -209,8 +231,34 @@ const favorites = { } }; +const layaLanguageCache = { + get(cacheKey) { + const row = getDb().prepare( + 'SELECT response FROM laya_language_cache WHERE cache_key = ?' + ).get(cacheKey); + return row ? JSON.parse(row.response) : null; + }, + + set(cacheKey, mode, model, response) { + getDb().prepare(` + INSERT INTO laya_language_cache (cache_key, mode, model, response, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(cache_key) DO UPDATE SET + response = excluded.response, + created_at = excluded.created_at + `).run(cacheKey, mode, model, JSON.stringify(response), Date.now()); + }, + + clear() { + getDb().prepare('DELETE FROM laya_language_cache').run(); + } +}; + module.exports = { getDb, initSchema, - favorites + ensureColumns, + LIVE_PROJECTION_COLUMNS, + favorites, + layaLanguageCache }; diff --git a/server/index.js b/server/index.js index 0774e48..c580887 100644 --- a/server/index.js +++ b/server/index.js @@ -180,6 +180,8 @@ app.use('/api/probe', require('./routes/probe')); app.use('/api/subtitle', require('./routes/subtitle')); app.use('/api/settings', require('./routes/settings')); app.use('/api/history', require('./routes/history')); +app.use('/api/laya', require('./routes/laya')); +app.use('/api/live', require('./routes/live')); // Version endpoint app.get('/api/version', (req, res) => { diff --git a/server/routes/laya.js b/server/routes/laya.js new file mode 100644 index 0000000..88c0e87 --- /dev/null +++ b/server/routes/laya.js @@ -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; diff --git a/server/routes/live.js b/server/routes/live.js new file mode 100644 index 0000000..887dd44 --- /dev/null +++ b/server/routes/live.js @@ -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; diff --git a/server/routes/probe.js b/server/routes/probe.js index 751ef43..2baf2d5 100644 --- a/server/routes/probe.js +++ b/server/routes/probe.js @@ -87,6 +87,22 @@ function analyzeProbeResult(probeResult, url) { const videoCodec = videoStream?.codec_name?.toLowerCase() || 'unknown'; const audioCodec = audioStream?.codec_name?.toLowerCase() || 'unknown'; const container = format.format_name?.toLowerCase() || 'unknown'; + const toPositiveNumber = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + }; + const toKbps = (value) => { + const bitrate = toPositiveNumber(value); + return bitrate === null ? null : Math.round(bitrate / 1000); + }; + const durationSeconds = toPositiveNumber(format.duration) ?? toPositiveNumber(videoStream?.duration) ?? toPositiveNumber(audioStream?.duration); + const videoBitrateKbps = toKbps(videoStream?.bit_rate); + const audioBitrateKbps = toKbps(audioStream?.bit_rate); + const overallBitrateKbps = toKbps(format.bit_rate); + const probeConfidence = durationSeconds !== null && videoBitrateKbps !== null && + (!audioStream || audioBitrateKbps !== null) && overallBitrateKbps !== null + ? 'complete' + : 'partial'; // Check codec compatibility const videoOk = BROWSER_VIDEO_CODECS.some(c => videoCodec.includes(c)); @@ -131,6 +147,11 @@ function analyzeProbeResult(probeResult, url) { width: videoStream?.width || 0, height: videoStream?.height || 0, audioChannels: audioStream?.channels || 0, // For Smart Audio Copy + durationSeconds, + videoBitrateKbps, + audioBitrateKbps, + overallBitrateKbps, + probeConfidence, container: container, compatible: compatible, needsRemux: needsRemux, @@ -157,7 +178,12 @@ router.get('/', async (req, res) => { container: 'unknown', compatible: false, needsRemux: false, - needsTranscode: true + needsTranscode: true, + durationSeconds: null, + videoBitrateKbps: null, + audioBitrateKbps: null, + overallBitrateKbps: null, + probeConfidence: 'partial' }); } @@ -193,6 +219,11 @@ router.get('/', async (req, res) => { compatible: false, needsRemux: false, needsTranscode: true, + durationSeconds: null, + videoBitrateKbps: null, + audioBitrateKbps: null, + overallBitrateKbps: null, + probeConfidence: 'partial', error: err.message }); } diff --git a/server/routes/sources.js b/server/routes/sources.js index 3184fc2..726f877 100644 --- a/server/routes/sources.js +++ b/server/routes/sources.js @@ -5,6 +5,7 @@ const { getDb } = require('../db/sqlite'); const xtreamApi = require('../services/xtreamApi'); const syncService = require('../services/syncService'); const m3uParser = require('../services/m3uParser'); +const liveProjection = require('../services/liveProjectionService'); // Get all sources router.get('/', async (req, res) => { @@ -46,6 +47,20 @@ router.get('/type/:type', async (req, res) => { } }); +router.put('/epg/order', async (req, res) => { + try { + const ordered = await sources.reorderEpg(req.body?.orderedIds); + res.json(ordered.map(source => ({ + ...source, + password: source.password ? '••••••••' : null + }))); + } catch (err) { + if (err.code) return res.status(400).json({ error: err.message, code: err.code }); + console.error('Error reordering EPG sources:', err); + res.status(500).json({ error: 'Failed to reorder EPG sources' }); + } +}); + // Get single source router.get('/:id', async (req, res) => { try { @@ -92,6 +107,9 @@ router.put('/:id', async (req, res) => { } const { name, url, username, password } = req.body; + if (existing.builtinKey) { + return res.status(409).json({ error: 'Built-in sources can only be enabled or disabled', code: 'BUILTIN_SOURCE_READONLY' }); + } const updated = await sources.update(req.params.id, { name: name || existing.name, url: url || existing.url, @@ -115,6 +133,9 @@ router.delete('/:id', async (req, res) => { if (!existing) { return res.status(404).json({ error: 'Source not found' }); } + if (existing.builtinKey) { + return res.status(409).json({ error: 'Built-in sources cannot be removed', code: 'BUILTIN_SOURCE_PROTECTED' }); + } // Cascade delete: Clean up SQLite data for this source const db = getDb(); @@ -132,6 +153,7 @@ router.delete('/:id', async (req, res) => { // Delete source config and related hidden items (favorites handled by db.js) await sources.delete(sourceId); + await liveProjection.queueRebuild(); res.json({ success: true }); } catch (err) { @@ -278,4 +300,3 @@ router.post('/sync-all', async (req, res) => { }); module.exports = router; - diff --git a/server/routes/transcode.js b/server/routes/transcode.js index 3f82ec9..8f7e5a7 100644 --- a/server/routes/transcode.js +++ b/server/routes/transcode.js @@ -5,6 +5,7 @@ const path = require('path'); const fs = require('fs').promises; const db = require('../db'); const transcodeSession = require('../services/transcodeSession'); +const { QUALITY_MODES, resolveQualityMode } = require('../services/transcodeQualityProfiles'); /** * Transcode Routes @@ -29,7 +30,11 @@ transcodeSession.startCleanupInterval(); * Body: { url: string, seekOffset?: number } */ router.post('/session', async (req, res) => { - const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels } = req.body; + const { + url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels, + targetVideoBitrateKbps, targetAudioBitrateKbps, allowHardwareFallback, + qualityMode + } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required' }); @@ -40,6 +45,7 @@ router.post('/session', async (req, res) => { const userAgent = db.getUserAgent(settings); try { + const qualitySelection = resolveQualityMode(qualityMode, settings); const session = await transcodeSession.createSession(url, { ffmpegPath, userAgent, @@ -47,6 +53,9 @@ router.post('/session', async (req, res) => { hwEncoder: settings.hwEncoder || 'software', maxResolution: settings.maxResolution || '1080p', quality: settings.quality || 'medium', + targetVideoBitrateKbps: targetVideoBitrateKbps ?? settings.targetTranscodeBitrateKbps ?? 6000, + targetAudioBitrateKbps: targetAudioBitrateKbps ?? 192, + allowHardwareFallback: allowHardwareFallback ?? settings.allowHardwareFallback ?? true, audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset // Upscaling options upscaleEnabled: settings.upscaleEnabled || false, @@ -55,7 +64,8 @@ router.post('/session', async (req, res) => { videoMode: videoMode, // 'copy' or 'encode' videoCodec: videoCodec, // 'h264', 'hevc', etc. audioCodec: audioCodec, // 'aac', 'ac3', etc. - audioChannels: audioChannels // number of channels (2=stereo) + audioChannels: audioChannels, // number of channels (2=stereo) + ...(qualitySelection?.sessionOptions || {}) }); await session.start(); @@ -71,10 +81,25 @@ router.post('/session', async (req, res) => { res.json({ sessionId: session.id, playlistUrl: `/api/transcode/${session.id}/stream.m3u8`, - status: session.status + status: session.status, + requestedEncoder: session.requestedEncoder, + effectiveEncoder: session.effectiveEncoder, + hardwareFallbackOccurred: session.hardwareFallbackOccurred, + ...(qualitySelection && { + qualityMode: qualitySelection.qualityMode, + mode: qualitySelection.mode, + selectedProfile: qualitySelection.selectedProfile, + availableProfiles: qualitySelection.availableProfiles + }) }); } catch (err) { + if (err.code === 'INVALID_QUALITY_MODE') { + return res.status(400).json({ + error: err.message, + availableProfiles: QUALITY_MODES + }); + } console.error('[Transcode] Session creation failed:', err); res.status(500).json({ error: 'Failed to create session', details: err.message }); } @@ -270,4 +295,3 @@ router.get('/', async (req, res) => { }); module.exports = router; - diff --git a/server/services/builtinEpgRegistry.js b/server/services/builtinEpgRegistry.js new file mode 100644 index 0000000..36817de --- /dev/null +++ b/server/services/builtinEpgRegistry.js @@ -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 }; diff --git a/server/services/channelLanguage.js b/server/services/channelLanguage.js new file mode 100644 index 0000000..e5e2763 --- /dev/null +++ b/server/services/channelLanguage.js @@ -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 }; diff --git a/server/services/epgMatchingService.js b/server/services/epgMatchingService.js new file mode 100644 index 0000000..c623b3c --- /dev/null +++ b/server/services/epgMatchingService.js @@ -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 }; diff --git a/server/services/epgSourceOrdering.js b/server/services/epgSourceOrdering.js new file mode 100644 index 0000000..e5b7dd1 --- /dev/null +++ b/server/services/epgSourceOrdering.js @@ -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 }; diff --git a/server/services/flexibleChannelSearch.js b/server/services/flexibleChannelSearch.js new file mode 100644 index 0000000..703246c --- /dev/null +++ b/server/services/flexibleChannelSearch.js @@ -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 }; diff --git a/server/services/iptvOrgCatalogService.js b/server/services/iptvOrgCatalogService.js new file mode 100644 index 0000000..aa3be9f --- /dev/null +++ b/server/services/iptvOrgCatalogService.js @@ -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 }; diff --git a/server/services/layaService.js b/server/services/layaService.js new file mode 100644 index 0000000..c12f024 --- /dev/null +++ b/server/services/layaService.js @@ -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 +}; diff --git a/server/services/liveGroupingScoring.js b/server/services/liveGroupingScoring.js new file mode 100644 index 0000000..844b4af --- /dev/null +++ b/server/services/liveGroupingScoring.js @@ -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 +}; diff --git a/server/services/liveProjectionService.js b/server/services/liveProjectionService.js new file mode 100644 index 0000000..c1913b1 --- /dev/null +++ b/server/services/liveProjectionService.js @@ -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 +}; diff --git a/server/services/syncService.js b/server/services/syncService.js index 8ac35f5..94c36f3 100644 --- a/server/services/syncService.js +++ b/server/services/syncService.js @@ -3,6 +3,7 @@ const { sources, settings } = require('../db'); // For source config and setting const xtreamApi = require('./xtreamApi'); const m3uParser = require('./m3uParser'); const epgParser = require('./epgParser'); +const liveProjection = require('./liveProjectionService'); // Sync tracking const activeSyncs = new Set(); // sourceId @@ -183,6 +184,7 @@ class SyncService { console.log(`[Sync] Fetching Live Streams for ${source.name}`); const liveStreams = await api.getLiveStreams(); await this.saveStreams(source.id, 'live', liveStreams); + await liveProjection.queueRebuild(); // 3. VOD Categories console.log(`[Sync] Fetching VOD Categories for ${source.name}`); @@ -559,6 +561,7 @@ class SyncService { })); await this.saveCategories(source.id, 'live', categories); + await liveProjection.queueRebuild(); console.log(`[Sync] M3U sync complete for ${source.name}`); } diff --git a/server/services/transcodeQualityProfiles.js b/server/services/transcodeQualityProfiles.js new file mode 100644 index 0000000..13ac145 --- /dev/null +++ b/server/services/transcodeQualityProfiles.js @@ -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 +}; diff --git a/server/services/transcodeSession.js b/server/services/transcodeSession.js index 4d6c2f7..c473476 100644 --- a/server/services/transcodeSession.js +++ b/server/services/transcodeSession.js @@ -63,20 +63,28 @@ class TranscodeSession extends EventEmitter { this.segments = new Map(); // segment index -> { ready: boolean, path: string } this.status = 'pending'; // pending | starting | running | stopped | error this.error = null; + this.requestedEncoder = options.hwEncoder || 'software'; + this.effectiveEncoder = null; + this.hardwareFallbackOccurred = false; + this.playlistReady = false; + this.processGeneration = 0; this.startTime = Date.now(); this.lastAccess = Date.now(); this.options = { + ...options, ffmpegPath: options.ffmpegPath || 'ffmpeg', userAgent: options.userAgent || 'Mozilla/5.0', seekOffset: options.seekOffset || 0, hwEncoder: options.hwEncoder || 'software', maxResolution: options.maxResolution || '1080p', quality: options.quality || 'medium', + targetVideoBitrateKbps: this.normalizeBitrate(options.targetVideoBitrateKbps, 6000), + targetAudioBitrateKbps: this.normalizeBitrate(options.targetAudioBitrateKbps, 192), + allowHardwareFallback: options.allowHardwareFallback !== false, // Upscaling options upscaleEnabled: options.upscaleEnabled || false, upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software' - upscaleTarget: options.upscaleTarget || '1080p', - ...options + upscaleTarget: options.upscaleTarget || '1080p' }; } @@ -100,27 +108,62 @@ class TranscodeSession extends EventEmitter { throw err; } - // Build FFmpeg arguments for HLS output - const args = this.buildFFmpegArgs(); + try { + const encoder = this.resolveEncoder(); + this.requestedEncoder = this.options.hwEncoder || 'software'; + this.startProcess(encoder); + // Save session metadata + await this.persist(); + + } catch (err) { + this.status = 'error'; + this.error = err.message; + throw err; + } + } + + normalizeBitrate(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback; + } + + resolveEncoder() { + let encoder = this.options.hwEncoder || 'software'; + if (encoder === 'auto') { + const hwCaps = hwDetect.getCapabilities(); + encoder = hwCaps?.recommended || 'software'; + console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`); + } + return encoder; + } + + startProcess(encoder) { + const args = this.buildFFmpegArgs(encoder); + const generation = ++this.processGeneration; + this.effectiveEncoder = encoder; + this.status = 'starting'; + this.error = null; + console.log(`[TranscodeSession ${this.id}] Starting with requested encoder=${this.requestedEncoder}, effective encoder=${encoder}`); console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`); try { - this.process = spawn(this.options.ffmpegPath, args, { + const process = spawn(this.options.ffmpegPath, args, { cwd: this.dir, windowsHide: true }); + this.process = process; this.status = 'running'; // Handle stdout (should be empty for file output) - this.process.stdout.on('data', (data) => { + process.stdout.on('data', (data) => { console.log(`[TranscodeSession ${this.id}] stdout: ${data}`); }); // Handle stderr (FFmpeg progress/errors) let stderrBuffer = ''; - this.process.stderr.on('data', (data) => { + process.stderr.on('data', (data) => { stderrBuffer += data.toString(); // Log periodically to avoid spam const lines = stderrBuffer.split('\n'); @@ -135,51 +178,62 @@ class TranscodeSession extends EventEmitter { }); // Handle process exit - this.process.on('exit', (code) => { + process.on('exit', (code) => { + if (generation !== this.processGeneration) return; if (code === 0 || code === null) { console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`); this.status = 'stopped'; } else if (code !== 255) { // 255 is often from SIGKILL - console.error(`[TranscodeSession ${this.id}] FFmpeg exited with code ${code}`); + const error = `FFmpeg exited with code ${code}`; + if (this.tryHardwareFallback(error, encoder)) return; + console.error(`[TranscodeSession ${this.id}] ${error}`); this.status = 'error'; - this.error = `FFmpeg exited with code ${code}`; + this.error = error; } this.process = null; this.emit('exit', code); }); // Handle spawn errors - this.process.on('error', (err) => { + process.on('error', (err) => { + if (generation !== this.processGeneration) return; + if (this.tryHardwareFallback(err.message, encoder)) return; console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err); this.status = 'error'; this.error = err.message; - this.emit('error', err); + if (this.listenerCount('error') > 0) { + this.emit('error', err); + } }); - // Save session metadata - await this.persist(); - } catch (err) { - this.status = 'error'; - this.error = err.message; + if (this.tryHardwareFallback(err.message, encoder)) return; throw err; } } + tryHardwareFallback(initialError, encoder) { + const canFallback = this.options.videoMode !== 'copy' && encoder !== 'software' && + this.options.allowHardwareFallback && !this.hardwareFallbackOccurred && !this.playlistReady; + if (!canFallback) return false; + + this.hardwareFallbackOccurred = true; + console.error(`[TranscodeSession ${this.id}] Hardware encoder ${encoder} failed during startup: ${initialError}`); + console.log(`[TranscodeSession ${this.id}] Retrying once with software encoder`); + this.startProcess('software'); + this.persist().catch(err => console.error(`[TranscodeSession ${this.id}] Failed to persist fallback state:`, err.message)); + return true; + } + /** * Build FFmpeg arguments for HLS output with optional GPU encoding */ - buildFFmpegArgs() { + buildFFmpegArgs(resolvedEncoder = null) { const segmentPattern = path.join(this.dir, 'seg%04d.m4s'); const videoMode = this.options.videoMode || 'encode'; // Resolve 'auto' encoder to detected hardware, fallback to software - let encoder = this.options.hwEncoder || 'software'; - if (encoder === 'auto') { - const hwCaps = hwDetect.getCapabilities(); - encoder = hwCaps?.recommended || 'software'; - console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`); - } + const encoder = resolvedEncoder || this.resolveEncoder(); const args = [ '-hide_banner', @@ -264,7 +318,7 @@ class TranscodeSession extends EventEmitter { args.push( '-c:a', 'aac', '-ar', '48000', - '-b:a', '192k', + '-b:a', `${this.options.targetAudioBitrateKbps}k`, '-af', `${panFilter},aresample=async=1` ); } @@ -336,6 +390,7 @@ class TranscodeSession extends EventEmitter { 'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 } }; const qp = qualityPresets[quality] || qualityPresets.medium; + const bitrate = this.options.targetVideoBitrateKbps; switch (encoder) { case 'nvenc': @@ -356,6 +411,7 @@ class TranscodeSession extends EventEmitter { this.addSoftwareEncoderArgs(args, resolution, qp.software); break; } + args.push('-b:v', `${bitrate}k`, '-maxrate', `${bitrate}k`, '-bufsize', `${bitrate * 2}k`); } /** @@ -432,8 +488,8 @@ class TranscodeSession extends EventEmitter { args.push( '-c:v', 'h264_nvenc', '-preset', 'p4', // Balanced preset (p1=fastest, p7=best) - '-rc', 'constqp', // Constant QP mode - '-qp', String(qp), + '-rc', 'vbr', // Bitrate-constrained variable bitrate + '-cq', String(qp), // Quality target within bitrate constraint '-bf', '3' // B-frames for better compression ); } @@ -448,10 +504,7 @@ class TranscodeSession extends EventEmitter { args.push( '-c:v', 'h264_amf', '-quality', 'quality', // Quality preset - '-rc', 'cqp', // Constant QP - '-qp_i', String(qp), - '-qp_p', String(qp + 2), - '-qp_b', String(qp + 4), + '-rc', 'vbr_peak', // Bitrate-constrained variable bitrate '-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility ); } @@ -556,6 +609,7 @@ class TranscodeSession extends EventEmitter { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { if (await this.isPlaylistReady()) { + this.playlistReady = true; return true; } await new Promise(resolve => setTimeout(resolve, 200)); @@ -600,6 +654,9 @@ class TranscodeSession extends EventEmitter { startTime: this.startTime, lastAccess: this.lastAccess, options: this.options, + requestedEncoder: this.requestedEncoder, + effectiveEncoder: this.effectiveEncoder, + hardwareFallbackOccurred: this.hardwareFallbackOccurred, seekOffset: this.options.seekOffset }; const metaPath = path.join(this.dir, 'session.json'); @@ -621,6 +678,9 @@ class TranscodeSession extends EventEmitter { session.startTime = metadata.startTime; session.lastAccess = metadata.lastAccess; session.status = 'stopped'; // Not running after restart + session.requestedEncoder = metadata.requestedEncoder || session.requestedEncoder; + session.effectiveEncoder = metadata.effectiveEncoder || null; + session.hardwareFallbackOccurred = metadata.hardwareFallbackOccurred || false; return session; } catch (err) { console.error(`Failed to restore session from ${sessionDir}:`, err.message); @@ -753,7 +813,10 @@ function getAllSessions() { status: s.status, startTime: s.startTime, lastAccess: s.lastAccess, - idleMs: Date.now() - s.lastAccess + idleMs: Date.now() - s.lastAccess, + requestedEncoder: s.requestedEncoder, + effectiveEncoder: s.effectiveEncoder, + hardwareFallbackOccurred: s.hardwareFallbackOccurred })); } diff --git a/test/channelLanguageDetection.test.js b/test/channelLanguageDetection.test.js new file mode 100644 index 0000000..83f8a48 --- /dev/null +++ b/test/channelLanguageDetection.test.js @@ -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'); +}); diff --git a/test/channelLanguageFusion.test.js b/test/channelLanguageFusion.test.js new file mode 100644 index 0000000..0562d08 --- /dev/null +++ b/test/channelLanguageFusion.test.js @@ -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:\/\//); +}); diff --git a/test/channelVariantGrouping.test.js b/test/channelVariantGrouping.test.js new file mode 100644 index 0000000..b1b074b --- /dev/null +++ b/test/channelVariantGrouping.test.js @@ -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'); +}); diff --git a/test/dockerBuildPushScript.test.js b/test/dockerBuildPushScript.test.js new file mode 100644 index 0000000..2d80c75 --- /dev/null +++ b/test/dockerBuildPushScript.test.js @@ -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}` + ]); +}); diff --git a/test/layaService.test.js b/test/layaService.test.js new file mode 100644 index 0000000..7d4ea3b --- /dev/null +++ b/test/layaService.test.js @@ -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/); +}); diff --git a/test/layaStatusPresentation.test.js b/test/layaStatusPresentation.test.js new file mode 100644 index 0000000..ebb7c75 --- /dev/null +++ b/test/layaStatusPresentation.test.js @@ -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); +}); diff --git a/test/liveAnalysisPresentation.test.js b/test/liveAnalysisPresentation.test.js new file mode 100644 index 0000000..513daaf --- /dev/null +++ b/test/liveAnalysisPresentation.test.js @@ -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.' + ); +}); diff --git a/test/liveAnalysisUserFlowDom.test.js b/test/liveAnalysisUserFlowDom.test.js new file mode 100644 index 0000000..1afcc5e --- /dev/null +++ b/test/liveAnalysisUserFlowDom.test.js @@ -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(` + + + + +
+
+ + +
+ +
+ + + `, { + 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); +}); diff --git a/test/liveChannelProjection.test.js b/test/liveChannelProjection.test.js new file mode 100644 index 0000000..0f4c455 --- /dev/null +++ b/test/liveChannelProjection.test.js @@ -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); +}); diff --git a/test/liveDetectionFunctionalChain.test.js b/test/liveDetectionFunctionalChain.test.js new file mode 100644 index 0000000..5f08c1f --- /dev/null +++ b/test/liveDetectionFunctionalChain.test.js @@ -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); +}); diff --git a/test/liveGroupingScoring.test.js b/test/liveGroupingScoring.test.js new file mode 100644 index 0000000..f133471 --- /dev/null +++ b/test/liveGroupingScoring.test.js @@ -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); +}); diff --git a/test/liveProjectionRoutes.test.js b/test/liveProjectionRoutes.test.js new file mode 100644 index 0000000..ae117d4 --- /dev/null +++ b/test/liveProjectionRoutes.test.js @@ -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); +}); diff --git a/test/liveProjectionService.test.js b/test/liveProjectionService.test.js new file mode 100644 index 0000000..e321d89 --- /dev/null +++ b/test/liveProjectionService.test.js @@ -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); +}); diff --git a/test/settingsDefaults.test.js b/test/settingsDefaults.test.js new file mode 100644 index 0000000..9de8178 --- /dev/null +++ b/test/settingsDefaults.test.js @@ -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'); +}); diff --git a/test/t23Backend.test.js b/test/t23Backend.test.js new file mode 100644 index 0000000..689fae7 --- /dev/null +++ b/test/t23Backend.test.js @@ -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); +}); diff --git a/test/t23Frontend.test.js b/test/t23Frontend.test.js new file mode 100644 index 0000000..1ff606b --- /dev/null +++ b/test/t23Frontend.test.js @@ -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('
', { runScripts: 'outside-only' }); + dom.window.Icons = { series: '', refresh: '' }; + 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'); +}); diff --git a/test/t24EpgSourcePriority.test.js b/test/t24EpgSourcePriority.test.js new file mode 100644 index 0000000..92f88a8 --- /dev/null +++ b/test/t24EpgSourcePriority.test.js @@ -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\)/); +}); diff --git a/test/t24Frontend.test.js b/test/t24Frontend.test.js new file mode 100644 index 0000000..53ee29b --- /dev/null +++ b/test/t24Frontend.test.js @@ -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(` +
+
+
+ `, { url: 'http://nodecast.test/', runScripts: 'outside-only' }); + dom.window.Icons = { + series: 'guide', refresh: 'refresh', settings: 'edit', close: 'delete' + }; + 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] }); +}); diff --git a/test/t25Frontend.test.js b/test/t25Frontend.test.js new file mode 100644 index 0000000..7dcf81c --- /dev/null +++ b/test/t25Frontend.test.js @@ -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(` + + + + +
+
+
+
+ `, { 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(); +}); diff --git a/test/t26Frontend.test.js b/test/t26Frontend.test.js new file mode 100644 index 0000000..a89ac45 --- /dev/null +++ b/test/t26Frontend.test.js @@ -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('
', { 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(); +}); diff --git a/test/t27BeinSearchRegression.test.js b/test/t27BeinSearchRegression.test.js new file mode 100644 index 0000000..a857c63 --- /dev/null +++ b/test/t27BeinSearchRegression.test.js @@ -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); +}); diff --git a/test/transcodeQualityProfiles.test.js b/test/transcodeQualityProfiles.test.js new file mode 100644 index 0000000..0fb9fb8 --- /dev/null +++ b/test/transcodeQualityProfiles.test.js @@ -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' + ); +});