e65c1df229
Spec: specs/dot_alfred_v2.md (Phase 2).
New package alfred/infrastructure/persistence/dot_alfred/v2/:
* sidecar_release.py / sidecar_root.py — Pydantic DTOs
(extra="forbid", frozen=True) for per-item sidecars and the
library-root index. schema_version enforced via model_validator.
* serializer.py — read_yaml / atomic_write_yaml (.tmp + os.replace).
SidecarSchemaError wraps YAML + Pydantic errors uniformly.
* bridge.py — lossless domain <-> sidecar for SeriesRelease /
MovieRelease; projection-only show_index_entry_from /
movie_index_entry_from with multi-episode-file flattening.
* repository.py — DotAlfredSeriesReleaseRepository /
DotAlfredMovieReleaseRepository (log+skip on corruption),
DotAlfredTVShowLibraryIndex / DotAlfredMovieLibraryIndex with
silent auto-heal on missing/corrupt index reads. Writes never
auto-heal (read paths handle that).
TMDB client extensions:
* TmdbSeasonInfo / TmdbShowInfo DTOs + pure parse_tv_show_info.
* TMDBClient.get_tv_show_info aggregates /tv/{id} +
/tv/{id}/external_ids.
Domain change:
* SubtitleTrack gains is_sdh: bool = False, populated from
ffprobe's hearing_impaired disposition. Required for v2 sidecar
parity (spec replaces v1's type: "sdh" with explicit flag).
Default keeps every existing caller unchanged.
Tests: 37 new v2 integration tests on tmp_path (round-trips, atomic
writes, schema mismatch handling, anchor warnings, auto-heal paths)
plus 16 TMDB DTO tests. Full suite: 1240 -> 1277 passed.
Implementation notes filed in .claude/specs/dot_alfred_v2_notes.md
(strict=True trade-off, upsert signature deviation from spec, etc.).
Phases 3-5 (TVShow/Movie refactor to TMDB-only, rescan_show rewrite,
v1 deletion + wiring) are next.
77 lines
2.3 KiB
Python
77 lines
2.3 KiB
Python
"""YAML I/O helpers for ``.alfred`` v2 sidecars.
|
|
|
|
This module is intentionally thin: validation lives entirely in the
|
|
Pydantic DTOs (``sidecar_release.py`` / ``sidecar_root.py``). Here we
|
|
only do two things:
|
|
|
|
* ``read_yaml`` — load text → dict, with friendly error translation.
|
|
* ``atomic_write_yaml`` — render dict → ``.tmp`` file, then
|
|
``os.replace`` to the final path. Atomic on POSIX and NTFS, so no
|
|
half-written file ever becomes visible to a concurrent reader.
|
|
|
|
The bridge module is responsible for converting between domain
|
|
aggregates and these DTOs; the repository module composes everything
|
|
(walks the library, calls the bridge, persists via these helpers).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from .sidecar_release import SCHEMA_VERSION
|
|
|
|
__all__ = [
|
|
"SCHEMA_VERSION",
|
|
"SidecarSchemaError",
|
|
"atomic_write_yaml",
|
|
"read_yaml",
|
|
]
|
|
|
|
|
|
class SidecarSchemaError(ValueError):
|
|
"""Raised when a sidecar file fails to load or validate.
|
|
|
|
Wraps both YAML parse errors and Pydantic validation errors so
|
|
callers can catch a single exception type and log + skip.
|
|
"""
|
|
|
|
|
|
def read_yaml(path: Path) -> dict[str, Any]:
|
|
"""Load a YAML file and return its top-level mapping.
|
|
|
|
Raises :class:`SidecarSchemaError` if the file is unreadable,
|
|
contains invalid YAML, or the top-level value is not a mapping
|
|
(a list or scalar at the root is always a bug for our sidecars).
|
|
"""
|
|
try:
|
|
text = path.read_text()
|
|
except OSError as exc:
|
|
raise SidecarSchemaError(f"cannot read {path}: {exc}") from exc
|
|
|
|
try:
|
|
data = yaml.safe_load(text)
|
|
except yaml.YAMLError as exc:
|
|
raise SidecarSchemaError(f"invalid YAML in {path}: {exc}") from exc
|
|
|
|
if not isinstance(data, dict):
|
|
raise SidecarSchemaError(
|
|
f"{path}: top-level must be a mapping, got {type(data).__name__}"
|
|
)
|
|
return data
|
|
|
|
|
|
def atomic_write_yaml(path: Path, data: dict[str, Any]) -> None:
|
|
"""Atomically write ``data`` as YAML to ``path``.
|
|
|
|
Uses the same ``write-tmp + os.replace`` pattern as v1: a reader
|
|
racing with a writer either sees the previous version or the new
|
|
one, never a torn file.
|
|
"""
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
tmp.write_text(yaml.safe_dump(data, sort_keys=False))
|
|
os.replace(tmp, path)
|