Files
alfred/specs/dot_alfred.md
T
francwa 1427c8a54b 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).
2026-05-21 18:05:55 +02:00

333 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `.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.