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 @@
Sources are checked from top to bottom.
+Higher sources take priority; lower sources fill missing program data.
+ + +Choose how Nodecast TV identifies matching streams and languages.
+Use this to analyze channels that were added before automatic analysis was enabled.
+docker compose --profile laya up -dThe model runs on this server. Channel names and metadata are not sent to an AI service.
+