58b98ab5ed
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>
157 lines
5.0 KiB
Python
157 lines
5.0 KiB
Python
"""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
|