feat(library): lazy materialization foundation for remote tracks (§Phase1)
Docker Build & Publish / build (push) Successful in 1m10s
Docker Build & Publish / push (push) Failing after 7s
Docker Build & Publish / Prune old image versions (push) Has been skipped

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:
Senko-san
2026-06-14 17:51:43 +03:00
parent 78007461e1
commit 58b98ab5ed
24 changed files with 492 additions and 31 deletions
+10 -1
View File
@@ -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,
)
+10 -1
View File
@@ -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,
)
+156
View File
@@ -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
+11 -1
View File
@@ -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,
)
+11 -1
View File
@@ -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,
)