goon/app/api/watch.py
goon-foss a4486980a4 style: myslniki em/en z komentarzy i docstringow (calosc repo)
1745 wystapien w 291 plikach (backend, scripts, mobile). Dominowal jeden ksztalt,
' — ' laczace zdania, wiec poszedl przecinek; myslnik otwierajacy mysl zdjety;
zakresy 0–5 i A–Z na dywiz.

Podzial komentarz/kod robil skaner stanow, nie regex, zeby nie ruszyc myslnika
stojacego w stringu albo w wyrazeniu regularnym. Potwierdzone maszynowo: po wycieciu
komentarzy z wersji HEAD i z dysku obie sa identyczne we wszystkich 291 plikach,
czyli poza komentarzami nie zmienilo sie nic. 233 pliki .py parsuja sie bez bledu,
tsc ma te same 11 bledow w tych samych 3 plikach co przed zmiana.

Dwie rzeczy wymagaly poprawki po automacie: wyrownane listy definicji, gdzie myslnik
byl separatorem kolumny (37 miejsc, kolumna przywrocona), i trzy przeniesienia mysli
do nowej linii, gdzie zostawal wiszacy przecinek na poczatku (poprawione recznie).

Bundla nie republikuje: Hermes i tak nie niesie komentarzy, wiec dla uzytkownika
to zero zmiany.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:51:57 +02:00

251 lines
7.8 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.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)