# `.alfred/` sidecar — spec (draft) **Status:** Draft 1 — 2026-05-21 **Owner:** Francwa **Context:** Préparation du `TVShowRepository` réel (et plus tard `MovieRepository`). Le port abstrait existe dans `alfred/domain/tv_shows/repositories.py` mais n'a aucune impl ni caller en prod aujourd'hui. --- ## Pourquoi un sidecar Alfred place les médias dans une library structurée. Une fois un show posé dans `library/tvshows//`, l'utilisateur **n'y touche plus** (politique assumée). Le filesystem **est** la source de vérité. Mais reconstruire un `TVShow` complet à chaque consultation suppose de : 1. Lister les saisons (`ls` du dossier show) 2. Pour chaque saison, lister les épisodes (`ls`) 3. Pour chaque épisode, lire les tags du fichier (ffprobe ⇒ audio/subtitle tracks, codecs, durée…) 4. Resolver le titre/IMDb/TMDB Les étapes 3 et 4 sont coûteuses (probe ⇒ ~50ms/fichier ; TMDB ⇒ I/O réseau). Sur une lib de 50 shows × 5 saisons × 10 épisodes = 2500 probes. Inacceptable au démarrage ou à chaque requête. **Le sidecar `.alfred/` mémoïse ces résultats** dans des fichiers YAML posés *à côté* du contenu qu'ils décrivent. Pattern éprouvé (`.nfo` Plex/Jellyfin, `.git/` à la racine repo, `metadata.json` divers). ### Propriétés visées - **Pas de DB centrale.** Chaque show est autonome. - **Déplaçable / sauvegardable.** Si le dossier voyage, son cache voyage avec. - **Lisible humain.** YAML, ouvrable, debuggable à la main. - **Append-friendly.** Granularité **par saison** : ajouter une saison écrit un seul fichier, ne touche rien d'autre. - **Self-healing.** Une désync (utilisateur qui retire un fichier en douce) est détectée au prochain load via mtime/size et auto-corrigée. - **Optionnel.** Un dossier sans `.alfred/` est traité comme du legacy : on scanne intégralement, et on écrit le sidecar à la première lecture (lazy populate). --- ## Disposition sur disque ``` library/tvshows/ └── Breaking.Bad/ ├── .alfred/ │ ├── show.yaml # métadonnées show (1 seul fichier) │ ├── season_01.yaml # contenu détaillé de la saison 1 │ ├── season_02.yaml │ └── ... ├── Season 01/ │ ├── Breaking.Bad.S01E01.Pilot.mkv │ └── ... └── Season 02/ ``` ### Pourquoi *par saison* et pas *par show* L'utilisateur télécharge saison par saison. L'unité d'ingestion naturelle est la saison. Conséquences : - L'écriture est **localisée** : ajouter S04 écrit `season_04.yaml`, point. - Pas de réécriture totale d'un mégafichier `show.yaml` à chaque épisode. - En cas de corruption d'un fichier saison, on perd 1 saison de cache, pas tout le show. ### Pourquoi pas un fichier *par épisode* Trop de noise filesystem (× le nombre d'épisodes), et le grain "saison" est celui qui matche le flow utilisateur. Un YAML de saison liste 10-25 épisodes, ça reste lisible. ### Naming convention `season_.yaml` avec zero-padding sur 2 digits. `season_00.yaml` pour les Specials (cohérent avec `SeasonNumber.is_special()`). --- ## Schémas YAML ### `show.yaml` ```yaml # Métadonnées de l'agrégat TVShow (root only — pas les saisons/épisodes). schema_version: 1 imdb_id: tt0903747 tmdb_id: 1396 title: Breaking Bad status: ended # ShowStatus.value expected_seasons: 5 # Audit trail léger created_at: 2026-05-21T10:42:00Z updated_at: 2026-05-21T10:42:00Z ``` ### `season_NN.yaml` ```yaml schema_version: 1 season_number: 1 name: null # optionnel, depuis TMDB expected_episodes: 7 aired_episodes: 7 episodes: - episode_number: 1 title: Pilot file: path: Season 01/Breaking.Bad.S01E01.Pilot.mkv size: 1837465182 mtime: 1684320000.5 # epoch seconds, .5 = précision sub-sec probe: duration_seconds: 3458.2 bitrate_kbps: 4250 video: - codec: h264 width: 1920 height: 1080 audio: - codec: ac3 channels: 6 channel_layout: 5.1 language: eng is_default: true subtitle: - codec: subrip language: eng is_default: false is_forced: false - episode_number: 2 title: Cat's in the Bag... file: path: Season 01/Breaking.Bad.S01E02.Cats.in.the.Bag.mkv size: 1923456789 mtime: 1684320120.0 probe: # ... même schéma updated_at: 2026-05-21T10:42:00Z ``` ### Conventions - **`schema_version`** sur chaque fichier — migration possible plus tard sans casser les anciens caches. - **`path` relative** au dossier du show. Permet de déplacer le show entier sans réécrire les chemins. - **`mtime` float** (epoch seconds, précision sub-seconde) — natif Python `os.stat().st_mtime`. - **Champs nullables explicites** (`name: null`) plutôt qu'omis, pour éviter les ambiguïtés "absent ≠ inconnu". - **Tracks listées dans l'ordre filesystem** (= ordre ffprobe). - **`updated_at` ISO 8601 UTC.** Pour debug, pas pour invalidation cache (qui se fait sur mtime fichier). --- ## Invalidation cache À chaque `find_by_imdb_id(imdb_id)` : 1. Lire `.alfred/show.yaml` → reconstruire le `TVShow` root (sans saisons). 2. Pour chaque `season_NN.yaml` trouvé : - Pour chaque épisode listé : - `os.stat(episode.file.path)` - Si `(size, mtime)` matchent ⇒ **utiliser le cache** : reconstruire l'`Episode` depuis le YAML. - Si fichier disparu ⇒ omettre l'épisode du load (sera retiré du cache au prochain `save()` parce que pas dans le builder). - Si fichier modifié ⇒ **rescan ce fichier précis** (probe), met à jour l'entrée builder. 3. Scanner `Season NN/` filesystem pour détecter les **fichiers nouveaux non listés dans le cache** ⇒ rescan + ajout au builder. 4. `builder.build()` ⇒ `TVShow` frozen, prêt à circuler. **Coût en cache-hit pur** : 1 `os.stat()` par épisode (microseconde). Pour 2500 épisodes ≈ 50ms total. Acceptable. **Coût en cold start** : équivalent au scan intégral d'aujourd'hui — mais **une seule fois**, ensuite tout est en cache. --- ## Stratégie d'écriture `TVShowRepository.save(show)` : 1. Calcule le dossier du show via convention (`library_root / show.get_folder_name()`). 2. Crée `.alfred/` si absent (`mkdir -p`). 3. Écrit `show.yaml` (toujours réécrit — c'est petit). 4. Pour chaque saison dans `show.seasons` ⇒ écrit `season_NN.yaml`. 5. Supprime les `season_NN.yaml` qui ne sont plus dans l'agrégat (saison vidée à la main par exemple). **Atomicité** : chaque fichier YAML est écrit en `.tmp` puis `os.replace()` ⇒ pas de fichier à moitié écrit visible. **Pas de lock multi-process.** Alfred est mono-process. Si jamais ça change, sidecar par show isole les contentions naturellement (lock par dossier show, pas global). --- ## Cas particuliers ### Show sans `.alfred/` Dossier legacy ou nouveau show fraîchement posé. ⇒ `find_by_imdb_id` scanne intégralement, `find_all` parcourt `library/tvshows/*/` et fait un cold scan pour chaque show. ⇒ Premier `save()` crée le sidecar. ### `.alfred/` corrompu (YAML invalide, schéma cassé) ⇒ Log warning, traite comme "sans `.alfred/`" pour ce show précis. Le prochain `save()` réécrit proprement. ### Show sans IMDb ID identifié `show.yaml` peut exister avec `imdb_id: null` mais ce n'est pas le cas nominal — `TVShowRepository.find_by_imdb_id` ne peut pas indexer un show sans ID. À traiter dans une couche supérieure (resolve TMDB d'abord, puis sauvegarder avec ID). ### Renommage de dossier par l'utilisateur `.alfred/` voyage avec. Mais le repo `find_all()` indexe par nom de dossier — donc le show réapparaît au nouveau chemin sans drame. Si `library_paths.get("tv_show")` change globalement, idem. ### Fichier supprimé manuellement entre deux loads Détecté au load (file disparu), retiré silencieusement à l'écriture suivante. Pas d'erreur — c'est juste le filesystem qui a parlé. --- ## Format alternatifs envisagés (et écartés) - **JSON** : plus rapide à parser mais moins lisible humain. Cohérent avec `ltm.json` mais on a déjà du YAML dans `knowledge/` ⇒ rester YAML pour la "donnée externe humaine-lisible", garder JSON pour le runtime memory. - **TOML** : moins adapté aux structures imbriquées profondes (tracks). - **SQLite par show** : overkill, pas lisible humain, moins déplaçable. --- ## Hors scope de cette spec - **`MovieRepository`** — sera traité dans une spec sœur (`dot_alfred_movies.md`). L'asymétrie naturelle (film = 1 fichier, pas de saison/épisode) appelle un schéma plus simple : un seul `.alfred/movie.yaml` à côté du fichier. - **Library audit / migration script** — `audit_library.py` (mentionné dans `project_workflow_roads.md`) consommera ce repo une fois prêt. - **Synchronisation entre `LTM.library` (memory) et le repo FS** — décision à prendre : est-ce que `library` reste une liste manuelle, ou est-ce que c'est dérivé via `TVShowRepository.find_all()` ? Question pour Session B. --- ## Plan d'implémentation (proposition) Chaque étape commit-prête, suite verte exigée. ### Étape 1 — Builder + freeze (préparation domaine) - `TVShowBuilder` mutable dans `alfred/domain/tv_shows/builders.py` - Freeze `Season` + `TVShow` (`@dataclass(frozen=True)`, dicts ⇒ `Mapping`-compatible, tracks `tuple`) - `TVShow.add_episode` retiré (ou conservé en deprecated warning si la feedback memory autorise — à vérifier : on a `feedback_tests_shims_regex` qui dit "pas de shims compat") - Update tests qui appellent `add_episode`/`add_season` ⇒ passer au builder ### Étape 2 — Sérialiseur YAML - `alfred/infrastructure/persistence/dot_alfred/serializer.py` - `serialize_show(show) -> dict`, `serialize_season(season) -> dict` - `deserialize_show(data) -> ShowYamlPayload`, `deserialize_season(data) -> SeasonYamlPayload` (DTOs intermédiaires, validation via Pydantic ou dataclasses) - Round-trip tests ### Étape 3 — Cache validator - `alfred/infrastructure/persistence/dot_alfred/cache.py` - `is_fresh(episode_payload, file_path) -> bool` : compare size + mtime - Tests sur fichier modifié, fichier disparu, fichier nouveau ### Étape 4 — Repository concret - `alfred/infrastructure/persistence/dot_alfred/tv_show_repository.py` - `DotAlfredTVShowRepository(library_root: Path)` - Implémente `save`, `find_by_imdb_id`, `find_all`, `delete`, `exists` - Utilise `Builder` pour assembler à la lecture - Tests intégration sur tmp_path ### Étape 5 — Wiring - `MemoryRegistry` ou contexte : exposer le repo - Tool ou use case qui le consomme (probablement plus tard, après les 4 mini-workflows du chantier `organize_media`) --- ## Questions ouvertes pour discussion 1. **DTO Pydantic ou dataclasses pour les payloads YAML ?** Pydantic apporte validation gratuite et messages d'erreur propres. Dataclasses restent plus alignées avec le reste du domaine. À trancher. 2. **Compression ?** Un YAML de saison long peut faire 50-100 KB. Sur 50 shows × 5 saisons = ~25 MB. Acceptable, pas de compression nécessaire. 3. **`.alfred/` versionné ?** Le sidecar n'est pas dans `.gitignore` du repo Alfred (il vit dans la lib utilisateur, pas dans le repo code). Mais l'utilisateur *pourrait* vouloir git-tracker sa lib. Pas notre problème — c'est un fichier comme un autre. 4. **Schema migration ?** `schema_version: 1` est en place. Pas de migrateur maintenant. À ajouter quand on passera à `2`. 5. **TMDB cache séparé ?** Aujourd'hui `episodic.store_search_results` cache les résultats TMDB en memory. Faut-il aussi les poser en `.alfred/` ? Probablement non — TMDB peut évoluer (status d'un show qui passe ongoing→ended), donc cache court-terme uniquement. 6. **Comportement si `expected_episodes`/`aired_episodes` change côté TMDB après écriture du sidecar ?** Re-fetch TMDB est hors du repo. Le sidecar reflète ce qu'on savait à l'écriture. Une couche de refresh périodique (futur) pourrait re-écrire les sidecars au refresh.