feat(library): lazy materialization foundation for remote tracks (§Phase1)
Adds nullable storage fields + availability column on tracks, remote source/source_id identity on albums/artists, TrackRepository.materialize() and get_or_create_remote() repos — groundwork for on-demand YTM library (placeholders saved without audio, materialized in-place on first play). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,14 @@ pytestmark = pytest.mark.asyncio
|
||||
class FakeArtistRepo:
|
||||
async def get_or_create(self, name: str) -> Artist:
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
return Artist(id=uuid.uuid4(), name=name, created_at=now, updated_at=now)
|
||||
return Artist(
|
||||
id=uuid.uuid4(),
|
||||
name=name,
|
||||
source=None,
|
||||
source_id=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
class FakeTrackRepo:
|
||||
@@ -46,6 +53,7 @@ class FakeTrackRepo:
|
||||
metadata_status=str(kw["metadata_status"]),
|
||||
metadata_error=None,
|
||||
enriched_at=None,
|
||||
availability="local",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -140,6 +148,7 @@ def _track(source: str, source_id: str) -> Track:
|
||||
metadata_status="pending",
|
||||
metadata_error=None,
|
||||
enriched_at=None,
|
||||
availability="local",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,14 @@ class FakeArtistRepo:
|
||||
async def get_or_create(self, name: str) -> Artist:
|
||||
if name not in self._by_name:
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
self._by_name[name] = Artist(id=uuid.uuid4(), name=name, created_at=now, updated_at=now)
|
||||
self._by_name[name] = Artist(
|
||||
id=uuid.uuid4(),
|
||||
name=name,
|
||||
source=None,
|
||||
source_id=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
return self._by_name[name]
|
||||
|
||||
|
||||
@@ -55,6 +62,7 @@ class FakeTrackRepo:
|
||||
metadata_status=str(kw["metadata_status"]),
|
||||
metadata_error=None,
|
||||
enriched_at=None,
|
||||
availability="local",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -139,6 +147,7 @@ async def test_dedup_skips_already_imported() -> None:
|
||||
metadata_status="pending",
|
||||
metadata_error=None,
|
||||
enriched_at=None,
|
||||
availability="local",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Integration tests for the lazy-materialization foundation:
|
||||
``TrackRepository.materialize`` and ``Album``/``ArtistRepository.get_or_create_remote``.
|
||||
|
||||
Requires a reachable Postgres; skips otherwise (same pattern as
|
||||
``test_upload_stream_api.py``).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
from app.infrastructure.db import Base, dispose_engine, get_engine, session_scope
|
||||
from app.infrastructure.db.models.enums import TrackAvailability
|
||||
from app.infrastructure.db.repositories import (
|
||||
SqlAlchemyAlbumRepository,
|
||||
SqlAlchemyArtistRepository,
|
||||
SqlAlchemyTrackRepository,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
_db_reachable_cache: bool | None = None
|
||||
|
||||
|
||||
async def _db_reachable() -> bool:
|
||||
global _db_reachable_cache
|
||||
if _db_reachable_cache is not None:
|
||||
return _db_reachable_cache
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(3):
|
||||
async with get_engine().connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
_db_reachable_cache = True
|
||||
except Exception:
|
||||
_db_reachable_cache = False
|
||||
return _db_reachable_cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db() -> AsyncIterator[None]:
|
||||
if not await _db_reachable():
|
||||
pytest.skip("Postgres not reachable — integration test skipped.")
|
||||
|
||||
async with get_engine().begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield None
|
||||
|
||||
async with get_engine().begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
async def test_placeholder_track_materializes_in_place(db: None) -> None:
|
||||
"""A remote placeholder (no storage) gets its audio fields filled in by
|
||||
``materialize`` without changing ``track.id`` — the stable content id that
|
||||
likes/playlists/queue may already reference."""
|
||||
async with session_scope() as session:
|
||||
artists = SqlAlchemyArtistRepository(session)
|
||||
tracks = SqlAlchemyTrackRepository(session)
|
||||
artist = await artists.get_or_create("Some Artist")
|
||||
|
||||
track_id = uuid.uuid4()
|
||||
placeholder = await tracks.add(
|
||||
id=track_id,
|
||||
title="Remote Track",
|
||||
artist_id=artist.id,
|
||||
storage_uri=None,
|
||||
file_format=None,
|
||||
file_size=None,
|
||||
source="youtube",
|
||||
source_id="abc123",
|
||||
metadata_status="pending",
|
||||
added_by=None,
|
||||
availability=TrackAvailability.REMOTE.value,
|
||||
)
|
||||
assert placeholder.availability == "remote"
|
||||
assert placeholder.storage_uri is None
|
||||
|
||||
materialized = await tracks.materialize(
|
||||
track_id,
|
||||
storage_uri="tracks/ab/abc123.m4a",
|
||||
file_format="m4a",
|
||||
file_size=12345,
|
||||
bitrate=160,
|
||||
)
|
||||
|
||||
assert materialized.id == track_id
|
||||
assert materialized.availability == "local"
|
||||
assert materialized.storage_uri == "tracks/ab/abc123.m4a"
|
||||
assert materialized.file_format == "m4a"
|
||||
assert materialized.file_size == 12345
|
||||
|
||||
|
||||
async def test_artist_get_or_create_remote_dedups_by_remote_id(db: None) -> None:
|
||||
async with session_scope() as session:
|
||||
artists = SqlAlchemyArtistRepository(session)
|
||||
|
||||
first = await artists.get_or_create_remote(
|
||||
name="Daft Punk", source="youtube", source_id="UCabc"
|
||||
)
|
||||
again = await artists.get_or_create_remote(
|
||||
name="Daft Punk (different display name)", source="youtube", source_id="UCabc"
|
||||
)
|
||||
|
||||
assert first.id == again.id
|
||||
assert again.source == "youtube"
|
||||
assert again.source_id == "UCabc"
|
||||
|
||||
|
||||
async def test_artist_get_or_create_remote_binds_existing_local_artist(db: None) -> None:
|
||||
async with session_scope() as session:
|
||||
artists = SqlAlchemyArtistRepository(session)
|
||||
|
||||
local = await artists.get_or_create("Pink Floyd")
|
||||
remote = await artists.get_or_create_remote(
|
||||
name="Pink Floyd", source="youtube", source_id="UCxyz"
|
||||
)
|
||||
|
||||
assert remote.id == local.id
|
||||
assert remote.source == "youtube"
|
||||
assert remote.source_id == "UCxyz"
|
||||
|
||||
|
||||
async def test_album_get_or_create_remote_dedups_by_remote_id(db: None) -> None:
|
||||
async with session_scope() as session:
|
||||
artists = SqlAlchemyArtistRepository(session)
|
||||
albums = SqlAlchemyAlbumRepository(session)
|
||||
artist = await artists.get_or_create("Daft Punk")
|
||||
|
||||
first = await albums.get_or_create_remote(
|
||||
title="Discovery",
|
||||
artist_id=artist.id,
|
||||
year=2001,
|
||||
musicbrainz_id=None,
|
||||
source="youtube",
|
||||
source_id="MPREb_abc",
|
||||
)
|
||||
again = await albums.get_or_create_remote(
|
||||
title="Discovery",
|
||||
artist_id=artist.id,
|
||||
year=None,
|
||||
musicbrainz_id=None,
|
||||
source="youtube",
|
||||
source_id="MPREb_abc",
|
||||
)
|
||||
|
||||
assert first.id == again.id
|
||||
assert again.source == "youtube"
|
||||
assert again.source_id == "MPREb_abc"
|
||||
assert again.year == 2001
|
||||
@@ -42,6 +42,7 @@ def _track(*, metadata_status: str = "pending", title: str = "raw-stem") -> Trac
|
||||
metadata_status=metadata_status,
|
||||
metadata_error=None,
|
||||
enriched_at=None,
|
||||
availability="local",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -67,7 +68,14 @@ class FakeArtistRepo:
|
||||
async def get_or_create(self, name: str) -> Artist:
|
||||
self.created.append(name)
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
return Artist(id=uuid.uuid4(), name=name, created_at=now, updated_at=now)
|
||||
return Artist(
|
||||
id=uuid.uuid4(),
|
||||
name=name,
|
||||
source=None,
|
||||
source_id=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
class FakeAlbumRepo:
|
||||
@@ -88,6 +96,8 @@ class FakeAlbumRepo:
|
||||
year=year,
|
||||
cover_path=self._existing_cover,
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
source=None,
|
||||
source_id=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@@ -149,6 +149,7 @@ def _pending_track() -> Track:
|
||||
metadata_status="pending",
|
||||
metadata_error=None,
|
||||
enriched_at=None,
|
||||
availability="local",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -180,7 +181,14 @@ class _FakeArtistRepo:
|
||||
async def get_or_create(self, name: str) -> Artist:
|
||||
self.created.append(name)
|
||||
now = datetime.now(UTC)
|
||||
return Artist(id=uuid.uuid4(), name=name, created_at=now, updated_at=now)
|
||||
return Artist(
|
||||
id=uuid.uuid4(),
|
||||
name=name,
|
||||
source=None,
|
||||
source_id=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -199,6 +207,8 @@ class _FakeAlbumRepo:
|
||||
year=year,
|
||||
cover_path=None,
|
||||
musicbrainz_id=musicbrainz_id,
|
||||
source=None,
|
||||
source_id=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user