"""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.device import get_device_id 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)], device_id: Annotated[str, Depends(get_device_id)], ) -> 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( device_id=device_id, 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=["device_id", "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, (device_id, 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)], device_id: Annotated[str, Depends(get_device_id)], ) -> None: row = session.get(ScenePlayProgress, (device_id, 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)], device_id: Annotated[str, Depends(get_device_id)], ) -> 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( device_id=device_id, 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=["device_id", "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, (device_id, 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)], device_id: Annotated[str, Depends(get_device_id)], ) -> None: row = session.get(MoviePlayProgress, (device_id, 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)], device_id: Annotated[str, Depends(get_device_id)], 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) .where(ScenePlayProgress.device_id == device_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, device_id=device_id), position_sec=prog.position_sec, duration_sec=prog.duration_sec, finished=prog.finished, last_played_at=prog.last_played_at, ) ) return WatchListOut(items=items)