docs(specs): add dot_alfred sidecar design doc

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).
This commit is contained in:
2026-05-21 18:05:55 +02:00
parent 8491edac22
commit 1427c8a54b
2 changed files with 345 additions and 0 deletions
+13
View File
@@ -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
+332
View File
@@ -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/<Show.Name>/`, 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_<NN>.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 `<file>.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.