goon/app/api/watch.py
jtrzupek 6eb7cdd320 feat(movies): watched/continue-watching tracking end-to-end
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>
2026-05-28 23:24:06 +02:00

242 lines
7.3 KiB
Python

"""Watch history + continue watching.
Single-user. Mobile pingu POST /scenes/{id}/progress przy:
- Klik Watch (position_sec=0) — wciąga scenę do recent watch
- Powrót z MX z ACTION_RESULT (gdy włączone EXTRA_RETURN_RESULT) — z faktyczną pozycją
Continue watching rail na home: GET /watch/recent?limit=10 → top scen po last_played_at,
filtruje dead-finished (>=95% lub flag finished). Mobile pokazuje progress bar
(position_sec / duration_sec).
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.api.scenes import _build_scene_out
from app.api.schemas import SceneOut
from app.auth import require_api_key
from app.db import get_session
from app.models.movie import Movie
from app.models.play_progress import MoviePlayProgress, ScenePlayProgress
from app.models.scene import Scene
router = APIRouter(tags=["watch"], dependencies=[Depends(require_api_key)])
class ProgressIn(BaseModel):
position_sec: int = 0
duration_sec: int | None = None
finished: bool = False
class ProgressOut(BaseModel):
scene_id: uuid.UUID
position_sec: int
duration_sec: int | None
finished: bool
last_played_at: datetime
@router.post("/scenes/{scene_id}/progress", response_model=ProgressOut)
def upsert_progress(
scene_id: uuid.UUID,
body: ProgressIn,
session: Annotated[Session, Depends(get_session)],
) -> ProgressOut:
if session.get(Scene, scene_id) is None:
raise HTTPException(status_code=404, detail="scene not found")
# PG upsert — eliminuje race condition gdy mobile wysyła progress równolegle
# (np. 2 instancje playera lub auto-save + manual save). Wcześniej `get → add →
# commit` rzucało IntegrityError(pk_scene_play_progress) przy concurrent writes.
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC)
position_sec = max(0, body.position_sec)
finished = body.finished or (
bool(body.duration_sec)
and body.duration_sec > 0
and position_sec >= int(body.duration_sec * 0.95)
)
stmt = (
pg_insert(ScenePlayProgress)
.values(
scene_id=scene_id,
position_sec=position_sec,
duration_sec=body.duration_sec,
finished=finished,
last_played_at=now,
)
.on_conflict_do_update(
index_elements=["scene_id"],
set_={
"position_sec": position_sec,
# duration_sec: zachowaj istniejący gdy body nie podaje
"duration_sec": (
body.duration_sec
if body.duration_sec is not None
else ScenePlayProgress.duration_sec
),
"finished": finished,
"last_played_at": now,
},
)
)
session.execute(stmt)
session.commit()
row = session.get(ScenePlayProgress, scene_id)
assert row is not None
return ProgressOut(
scene_id=scene_id,
position_sec=row.position_sec,
duration_sec=row.duration_sec,
finished=row.finished,
last_played_at=row.last_played_at,
)
@router.delete(
"/scenes/{scene_id}/progress",
status_code=status.HTTP_204_NO_CONTENT,
)
def remove_progress(
scene_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> None:
row = session.get(ScenePlayProgress, scene_id)
if row is None:
return
session.delete(row)
session.commit()
# ---- Movie progress (mirror scen) ------------------------------------------
class MovieProgressOut(BaseModel):
movie_id: uuid.UUID
position_sec: int
duration_sec: int | None
finished: bool
last_played_at: datetime
@router.post("/movies/{movie_id}/progress", response_model=MovieProgressOut)
def upsert_movie_progress(
movie_id: uuid.UUID,
body: ProgressIn,
session: Annotated[Session, Depends(get_session)],
) -> MovieProgressOut:
"""Mirror upsert_progress dla filmów (bug-report b207ff17 2026-05-26 —
"przydałoby się oznaczenie filmów już obejrzanych")."""
if session.get(Movie, movie_id) is None:
raise HTTPException(status_code=404, detail="movie not found")
from sqlalchemy.dialects.postgresql import insert as pg_insert
now = datetime.now(UTC)
position_sec = max(0, body.position_sec)
finished = body.finished or (
bool(body.duration_sec)
and body.duration_sec > 0
and position_sec >= int(body.duration_sec * 0.95)
)
stmt = (
pg_insert(MoviePlayProgress)
.values(
movie_id=movie_id,
position_sec=position_sec,
duration_sec=body.duration_sec,
finished=finished,
last_played_at=now,
)
.on_conflict_do_update(
index_elements=["movie_id"],
set_={
"position_sec": position_sec,
"duration_sec": (
body.duration_sec
if body.duration_sec is not None
else MoviePlayProgress.duration_sec
),
"finished": finished,
"last_played_at": now,
},
)
)
session.execute(stmt)
session.commit()
row = session.get(MoviePlayProgress, movie_id)
assert row is not None
return MovieProgressOut(
movie_id=movie_id,
position_sec=row.position_sec,
duration_sec=row.duration_sec,
finished=row.finished,
last_played_at=row.last_played_at,
)
@router.delete(
"/movies/{movie_id}/progress",
status_code=status.HTTP_204_NO_CONTENT,
)
def remove_movie_progress(
movie_id: uuid.UUID,
session: Annotated[Session, Depends(get_session)],
) -> None:
row = session.get(MoviePlayProgress, movie_id)
if row is None:
return
session.delete(row)
session.commit()
class WatchEntry(BaseModel):
scene: SceneOut
position_sec: int
duration_sec: int | None
finished: bool
last_played_at: datetime
class WatchListOut(BaseModel):
items: list[WatchEntry]
@router.get("/watch/recent", response_model=WatchListOut)
def list_recent(
session: Annotated[Session, Depends(get_session)],
limit: int = Query(default=10, ge=1, le=50),
include_finished: bool = Query(default=False),
) -> WatchListOut:
"""Top-N scen po last_played_at desc. Domyślnie pomija sceny finished
(user nie chce widzieć już dograne w continue rail)."""
stmt = (
select(ScenePlayProgress, Scene)
.join(Scene, Scene.id == ScenePlayProgress.scene_id)
.order_by(desc(ScenePlayProgress.last_played_at))
.limit(limit)
)
if not include_finished:
stmt = stmt.where(ScenePlayProgress.finished.is_(False))
items: list[WatchEntry] = []
for prog, scene in session.execute(stmt).all():
items.append(
WatchEntry(
scene=_build_scene_out(session, scene),
position_sec=prog.position_sec,
duration_sec=prog.duration_sec,
finished=prog.finished,
last_played_at=prog.last_played_at,
)
)
return WatchListOut(items=items)