"""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.play_progress import 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() 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)