Files
Aurora-Core/app/repositories/polymorphic.py
T

209 lines
7.3 KiB
Python
Raw Normal View History

2026-09-04 17:34:03 -05:00
"""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
2026-09-04 17:34:03 -05:00
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
2026-09-04 17:34:03 -05:00
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()
2026-09-04 17:34:03 -05:00
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())