feat: support audio & vidéo — lecteurs HTML5 intégrés #109
CI / lint (push) Successful in 1m58s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m32s
CI / build (push) Successful in 2m6s
CI / e2e (push) Successful in 12m57s

This commit is contained in:
2026-09-23 09:14:34 -04:00
parent eccbf7474e
commit e2417cb5ab
26 changed files with 1057 additions and 121 deletions
+99
View File
@@ -0,0 +1,99 @@
# #109 — Support audio & vidéo (lecteurs HTML5 intégrés)
> **Version livrée :** 2.18.0 · **Statut :** ✅ · **Impact :** 🟡
> **Zone :** backend (`main`, `indexer`, `media_types`, `bookslm`) + frontend
> (`viewer.js`, `utils.js`, `style.css`, `sw.js`).
## Contexte
Le socle média de #108 (`backend/media_types.py`) exposait déjà
`AUDIO_EXTENSIONS` / `VIDEO_EXTENSIONS`, mais ces fichiers n'étaient ni indexés
ni affichables : ils tombaient dans le chemin binaire « Ce fichier est binaire
et ne peut pas être affiché » + bouton download.
## Ce qui a été livré
### A. Backend
- **Indexation** : `AUDIO_EXTENSIONS` et `VIDEO_EXTENSIONS` sont intégrées à
`SUPPORTED_EXTENSIONS` (`indexer.py`). La branche `is_media(ext)` existante
indexe **nom / taille / mtime** sans jamais lire les octets (`content: ""`),
donc le TF-IDF et les logs restent propres. Le watcher et le filtre `ext:`
suivent automatiquement.
- **Streaming** : le helper Range de `pdf/stream` a été extrait en
`_stream_file_with_range(file_path, request, media_type)` (206 +
`Content-Range` + `Accept-Ranges`, `416` sur plage invalide, `FileResponse`
simple sinon, lectures offloadées via `asyncio.to_thread`). `pdf/stream`
l'utilise désormais aussi (comportement inchangé, tests de régression).
- **Nouvel endpoint `GET /api/media/{vault}?path=…`** : sert audio/vidéo avec le
MIME `media_types.media_mime_type` (surcharges `.m4a→audio/mp4`,
`.opus/.oga→audio/ogg`, `.mov→video/quicktime`, `.m4v→video/mp4`).
- **Gardes-fous** : `_resolve_safe_path`, `check_vault_access`, et
`OBSIGATE_MEDIA_MAX_INLINE_MB` (défaut **500 Mo**) — au-delà, l'endpoint
renvoie `413` et la vue fichier bascule sur l'UI de téléchargement.
- **`api_file_view()`** renvoie, avant tout `read_text()`, `is_audio` /
`is_video` / `stream_url` / `media_mime` / `size_bytes`. Au-delà de la limite :
`unsupported: true` + `media_too_large: true`.
### B/C. Frontend — lecteurs
- `viewer.js` dispatche `data.is_audio` → `renderAudioViewer()` et
`data.is_video` → `renderVideoViewer()`.
- **Audio** : `<audio controls preload="metadata">` pleine largeur, artwork
placeholder (icône Lucide `audio-lines`), durée lue via `loadedmetadata`,
toolbar (titre, voûte + taille, badge durée, ouvrir l'original, télécharger).
- **Vidéo** : `<video controls playsinline preload="metadata">` centrée sur une
scène noire letterboxée (`max-height: calc(100vh - 180px)`), même toolbar
avec durée + résolution.
- `renderMediaFallback()` : sur l'événement `error` de l'élément média (codec
hors web-natif : `.mkv`, `.avi`, HEVC…) ou si le fichier est trop volumineux,
remplace le lecteur par l'UI binaire + message
`viewer.media_unsupported` / `viewer.media_too_large` + **Télécharger** /
**Ouvrir dans un nouvel onglet**.
- **Pause** au changement de vue : `_mediaViewerCleanup` met en pause et détache
la source quand `renderFile()` re-rend la zone (changement d'onglet, navigation)
— pas de lecture persistante en v1 (cohérence #75).
- `EXT_ICONS` (`utils.js`) : audio → `audio-lines`, vidéo → `video`.
- i18n FR/EN (`viewer.media_unsupported`, `viewer.media_too_large`).
### D. Recherche & intégrations
- Filtres `ext:mp3`, `ext:mp4`, etc. opérationnels (les médias entrent dans
l'index).
- Récents / dashboards : previews vides (aucun texte extrait) — comportement
naturel de la branche binaire.
- **BooksLM** : `_file_entry()` ignore désormais tout média (`is_media`) — les
octets ne sont jamais envoyés au modèle ; les images restent gérées à part via
`load_vault_image_data_url` (vision).
- **Hors scope v1** (porte notée) : transcription audio via Whisper.
### E. Mobile & PWA
- Le service worker ne met **jamais** en cache le flux média
(`/api/media/{vault}`), tout en conservant le cache des miniatures
(`/api/media/{vault}/thumb`) et la stratégie Network First pour le reste. Les
requêtes `Range` étaient déjà exclues.
### F. Tests
- `tests/test_media_stream.py` : 206 + `Content-Range` + 1024 octets, `416` hors
borne, `200` + `Accept-Ranges` sans Range, suffix range, `413` au-delà de la
limite, `403` path traversal, `403` vault sans accès, `400` non-média, MIME
`.mov`, régression `pdf/stream`.
- `tests/test_media_indexing.py` : `.mp3`/`.mp4`/`.flac` dans l'arborescence et
l'index, `content == ""`, watcher pertinent, filtre `ext:mp3`.
- `tests/frontend/media-viewer.test.mjs` : helpers purs (`formatMediaDuration`,
`buildMediaUrl`) + vérifications statiques (dispatch, `<audio>`/`<video>`,
fallback, CSS, icônes, i18n, service worker, endpoints backend).
- `tests/e2e/media-viewer.spec.js` : lecture `<audio>` et `<video>` via
`/api/media` + réponse `206` sur requête `Range`. Fixtures :
`test_vault/sample-audio.mp3` (sine 1 s) et `test_vault/sample-video.webm`
(VP8 64×64).
## Limitations connues
- Formats hors web-natifs (`.mkv`, `.avi`, HEVC, AC-4) : non lisibles sans
transcodage (ffmpeg hors scope) → repli téléchargement / lecteur de l'OS.
- HLS, sous-titres `<track src=".vtt">` et vignettes vidéo : hors scope v1.
- Gros médias (> 500 Mo par défaut) : pas de lecture intégrée (protège le worker
uvicorn unique) ; ajustable via `OBSIGATE_MEDIA_MAX_INLINE_MB`.