goon/app/api/movies.py
jtrzupek c8baa11604 feat(api): device-scope user state (favorites/progress/blacklists)
Public instance has no accounts, so all user state was GLOBAL in DB — new users
saw/overwrote each other's (and Jan's) favorites, watched badges and blacklists
(bug 2026-06-10). Add device_id (VARCHAR 64) to 9 state tables with composite PK
(device_id, entity_id); app sends X-Device-Id header (get_device_id dep). All
favorites/scene-favorites/blacklist/watch + scene&movie list/detail (is_favorite,
watched, blacklist-hide) now filter by device. Existing rows backfilled to
'legacy-shared'; POST /me/adopt-legacy reassigns them to the caller once. Old
clients (no header) map to legacy-shared so they keep working until OTA updates.

Migration 0022: add col, backfill, composite PK. Verified on prod: 967 progress
rows preserved, device isolation holds (new device sees none of legacy state).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:58:01 +02:00

283 lines
10 KiB
Python

"""GET /movies — lista i szczegóły filmów."""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session
from app.api.schemas import (
ExternalRefOut,
MovieChapterOut,
MovieListOut,
MovieOut,
PerformerOut,
PlaybackSourceOut,
StudioOut,
TagOut,
)
from app.auth import require_api_key
from app.db import get_session
from app.models.movie import (
Movie,
MovieChapter,
MovieExternalRef,
MoviePerformer,
MovieTag,
)
from app.api.device import LEGACY_DEVICE, get_device_id
from app.models.favorite_movie import FavoriteMovie
from app.models.movie_playback_source import MoviePlaybackSource
from app.models.performer import Performer
from app.models.play_progress import MoviePlayProgress
from app.models.source import Source
from app.models.studio import Studio
from app.models.tag import Tag
router = APIRouter(prefix="/movies", tags=["movies"], dependencies=[Depends(require_api_key)])
_VALID_SORTS = {"created_at", "release_year", "release_date", "title", "rating"}
def _split_csv(raw: str | None) -> list[str]:
if not raw:
return []
return [s.strip() for s in raw.split(",") if s.strip()]
@router.get("", response_model=MovieListOut)
def list_movies(
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
q: str | None = Query(default=None, description="Title search (trgm)"),
studio_slugs: str | None = Query(default=None, description="Comma-separated studio slugs (OR)"),
tags: str | None = Query(default=None, description="Comma-separated tag slugs (AND)"),
performer_ids: str | None = Query(default=None, description="Comma-separated performer UUIDs (AND)"),
year_from: int | None = Query(default=None, ge=1900, le=2100),
year_to: int | None = Query(default=None, ge=1900, le=2100),
has_playback: bool | None = Query(default=None),
sort: str = Query(default="created_at"),
page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=200),
) -> MovieListOut:
if sort not in _VALID_SORTS:
raise HTTPException(status_code=400, detail=f"sort must be one of {sorted(_VALID_SORTS)}")
base = select(Movie)
if q:
base = base.where(Movie.title_normalized.ilike(f"%{q.lower()}%"))
studio_slug_list = _split_csv(studio_slugs)
if studio_slug_list:
base = base.where(
Movie.studio_id.in_(select(Studio.id).where(Studio.slug.in_(studio_slug_list)))
)
for slug in _split_csv(tags):
base = base.where(
exists(
select(1).select_from(MovieTag).join(Tag, Tag.id == MovieTag.tag_id)
.where(MovieTag.movie_id == Movie.id, Tag.slug == slug)
)
)
perf_id_strings = _split_csv(performer_ids)
if perf_id_strings:
try:
perf_ids = [uuid.UUID(s) for s in perf_id_strings]
except ValueError as e:
raise HTTPException(status_code=400, detail=f"invalid performer UUID: {e}") from e
for pid in perf_ids:
base = base.where(
exists(
select(1).select_from(MoviePerformer).where(
MoviePerformer.movie_id == Movie.id,
MoviePerformer.performer_id == pid,
)
)
)
if year_from is not None:
base = base.where(Movie.release_year >= year_from)
if year_to is not None:
base = base.where(Movie.release_year <= year_to)
if has_playback is True:
base = base.where(
exists(
select(1).where(
MoviePlaybackSource.movie_id == Movie.id,
MoviePlaybackSource.dead_at.is_(None),
)
)
)
total = session.execute(
select(func.count()).select_from(base.subquery())
).scalar_one()
if sort == "created_at":
base = base.order_by(Movie.created_at.desc())
elif sort == "release_year":
base = base.order_by(Movie.release_year.desc().nulls_last(), Movie.created_at.desc())
elif sort == "release_date":
base = base.order_by(Movie.release_date.desc().nulls_last(), Movie.created_at.desc())
elif sort == "title":
base = base.order_by(Movie.title_normalized.asc())
elif sort == "rating":
base = base.order_by(Movie.rating.desc().nulls_last(), Movie.created_at.desc())
base = base.limit(per_page).offset((page - 1) * per_page)
movies = session.execute(base).scalars().all()
items = [_movie_to_out(session, m, device_id=device_id) for m in movies]
return MovieListOut(items=items, total=total, page=page, per_page=per_page)
# Movie playback origin policy — module-level (kiedyś było inline per-request
# definition, code-review #19 — perf hit + dorosły kod).
# Ranking ustalony ad-hoc 2026-05-09 (extract_stream_from_hoster na 5 sample
# random per origin).
_MOVIE_PREFERRED_ORIGINS = (
"mangoporn:luluvid", # KVS, działa
"mangoporn:mixdrop", # po domain fix może działać
"mangoporn:voe", # czasem yt-dlp łapie
"mangoporn",
"streamporn",
"pandamovies",
)
# File hosters które NIGDY nie dadzą się stream-extract bez premium account —
# odfiltrowywane całkowicie (zaśmiecały listę watch options, bug-report
# 2026-05-15). Streamtape przywrócony 2026-05-15 — ma dedicated extractor,
# ~5% URLów żyje.
_MOVIE_DROP_ORIGINS = frozenset({
"mangoporn:rapidgator",
"mangoporn:nitroflare",
"mangoporn:frdl",
})
# Raw landing origins ukrywane gdy są sub-hosters (zob. komentarz w get_movie).
_MOVIE_LANDING_HIDE = frozenset({"mangoporn", "pandamovies", "streamporn"})
def _movie_origin_priority(origin: str) -> int:
try:
return _MOVIE_PREFERRED_ORIGINS.index(origin)
except ValueError:
return 500 # neutralne (paradisehill, mangoporn:* nieklasyfikowane)
@router.get("/{movie_id}", response_model=MovieOut)
def get_movie(
movie_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
device_id: Annotated[str, Depends(get_device_id)],
) -> MovieOut:
movie = session.get(Movie, movie_id)
if movie is None:
raise HTTPException(status_code=404, detail="movie not found")
return _movie_to_out(session, movie, device_id=device_id)
def _movie_to_out(session: Session, movie: Movie, *, device_id: str = LEGACY_DEVICE) -> MovieOut:
studio_out: StudioOut | None = None
if movie.studio_id:
studio = session.get(Studio, movie.studio_id)
if studio is not None:
studio_out = StudioOut.model_validate(studio)
performer_rows = session.execute(
select(Performer, MoviePerformer.as_alias)
.join(MoviePerformer, MoviePerformer.performer_id == Performer.id)
.where(MoviePerformer.movie_id == movie.id)
.order_by(MoviePerformer.position.asc().nulls_last())
).all()
performers = [
PerformerOut(
id=p.id,
canonical_name=p.canonical_name,
slug=p.slug,
gender=p.gender.value if p.gender else None,
as_alias=alias,
)
for p, alias in performer_rows
]
tag_rows = session.execute(
select(Tag).join(MovieTag, MovieTag.tag_id == Tag.id)
.where(MovieTag.movie_id == movie.id)
.order_by(Tag.name.asc())
).scalars().all()
tags = [TagOut.model_validate(t) for t in tag_rows]
chapter_rows = session.execute(
select(MovieChapter).where(MovieChapter.movie_id == movie.id)
.order_by(MovieChapter.chapter_index.asc())
).scalars().all()
chapters = [MovieChapterOut.model_validate(c) for c in chapter_rows]
ref_rows = session.execute(
select(MovieExternalRef, Source.name)
.join(Source, Source.id == MovieExternalRef.source_id)
.where(MovieExternalRef.movie_id == movie.id)
).all()
external_refs = [
ExternalRefOut(
source=name,
external_id=ref.external_id,
url=ref.url,
last_seen=ref.last_seen,
)
for ref, name in ref_rows
]
pb_rows = session.execute(
select(MoviePlaybackSource)
.where(MoviePlaybackSource.movie_id == movie.id)
.where(MoviePlaybackSource.dead_at.is_(None))
.order_by(MoviePlaybackSource.created_at.desc())
).scalars().all()
pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_DROP_ORIGINS]
# Bug-report 2026-05-16: raw landing origins (`mangoporn`/`pandamovies`/
# `streamporn` BEZ `:host`) otwierały WebView z reklamami pełnoekranowymi
# i myliły usera. Ukrywamy raw landing GDY ten sam movie ma co najmniej
# jeden sub-host entry (origin zawiera `:`). Jeśli movie nie ma sub-hosters
# (bo theme HTML się zmienił lub regex nie złapał), zostawiamy landing jako
# last-resort.
has_subhost = any(":" in p.origin for p in pb_rows)
if has_subhost:
pb_rows = [p for p in pb_rows if p.origin not in _MOVIE_LANDING_HIDE]
pb_rows = sorted(pb_rows, key=lambda p: _movie_origin_priority(p.origin))
playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows]
is_fav = session.get(FavoriteMovie, (device_id, movie.id)) is not None
progress = session.get(MoviePlayProgress, (device_id, movie.id))
return MovieOut(
id=movie.id,
title=movie.title,
slug=movie.slug,
release_year=movie.release_year,
release_date=movie.release_date,
duration_sec=movie.duration_sec,
description=movie.description,
director=movie.director,
country=movie.country,
rating=movie.rating,
poster_url=movie.poster_url,
backdrop_url=movie.backdrop_url,
studio=studio_out,
performers=performers,
tags=tags,
chapters=chapters,
external_refs=external_refs,
playback_sources=playback_sources,
created_at=movie.created_at,
is_favorite=is_fav,
last_played_at=progress.last_played_at if progress else None,
finished=progress.finished if progress else False,
position_sec=progress.position_sec if progress else 0,
)