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:
@@ -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
|
||||
Reference in New Issue
Block a user