"""Shared data access for the polymorphic satellite tables — credits, artwork, videos, external IDs — that attach to more than one entity type via an (entity_type, entity_id) pair rather than a typed foreign key. """ from __future__ import annotations import uuid from collections.abc import Sequence from typing import Protocol from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.artwork import Artwork from app.models.credit import Credit from app.models.enums import ArtworkType from app.models.external_id import ExternalID from app.models.provider import Provider from app.models.video import Video _VALID_ARTWORK_TYPES = {t.value for t in ArtworkType} class _NormalizedArtworkLike(Protocol): """Structural type for app.services.metadata.normalizer.NormalizedArtwork — a repository shouldn't import from the services layer, so this describes the shape `sync_artwork` needs instead of the concrete class. """ artwork_type: str url: str provider_asset_id: str | None width: int | None height: int | None aspect_ratio: float | None vote_average: float | None is_primary: bool async def get_external_ids( session: AsyncSession, entity_type: str, entity_id: uuid.UUID ) -> dict[str, str]: stmt = ( select(Provider.slug, ExternalID.external_id) .join(Provider, Provider.id == ExternalID.provider_id) .where(ExternalID.entity_type == entity_type, ExternalID.entity_id == entity_id) ) result = await session.execute(stmt) return {slug: external_id for slug, external_id in result.all()} async def get_external_ids_bulk( session: AsyncSession, entity_type: str, entity_ids: list[uuid.UUID] ) -> dict[uuid.UUID, dict[str, str]]: if not entity_ids: return {} stmt = ( select(ExternalID.entity_id, Provider.slug, ExternalID.external_id) .join(Provider, Provider.id == ExternalID.provider_id) .where(ExternalID.entity_type == entity_type, ExternalID.entity_id.in_(entity_ids)) ) result = await session.execute(stmt) out: dict[uuid.UUID, dict[str, str]] = {eid: {} for eid in entity_ids} for entity_id, slug, external_id in result.all(): out.setdefault(entity_id, {})[slug] = external_id return out async def get_primary_artwork_ids( session: AsyncSession, entity_type: str, entity_ids: list[uuid.UUID] ) -> list[uuid.UUID]: """IDs of artwork rows marked `is_primary` for any of `entity_ids` — the small subset of a title's images (TMDb's own selected poster/backdrop/ profile) that get auto-downloaded for local hosting, out of the dozens of alternates ingestion also stores. See app/services/artwork/.""" if not entity_ids: return [] stmt = select(Artwork.id).where( Artwork.entity_type == entity_type, Artwork.entity_id.in_(entity_ids), Artwork.is_primary.is_(True), ) result = await session.execute(stmt) return list(result.scalars().all()) async def sync_artwork( session: AsyncSession, *, entity_type: str, entity_id: uuid.UUID, provider_id: uuid.UUID, artwork_list: Sequence[_NormalizedArtworkLike], ) -> None: """Upsert an entity's artwork rows keyed by `provider_asset_id` (TMDb's stable per-image file path), instead of deleting and recreating them on every re-sync. This matters specifically because of `app/services/artwork/` (section 10's self-hosting): a delete-then-recreate replace would generate a fresh Artwork.id with `local_path=None` every time a title gets re-ingested, silently throwing away every previously-downloaded local copy and forcing a full re-download on each sync — defeating the point of caching them locally at all. Rows for images no longer present in `artwork_list` (dropped from the provider's alternates) are still deleted, same as before. """ existing = ( ( await session.execute( select(Artwork).where( Artwork.entity_type == entity_type, Artwork.entity_id == entity_id ) ) ) .scalars() .all() ) existing_by_asset_id = { row.provider_asset_id: row for row in existing if row.provider_asset_id is not None } seen_asset_ids: set[str] = set() for art in artwork_list: art_type = art.artwork_type if art.artwork_type in _VALID_ARTWORK_TYPES else "poster" matched = ( existing_by_asset_id.get(art.provider_asset_id) if art.provider_asset_id else None ) if matched is not None: seen_asset_ids.add(art.provider_asset_id) # type: ignore[arg-type] matched.artwork_type = ArtworkType(art_type) matched.width = art.width matched.height = art.height matched.aspect_ratio = art.aspect_ratio matched.vote_average = art.vote_average matched.is_primary = art.is_primary matched.original_url = art.url # local_path/checksum deliberately untouched — see docstring. else: session.add( Artwork( entity_type=entity_type, entity_id=entity_id, artwork_type=ArtworkType(art_type), width=art.width, height=art.height, aspect_ratio=art.aspect_ratio, provider_id=provider_id, provider_asset_id=art.provider_asset_id, original_url=art.url, vote_average=art.vote_average, is_primary=art.is_primary, ) ) if art.provider_asset_id: seen_asset_ids.add(art.provider_asset_id) for row in existing: if row.provider_asset_id not in seen_asset_ids: await session.delete(row) await session.flush() async def get_artwork( session: AsyncSession, entity_type: str, entity_id: uuid.UUID, artwork_type: ArtworkType | None = None, ) -> list[Artwork]: stmt = ( select(Artwork) .options(selectinload(Artwork.provider)) .where(Artwork.entity_type == entity_type, Artwork.entity_id == entity_id) .order_by(Artwork.is_primary.desc(), Artwork.vote_average.desc().nulls_last()) ) if artwork_type is not None: stmt = stmt.where(Artwork.artwork_type == artwork_type) result = await session.execute(stmt) return list(result.scalars().all()) async def get_videos(session: AsyncSession, entity_type: str, entity_id: uuid.UUID) -> list[Video]: stmt = ( select(Video) .options(selectinload(Video.provider)) .where(Video.entity_type == entity_type, Video.entity_id == entity_id) .order_by(Video.is_official.desc(), Video.published_at.desc().nulls_last()) ) result = await session.execute(stmt) return list(result.scalars().all()) async def get_credits( session: AsyncSession, entity_type: str, entity_id: uuid.UUID ) -> list[Credit]: stmt = ( select(Credit) .options(selectinload(Credit.person)) .where(Credit.entity_type == entity_type, Credit.entity_id == entity_id) .order_by(Credit.billing_order.asc().nulls_last()) ) result = await session.execute(stmt) return list(result.scalars().all())