From 6eb7cdd32086e9e5d510ebc945e8005153da7e0a Mon Sep 17 00:00:00 2001 From: jtrzupek Date: Thu, 28 May 2026 23:24:06 +0200 Subject: [PATCH] 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//progress zwracal 404 (silently caught). Po dodaniu dedykowanego movie endpoint proper routing dziala. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../20260528_0018_movie_play_progress.py | 47 ++++++++++ app/api/movies.py | 5 ++ app/api/schemas.py | 5 ++ app/api/watch.py | 85 ++++++++++++++++++- app/models/__init__.py | 3 +- app/models/play_progress.py | 26 ++++++ mobile/src/api.ts | 11 +++ mobile/src/components/MoviePosterCard.tsx | 51 ++++++++++- mobile/src/navigation.tsx | 4 + mobile/src/screens/MovieDetailScreen.tsx | 8 +- mobile/src/screens/PlayerScreen.tsx | 42 ++++++--- mobile/src/types.ts | 5 ++ 12 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 alembic/versions/20260528_0018_movie_play_progress.py diff --git a/alembic/versions/20260528_0018_movie_play_progress.py b/alembic/versions/20260528_0018_movie_play_progress.py new file mode 100644 index 0000000..6f0b378 --- /dev/null +++ b/alembic/versions/20260528_0018_movie_play_progress.py @@ -0,0 +1,47 @@ +"""movie_play_progress — pozycja odtwarzania per film (continue watching). + +Revision ID: 0018_movie_play_progress +Revises: 0017_drop_realdebrid_cache +Create Date: 2026-05-28 + +Mirror `scene_play_progress`: pojedyncza tabela, PK=movie_id (single-user app). +position_sec + finished + last_played_at. duration_sec mirror z filmu (movies.duration_sec +może być None gdy connector go nie wyciągnął — pozwala na progress_pct mimo to). + +User-report 2026-05-26 (b207ff17): "Tutaj też przydałoby się oznaczenie filmów już +obejrzanych" — sceny mają watched badge + dim, filmów brakowało. +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0018_movie_play_progress" +down_revision: str | None = "0017_drop_realdebrid_cache" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "movie_play_progress", + sa.Column( + "movie_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("movies.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column("position_sec", sa.Integer(), nullable=False, server_default="0"), + sa.Column("duration_sec", sa.Integer(), nullable=True), + sa.Column("finished", sa.Boolean(), nullable=False, server_default="false"), + sa.Column( + "last_played_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_table("movie_play_progress") diff --git a/app/api/movies.py b/app/api/movies.py index 58a9f29..b443c44 100644 --- a/app/api/movies.py +++ b/app/api/movies.py @@ -30,6 +30,7 @@ from app.models.movie import ( from app.models.favorite_movie import FavoriteMovie from app.models.movie_playback_source import MoviePlaybackSource from app.models.performer import Performer +from app.models.play_progress import MoviePlayProgress from app.models.source import Source from app.models.studio import Studio from app.models.tag import Tag @@ -250,6 +251,7 @@ def _movie_to_out(session: Session, movie: Movie) -> MovieOut: playback_sources = [PlaybackSourceOut.model_validate(p) for p in pb_rows] is_fav = session.get(FavoriteMovie, movie.id) is not None + progress = session.get(MoviePlayProgress, movie.id) return MovieOut( id=movie.id, @@ -272,4 +274,7 @@ def _movie_to_out(session: Session, movie: Movie) -> MovieOut: playback_sources=playback_sources, created_at=movie.created_at, is_favorite=is_fav, + last_played_at=progress.last_played_at if progress else None, + finished=progress.finished if progress else False, + position_sec=progress.position_sec if progress else 0, ) diff --git a/app/api/schemas.py b/app/api/schemas.py index 160d8d8..d9396c9 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -118,6 +118,11 @@ class MovieOut(BaseModel): # and MovieDetail favorite star. created_at: datetime | None = None is_favorite: bool = False + # Watched / continue-watching state (mirror SceneOut, bug-report b207ff17 + # 2026-05-26 "przydałoby się oznaczenie filmów już obejrzanych"). + last_played_at: datetime | None = None + finished: bool = False + position_sec: int = 0 class MovieListOut(BaseModel): diff --git a/app/api/watch.py b/app/api/watch.py index 915f9a0..96947c0 100644 --- a/app/api/watch.py +++ b/app/api/watch.py @@ -23,7 +23,8 @@ 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.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)]) @@ -116,6 +117,88 @@ def remove_progress( 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 diff --git a/app/models/__init__.py b/app/models/__init__.py index 53888df..21eff72 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -17,7 +17,7 @@ from app.models.movie import ( ) from app.models.movie_playback_source import MoviePlaybackSource from app.models.performer import Performer, PerformerAlias, PerformerExternalRef -from app.models.play_progress import ScenePlayProgress +from app.models.play_progress import MoviePlayProgress, ScenePlayProgress from app.models.playback_source import PlaybackSource from app.models.scene import ( Scene, @@ -52,6 +52,7 @@ __all__ = [ "Performer", "PerformerAlias", "PerformerExternalRef", + "MoviePlayProgress", "ScenePlayProgress", "PlaybackSource", "Scene", diff --git a/app/models/play_progress.py b/app/models/play_progress.py index ceb003a..a4083ee 100644 --- a/app/models/play_progress.py +++ b/app/models/play_progress.py @@ -31,3 +31,29 @@ class ScenePlayProgress(Base): last_played_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) + + +class MoviePlayProgress(Base): + """Pozycja odtwarzania per film. Mirror ScenePlayProgress dla movies. + + User-report 2026-05-26 (b207ff17): "przydałoby się oznaczenie filmów już + obejrzanych" — sceny mają watched badge, filmów brakowało. + """ + + __tablename__ = "movie_play_progress" + + movie_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("movies.id", ondelete="CASCADE"), + primary_key=True, + ) + position_sec: Mapped[int] = mapped_column( + Integer, nullable=False, server_default="0", default=0 + ) + duration_sec: Mapped[int | None] = mapped_column(Integer) + finished: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default="false", default=False + ) + last_played_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 6f872c1..de700da 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -477,6 +477,17 @@ export class GoonClient { }); } + // Movie progress — mirror upsertProgress dla movies. Backend dodał 2026-05-28. + async upsertMovieProgress( + movieId: string, + body: { position_sec: number; duration_sec?: number; finished?: boolean }, + ): Promise { + return this.request(`/movies/${movieId}/progress`, { + method: 'POST', + body: JSON.stringify(body), + }); + } + async listRecentWatch(limit = 10): Promise { return this.request(`/watch/recent?limit=${limit}`); } diff --git a/mobile/src/components/MoviePosterCard.tsx b/mobile/src/components/MoviePosterCard.tsx index ac7c4a8..8e13370 100644 --- a/mobile/src/components/MoviePosterCard.tsx +++ b/mobile/src/components/MoviePosterCard.tsx @@ -16,13 +16,25 @@ export function MoviePosterCard({ onPress: () => void; }) { const studio = movie.studio?.name; + // Watched indicator (parytet ze ScenesScreen): finished=true → dim posteru + + // ✓ badge w prawym górnym. Pośredni progress bez dim'a (user może wrócić). + // bug-report b207ff17 2026-05-26. + const dim = movie.finished === true; + const progressPct = + !dim && movie.position_sec && movie.duration_sec && movie.duration_sec > 0 + ? Math.min(100, Math.round((movie.position_sec / movie.duration_sec) * 100)) + : 0; return ( {movie.poster_url ? ( - + ) : ( - + {movie.title} )} @@ -36,8 +48,18 @@ export function MoviePosterCard({ NEW ) : null} + {dim ? ( + + + + ) : null} + {progressPct > 0 ? ( + + + + ) : null} - + {movie.title} @@ -80,6 +102,29 @@ const styles = StyleSheet.create({ paddingVertical: 2, }, newBadgeText: { color: theme.fg, fontSize: 10, fontWeight: '800', letterSpacing: 0.5 }, + posterDimmed: { opacity: 0.45 }, + watchedBadge: { + position: 'absolute', + bottom: 8, + right: 8, + backgroundColor: 'rgba(0,0,0,0.7)', + borderRadius: 999, + width: 22, + height: 22, + alignItems: 'center', + justifyContent: 'center', + }, + watchedBadgeText: { color: theme.fg, fontSize: 12, fontWeight: '700' }, + progressBg: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: 3, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + progressFg: { height: 3, backgroundColor: theme.accent }, title: { color: theme.fg, fontSize: 13, fontWeight: '600', marginTop: 6 }, + titleDimmed: { color: theme.muted }, meta: { color: theme.muted, fontSize: 11, marginTop: 2 }, }); diff --git a/mobile/src/navigation.tsx b/mobile/src/navigation.tsx index 7c89f94..cbee240 100644 --- a/mobile/src/navigation.tsx +++ b/mobile/src/navigation.tsx @@ -52,6 +52,10 @@ export type RootStackParamList = { Player: { url: string; sceneId: string; + // 'movie' = MovieDetail wywołał Player z movieId zamiast sceneId. Backend + // ma /movies/{id}/progress oddzielnie od /scenes/{id}/progress (2026-05-28). + // Default 'scene' dla back-compat z istniejącymi nav callami. + entityKind?: 'scene' | 'movie'; durationSec?: number | null; refererHost?: string; title?: string; diff --git a/mobile/src/screens/MovieDetailScreen.tsx b/mobile/src/screens/MovieDetailScreen.tsx index 3f40f26..e648f2e 100644 --- a/mobile/src/screens/MovieDetailScreen.tsx +++ b/mobile/src/screens/MovieDetailScreen.tsx @@ -208,6 +208,7 @@ function WatchChip({ navigation.navigate('Player', { url: p.stream_url || p.embed_url || pb.page_url, sceneId: movieId, + entityKind: 'movie', durationSec: pb.duration_sec ?? null, title: `${title} — ${(p.raw as any).part_label ?? p.quality}`, mode: p.stream_url ? 'video' : 'webview', @@ -225,9 +226,12 @@ function WatchChip({ const fallbackEmbed = res.best?.embed_url || pb.embed_url || pb.page_url; navigation.navigate('Player', { url: target, - // Player używa sceneId do progress tracking; movies progress przyjdzie później. - // Na razie pass movie id — backend /scenes/{id}/progress zwróci 404, mobile silently catch. + // sceneId pozostaje nazwą param-u (legacy z kiedy Player obsługiwał tylko sceny), + // ale dla entityKind='movie' Player rzutuje to do /movies/{id}/progress. + // Bug-report b207ff17 2026-05-26 ("oznaczenie obejrzanych filmów") — backend + // dostał movie_play_progress 2026-05-28. sceneId: movieId, + entityKind: 'movie', durationSec: pb.duration_sec ?? null, title, mode: res.best?.stream_url ? 'video' : 'webview', diff --git a/mobile/src/screens/PlayerScreen.tsx b/mobile/src/screens/PlayerScreen.tsx index d0668c8..725ad85 100644 --- a/mobile/src/screens/PlayerScreen.tsx +++ b/mobile/src/screens/PlayerScreen.tsx @@ -25,6 +25,12 @@ import { theme } from '../theme'; interface RouteParams { url: string; sceneId: string; + // 'scene' (default — back-compat z istniejącymi nav callami) lub 'movie'. + // Player dispatcheruje upsertProgress vs upsertMovieProgress. Wcześniej + // MovieDetail przekazywał movieId jako sceneId — backend /scenes// + // progress zwracał 404 (silently caught). Po dodaniu /movies/{id}/progress + // (2026-05-28) mamy proper routing. + entityKind?: 'scene' | 'movie'; durationSec?: number | null; refererHost?: string; title?: string; @@ -79,7 +85,15 @@ export function PlayerScreen() { function NativeVideoPlayer({ params }: { params: RouteParams }) { const client = useClient(); const nav = useNavigation>(); - const { url, sceneId, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params; + const { url, sceneId, entityKind, durationSec, refererHost, title, fallbackEmbedUrl, headers: paramHeaders, fallbackProxyUrl } = params; + // 'movie' → /movies/{id}/progress, 'scene' (default) → /scenes/{id}/progress. + const upsertProgress = React.useCallback( + (body: { position_sec: number; duration_sec?: number; finished?: boolean }) => + entityKind === 'movie' + ? client.upsertMovieProgress(sceneId, body) + : client.upsertProgress(sceneId, body), + [client, sceneId, entityKind], + ); const source: VideoSource = React.useMemo(() => { // Backend dostarczone headers (Referer + UA z extractor) mają precedencję — @@ -166,8 +180,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { const durInt = Math.floor(dur || durationSec || 0) || null; if (posInt > 0 && Math.abs(posInt - lastReportedRef.current) >= 10) { lastReportedRef.current = posInt; - client - .upsertProgress(sceneId, { + upsertProgress({ position_sec: posInt, duration_sec: durInt ?? undefined, // "Watched" = email-style "read": min. 30s aktywnej odtwarzania (bo 1 sec @@ -188,8 +201,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { const pos = Math.floor(player.currentTime || 0); const dur = Math.floor(player.duration || durationSec || 0) || null; if (pos > 0) { - client - .upsertProgress(sceneId, { + upsertProgress({ position_sec: pos, duration_sec: dur ?? undefined, finished: pos >= 30, @@ -200,7 +212,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { // best-effort } }; - }, [player, sceneId, durationSec, client, knownDuration]); + }, [player, sceneId, durationSec, upsertProgress, knownDuration]); // ----- controls visibility (auto-hide po 3.5s bez interakcji) ---------------- const [controlsVisible, setControlsVisible] = React.useState(true); @@ -940,7 +952,15 @@ const INJECTED_JS = ` function EmbedWebViewPlayer({ params }: { params: RouteParams }) { const client = useClient(); const nav = useNavigation>(); - const { url, sceneId, durationSec, refererHost } = params; + const { url, sceneId, entityKind, durationSec, refererHost } = params; + // Dispatch dla movie vs scene progress endpoint (jak w NativeVideoPlayer). + const upsertWatchProgress = React.useCallback( + (body: { position_sec: number; duration_sec?: number; finished?: boolean }) => + entityKind === 'movie' + ? client.upsertMovieProgress(sceneId, body) + : client.upsertProgress(sceneId, body), + [client, sceneId, entityKind], + ); const [extractedUrl, setExtractedUrl] = React.useState(null); const [resolveStatus, setResolveStatus] = React.useState<'idle' | 'pending' | 'failed'>('idle'); const [resolveError, setResolveError] = React.useState(null); @@ -1055,10 +1075,10 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) { }, [extractedUrl, nav, sceneId, durationSec, refererHost]); React.useEffect(() => { - client - .upsertProgress(sceneId, { position_sec: 0, duration_sec: durationSec ?? undefined }) - .catch(() => {}); - }, [client, sceneId, durationSec]); + upsertWatchProgress({ position_sec: 0, duration_sec: durationSec ?? undefined }).catch( + () => {}, + ); + }, [upsertWatchProgress, durationSec]); // WebView blokuje cross-origin nav (popunder ad opens new tab/page poza embeddem). // Pierwsze ładowanie (`url`) ma `navigationType=undefined` lub `other` — przepuszczamy. diff --git a/mobile/src/types.ts b/mobile/src/types.ts index 3e5b70c..c79320a 100644 --- a/mobile/src/types.ts +++ b/mobile/src/types.ts @@ -197,6 +197,11 @@ export interface MovieOut { playback_sources: PlaybackSource[]; created_at?: string | null; is_favorite?: boolean; + // Watched / continue-watching state (mirror SceneOut). Backend zaczął + // zwracać 2026-05-28 — pre-0.2.0 buildy zostawią defaults (false/0/null). + last_played_at?: string | null; + finished?: boolean; + position_sec?: number; } export interface MovieListOut {