diff --git a/alfred/domain/releases/entities.py b/alfred/domain/releases/entities.py index 0584117..9078171 100644 --- a/alfred/domain/releases/entities.py +++ b/alfred/domain/releases/entities.py @@ -13,6 +13,7 @@ All entities are frozen. Mutation goes through the builders in from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from ..shared.exceptions import ValidationError from ..shared.media import AudioTrack, SubtitleTrack @@ -179,12 +180,19 @@ class MovieRelease: ``movies/`` library root. :attr:`file_path` is the video file name relative to the folder (movies are one folder, one file in 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 imdb_id: ImdbId | None folder: str file_path: FilePath + added_at: datetime tracks: TrackProfile = TrackProfile() def __post_init__(self) -> None: @@ -202,3 +210,8 @@ class MovieRelease: f"MovieRelease.folder must be a non-empty string, " 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)}" + ) diff --git a/alfred/infrastructure/persistence/dot_alfred/v2/bridge.py b/alfred/infrastructure/persistence/dot_alfred/v2/bridge.py index 029897c..5289f8f 100644 --- a/alfred/infrastructure/persistence/dot_alfred/v2/bridge.py +++ b/alfred/infrastructure/persistence/dot_alfred/v2/bridge.py @@ -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, folder=release.folder, file=str(release.file_path), + added_at=release.added_at, 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), ) @@ -165,6 +166,7 @@ def movie_release_from_sidecar(sidecar: MovieReleaseSidecar) -> MovieRelease: imdb_id=ImdbId(sidecar.imdb_id) if sidecar.imdb_id else None, folder=sidecar.folder, file_path=FilePath(sidecar.file), + added_at=sidecar.added_at, tracks=TrackProfile( audio_tracks=tuple( _audio_from_entry(a, i) for i, a in enumerate(sidecar.audio) diff --git a/alfred/infrastructure/persistence/dot_alfred/v2/sidecar_release.py b/alfred/infrastructure/persistence/dot_alfred/v2/sidecar_release.py index 9264578..c7233e5 100644 --- a/alfred/infrastructure/persistence/dot_alfred/v2/sidecar_release.py +++ b/alfred/infrastructure/persistence/dot_alfred/v2/sidecar_release.py @@ -18,12 +18,23 @@ and types. The bridge module translates between these DTOs and the from __future__ import annotations +from datetime import datetime + from pydantic import BaseModel, ConfigDict, Field, model_validator from .....domain.releases.value_objects import ReleaseMode # 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): @@ -154,6 +165,7 @@ class MovieReleaseSidecar(_Strict): imdb_id: str | None = None folder: str = Field(min_length=1) file: str = Field(min_length=1) + added_at: datetime audio: tuple[AudioTrackEntry, ...] = () subtitles: tuple[SubtitleEntry, ...] = () diff --git a/tests/domain/releases/test_entities.py b/tests/domain/releases/test_entities.py index f9dcb65..9b49cb3 100644 --- a/tests/domain/releases/test_entities.py +++ b/tests/domain/releases/test_entities.py @@ -1,6 +1,7 @@ """Tests for the releases domain entities.""" import dataclasses +from datetime import UTC, datetime 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.tv_shows.value_objects import EpisodeNumber, SeasonNumber +_ADDED_AT = datetime(2026, 5, 25, 8, 30, 0, tzinfo=UTC) + # --------------------------------------------------------------------------- # Helpers @@ -238,9 +241,11 @@ class TestMovieRelease: imdb_id=ImdbId("tt1375666"), folder="Inception.2010.1080p.BluRay.x264-GROUP", file_path=FilePath("Inception.2010.1080p.BluRay.x264-GROUP.mkv"), + added_at=_ADDED_AT, ) assert m.tmdb_id == TmdbId(27205) assert m.imdb_id == ImdbId("tt1375666") + assert m.added_at == _ADDED_AT def test_optional_imdb(self): m = MovieRelease( @@ -248,6 +253,7 @@ class TestMovieRelease: imdb_id=None, folder="X", file_path=FilePath("x.mkv"), + added_at=_ADDED_AT, ) assert m.imdb_id is None @@ -258,6 +264,7 @@ class TestMovieRelease: imdb_id=None, folder="X", file_path=FilePath("x.mkv"), + added_at=_ADDED_AT, ) def test_folder_must_be_non_empty(self): @@ -267,4 +274,15 @@ class TestMovieRelease: imdb_id=None, folder="", 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] ) diff --git a/tests/infrastructure/persistence/dot_alfred/v2/conftest.py b/tests/infrastructure/persistence/dot_alfred/v2/conftest.py index 4c552f2..35d53e6 100644 --- a/tests/infrastructure/persistence/dot_alfred/v2/conftest.py +++ b/tests/infrastructure/persistence/dot_alfred/v2/conftest.py @@ -141,6 +141,7 @@ def inception_release() -> MovieRelease: imdb_id=ImdbId("tt1375666"), folder="Inception.2010.1080p.BluRay.x264-GROUP", file_path=FilePath("Inception.2010.1080p.BluRay.x264-GROUP.mkv"), + added_at=datetime(2026, 5, 25, 8, 30, 0, tzinfo=UTC), tracks=TrackProfile( audio_tracks=( AudioTrack( diff --git a/tests/infrastructure/persistence/dot_alfred/v2/test_round_trip.py b/tests/infrastructure/persistence/dot_alfred/v2/test_round_trip.py index 705590f..0eb039a 100644 --- a/tests/infrastructure/persistence/dot_alfred/v2/test_round_trip.py +++ b/tests/infrastructure/persistence/dot_alfred/v2/test_round_trip.py @@ -17,6 +17,7 @@ from alfred.infrastructure.persistence.dot_alfred.v2.bridge import ( series_release_to_sidecar, ) from alfred.infrastructure.persistence.dot_alfred.v2.sidecar_release import ( + SCHEMA_VERSION, MovieReleaseSidecar, SeriesReleaseSidecar, ) @@ -25,7 +26,7 @@ from alfred.infrastructure.persistence.dot_alfred.v2.sidecar_release import ( class TestSeriesReleaseRoundTrip: def test_domain_to_sidecar_preserves_top_level(self, 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.imdb_id == "tt0804484" assert len(sidecar.releases) == 2 @@ -71,7 +72,7 @@ class TestSeriesReleaseRoundTrip: class TestMovieReleaseRoundTrip: def test_domain_to_sidecar_preserves_top_level(self, 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.imdb_id == "tt1375666" assert sidecar.folder == "Inception.2010.1080p.BluRay.x264-GROUP" @@ -89,3 +90,10 @@ class TestMovieReleaseRoundTrip: forced = restored.tracks.subtitle_tracks[1] assert forced.is_forced is True 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