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

12 KiB
Raw Blame History

.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

# 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_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 scriptaudit_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.