goon/app/api/movies.py
goon-foss ad0284585b Initial commit
Goon — self-hosted aggregator for adult-content scene metadata.

Indexes scenes from TPDB, StashDB, and 30+ public adult tube sites.
Cross-source deduplication via perceptual hash + Levenshtein distance.
FastAPI backend + APScheduler worker + React Native (Expo) mobile client.

FOSS, ad-free, donation-funded. See README for details.
2026-05-20 10:10:22 +02:00

275 lines
9.6 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.models.favorite_movie import FavoriteMovie
from app.models.movie_playback_source import MoviePlaybackSource
from app.models.performer import Performer
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
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,
)