"""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.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)], 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) 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)], ) -> 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) def _movie_to_out(session: Session, movie: Movie) -> 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, movie.id) is not None progress = session.get(MoviePlayProgress, 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, )