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]
|
## [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
|
### Internal
|
||||||
|
|
||||||
- **`specs/` is now tracked.** The repo-level `.gitignore` had a
|
- **`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