* fix(progress): track resume points independently of watched state
Re-watching a finished item never re-entered Continue Watching: completion
latched completed = TRUE one-way, pinned position_seconds to the duration,
and the resume query filtered on completed = FALSE — so a rewatch heartbeat
could never surface the item again (and releasing the latch would have
erased the watched state clients display).
Adopt the Jellyfin invariant instead of guard heuristics:
- Completion resets position_seconds to 0 (UpdateProgress, SetProgress,
SetProgressAt, SetProgressIfNewer, MarkWatched, MarkProgressBatch), so
position_seconds > 0 now means "live resume point".
- completed stays a pure one-way watched latch; rewatch heartbeats re-enter
Continue Watching through plain GREATEST/MAX while the watched flag and
PlayCount survive (matching Plex and Jellyfin master).
- ListProgress("in_progress") keys on position_seconds > 0 in both stores;
the SQLite store also gains the min-resume floor the Postgres store had.
- jellycompat reports Played=true with live PositionTicks during a rewatch
(resumePositionTicks no longer zeroes played items) — the DTO shape real
Jellyfin emits since jellyfin/jellyfin#15762.
- Web mirrors the latch (playbackProgressCache), resumes rewatches at their
stored position, and shows progress bars on rewatched episodes.
- ABS audiobook surfaces keep today's behavior: finished books report 100%
via the completed flag and Continue Listening still excludes them.
- Migrations reset legacy completed rows (position pinned to duration) to
0: a Goose migration for Postgres and a user_version-gated one-time fix
for the per-user SQLite DBs.
Replaces the guard-based approach of #109, whose restart detection
(50% fraction + 60s time gap) could never release the latch for immediate
rewatches (blocked heartbeats refreshed updated_at, re-arming the gap) and
un-watched items on position-0 heartbeats.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(progress): address review — migration gate, one-way latch, missed writers/readers
Review fixes for the position-based watch-progress model:
- The per-user SQLite data fix is now migrateToV11 in the existing
versioned runMigrations chain (schemaVersion 11). The previous
standalone PRAGMA gate compared against 1, but existing DBs already
sit at user_version 10, so the reset never ran for them — and the
gate would have rewound the version. Fresh DBs short-circuit to the
current version as before.
- `completed` is now one-way across every playback/sync writer:
SetProgress (the RecordPlaybackStop path — stopping a rewatch below
the watched threshold no longer clears the watched state),
SetProgressAt, SetProgressIfNewer (both stores), and the history
import upsert, which also stops pinning completed imports to
position = duration. Mark-unwatched still releases the latch via
ClearProgress/ClearProgressBatch.
- MarkProgressBatch regains its freshness guard: a delayed batch mark
carrying an old timestamp can no longer zero a newer rewatch resume
point (the position-reset now rides the original updated_at check).
- Catalog read paths align with the new in-progress definition
(position_seconds > 0, completed-agnostic): smart-collection
in_progress filter, progress sort ratio, episode progress CTE, and
both next-up predicates.
- jellycompat derives PlayedPercentage and PlaybackPositionTicks from
the same clamped position; a played item at rest reports 100 (as the
old model did) while a rewatch reports its live fraction.
- ABS audiobook UpsertProgress stores position 0 on finish so finished
books can't surface as phantom resume entries; re-listens still move
position forward from 0 with the latch intact.
- The web optimistic cache zeroes the resume point on completion,
mirroring the server invariant until the refetch lands.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
118 lines
4.1 KiB
TypeScript
118 lines
4.1 KiB
TypeScript
import type { QueryClient } from "@tanstack/react-query";
|
|
import type { ItemDetail, LeafItemUserData, ProgressListResponse, WatchDetail } from "@/api/types";
|
|
import { progressKeys } from "./keys";
|
|
|
|
export interface PlaybackProgressSnapshot {
|
|
contentId: string;
|
|
positionSeconds: number;
|
|
durationSeconds?: number;
|
|
lastFileId?: number | null;
|
|
lastResolution?: string;
|
|
lastHDR?: boolean;
|
|
lastCodecVideo?: string;
|
|
lastEditionKey?: string;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
function mergeLeafProgress(
|
|
existing: LeafItemUserData | undefined,
|
|
snapshot: PlaybackProgressSnapshot,
|
|
): LeafItemUserData {
|
|
const rawPosition = Math.max(0, snapshot.positionSeconds);
|
|
const durationSeconds = snapshot.durationSeconds ?? existing?.duration_seconds;
|
|
// Mirror the server model: played is a one-way latch (a rewatch keeps the
|
|
// watched badge), completion clears the resume point, and any nonzero
|
|
// position is an active resume point.
|
|
const completedNow =
|
|
durationSeconds != null && durationSeconds > 0 && rawPosition >= durationSeconds;
|
|
const played = existing?.played === true || completedNow;
|
|
const positionSeconds = completedNow ? 0 : rawPosition;
|
|
|
|
return {
|
|
played,
|
|
is_in_progress: positionSeconds > 0,
|
|
position_seconds: positionSeconds,
|
|
duration_seconds: durationSeconds,
|
|
last_file_id: snapshot.lastFileId ?? existing?.last_file_id,
|
|
last_resolution: snapshot.lastResolution ?? existing?.last_resolution,
|
|
last_hdr: snapshot.lastHDR ?? existing?.last_hdr,
|
|
last_codec_video: snapshot.lastCodecVideo ?? existing?.last_codec_video,
|
|
last_edition_key: snapshot.lastEditionKey ?? existing?.last_edition_key,
|
|
};
|
|
}
|
|
|
|
function applySnapshotToItemDetail(
|
|
existing: ItemDetail | undefined,
|
|
snapshot: PlaybackProgressSnapshot,
|
|
): ItemDetail | undefined {
|
|
if (!existing) return existing;
|
|
|
|
const currentUserData =
|
|
existing.user_data && "position_seconds" in existing.user_data ? existing.user_data : undefined;
|
|
|
|
return {
|
|
...existing,
|
|
user_data: mergeLeafProgress(currentUserData, snapshot),
|
|
};
|
|
}
|
|
|
|
function applySnapshotToWatchDetail(
|
|
existing: WatchDetail | undefined,
|
|
snapshot: PlaybackProgressSnapshot,
|
|
): WatchDetail | undefined {
|
|
if (!existing) return existing;
|
|
|
|
return {
|
|
...existing,
|
|
user_data: mergeLeafProgress(existing.user_data, snapshot),
|
|
};
|
|
}
|
|
|
|
export function applyPlaybackProgressToCache(
|
|
queryClient: QueryClient,
|
|
snapshot: PlaybackProgressSnapshot,
|
|
) {
|
|
for (const [queryKey, existing] of queryClient.getQueriesData<ItemDetail>({
|
|
queryKey: ["catalog", "items", snapshot.contentId, "detail"],
|
|
})) {
|
|
queryClient.setQueryData<ItemDetail>(queryKey, applySnapshotToItemDetail(existing, snapshot));
|
|
}
|
|
for (const [queryKey, existing] of queryClient.getQueriesData<ItemDetail>({
|
|
queryKey: ["items", "detail", snapshot.contentId],
|
|
})) {
|
|
queryClient.setQueryData<ItemDetail>(queryKey, applySnapshotToItemDetail(existing, snapshot));
|
|
}
|
|
for (const [queryKey, existing] of queryClient.getQueriesData<WatchDetail>({
|
|
queryKey: ["items", "watchDetail", snapshot.contentId],
|
|
})) {
|
|
queryClient.setQueryData<WatchDetail>(queryKey, applySnapshotToWatchDetail(existing, snapshot));
|
|
}
|
|
|
|
const updatedAt = snapshot.updatedAt ?? new Date().toISOString();
|
|
for (const [queryKey, existing] of queryClient.getQueriesData<ProgressListResponse>({
|
|
queryKey: progressKeys.all,
|
|
})) {
|
|
if (!existing) continue;
|
|
|
|
const nextProgress = existing.progress.map((entry) => {
|
|
if (entry.media_item_id !== snapshot.contentId) return entry;
|
|
const rawPosition = Math.max(0, snapshot.positionSeconds);
|
|
const duration = snapshot.durationSeconds ?? entry.duration_seconds;
|
|
// Mirror the server model: completion latches and clears the resume point.
|
|
const completedNow = duration > 0 && rawPosition >= duration;
|
|
return {
|
|
...entry,
|
|
position_seconds: completedNow ? 0 : rawPosition,
|
|
duration_seconds: duration,
|
|
completed: entry.completed || completedNow,
|
|
updated_at: updatedAt,
|
|
};
|
|
});
|
|
|
|
queryClient.setQueryData<ProgressListResponse>(queryKey, {
|
|
...existing,
|
|
progress: nextProgress,
|
|
});
|
|
}
|
|
}
|