Goon — self-hosted aggregator for adult-content scene metadata. Indexes scenes from TPDB, StashDB, and 30+ public adult tube sites. Cross-source deduplication via perceptual hash + Levenshtein distance. FastAPI backend + APScheduler worker + React Native (Expo) mobile client. FOSS, ad-free, donation-funded. See README for details.
159 lines
4.9 KiB
Python
159 lines
4.9 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.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)
|