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).
12 KiB
.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 :
- Lister les saisons (
lsdu dossier show) - Pour chaque saison, lister les épisodes (
ls) - Pour chaque épisode, lire les tags du fichier (ffprobe ⇒ audio/subtitle tracks, codecs, durée…)
- 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
# 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
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_versionsur chaque fichier — migration possible plus tard sans casser les anciens caches.pathrelative au dossier du show. Permet de déplacer le show entier sans réécrire les chemins.mtimefloat (epoch seconds, précision sub-seconde) — natif Pythonos.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_atISO 8601 UTC. Pour debug, pas pour invalidation cache (qui se fait sur mtime fichier).
Invalidation cache
À chaque find_by_imdb_id(imdb_id) :
- Lire
.alfred/show.yaml→ reconstruire leTVShowroot (sans saisons). - Pour chaque
season_NN.yamltrouvé :- Pour chaque épisode listé :
os.stat(episode.file.path)- Si
(size, mtime)matchent ⇒ utiliser le cache : reconstruire l'Episodedepuis 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.
- Pour chaque épisode listé :
- Scanner
Season NN/filesystem pour détecter les fichiers nouveaux non listés dans le cache ⇒ rescan + ajout au builder. builder.build()⇒TVShowfrozen, 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) :
- Calcule le dossier du show via convention (
library_root / show.get_folder_name()). - Crée
.alfred/si absent (mkdir -p). - Écrit
show.yaml(toujours réécrit — c'est petit). - Pour chaque saison dans
show.seasons⇒ écritseason_NN.yaml. - Supprime les
season_NN.yamlqui 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.jsonmais on a déjà du YAML dansknowledge/⇒ 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é dansproject_workflow_roads.md) consommera ce repo une fois prêt. - Synchronisation entre
LTM.library(memory) et le repo FS — décision à prendre : est-ce quelibraryreste une liste manuelle, ou est-ce que c'est dérivé viaTVShowRepository.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)
TVShowBuildermutable dansalfred/domain/tv_shows/builders.py- Freeze
Season+TVShow(@dataclass(frozen=True), dicts ⇒Mapping-compatible, trackstuple) TVShow.add_episoderetiré (ou conservé en deprecated warning si la feedback memory autorise — à vérifier : on afeedback_tests_shims_regexqui 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.pyserialize_show(show) -> dict,serialize_season(season) -> dictdeserialize_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.pyis_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.pyDotAlfredTVShowRepository(library_root: Path)- Implémente
save,find_by_imdb_id,find_all,delete,exists - Utilise
Builderpour assembler à la lecture - Tests intégration sur tmp_path
Étape 5 — Wiring
MemoryRegistryou 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
-
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.
-
Compression ? Un YAML de saison long peut faire 50-100 KB. Sur 50 shows × 5 saisons = ~25 MB. Acceptable, pas de compression nécessaire.
-
.alfred/versionné ? Le sidecar n'est pas dans.gitignoredu 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. -
Schema migration ?
schema_version: 1est en place. Pas de migrateur maintenant. À ajouter quand on passera à2. -
TMDB cache séparé ? Aujourd'hui
episodic.store_search_resultscache 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. -
Comportement si
expected_episodes/aired_episodeschange 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.