From 1427c8a54b888a6fc9839ca13736fd615d00b6c1 Mon Sep 17 00:00:00 2001 From: Francwa Date: Thu, 21 May 2026 18:05:55 +0200 Subject: [PATCH] docs(specs): add dot_alfred sidecar design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First entry in the new specs/ directory. Specifies the layout and semantics of the per-show .alfred/ sidecar that will back the future concrete TVShowRepository: - One .alfred/ directory per show, containing show.yaml + one season_NN.yaml per season (zero-padded, season_00 for Specials). - Per-episode entries store file size + mtime so cache lookups skip a full ffprobe rescan when nothing changed. - Self-healing on drift (file missing/modified/new) without raising. - Atomic writes via temp file + os.replace(). - Phased implementation plan (builder + freeze first, then serializer, then cache validator, then repo, then wiring). No code yet — spec only, awaiting review before the implementation phases. Companion entry in CHANGELOG (Added). --- CHANGELOG.md | 13 ++ specs/dot_alfred.md | 332 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 specs/dot_alfred.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e60707..2cb610f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,19 @@ callers). ## [Unreleased] +### Added + +- **Design doc for `.alfred/` sidecar persistence + (`specs/dot_alfred.md`).** First entry in the new `specs/` directory. + Specifies a per-show `.alfred/` directory holding a `show.yaml` and + one `season_NN.yaml` per season, used by the upcoming concrete + `TVShowRepository` to cache parse/probe results and avoid full + rescans on every library read. Covers schema, naming conventions, + cache invalidation strategy (size + mtime), self-healing on + drift, atomicity (`os.replace`), edge cases (legacy folders, + corrupted sidecars, manual file removal), and a phased + implementation plan. No code yet — spec only. + ### Internal - **`specs/` is now tracked.** The repo-level `.gitignore` had a diff --git a/specs/dot_alfred.md b/specs/dot_alfred.md new file mode 100644 index 0000000..d515963 --- /dev/null +++ b/specs/dot_alfred.md @@ -0,0 +1,332 @@ +# `.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.