feat(dot_alfred/v2): bump SCHEMA_VERSION to 2 — added_at on MovieRelease

Phase 3 prep: Movie aggregate is about to become TMDB-only (no
filesystem fields). added_at is a release-time observation, not a
TMDB-aggregate concern, so it moves to MovieRelease +
MovieReleaseSidecar.

- Add added_at: datetime (required) to MovieRelease with a
  type-check in __post_init__.
- Add added_at: datetime (required) to MovieReleaseSidecar.
- Bump SCHEMA_VERSION 1 → 2 with a version-history note.
- Bridge round-trips added_at via Pydantic mode="json" (datetime
  → ISO 8601 string).
- Tests: update MovieRelease fixtures, add a validator test, add
  an added_at round-trip test, switch hard-coded `1` assertions
  to SCHEMA_VERSION for future-proofing.

No v1 sidecars in the wild yet — no migration code needed.
This commit is contained in:
2026-05-25 19:47:25 +02:00
parent e65c1df229
commit 2f160644da
6 changed files with 57 additions and 3 deletions
+13
View File
@@ -13,6 +13,7 @@ All entities are frozen. Mutation goes through the builders in
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from ..shared.exceptions import ValidationError from ..shared.exceptions import ValidationError
from ..shared.media import AudioTrack, SubtitleTrack from ..shared.media import AudioTrack, SubtitleTrack
@@ -179,12 +180,19 @@ class MovieRelease:
``movies/`` library root. :attr:`file_path` is the video file ``movies/`` library root. :attr:`file_path` is the video file
name relative to the folder (movies are one folder, one file in name relative to the folder (movies are one folder, one file in
Alfred's layout — no sub-folders). Alfred's layout — no sub-folders).
:attr:`added_at` is the UTC timestamp at which the release was
first observed in the library — set by the caller (organizer /
rescan) when the aggregate is built. Persisted by the v2 movie
sidecar; not derived from the filesystem (mtime drifts across
moves and hard-links).
""" """
tmdb_id: TmdbId tmdb_id: TmdbId
imdb_id: ImdbId | None imdb_id: ImdbId | None
folder: str folder: str
file_path: FilePath file_path: FilePath
added_at: datetime
tracks: TrackProfile = TrackProfile() tracks: TrackProfile = TrackProfile()
def __post_init__(self) -> None: def __post_init__(self) -> None:
@@ -202,3 +210,8 @@ class MovieRelease:
f"MovieRelease.folder must be a non-empty string, " f"MovieRelease.folder must be a non-empty string, "
f"got {self.folder!r}" f"got {self.folder!r}"
) )
if not isinstance(self.added_at, datetime):
raise ValidationError(
f"MovieRelease.added_at must be datetime, "
f"got {type(self.added_at)}"
)
@@ -153,6 +153,7 @@ def movie_release_to_sidecar(release: MovieRelease) -> MovieReleaseSidecar:
imdb_id=str(release.imdb_id) if release.imdb_id is not None else None, imdb_id=str(release.imdb_id) if release.imdb_id is not None else None,
folder=release.folder, folder=release.folder,
file=str(release.file_path), file=str(release.file_path),
added_at=release.added_at,
audio=tuple(_audio_to_entry(t) for t in release.tracks.audio_tracks), audio=tuple(_audio_to_entry(t) for t in release.tracks.audio_tracks),
subtitles=tuple(_sub_to_entry(t) for t in release.tracks.subtitle_tracks), subtitles=tuple(_sub_to_entry(t) for t in release.tracks.subtitle_tracks),
) )
@@ -165,6 +166,7 @@ def movie_release_from_sidecar(sidecar: MovieReleaseSidecar) -> MovieRelease:
imdb_id=ImdbId(sidecar.imdb_id) if sidecar.imdb_id else None, imdb_id=ImdbId(sidecar.imdb_id) if sidecar.imdb_id else None,
folder=sidecar.folder, folder=sidecar.folder,
file_path=FilePath(sidecar.file), file_path=FilePath(sidecar.file),
added_at=sidecar.added_at,
tracks=TrackProfile( tracks=TrackProfile(
audio_tracks=tuple( audio_tracks=tuple(
_audio_from_entry(a, i) for i, a in enumerate(sidecar.audio) _audio_from_entry(a, i) for i, a in enumerate(sidecar.audio)
@@ -18,12 +18,23 @@ and types. The bridge module translates between these DTOs and the
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
from .....domain.releases.value_objects import ReleaseMode from .....domain.releases.value_objects import ReleaseMode
# Reused by the root-index module; declared here once. # Reused by the root-index module; declared here once.
SCHEMA_VERSION = 1 #
# Version history:
#
# * ``1`` — initial v2 schema (Phase 2).
# * ``2`` — Phase 3: ``MovieReleaseSidecar.added_at: datetime`` added.
# Movie aggregates lost ``added_at`` (it was filesystem bookkeeping
# on a TMDB-only entity); the field moves here as a release-time
# observation. No migration code shipped — no v2.1 sidecars exist
# in the wild yet.
SCHEMA_VERSION = 2
class _Strict(BaseModel): class _Strict(BaseModel):
@@ -154,6 +165,7 @@ class MovieReleaseSidecar(_Strict):
imdb_id: str | None = None imdb_id: str | None = None
folder: str = Field(min_length=1) folder: str = Field(min_length=1)
file: str = Field(min_length=1) file: str = Field(min_length=1)
added_at: datetime
audio: tuple[AudioTrackEntry, ...] = () audio: tuple[AudioTrackEntry, ...] = ()
subtitles: tuple[SubtitleEntry, ...] = () subtitles: tuple[SubtitleEntry, ...] = ()
+18
View File
@@ -1,6 +1,7 @@
"""Tests for the releases domain entities.""" """Tests for the releases domain entities."""
import dataclasses import dataclasses
from datetime import UTC, datetime
import pytest import pytest
@@ -17,6 +18,8 @@ from alfred.domain.shared.media import AudioTrack, SubtitleTrack
from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber
_ADDED_AT = datetime(2026, 5, 25, 8, 30, 0, tzinfo=UTC)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@@ -238,9 +241,11 @@ class TestMovieRelease:
imdb_id=ImdbId("tt1375666"), imdb_id=ImdbId("tt1375666"),
folder="Inception.2010.1080p.BluRay.x264-GROUP", folder="Inception.2010.1080p.BluRay.x264-GROUP",
file_path=FilePath("Inception.2010.1080p.BluRay.x264-GROUP.mkv"), file_path=FilePath("Inception.2010.1080p.BluRay.x264-GROUP.mkv"),
added_at=_ADDED_AT,
) )
assert m.tmdb_id == TmdbId(27205) assert m.tmdb_id == TmdbId(27205)
assert m.imdb_id == ImdbId("tt1375666") assert m.imdb_id == ImdbId("tt1375666")
assert m.added_at == _ADDED_AT
def test_optional_imdb(self): def test_optional_imdb(self):
m = MovieRelease( m = MovieRelease(
@@ -248,6 +253,7 @@ class TestMovieRelease:
imdb_id=None, imdb_id=None,
folder="X", folder="X",
file_path=FilePath("x.mkv"), file_path=FilePath("x.mkv"),
added_at=_ADDED_AT,
) )
assert m.imdb_id is None assert m.imdb_id is None
@@ -258,6 +264,7 @@ class TestMovieRelease:
imdb_id=None, imdb_id=None,
folder="X", folder="X",
file_path=FilePath("x.mkv"), file_path=FilePath("x.mkv"),
added_at=_ADDED_AT,
) )
def test_folder_must_be_non_empty(self): def test_folder_must_be_non_empty(self):
@@ -267,4 +274,15 @@ class TestMovieRelease:
imdb_id=None, imdb_id=None,
folder="", folder="",
file_path=FilePath("x.mkv"), file_path=FilePath("x.mkv"),
added_at=_ADDED_AT,
)
def test_added_at_must_be_datetime(self):
with pytest.raises(ValidationError):
MovieRelease(
tmdb_id=TmdbId(27205),
imdb_id=None,
folder="X",
file_path=FilePath("x.mkv"),
added_at="2026-05-25", # type: ignore[arg-type]
) )
@@ -141,6 +141,7 @@ def inception_release() -> MovieRelease:
imdb_id=ImdbId("tt1375666"), imdb_id=ImdbId("tt1375666"),
folder="Inception.2010.1080p.BluRay.x264-GROUP", folder="Inception.2010.1080p.BluRay.x264-GROUP",
file_path=FilePath("Inception.2010.1080p.BluRay.x264-GROUP.mkv"), file_path=FilePath("Inception.2010.1080p.BluRay.x264-GROUP.mkv"),
added_at=datetime(2026, 5, 25, 8, 30, 0, tzinfo=UTC),
tracks=TrackProfile( tracks=TrackProfile(
audio_tracks=( audio_tracks=(
AudioTrack( AudioTrack(
@@ -17,6 +17,7 @@ from alfred.infrastructure.persistence.dot_alfred.v2.bridge import (
series_release_to_sidecar, series_release_to_sidecar,
) )
from alfred.infrastructure.persistence.dot_alfred.v2.sidecar_release import ( from alfred.infrastructure.persistence.dot_alfred.v2.sidecar_release import (
SCHEMA_VERSION,
MovieReleaseSidecar, MovieReleaseSidecar,
SeriesReleaseSidecar, SeriesReleaseSidecar,
) )
@@ -25,7 +26,7 @@ from alfred.infrastructure.persistence.dot_alfred.v2.sidecar_release import (
class TestSeriesReleaseRoundTrip: class TestSeriesReleaseRoundTrip:
def test_domain_to_sidecar_preserves_top_level(self, foundation_release): def test_domain_to_sidecar_preserves_top_level(self, foundation_release):
sidecar = series_release_to_sidecar(foundation_release) sidecar = series_release_to_sidecar(foundation_release)
assert sidecar.schema_version == 1 assert sidecar.schema_version == SCHEMA_VERSION
assert sidecar.tmdb_id == 84958 assert sidecar.tmdb_id == 84958
assert sidecar.imdb_id == "tt0804484" assert sidecar.imdb_id == "tt0804484"
assert len(sidecar.releases) == 2 assert len(sidecar.releases) == 2
@@ -71,7 +72,7 @@ class TestSeriesReleaseRoundTrip:
class TestMovieReleaseRoundTrip: class TestMovieReleaseRoundTrip:
def test_domain_to_sidecar_preserves_top_level(self, inception_release): def test_domain_to_sidecar_preserves_top_level(self, inception_release):
sidecar = movie_release_to_sidecar(inception_release) sidecar = movie_release_to_sidecar(inception_release)
assert sidecar.schema_version == 1 assert sidecar.schema_version == SCHEMA_VERSION
assert sidecar.tmdb_id == 27205 assert sidecar.tmdb_id == 27205
assert sidecar.imdb_id == "tt1375666" assert sidecar.imdb_id == "tt1375666"
assert sidecar.folder == "Inception.2010.1080p.BluRay.x264-GROUP" assert sidecar.folder == "Inception.2010.1080p.BluRay.x264-GROUP"
@@ -89,3 +90,10 @@ class TestMovieReleaseRoundTrip:
forced = restored.tracks.subtitle_tracks[1] forced = restored.tracks.subtitle_tracks[1]
assert forced.is_forced is True assert forced.is_forced is True
assert forced.language == "fre" assert forced.language == "fre"
def test_added_at_round_trips_through_yaml(self, inception_release):
sidecar = movie_release_to_sidecar(inception_release)
text = yaml.safe_dump(sidecar.model_dump(mode="json"))
reloaded = MovieReleaseSidecar.model_validate(yaml.safe_load(text))
restored = movie_release_from_sidecar(reloaded)
assert restored.added_at == inception_release.added_at