Bug-report b207ff17 2026-05-26 ("przydaloby sie oznaczenie filmow juz
obejrzanych" - sceny mialy watched badge + dim, filmom brakowalo).
Backend:
- alembic 0018_movie_play_progress: nowa tabela (mirror scene_play_progress)
- MoviePlayProgress SQLAlchemy model
- MovieOut schema dolane finished/position_sec/last_played_at
- POST+DELETE /movies/{id}/progress endpointy (upsert via pg ON CONFLICT)
- _movie_to_out wstrzykuje progress z DB
Mobile:
- RouteParams.entityKind: 'scene'|'movie' (default scene dla back-compat)
- PlayerScreen NativeVideoPlayer + EmbedWebViewPlayer dispatchuja
upsertProgress vs upsertMovieProgress po entityKind
- MovieDetailScreen przekazuje entityKind='movie' do nav
- MoviePosterCard renderuje dim + check badge + progress bar
(parity ze ScenesScreen pattern)
Wczesniej MovieDetail przekazywal movieId jako sceneId -> backend
/scenes/<movieId>/progress zwracal 404 (silently caught). Po dodaniu
dedykowanego movie endpoint proper routing dziala.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
280 lines
9.9 KiB
Python
280 lines
9.9 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.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,
|
|
)
|