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:
@@ -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, ...] = ()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user