From b3e44f9d373a4115d0c6fea39dfd4b16e2060401 Mon Sep 17 00:00:00 2001 From: cerede2000 <10638828+cerede2000@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:20:32 +0200 Subject: [PATCH] feat: add English and French localization --- .env.example | 39 +- .github/workflows/ci.yml | 12 +- .gitignore | 1 - CHANGELOG.md | 19 + DOCKERHUB.md | 74 +++ README.fr.md | 120 +++++ README.md | 431 +++++------------- compose.dockerhub.yml | 30 ++ compose.ghcr.yml | 9 +- compose.yml | 3 + frontend/index.html | 11 +- frontend/public/manifest.json | 2 +- frontend/src/App.vue | 4 +- frontend/src/api/client.ts | 4 +- frontend/src/components/AppHeader.vue | 42 +- frontend/src/components/BackToTop.vue | 7 +- frontend/src/components/ChannelIndexRail.vue | 6 +- frontend/src/components/DateNav.vue | 10 +- frontend/src/components/NowCard.vue | 6 +- frontend/src/components/ProgrammeBadges.vue | 9 +- frontend/src/components/ProgrammeModal.vue | 30 +- frontend/src/components/RatingBadge.vue | 9 +- frontend/src/components/ViewSwitcher.vue | 12 +- .../src/components/admin/AdminChannels.vue | 116 ++--- .../src/components/admin/AdminDisplay.vue | 42 +- .../src/components/admin/AdminSources.vue | 101 ++-- frontend/src/i18n/index.ts | 244 ++++++++++ frontend/src/main.ts | 2 + frontend/src/stores/detail.ts | 3 +- frontend/src/utils/grid.ts | 3 +- frontend/src/utils/time.ts | 21 +- frontend/src/views/AdminView.vue | 30 +- frontend/src/views/GridView.vue | 57 ++- frontend/src/views/NowView.vue | 33 +- frontend/src/views/SearchView.vue | 24 +- frontend/src/views/TonightView.vue | 49 +- frontend/tests/DateNav.spec.ts | 8 +- frontend/tests/NowCard.spec.ts | 10 +- frontend/tests/RatingBadge.spec.ts | 2 +- frontend/tests/i18n.spec.ts | 43 ++ frontend/tests/time.spec.ts | 12 +- 41 files changed, 1017 insertions(+), 673 deletions(-) create mode 100644 DOCKERHUB.md create mode 100644 README.fr.md create mode 100644 compose.dockerhub.yml create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/tests/i18n.spec.ts diff --git a/.env.example b/.env.example index e483f2c..835739a 100644 --- a/.env.example +++ b/.env.example @@ -1,48 +1,27 @@ -# ─── TV Guide — configuration ──────────────────────────────────────────────── -# Copiez ce fichier vers .env puis ajustez les valeurs. -# cp .env.example .env +# TV Guide configuration. Copy to .env and adjust as needed. -# Fuseau horaire d'affichage par défaut (modifiable ensuite dans l'administration). TZ=Europe/Paris - -# Port HTTP publié sur l'hôte (le conteneur écoute toujours sur 8080). APP_PORT=8080 - -# Répertoire de persistance DANS le conteneur. Ne pas modifier sauf besoin précis : -# le volume est monté sur /data dans compose.yml. DATA_DIR=/data - -# Verbosité des logs : DEBUG, INFO, WARNING, ERROR LOG_LEVEL=INFO -# Mot de passe de l'administration. -# - vide (défaut) : administration ouverte — réservez ce mode à un LAN de confiance ; -# - renseigné : la consultation reste publique, mais Sources/Chaînes/Affichage -# exigent une connexion (jeton de session de 24 h, anti-brute-force intégré). -# OBLIGATOIRE si l'application est exposée sur Internet. Utilisez HTTPS (reverse proxy). +# Protect administration when exposed outside a trusted LAN. ADMIN_PASSWORD= -# Autoriser des sources XMLTV pointant vers des adresses privées (LAN, loopback). -# Laisser à false sur une instance exposée : protège contre les attaques SSRF. -# Passer à true si votre fichier XMLTV est servi par une machine de votre LAN. +# Set to true only when XMLTV files are served from your private network. ALLOW_PRIVATE_XMLTV_SOURCES=false -# Taille maximale d'un téléchargement XMLTV (fichier compressé ou non), en Mo. XMLTV_MAX_DOWNLOAD_MB=100 - -# Taille maximale après décompression GZIP, en Mo (protection contre les bombes gzip/zip/xz). XMLTV_MAX_UNCOMPRESSED_MB=500 - -# Timeout des requêtes HTTP vers les sources XMLTV, en secondes. XMLTV_HTTP_TIMEOUT_SECONDS=30 +XMLTV_DOWNLOAD_RETRIES=3 -# Nombre maximal d'imports XMLTV simultanés. 1 est recommandé avec SQLite : -# évite de multiplier les pics RAM/CPU et les écritures concurrentes. +# Keep one worker with SQLite to minimize concurrent RAM/CPU peaks. XMLTV_SYNC_WORKERS=1 +XMLTV_USER_AGENT=tvguide/1.0 -# Nombre maximal d'arènes de l'allocateur glibc. La valeur 2 évite que la -# décompression 7z conserve durablement son pic RAM après les imports. +# Reduce retained native allocator arenas after archive decompression. MALLOC_ARENA_MAX=2 -# User-Agent envoyé lors du téléchargement des sources XMLTV. -#XMLTV_USER_AGENT=tvguide/1.0 +# Restrict this to your reverse proxy IP when the container port is exposed. +FORWARDED_ALLOW_IPS=* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e7aac0..f35c34b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,12 +89,21 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Log in to Docker Hub + if: startsWith(github.ref, 'refs/tags/v') + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Image metadata if: startsWith(github.ref, 'refs/tags/v') id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/${{ github.repository }} + images: | + ghcr.io/${{ github.repository }} + ${{ secrets.DOCKERHUB_USERNAME }}/tvguide tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} @@ -109,5 +118,6 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 3e569aa..b1c36fe 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,3 @@ data/ # Frontend build copied into the backend (Docker build artifact) backend/app/static/ -.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c98a5..c406717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ Ce projet suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et [SemVer](https://semver.org/lang/fr/). +## [1.17.0] — 2026-07-18 + +### Internationalization + +- Added complete English and French user interfaces, including navigation, + administration, programme metadata, dates, durations, and accessibility text. +- The saved language takes priority; first visits use the primary browser + language (`fr*` selects French, all other languages fall back to English). +- Added an accessible EN/FR selector and tests for detection, persistence, + fallback, and interpolation. + +### Documentation and distribution + +- English README is now the default, with a linked French README. +- Added complete Docker Hub documentation, Compose example, environment + reference, and a dedicated `compose.dockerhub.yml`. +- Stable release tags now publish the same multi-platform image to GHCR and + Docker Hub; the English Docker Hub overview is tracked in `DOCKERHUB.md`. + ## [1.16.2] — 2026-07-18 ### Performance et mémoire diff --git a/DOCKERHUB.md b/DOCKERHUB.md new file mode 100644 index 0000000..185e6c9 --- /dev/null +++ b/DOCKERHUB.md @@ -0,0 +1,74 @@ +# TV Guide + +Lightweight, self-hosted XMLTV schedule browser with live, evening, grid, and full-text search views. + +- English and French UI with browser-language detection +- XML, GZIP, ZIP, 7-Zip, XZ, and BZIP2 imports +- Atomic imports and smart source synchronization +- SQLite FTS5 search +- Administration password support +- Non-root, read-only compatible container +- `linux/amd64` and `linux/arm64` + +Source, issues, and release notes: [github.com/cerede2000/tvguide](https://github.com/cerede2000/tvguide) + +## Docker Compose + +```yaml +services: + tvguide: + image: cerede2000/tvguide:latest + container_name: tvguide + restart: unless-stopped + ports: + - "8080:8080" + environment: + TZ: Europe/Paris + ADMIN_PASSWORD: change-this-password + XMLTV_SYNC_WORKERS: 1 + MALLOC_ARENA_MAX: 2 + volumes: + - ./data:/data + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 +``` + +```bash +mkdir -p data/imports +chown -R 1000:1000 data +docker compose up -d +``` + +Open `http://localhost:8080`, then add an XMLTV source from **Administration → XMLTV sources**. + +## Environment variables + +| Variable | Default | Description | +| --- | --- | --- | +| `TZ` | `Europe/Paris` | Default display and XMLTV fallback timezone. | +| `ADMIN_PASSWORD` | empty | Protects administration when set. | +| `LOG_LEVEL` | `INFO` | Logging level. | +| `ALLOW_PRIVATE_XMLTV_SOURCES` | `false` | Allows private/loopback XMLTV URLs. | +| `XMLTV_MAX_DOWNLOAD_MB` | `100` | Maximum download size. | +| `XMLTV_MAX_UNCOMPRESSED_MB` | `500` | Maximum decompressed size. | +| `XMLTV_HTTP_TIMEOUT_SECONDS` | `30` | Download timeout. | +| `XMLTV_DOWNLOAD_RETRIES` | `3` | Download retry count. | +| `XMLTV_SYNC_WORKERS` | `1` | Concurrent imports; keep `1` for lower RAM/CPU. | +| `XMLTV_USER_AGENT` | `tvguide/1.0` | XMLTV download User-Agent. | +| `MALLOC_ARENA_MAX` | `2` | Limits retained native allocator arenas. | +| `FORWARDED_ALLOW_IPS` | `*` | Trusted reverse-proxy IPs. | + +The container listens on port `8080`, runs as UID/GID `1000`, stores all persistent data under `/data`, and exposes `GET /api/health`. + +## Tags + +- `latest`: latest stable release +- `1.17.0`: exact release +- `1.17`: latest patch in the release line + +Pin an exact version for controlled deployments. Database migrations run automatically at startup; back up `/data/app.db` before upgrades. + +Full documentation: [English](https://github.com/cerede2000/tvguide/blob/main/README.md) · [Français](https://github.com/cerede2000/tvguide/blob/main/README.fr.md) diff --git a/README.fr.md b/README.fr.md new file mode 100644 index 0000000..6237002 --- /dev/null +++ b/README.fr.md @@ -0,0 +1,120 @@ +# Guide TV + +[English](README.md) · [Français](README.fr.md) + +Application web légère et auto-hébergée pour consulter une ou plusieurs sources XMLTV. Elle fournit uniquement le programme : aucun lecteur IPTV, M3U, enregistrement ou flux vidéo. + +## Fonctionnalités + +- Programmes en cours avec progression, temps restant et émissions suivantes. +- Vues Ce soir et 2e partie avec plages horaires configurables. +- Grille proportionnelle, navigation par date, zoom et interface responsive. +- Recherche rapide SQLite FTS5 dans les titres, sous-titres et descriptions. +- Sources XMLTV par URL ou fichier local, imports atomiques et planification intelligente. +- Ordre des chaînes, noms/logos personnalisés et configuration portable. +- Thèmes clair, sombre et système. +- Interface anglaise et française : préférence mémorisée, sinon langue du navigateur. +- Image multi-architecture `linux/amd64` et `linux/arm64`. + +Formats acceptés : XML, GZIP, ZIP, 7-Zip, XZ et BZIP2, détectés depuis le contenu. + +## Démarrage rapide avec Docker Compose + +```yaml +services: + tvguide: + image: cerede2000/tvguide:latest + container_name: tvguide + restart: unless-stopped + ports: + - "8080:8080" + environment: + TZ: Europe/Paris + ADMIN_PASSWORD: changez-ce-mot-de-passe + XMLTV_SYNC_WORKERS: 1 + MALLOC_ARENA_MAX: 2 + volumes: + - ./data:/data + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 +``` + +```bash +mkdir -p data/imports +chown -R 1000:1000 data +docker compose up -d +``` + +Ouvrez , puis allez dans **Administration → Sources XMLTV → Ajouter une source**. Le premier import démarre immédiatement. + +La même image est disponible sur `ghcr.io/cerede2000/tvguide:latest`. + +## Variables d’environnement + +| Variable | Défaut | Description | +| --- | --- | --- | +| `TZ` | `Europe/Paris` | Fuseau d’affichage par défaut et fuseau de secours des dates XMLTV sans offset. | +| `APP_PORT` | `8080` | Port hôte des fichiers Compose. Le conteneur écoute sur `8080`. | +| `DATA_DIR` | `/data` | Répertoire persistant dans le conteneur. | +| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING` ou `ERROR`. | +| `ADMIN_PASSWORD` | vide | Protège l’administration. Fortement recommandé hors d’un LAN de confiance. | +| `ALLOW_PRIVATE_XMLTV_SOURCES` | `false` | Autorise les URL privées ou loopback. | +| `XMLTV_MAX_DOWNLOAD_MB` | `100` | Taille maximale téléchargée. | +| `XMLTV_MAX_UNCOMPRESSED_MB` | `500` | Taille maximale décompressée. | +| `XMLTV_HTTP_TIMEOUT_SECONDS` | `30` | Timeout HTTP des téléchargements. | +| `XMLTV_DOWNLOAD_RETRIES` | `3` | Nombre de nouvelles tentatives. | +| `XMLTV_SYNC_WORKERS` | `1` | Imports simultanés. Gardez `1` avec SQLite pour limiter RAM/CPU. | +| `XMLTV_USER_AGENT` | `tvguide/1.0` | User-Agent envoyé aux serveurs XMLTV. | +| `MALLOC_ARENA_MAX` | `2` | Limite les arènes de l’allocateur natif après les pics d’import. | +| `FORWARDED_ALLOW_IPS` | `*` | IP des reverse proxies autorisés à fournir les en-têtes forwarded. | + +Les réglages d’affichage sont gérés dans l’application et stockés en base. + +## Fichier XMLTV local + +```bash +cp guide.xml.gz ./data/imports/ +``` + +Configurez ensuite une source locale avec `imports/guide.xml.gz`. Les chemins hors de `/data` sont refusés. + +## Mise à jour et sauvegarde + +```bash +docker compose pull +docker compose up -d +docker compose logs -f tvguide +``` + +Les migrations s’exécutent automatiquement au démarrage. Sauvegardez `data/app.db` avant une mise à jour : + +```bash +docker compose exec tvguide python -c \ + "import sqlite3; sqlite3.connect('/data/app.db').execute(\"VACUUM INTO '/data/backup.db'\")" +``` + +Depuis `1.16.1`, un index FTS5 est créé automatiquement. Prévoyez temporairement un espace libre proche de la taille de la base lors de la première migration. Le WAL est checkpointé et tronqué après les migrations et les imports réussis. + +## Construction depuis les sources + +```bash +git clone https://github.com/cerede2000/tvguide.git +cd tvguide +cp .env.example .env +mkdir -p data/imports +chown -R 1000:1000 data +docker compose up -d --build +``` + +## Reverse proxy et sécurité + +L’application sert du HTTP sur le port `8080` et accepte les en-têtes `X-Forwarded-*`. Utilisez HTTPS et un `ADMIN_PASSWORD` fort pour toute exposition Internet. Les vues de consultation restent publiques ; les endpoints d’administration nécessitent un jeton quand un mot de passe est défini. + +Toutes les données persistantes sont sous `/data`. Le healthcheck est disponible sur `GET /api/health` et les logs sont écrits sur stdout/stderr. + +## Licence + +[MIT](LICENSE) diff --git a/README.md b/README.md index 6b24f5a..3ed604d 100644 --- a/README.md +++ b/README.md @@ -1,226 +1,144 @@ -# 📺 TV Guide — guide TV XMLTV auto-hébergé +# TV Guide -Application web auto-hébergée pour consulter un programme TV à partir d'une ou -plusieurs sources **XMLTV** (`.xml`, `.xml.gz`, `.zip` ou `.xz`, par URL ou -fichier local). +[English](README.md) · [Français](README.fr.md) -Uniquement un guide TV : pas de player vidéo, pas d'IPTV, pas de M3U, pas -d'enregistrement. +A lightweight, self-hosted web TV guide for one or more XMLTV sources. It provides schedules only: no IPTV player, M3U support, recording, or video streaming. -## Fonctionnalités +## Features -- **Maintenant** — programmes en cours avec progression, temps restant et - programme suivant (bascule automatique). -- **Ce soir / 2e partie de soirée** — vue éditoriale par chaîne, plages - horaires configurables, navigation par date. -- **Grille TV** — vraie timeline proportionnelle : colonne chaînes et ligne - horaire fixes, ligne « maintenant », zoom (2/4/6 px/min), raccourcis - matin/après-midi/soirée/nuit, souris/molette/trackpad/tactile. -- **Recherche** — titre, sous-titre, description ; filtres chaîne, catégorie, - date. -- **Fiche programme** — description, saison/épisode, casting, année, pays, - classification (seules les données présentes dans le flux sont affichées). -- **Administration** — sources XMLTV (CRUD, synchronisation manuelle avec - progression, erreurs détaillées), chaînes (activation, ordre par - glisser-déposer, nom/logo personnalisés), affichage (thème, fuseau horaire, - horaires de soirée, format 24 h…). -- **Synchronisation automatique** toutes les 6/12/24 h, import **atomique** : - en cas d'échec, les anciennes données sont conservées. -- Thèmes clair / sombre / système, responsive desktop → mobile. +- Live programmes with progress, remaining time, and upcoming shows. +- Tonight and late-evening views with configurable time windows. +- Proportional timeline grid, date navigation, zoom, and responsive controls. +- Fast SQLite FTS5 search across titles, subtitles, and descriptions. +- XMLTV sources by URL or local file, with atomic imports and smart scheduling. +- Channel ordering, custom names/logos, portable configuration import/export. +- Light, dark, and system themes. +- English and French UI, selected from the saved preference or browser language. +- Multi-platform image for `linux/amd64` and `linux/arm64`. -*Captures d'écran : à venir (`docs/screenshots/`).* +Supported source formats: XML, GZIP, ZIP, 7-Zip, XZ, and BZIP2. Formats are detected from file contents. -## Prérequis +## Quick start with Docker Compose -- Docker ≥ 24 et Docker Compose v2 (amd64 ou arm64) ; -- une source XMLTV (URL ou fichier). +```yaml +services: + tvguide: + image: cerede2000/tvguide:latest + container_name: tvguide + restart: unless-stopped + ports: + - "8080:8080" + environment: + TZ: Europe/Paris + ADMIN_PASSWORD: change-this-password + XMLTV_SYNC_WORKERS: 1 + MALLOC_ARENA_MAX: 2 + volumes: + - ./data:/data + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 +``` -## Démarrage rapide +```bash +mkdir -p data/imports +chown -R 1000:1000 data +docker compose up -d +``` -L'image officielle est publiée sur GHCR : **il n'est plus nécessaire de -builder quoi que ce soit** — un simple `docker run` ou un compose suffit. +Open , then go to **Administration → XMLTV sources → Add source**. The first import starts immediately. -### Option A — image prête à l'emploi (GHCR, recommandé) +The same image is also available from `ghcr.io/cerede2000/tvguide:latest`. -En une seule commande : +## Docker run ```bash -mkdir -p tvguide/data/imports && cd tvguide && chown -R 1000:1000 data +mkdir -p tvguide/data/imports +chown -R 1000:1000 tvguide/data docker run -d --name tvguide \ -p 8080:8080 \ -e TZ=Europe/Paris \ - -v "$(pwd)/data:/data" \ + -e ADMIN_PASSWORD=change-this-password \ + -v "$(pwd)/tvguide/data:/data" \ --restart unless-stopped \ --security-opt no-new-privileges:true \ --read-only --tmpfs /tmp:size=16m,mode=1777 \ - ghcr.io/cerede2000/tvguide:latest -``` - -Ou avec compose (recommandé) — aucun code source nécessaire, juste -`compose.ghcr.yml` et un `.env` : - -```bash -mkdir tvguide && cd tvguide -curl -LO https://raw.githubusercontent.com/cerede2000/tvguide/main/compose.ghcr.yml -curl -Lo .env https://raw.githubusercontent.com/cerede2000/tvguide/main/.env.example -mkdir -p data/imports && chown -R 1000:1000 data -docker compose -f compose.ghcr.yml up -d -``` - -L'image `ghcr.io/cerede2000/tvguide` est multi-arch (amd64 + arm64), publiée -par la CI à chaque tag de version. Mise à jour : -`docker compose -f compose.ghcr.yml pull && docker compose -f compose.ghcr.yml up -d`. - -### Option B — build depuis les sources - -```bash -git clone https://github.com/cerede2000/tvguide.git -cd tvguide -cp .env.example .env -mkdir -p data/imports && chown -R 1000:1000 data -docker compose up -d + cerede2000/tvguide:latest ``` -> **Permissions du dossier `data/`** : le conteneur tourne en non-root -> (uid/gid **1000**). Si Docker crée le dossier lui-même, il appartiendra à -> root et l'application ne pourra pas écrire sa base — d'où le -> `chown -R 1000:1000 data` avant le premier démarrage. +The container runs as UID/GID `1000`. Ensure that the bind-mounted data directory is writable by that user. -Ouvrez ensuite , allez dans **Administration → Sources -XMLTV → Ajouter une source**, saisissez l'URL de votre guide (par exemple -`https://exemple.org/guide.xml.gz`) : l'import démarre immédiatement et les -chaînes apparaissent dans les vues de consultation. +## Environment variables -### Formats acceptés - -XML brut, GZIP, ZIP, XZ, BZIP2 et 7-Zip — détectés par le contenu du fichier (pas par -l'extension ni les en-têtes HTTP) : `guide.xml`, `guide.xml.gz`, `guide.zip`, -`guide.xml.xz`… Dans un ZIP contenant plusieurs fichiers, l'entrée `.xml` la -plus volumineuse est utilisée. Les serveurs qui annoncent un -`Content-Encoding` erroné sont gérés. - -### Ajouter une source « fichier local » - -Déposez le fichier dans le volume de données puis référencez-le relativement à -`/data` : +| Variable | Default | Description | +| --- | --- | --- | +| `TZ` | `Europe/Paris` | Default display timezone and fallback timezone for XMLTV timestamps without an offset. | +| `APP_PORT` | `8080` | Host port used by the supplied Compose files. The container listens on `8080`. | +| `DATA_DIR` | `/data` | Persistent directory inside the container. | +| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, or `ERROR`. | +| `ADMIN_PASSWORD` | empty | Protects administration when set. Strongly recommended outside a trusted LAN. | +| `ALLOW_PRIVATE_XMLTV_SOURCES` | `false` | Allows source URLs resolving to private or loopback addresses. | +| `XMLTV_MAX_DOWNLOAD_MB` | `100` | Maximum compressed or raw download size. | +| `XMLTV_MAX_UNCOMPRESSED_MB` | `500` | Maximum decompressed size. | +| `XMLTV_HTTP_TIMEOUT_SECONDS` | `30` | HTTP timeout for XMLTV downloads. | +| `XMLTV_DOWNLOAD_RETRIES` | `3` | Download retry count. | +| `XMLTV_SYNC_WORKERS` | `1` | Maximum concurrent imports. Keep `1` with SQLite for lower RAM/CPU use. | +| `XMLTV_USER_AGENT` | `tvguide/1.0` | User-Agent sent to XMLTV servers. | +| `MALLOC_ARENA_MAX` | `2` | Limits native allocator arenas so import peaks are returned more readily. | +| `FORWARDED_ALLOW_IPS` | `*` | Reverse-proxy IPs allowed to provide forwarded headers. | + +Display settings such as theme, default view, timezone, time format, and evening windows are stored in the database and managed from the application. + +## Local XMLTV files + +Copy a file below the data volume and use a path relative to `/data`: ```bash cp guide.xml.gz ./data/imports/ -# Dans l'admin : type « Fichier local », chemin : imports/guide.xml.gz ``` -Seuls les chemins situés sous `/data` sont acceptés (protection contre la -traversée de répertoires). - -### Synchronisation intelligente +Then configure a local source with `imports/guide.xml.gz`. Paths outside `/data` are rejected. -Avant chaque synchronisation planifiée, l'application vérifie l'**empreinte** -du document (requête HEAD — ETag / Last-Modified / taille — pour les URL, -mtime/taille pour les fichiers locaux) : si le fichier n'a pas changé depuis -le dernier import, **rien n'est téléchargé ni importé**. L'application -observe ensuite le **rythme de publication** du fournisseur (médiane des -changements constatés) et cale les vérifications juste après l'heure de -publication prévue. Garde-fous : +## Updates -- au moins **un import complet par 24 h**, même si l'empreinte semble - inchangée ; -- l'« intervalle » configuré sur la source devient une borne maximale ; -- le bouton **« Forcer l'import »** de l'administration télécharge et importe - sans tenir compte de l'empreinte ; -- si le serveur n'expose ni ETag ni Last-Modified, le fichier téléchargé est - comparé par SHA-256 : une copie identique évite le parsing et la réécriture - de la base. - -## Configuration (variables d'environnement) - -| Variable | Défaut | Description | -| --- | --- | --- | -| `TZ` | `Europe/Paris` | Fuseau horaire par défaut de l'affichage (modifiable ensuite dans l'admin). Les timestamps XMLTV sans offset explicite sont interprétés dans ce fuseau. | -| `APP_PORT` | `8080` | Port HTTP publié sur l'hôte. | -| `DATA_DIR` | `/data` | Répertoire de persistance dans le conteneur (ne pas changer en usage normal). | -| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`. | -| `ADMIN_PASSWORD` | *(vide)* | Si renseigné, l'administration exige une connexion (la consultation reste publique). **Obligatoire pour une exposition Internet.** | -| `ALLOW_PRIVATE_XMLTV_SOURCES` | `false` | Autorise les sources XMLTV pointant vers des adresses privées/loopback (serveur sur votre LAN). Laissez `false` si l'instance est exposée : protège contre le SSRF. | -| `XMLTV_MAX_DOWNLOAD_MB` | `100` | Taille maximale d'un téléchargement XMLTV. | -| `XMLTV_MAX_UNCOMPRESSED_MB` | `500` | Taille maximale après décompression GZIP (protection contre les bombes gzip/zip/xz). | -| `XMLTV_HTTP_TIMEOUT_SECONDS` | `30` | Timeout HTTP des téléchargements. | -| `XMLTV_SYNC_WORKERS` | `1` | Nombre maximal d'imports XMLTV simultanés. Garder `1` avec SQLite limite la RAM/CPU et évite la concurrence entre writers. | -| `MALLOC_ARENA_MAX` | `2` | Borne les arènes de l'allocateur natif afin que les pics de décompression 7z ne restent pas réservés après l'import. | -| `XMLTV_USER_AGENT` | `tvguide/1.0` | User-Agent envoyé aux serveurs XMLTV. | -| `FORWARDED_ALLOW_IPS` | `*` | IPs de reverse proxy autorisées à définir les en-têtes `X-Forwarded-*` (voir ci-dessous). | - -Les réglages d'affichage (thème, vue par défaut, horaires de soirée, fuseau, -format 24 h, nombre de jours) se modifient dans **Administration → Affichage** -et sont stockés en base. - -## Persistance et sauvegarde - -Tout l'état vit dans le volume `/data` : - -```text -data/ -├── app.db # base SQLite (chaînes, programmes, sources, réglages) -├── imports/ # fichiers XMLTV locaux que vous déposez -└── cache/ # réservé (cache d'images futur) +```bash +docker compose pull +docker compose up -d +docker compose logs -f tvguide ``` -**Sauvegarde** : arrêter le conteneur puis copier le dossier, ou à chaud : +Database migrations run automatically at startup. Back up `data/app.db` before an upgrade: ```bash docker compose exec tvguide python -c \ "import sqlite3; sqlite3.connect('/data/app.db').execute(\"VACUUM INTO '/data/backup.db'\")" -mv data/backup.db /chemin/sauvegarde/ ``` -**Restauration** : conteneur arrêté, remplacer `data/app.db` par la sauvegarde -puis `docker compose up -d`. Les migrations de schéma s'appliquent -automatiquement au démarrage (Alembic). - -### Migration vers 1.16.2 (index FTS5) - -La mise à jour crée automatiquement un index de recherche plein texte sur les -titres, sous-titres et descriptions existants. Aucune commande SQL manuelle -n'est nécessaire et les sources, chaînes, réglages et programmes sont -conservés. La première ouverture peut prendre plus longtemps selon le nombre -de programmes. - -Avant la mise à jour : +Versions from `1.16.1` onward create an FTS5 index automatically. Allow free disk space roughly equal to the database size during its first migration. The WAL is checkpointed and truncated after migrations and successful imports. -1. sauvegardez `data/app.db` avec la procédure ci-dessus ; -2. vérifiez que le volume dispose idéalement de **deux fois la taille actuelle - de `app.db`** en espace libre, pour la construction de l'index et son WAL ; -3. lancez `pull` puis `up -d` et suivez le démarrage avec - `docker compose -f compose.ghcr.yml logs -f tvguide`. +## Build from source -Le fichier WAL est checkpointé et tronqué après la migration puis après chaque -import réussi. L'index FTS5 augmente durablement la taille de la base en échange -d'une recherche qui ne parcourt plus tous les programmes. Pour revenir à -1.16.0, restaurez la sauvegarde réalisée avant migration ; ne démarrez pas -directement l'ancienne image sur une base déjà migrée. - -## Reverse proxy - -L'application écoute en HTTP simple sur un seul port et honore -`X-Forwarded-For` / `X-Forwarded-Proto` / `X-Forwarded-Host` (uvicorn -`--proxy-headers`). Par défaut tous les proxys sont approuvés -(`FORWARDED_ALLOW_IPS=*`) ; restreignez à l'IP de votre proxy si le port du -conteneur est joignable par d'autres machines. +```bash +git clone https://github.com/cerede2000/tvguide.git +cd tvguide +cp .env.example .env +mkdir -p data/imports +chown -R 1000:1000 data +docker compose up -d --build +``` -### Traefik (labels Docker) +Run the test suites: -```yaml -services: - tvguide: - # … voir compose.yml … - labels: - - traefik.enable=true - - traefik.http.routers.tvguide.rule=Host(`tv.example.org`) - - traefik.http.routers.tvguide.entrypoints=websecure - - traefik.http.routers.tvguide.tls.certresolver=letsencrypt - - traefik.http.services.tvguide.loadbalancer.server.port=8080 +```bash +cd backend && uv sync --frozen && uv run pytest -q +cd ../frontend && npm ci && npm test && npm run build ``` -### Nginx +## Reverse proxy and security + +The application serves plain HTTP on port `8080` and supports `X-Forwarded-*` headers. Use HTTPS at the reverse proxy and set a strong `ADMIN_PASSWORD` when the service is reachable from the Internet. ```nginx location / { @@ -228,161 +146,24 @@ location / { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; -} -``` - -### Caddy - -```caddyfile -tv.example.org { - reverse_proxy 127.0.0.1:8080 } ``` -HAProxy fonctionne de la même manière (backend HTTP simple). - -### Authentification et exposition sur Internet - -Le modèle : **consultation publique, administration protégée**. - -1. Définissez `ADMIN_PASSWORD` dans `.env` (long et aléatoire, ex. - `openssl rand -base64 24`) puis `docker compose up -d`. -2. Servez l'application **en HTTPS** via votre reverse proxy (le mot de passe - et le jeton transitent dans les requêtes). -3. C'est tout : les vues Maintenant/Ce soir/Grille/Recherche restent - accessibles à tous ; l'onglet Administration demande une connexion. - -Fonctionnement : `POST /api/auth/login` échange le mot de passe contre un -jeton signé (HMAC-SHA256, validité 24 h) exigé par tous les endpoints -`/api/admin/*`. Les tentatives de connexion sont limitées (verrouillage 60 s -après 5 échecs par IP), la comparaison du mot de passe est en temps constant, -et changer `ADMIN_PASSWORD` révoque immédiatement tous les jetons émis. Sans -`ADMIN_PASSWORD`, l'administration reste ouverte — réservez ce mode à un LAN -de confiance. +Public schedule endpoints remain accessible; administration endpoints require a bearer token when a password is configured. Login attempts are rate-limited. -Défense en profondeur (optionnel) : ajoutez par-dessus une authentification -reverse-proxy (**Authelia**, **Authentik**, **oauth2-proxy**, basic-auth) sur -`/api/admin` et `/api/auth`, ou restreignez-les par IP. +## Data and health -## API - -Endpoints principaux (JSON, préfixe `/api`) : +All persistent state is under `/data`: ```text -GET /api/health santé (DB + dernière synchro) -GET /api/channels chaînes actives ordonnées -GET /api/programmes/live en cours + suivant par chaîne -GET /api/programmes/evening?date=… première partie de soirée -GET /api/programmes/late-evening?date=… deuxième partie de soirée -GET /api/programmes/grid?start=…&end=… grille (fenêtre ≤ 48 h, ISO 8601) -GET /api/programmes/{id} fiche détaillée -GET /api/search?q=…&channel_id=&category=&date= -GET /api/categories catégories observées + comptes -GET /api/settings réglages d'affichage -PUT /api/admin/settings -GET/POST /api/admin/sources -PUT/DELETE /api/admin/sources/{id} -POST /api/admin/sources/{id}/sync (202, puis /status) -GET /api/admin/sources/{id}/status -GET /api/admin/channels -PATCH /api/admin/channels/{id} -PUT /api/admin/channels/order {"channels":[{"id":10,"order":1}]} -PUT /api/admin/channels/bulk-enabled {"enabled":true,"ids":[1,2]} (ids optionnel) -GET /api/admin/channels/export configuration JSON portable -POST /api/admin/channels/import applique un export (chaînes disparues ignorées) +/data/app.db +/data/imports/ +/data/config/ +/data/tmp/ ``` -Toutes les dates sont stockées et échangées en **UTC** (suffixe `Z`) ; -l'affichage utilise le fuseau configuré. Les timestamps XMLTV avec offset -(`20260705211000 +0200`) sont convertis en UTC à l'import. - -## Logs - -Sur stdout/stderr, consultables via : - -```bash -docker logs -f tvguide -``` - -```text -2026-07-05T18:30:00+0000 INFO XMLTV sync started source='Mon guide' -2026-07-05T18:30:04+0000 INFO Download complete size=12.4MB -2026-07-05T18:30:07+0000 INFO XML validated -2026-07-05T18:30:15+0000 INFO Imported channels=182 programmes=43792 -2026-07-05T18:30:15+0000 INFO XMLTV sync completed source='Mon guide' -``` - -Aucun secret n'est journalisé. - -## Healthcheck - -`GET /api/health` renvoie : - -```json -{ "status": "ok", "database": "ok", "xmltv_last_sync": "2026-07-05T12:00:00Z" } -``` - -Le `HEALTHCHECK` Docker intégré interroge cet endpoint toutes les 30 s -(`docker ps` affiche `healthy`). - -## Sécurité - -- Conteneur **non-root** (uid 1000), `no-new-privileges`, système de fichiers - racine en **lecture seule** (`/tmp` en petit tmpfs, fichiers de travail XMLTV - volumineux et écritures persistantes limités à `/data`). -- **SSRF** : seuls http/https sont acceptés ; les adresses privées, loopback, - link-local (dont les métadonnées cloud 169.254.169.254) sont refusées à la - configuration **et** à chaque redirection, sauf - `ALLOW_PRIVATE_XMLTV_SOURCES=true`. Limite connue : la validation résout le - DNS séparément de la requête (fenêtre de DNS-rebinding théorique) — un - compromis raisonnable pour des sources configurées par l'administrateur. -- **XML** : parsing via defusedxml — entités et références externes interdites - (XXE, billion laughs). Flux en streaming, mémoire plate. -- **Limites** : taille de téléchargement, taille décompressée, timeouts, - redirections bornées, retries bornés. -- Chemins locaux confinés à `/data` (liens symboliques résolus). - -## Dépannage - -| Symptôme | Piste | -| --- | --- | -| `Échec : l'hôte … pointe vers une adresse privée` | Votre source XMLTV est sur le LAN : mettez `ALLOW_PRIVATE_XMLTV_SOURCES=true` dans `.env` puis `docker compose up -d`. | -| `Échec : HTTP 503` sur une source | Serveur distant en panne ; les dernières données réussies restent affichées, nouvelle tentative à l'intervalle configuré ou via « Synchroniser maintenant ». | -| `chemin local refusé (hors de /data)` | Le fichier doit être dans `./data/` (monté sur `/data`) et référencé relativement, ex. `imports/guide.xml`. | -| Heures décalées | Vérifiez le fuseau dans **Administration → Affichage** ; les flux sans offset explicite sont interprétés dans `TZ`. | -| Grille vide sur une date | La profondeur du guide dépend de la source (souvent 7–14 jours). | -| Base corrompue après coupure | SQLite est en WAL ; supprimez `app.db-wal`/`app.db-shm` seulement si la base ne s'ouvre plus, après sauvegarde. | -| `sqlite3.OperationalError: unable to open database file` au démarrage | Le dossier `data/` n'appartient pas à l'utilisateur du conteneur : `chown -R 1000:1000 data` puis redémarrez. | - -## Mise à jour - -```bash -git pull -docker compose build --pull -docker compose up -d -``` - -Les migrations de base s'exécutent automatiquement au démarrage. - -## Développement - -```bash -# Backend (Python 3.13 + uv) -cd backend -uv sync -DATA_DIR=./data uv run uvicorn app.main:app --reload --port 8080 -uv run pytest # 78 tests -uv run ruff check . - -# Frontend (Node 22) -cd frontend -npm install -npm run dev # http://localhost:5173, proxy /api → :8080 -npm test # 31 tests -npm run lint && npm run build -``` +Health endpoint: `GET /api/health`. Container logs are written to stdout/stderr. -## Licence +## License [MIT](LICENSE) diff --git a/compose.dockerhub.yml b/compose.dockerhub.yml new file mode 100644 index 0000000..805e304 --- /dev/null +++ b/compose.dockerhub.yml @@ -0,0 +1,30 @@ +# Deployment from the published Docker Hub image. Only this file and an +# optional .env file are required on the server. + +services: + tvguide: + image: cerede2000/tvguide:latest + container_name: tvguide + restart: unless-stopped + ports: + - "${APP_PORT:-8080}:8080" + environment: + TZ: ${TZ:-Europe/Paris} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + ALLOW_PRIVATE_XMLTV_SOURCES: ${ALLOW_PRIVATE_XMLTV_SOURCES:-false} + XMLTV_MAX_DOWNLOAD_MB: ${XMLTV_MAX_DOWNLOAD_MB:-100} + XMLTV_MAX_UNCOMPRESSED_MB: ${XMLTV_MAX_UNCOMPRESSED_MB:-500} + XMLTV_HTTP_TIMEOUT_SECONDS: ${XMLTV_HTTP_TIMEOUT_SECONDS:-30} + XMLTV_DOWNLOAD_RETRIES: ${XMLTV_DOWNLOAD_RETRIES:-3} + XMLTV_SYNC_WORKERS: ${XMLTV_SYNC_WORKERS:-1} + XMLTV_USER_AGENT: ${XMLTV_USER_AGENT:-tvguide/1.0} + MALLOC_ARENA_MAX: ${MALLOC_ARENA_MAX:-2} + FORWARDED_ALLOW_IPS: ${FORWARDED_ALLOW_IPS:-*} + volumes: + - ./data:/data + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 diff --git a/compose.ghcr.yml b/compose.ghcr.yml index e34e84f..5f8902d 100644 --- a/compose.ghcr.yml +++ b/compose.ghcr.yml @@ -1,10 +1,10 @@ -# Déploiement depuis l'image publiée sur GHCR : aucun code source nécessaire, -# seuls ce fichier et un .env suffisent sur le serveur. +# Deployment from the published GHCR image. Only this file and an optional +# .env file are required on the server. # # docker compose -f compose.ghcr.yml pull # docker compose -f compose.ghcr.yml up -d # -# Épinglez une version précise en remplaçant `latest` (ex. :1.16.2). +# Pin a specific version by replacing `latest` (for example `:1.17.0`). services: tvguide: @@ -23,8 +23,11 @@ services: XMLTV_MAX_DOWNLOAD_MB: ${XMLTV_MAX_DOWNLOAD_MB:-100} XMLTV_MAX_UNCOMPRESSED_MB: ${XMLTV_MAX_UNCOMPRESSED_MB:-500} XMLTV_HTTP_TIMEOUT_SECONDS: ${XMLTV_HTTP_TIMEOUT_SECONDS:-30} + XMLTV_DOWNLOAD_RETRIES: ${XMLTV_DOWNLOAD_RETRIES:-3} XMLTV_SYNC_WORKERS: ${XMLTV_SYNC_WORKERS:-1} + XMLTV_USER_AGENT: ${XMLTV_USER_AGENT:-tvguide/1.0} MALLOC_ARENA_MAX: ${MALLOC_ARENA_MAX:-2} + FORWARDED_ALLOW_IPS: ${FORWARDED_ALLOW_IPS:-*} volumes: - ./data:/data diff --git a/compose.yml b/compose.yml index 0f6b9e6..2da63e5 100644 --- a/compose.yml +++ b/compose.yml @@ -17,8 +17,11 @@ services: XMLTV_MAX_DOWNLOAD_MB: ${XMLTV_MAX_DOWNLOAD_MB:-100} XMLTV_MAX_UNCOMPRESSED_MB: ${XMLTV_MAX_UNCOMPRESSED_MB:-500} XMLTV_HTTP_TIMEOUT_SECONDS: ${XMLTV_HTTP_TIMEOUT_SECONDS:-30} + XMLTV_DOWNLOAD_RETRIES: ${XMLTV_DOWNLOAD_RETRIES:-3} XMLTV_SYNC_WORKERS: ${XMLTV_SYNC_WORKERS:-1} + XMLTV_USER_AGENT: ${XMLTV_USER_AGENT:-tvguide/1.0} MALLOC_ARENA_MAX: ${MALLOC_ARENA_MAX:-2} + FORWARDED_ALLOW_IPS: ${FORWARDED_ALLOW_IPS:-*} volumes: - ./data:/data diff --git a/frontend/index.html b/frontend/index.html index a02fd72..990ae70 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,22 +1,21 @@ - + - + - + - + - Guide TV + TV Guide
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 3809163..7f65824 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -1,7 +1,7 @@ { "name": "Guide TV", "short_name": "Guide TV", - "description": "Guide TV auto-hébergé basé sur XMLTV", + "description": "Self-hosted XMLTV web guide", "start_url": "/", "scope": "/", "display": "standalone", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index fa6f27e..c32340b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,9 +4,11 @@ import { onBeforeUnmount, onMounted } from 'vue' import AppHeader from '@/components/AppHeader.vue' import BackToTop from '@/components/BackToTop.vue' import ProgrammeModal from '@/components/ProgrammeModal.vue' +import { useI18n } from '@/i18n' import { useChromeStore } from '@/stores/chrome' const chrome = useChromeStore() +const { t } = useI18n() let lastY = 0 let scrollTimer = 0 @@ -40,7 +42,7 @@ onBeforeUnmount(() => window.removeEventListener('scroll', onScroll)) - diff --git a/frontend/src/components/admin/AdminChannels.vue b/frontend/src/components/admin/AdminChannels.vue index 9e29d67..bee71d8 100644 --- a/frontend/src/components/admin/AdminChannels.vue +++ b/frontend/src/components/admin/AdminChannels.vue @@ -7,6 +7,9 @@ import { useIncrementalList } from '@/composables/useIncrementalList' import type { AdminChannel, ChannelConfigExport } from '@/types/api' import { planNumberChange } from '@/utils/channels' import { normalizeText } from '@/utils/text' +import { useI18n } from '@/i18n' + +const { localeCode, t } = useI18n() const channels = ref([]) const loading = ref(true) @@ -38,7 +41,7 @@ const displayed = computed(() => const available = computed(() => channels.value .filter((channel) => !channel.enabled) - .sort((a, b) => a.effective_name.localeCompare(b.effective_name, 'fr')), + .sort((a, b) => a.effective_name.localeCompare(b.effective_name, localeCode.value)), ) function matchName(channel: AdminChannel, query: string): boolean { @@ -100,7 +103,7 @@ async function fetchChannels() { channels.value = await api.adminChannels() error.value = '' } catch (err) { - error.value = err instanceof Error ? err.message : 'Erreur inconnue' + error.value = err instanceof Error ? err.message : t('common.unknownError') } finally { loading.value = false } @@ -117,7 +120,7 @@ async function applyNumberChanges(changes: { id: number; order: number }[]) { try { await api.reorderChannels(changes) } catch (err) { - error.value = err instanceof Error ? err.message : 'Erreur inconnue' + error.value = err instanceof Error ? err.message : t('common.unknownError') await fetchChannels() } } @@ -134,7 +137,7 @@ async function add(channel: AdminChannel) { await applyNumberChanges([{ id: channel.id, order: maxNumber.value + 1 }]) } } catch (err) { - error.value = err instanceof Error ? err.message : 'Erreur inconnue' + error.value = err instanceof Error ? err.message : t('common.unknownError') } } @@ -143,7 +146,7 @@ async function remove(channel: AdminChannel) { await api.patchChannel(channel.id, { enabled: false }) channel.enabled = false // its number stays: re-adding restores it as-is } catch (err) { - error.value = err instanceof Error ? err.message : 'Erreur inconnue' + error.value = err instanceof Error ? err.message : t('common.unknownError') } } @@ -164,7 +167,7 @@ async function addAllFiltered() { .map((c) => ({ id: c.id, order: ++next })) await applyNumberChanges(changes) } catch (err) { - error.value = err instanceof Error ? err.message : 'Erreur inconnue' + error.value = err instanceof Error ? err.message : t('common.unknownError') } } @@ -180,7 +183,7 @@ async function removeAllFiltered() { c.enabled = false // numbers untouched }) } catch (err) { - error.value = err instanceof Error ? err.message : 'Erreur inconnue' + error.value = err instanceof Error ? err.message : t('common.unknownError') } } @@ -256,7 +259,7 @@ async function saveEdit() { Object.assign(editing.value, updated) editing.value = null } catch (err) { - error.value = err instanceof Error ? err.message : 'Erreur inconnue' + error.value = err instanceof Error ? err.message : t('common.unknownError') } } @@ -269,11 +272,11 @@ async function exportConfig() { const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url - link.download = `tvguide-chaines-${new Date().toISOString().slice(0, 10)}.json` + link.download = `tvguide-channels-${new Date().toISOString().slice(0, 10)}.json` link.click() URL.revokeObjectURL(url) } catch (err) { - error.value = err instanceof Error ? err.message : 'Erreur inconnue' + error.value = err instanceof Error ? err.message : t('common.unknownError') } } @@ -287,22 +290,22 @@ async function onImportFile(event: Event) { try { const payload = JSON.parse(await file.text()) as ChannelConfigExport if (!Array.isArray(payload.channels)) { - throw new Error('fichier invalide : clé "channels" absente') + throw new Error(t('channels.invalidFile')) } const result = await api.importChannels(payload) notice.value = - `Import terminé : ${result.applied} chaîne(s) appliquée(s)` + + t('channels.importDone', { count: result.applied }) + (result.skipped_count - ? `, ${result.skipped_count} ignorée(s) (absentes des sources XMLTV actuelles)` + ? t('channels.importSkipped', { count: result.skipped_count }) : '') await fetchChannels() } catch (err) { error.value = err instanceof SyntaxError - ? 'fichier JSON invalide' + ? t('channels.invalidJson') : err instanceof Error ? err.message - : 'Erreur inconnue' + : t('common.unknownError') } } @@ -312,14 +315,14 @@ onMounted(fetchChannels)