* 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>
167 lines
5.1 KiB
Go
167 lines
5.1 KiB
Go
package jellycompat
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
)
|
|
|
|
func TestUserDataDTOPlayedReportsZeroPosition(t *testing.T) {
|
|
// Watched rows store position 0, so played items naturally report 0 ticks.
|
|
data := &catalog.SeasonUserData{
|
|
PositionSeconds: 0,
|
|
DurationSeconds: 1290.0,
|
|
Played: true,
|
|
}
|
|
dto := userDataDTO("item-1", data, false, nil)
|
|
if dto.PlaybackPositionTicks != 0 {
|
|
t.Fatalf("PlaybackPositionTicks = %d, want 0 when Played=true", dto.PlaybackPositionTicks)
|
|
}
|
|
if !dto.Played {
|
|
t.Fatalf("Played = false, want true")
|
|
}
|
|
if dto.PlayedPercentage != 100 {
|
|
t.Fatalf("PlayedPercentage = %v, want 100 for played item at rest", dto.PlayedPercentage)
|
|
}
|
|
}
|
|
|
|
func TestUserDataDTOPlayedRewatchReportsResumePosition(t *testing.T) {
|
|
// A rewatch in flight keeps Played=true with a live resume point; clients
|
|
// must see both the checkmark and the position (matches Jellyfin).
|
|
data := &catalog.SeasonUserData{
|
|
PositionSeconds: 600.0,
|
|
DurationSeconds: 1290.0,
|
|
Played: true,
|
|
}
|
|
dto := userDataDTO("item-1", data, false, nil)
|
|
want := secondsToTicks(600.0)
|
|
if dto.PlaybackPositionTicks != want {
|
|
t.Fatalf("PlaybackPositionTicks = %d, want %d for rewatch in flight", dto.PlaybackPositionTicks, want)
|
|
}
|
|
if !dto.Played {
|
|
t.Fatalf("Played = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestUserDataDTOClampsPositionPastDuration(t *testing.T) {
|
|
data := &catalog.SeasonUserData{
|
|
PositionSeconds: 1290.33,
|
|
DurationSeconds: 1290.0,
|
|
Played: false,
|
|
}
|
|
dto := userDataDTO("item-2", data, false, nil)
|
|
want := secondsToTicks(1290.0)
|
|
if dto.PlaybackPositionTicks != want {
|
|
t.Fatalf("PlaybackPositionTicks = %d, want %d (clamped to duration)", dto.PlaybackPositionTicks, want)
|
|
}
|
|
if dto.PlayedPercentage > 100 {
|
|
t.Fatalf("PlayedPercentage = %v, want <= 100 (derived from the clamped position)", dto.PlayedPercentage)
|
|
}
|
|
}
|
|
|
|
func TestUserDataDTOPreservesValidPosition(t *testing.T) {
|
|
data := &catalog.SeasonUserData{
|
|
PositionSeconds: 600.0,
|
|
DurationSeconds: 1290.0,
|
|
Played: false,
|
|
}
|
|
dto := userDataDTO("item-3", data, false, nil)
|
|
want := secondsToTicks(600.0)
|
|
if dto.PlaybackPositionTicks != want {
|
|
t.Fatalf("PlaybackPositionTicks = %d, want %d", dto.PlaybackPositionTicks, want)
|
|
}
|
|
}
|
|
|
|
func TestUserDataDTOProgressCompletedZeros(t *testing.T) {
|
|
// Completed rows store position 0, so watched items report 0 ticks.
|
|
progress := &upstreamProgress{
|
|
MediaItemID: "x",
|
|
PositionSeconds: 0,
|
|
DurationSeconds: 1290.0,
|
|
Completed: true,
|
|
}
|
|
dto := userDataDTO("item-4", nil, false, progress)
|
|
if dto.PlaybackPositionTicks != 0 {
|
|
t.Fatalf("PlaybackPositionTicks = %d, want 0 when Completed=true", dto.PlaybackPositionTicks)
|
|
}
|
|
if !dto.Played {
|
|
t.Fatalf("Played = false, want true")
|
|
}
|
|
if dto.PlayedPercentage != 100 {
|
|
t.Fatalf("PlayedPercentage = %v, want 100 for completed item at rest", dto.PlayedPercentage)
|
|
}
|
|
}
|
|
|
|
func TestUserDataDTOProgressRewatchKeepsPlayedAndPosition(t *testing.T) {
|
|
progress := &upstreamProgress{
|
|
MediaItemID: "x",
|
|
PositionSeconds: 600.0,
|
|
DurationSeconds: 1290.0,
|
|
Completed: true,
|
|
}
|
|
dto := userDataDTO("item-4", nil, false, progress)
|
|
want := secondsToTicks(600.0)
|
|
if dto.PlaybackPositionTicks != want {
|
|
t.Fatalf("PlaybackPositionTicks = %d, want %d for rewatch in flight", dto.PlaybackPositionTicks, want)
|
|
}
|
|
if !dto.Played {
|
|
t.Fatalf("Played = false, want true")
|
|
}
|
|
if dto.PlayCount != 1 {
|
|
t.Fatalf("PlayCount = %d, want 1 (watched state survives rewatch)", dto.PlayCount)
|
|
}
|
|
wantPct := (600.0 / 1290.0) * 100
|
|
if dto.PlayedPercentage != wantPct {
|
|
t.Fatalf("PlayedPercentage = %v, want %v (live rewatch fraction)", dto.PlayedPercentage, wantPct)
|
|
}
|
|
}
|
|
|
|
func TestUserDataDTOProgressClampsPosition(t *testing.T) {
|
|
progress := &upstreamProgress{
|
|
MediaItemID: "x",
|
|
PositionSeconds: 2000.0,
|
|
DurationSeconds: 1290.0,
|
|
Completed: false,
|
|
}
|
|
dto := userDataDTO("item-5", nil, false, progress)
|
|
want := secondsToTicks(1290.0)
|
|
if dto.PlaybackPositionTicks != want {
|
|
t.Fatalf("PlaybackPositionTicks = %d, want %d (clamped)", dto.PlaybackPositionTicks, want)
|
|
}
|
|
}
|
|
|
|
func TestClampSeekSecondsCapsToLongestSource(t *testing.T) {
|
|
sources := []PlaybackMediaSource{
|
|
{Version: catalog.FileVersion{Duration: 1290}},
|
|
{Version: catalog.FileVersion{Duration: 1500}},
|
|
}
|
|
got := clampSeekSeconds(2000, sources)
|
|
if got != 1500 {
|
|
t.Fatalf("clampSeekSeconds = %v, want 1500", got)
|
|
}
|
|
}
|
|
|
|
func TestClampSeekSecondsPassesValidSeek(t *testing.T) {
|
|
sources := []PlaybackMediaSource{
|
|
{Version: catalog.FileVersion{Duration: 1290}},
|
|
}
|
|
got := clampSeekSeconds(600, sources)
|
|
if got != 600 {
|
|
t.Fatalf("clampSeekSeconds = %v, want 600", got)
|
|
}
|
|
}
|
|
|
|
func TestClampSeekSecondsHandlesNegative(t *testing.T) {
|
|
got := clampSeekSeconds(-5, []PlaybackMediaSource{{Version: catalog.FileVersion{Duration: 100}}})
|
|
if got != 0 {
|
|
t.Fatalf("clampSeekSeconds = %v, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestClampSeekSecondsNoDurationLeavesValue(t *testing.T) {
|
|
got := clampSeekSeconds(42, []PlaybackMediaSource{{Version: catalog.FileVersion{Duration: 0}}})
|
|
if got != 42 {
|
|
t.Fatalf("clampSeekSeconds = %v, want 42", got)
|
|
}
|
|
}
|