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:
parent
6ee0516e62
commit
6eb7cdd320
12 changed files with 274 additions and 18 deletions
47
alembic/versions/20260528_0018_movie_play_progress.py
Normal file
47
alembic/versions/20260528_0018_movie_play_progress.py
Normal 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")
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
) : null}
|
||||
{progressPct > 0 ? (
|
||||
<View style={styles.progressBg}>
|
||||
<View style={[styles.progressFg, { width: `${progressPct}%` }]} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue