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/<movieId>/progress zwracal 404 (silently caught). Po dodaniu
dedykowanego movie endpoint proper routing dziala.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-05-28 23:24:06 +02:00
parent 6ee0516e62
commit 6eb7cdd320
12 changed files with 274 additions and 18 deletions

View file

@ -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")

View file

@ -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,
)

View file

@ -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):

View file

@ -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

View file

@ -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",

View file

@ -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
)

View file

@ -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<ProgressOut> {
return this.request(`/movies/${movieId}/progress`, {
method: 'POST',
body: JSON.stringify(body),
});
}
async listRecentWatch(limit = 10): Promise<WatchListOut> {
return this.request(`/watch/recent?limit=${limit}`);
}

View file

@ -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 (
<Pressable style={styles.card} onPress={onPress}>
<View style={styles.posterWrap}>
{movie.poster_url ? (
<Image source={{ uri: movie.poster_url }} style={styles.poster} contentFit="cover" />
<Image
source={{ uri: movie.poster_url }}
style={[styles.poster, dim && styles.posterDimmed]}
contentFit="cover"
/>
) : (
<View style={[styles.poster, styles.posterPlaceholder]}>
<View style={[styles.poster, styles.posterPlaceholder, dim && styles.posterDimmed]}>
<Text style={styles.posterPlaceholderText}>{movie.title}</Text>
</View>
)}
@ -36,8 +48,18 @@ export function MoviePosterCard({
<Text style={styles.newBadgeText}>NEW</Text>
</View>
) : null}
{dim ? (
<View style={styles.watchedBadge}>
<Text style={styles.watchedBadgeText}></Text>
</View>
) : null}
{progressPct > 0 ? (
<View style={styles.progressBg}>
<View style={[styles.progressFg, { width: `${progressPct}%` }]} />
</View>
) : null}
</View>
<Text style={styles.title} numberOfLines={2}>
<Text style={[styles.title, dim && styles.titleDimmed]} numberOfLines={2}>
{movie.title}
</Text>
<Text style={styles.meta} numberOfLines={1}>
@ -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 },
});

View file

@ -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;

View file

@ -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',

View file

@ -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/<movieId>/
// 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<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
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<NativeStackNavigationProp<RootStackParamList, 'Player'>>();
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<string | null>(null);
const [resolveStatus, setResolveStatus] = React.useState<'idle' | 'pending' | 'failed'>('idle');
const [resolveError, setResolveError] = React.useState<string | null>(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.

View file

@ -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 {