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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user