* 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>
299 lines
11 KiB
Go
299 lines
11 KiB
Go
package userdb
|
|
|
|
import (
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
func newRewatchTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
if err := InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func rewatchGetProgress(t *testing.T, db *sql.DB, profileID, itemID string) *WatchProgress {
|
|
t.Helper()
|
|
wp, err := GetProgress(db, profileID, itemID)
|
|
if err != nil {
|
|
t.Fatalf("GetProgress: %v", err)
|
|
}
|
|
if wp == nil {
|
|
t.Fatalf("GetProgress(%s) = nil, want row", itemID)
|
|
}
|
|
return wp
|
|
}
|
|
|
|
func rewatchInProgressIDs(t *testing.T, db *sql.DB, profileID string) []string {
|
|
t.Helper()
|
|
entries, err := ListProgress(db, profileID, "in_progress", 50, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListProgress: %v", err)
|
|
}
|
|
ids := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
ids = append(ids, e.MediaItemID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// TestProgressRewatchLifecycle pins the watch → complete → rewatch → complete
|
|
// cycle of the position-based Continue Watching model: completion resets the
|
|
// resume point to 0 and latches completed; a later heartbeat re-enters the
|
|
// in-progress set through plain MAX while completed stays true; finishing the
|
|
// rewatch resets the resume point again.
|
|
func TestProgressRewatchLifecycle(t *testing.T) {
|
|
db := newRewatchTestDB(t)
|
|
th := userstore.ProgressThresholds{WatchedPct: 90, MinResumePct: 5}
|
|
|
|
// First watch in progress.
|
|
if err := UpdateProgress(db, "p", "m", 600, 7200, th); err != nil {
|
|
t.Fatalf("UpdateProgress(first watch): %v", err)
|
|
}
|
|
wp := rewatchGetProgress(t, db, "p", "m")
|
|
if wp.Completed || wp.PositionSeconds != 600 {
|
|
t.Fatalf("first watch: completed=%v pos=%v, want false/600", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
if ids := rewatchInProgressIDs(t, db, "p"); len(ids) != 1 || ids[0] != "m" {
|
|
t.Fatalf("in_progress = %v, want [m]", ids)
|
|
}
|
|
|
|
// Completion: watched latch set, resume point cleared, leaves Continue Watching.
|
|
if err := UpdateProgress(db, "p", "m", 7000, 7200, th); err != nil {
|
|
t.Fatalf("UpdateProgress(completion): %v", err)
|
|
}
|
|
wp = rewatchGetProgress(t, db, "p", "m")
|
|
if !wp.Completed || wp.PositionSeconds != 0 {
|
|
t.Fatalf("completion: completed=%v pos=%v, want true/0", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
if ids := rewatchInProgressIDs(t, db, "p"); len(ids) != 0 {
|
|
t.Fatalf("in_progress after completion = %v, want empty", ids)
|
|
}
|
|
|
|
// Rewatch: re-enters Continue Watching, watched state survives.
|
|
if err := UpdateProgress(db, "p", "m", 900, 7200, th); err != nil {
|
|
t.Fatalf("UpdateProgress(rewatch): %v", err)
|
|
}
|
|
wp = rewatchGetProgress(t, db, "p", "m")
|
|
if !wp.Completed || wp.PositionSeconds != 900 {
|
|
t.Fatalf("rewatch: completed=%v pos=%v, want true/900", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
if ids := rewatchInProgressIDs(t, db, "p"); len(ids) != 1 || ids[0] != "m" {
|
|
t.Fatalf("in_progress during rewatch = %v, want [m]", ids)
|
|
}
|
|
|
|
// Rewatch completes: resume point cleared again, still watched.
|
|
if err := UpdateProgress(db, "p", "m", 7100, 7200, th); err != nil {
|
|
t.Fatalf("UpdateProgress(rewatch completion): %v", err)
|
|
}
|
|
wp = rewatchGetProgress(t, db, "p", "m")
|
|
if !wp.Completed || wp.PositionSeconds != 0 {
|
|
t.Fatalf("rewatch completion: completed=%v pos=%v, want true/0", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
if ids := rewatchInProgressIDs(t, db, "p"); len(ids) != 0 {
|
|
t.Fatalf("in_progress after rewatch completion = %v, want empty", ids)
|
|
}
|
|
}
|
|
|
|
func TestProgressRewatchGuards(t *testing.T) {
|
|
th := userstore.ProgressThresholds{WatchedPct: 90, MinResumePct: 5}
|
|
|
|
t.Run("zero position heartbeat does not disturb watched item", func(t *testing.T) {
|
|
db := newRewatchTestDB(t)
|
|
if err := MarkWatched(db, "p", "m", 7200); err != nil {
|
|
t.Fatalf("MarkWatched: %v", err)
|
|
}
|
|
// Opening a watched item and backing out reports position 0.
|
|
if err := UpdateProgress(db, "p", "m", 0, 7200, th); err != nil {
|
|
t.Fatalf("UpdateProgress(zero): %v", err)
|
|
}
|
|
wp := rewatchGetProgress(t, db, "p", "m")
|
|
if !wp.Completed || wp.PositionSeconds != 0 {
|
|
t.Fatalf("got completed=%v pos=%v, want true/0", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
if ids := rewatchInProgressIDs(t, db, "p"); len(ids) != 0 {
|
|
t.Fatalf("in_progress = %v, want empty", ids)
|
|
}
|
|
})
|
|
|
|
t.Run("below min-resume heartbeat is discarded", func(t *testing.T) {
|
|
db := newRewatchTestDB(t)
|
|
if err := MarkWatched(db, "p", "m", 7200); err != nil {
|
|
t.Fatalf("MarkWatched: %v", err)
|
|
}
|
|
// 100/7200 = 1.4% < 5% floor: no write, no resume entry.
|
|
if err := UpdateProgress(db, "p", "m", 100, 7200, th); err != nil {
|
|
t.Fatalf("UpdateProgress(below floor): %v", err)
|
|
}
|
|
wp := rewatchGetProgress(t, db, "p", "m")
|
|
if !wp.Completed || wp.PositionSeconds != 0 {
|
|
t.Fatalf("got completed=%v pos=%v, want true/0", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
})
|
|
|
|
t.Run("mark watched cancels rewatch resume point", func(t *testing.T) {
|
|
db := newRewatchTestDB(t)
|
|
if err := MarkWatched(db, "p", "m", 7200); err != nil {
|
|
t.Fatalf("MarkWatched: %v", err)
|
|
}
|
|
if err := UpdateProgress(db, "p", "m", 900, 7200, th); err != nil {
|
|
t.Fatalf("UpdateProgress(rewatch): %v", err)
|
|
}
|
|
if err := MarkWatched(db, "p", "m", 7200); err != nil {
|
|
t.Fatalf("MarkWatched(again): %v", err)
|
|
}
|
|
wp := rewatchGetProgress(t, db, "p", "m")
|
|
if !wp.Completed || wp.PositionSeconds != 0 {
|
|
t.Fatalf("got completed=%v pos=%v, want true/0", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
})
|
|
|
|
t.Run("mark progress batch clears stale resume points", func(t *testing.T) {
|
|
db := newRewatchTestDB(t)
|
|
// In-progress row at 40%, then series-level mark played.
|
|
if err := UpdateProgress(db, "p", "ep", 2880, 7200, th); err != nil {
|
|
t.Fatalf("UpdateProgress: %v", err)
|
|
}
|
|
if err := MarkProgressBatch(db, "p", []string{"ep"}, time.Time{}); err != nil {
|
|
t.Fatalf("MarkProgressBatch: %v", err)
|
|
}
|
|
wp := rewatchGetProgress(t, db, "p", "ep")
|
|
if !wp.Completed || wp.PositionSeconds != 0 {
|
|
t.Fatalf("got completed=%v pos=%v, want true/0", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
if ids := rewatchInProgressIDs(t, db, "p"); len(ids) != 0 {
|
|
t.Fatalf("in_progress = %v, want empty", ids)
|
|
}
|
|
})
|
|
|
|
t.Run("playback stop mid-rewatch keeps the watched latch", func(t *testing.T) {
|
|
db := newRewatchTestDB(t)
|
|
if err := MarkWatched(db, "p", "m", 7200); err != nil {
|
|
t.Fatalf("MarkWatched: %v", err)
|
|
}
|
|
// RecordPlaybackStop persists the final position via SetProgress with
|
|
// completed=false (below the watched threshold). The latch must hold.
|
|
if err := SetProgress(db, "p", "m", 2880, 7200, th); err != nil {
|
|
t.Fatalf("SetProgress: %v", err)
|
|
}
|
|
wp := rewatchGetProgress(t, db, "p", "m")
|
|
if !wp.Completed || wp.PositionSeconds != 2880 {
|
|
t.Fatalf("got completed=%v pos=%v, want true/2880 (latch + resume point)", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
})
|
|
|
|
t.Run("set progress if newer keeps the watched latch", func(t *testing.T) {
|
|
db := newRewatchTestDB(t)
|
|
if err := MarkWatched(db, "p", "m", 7200); err != nil {
|
|
t.Fatalf("MarkWatched: %v", err)
|
|
}
|
|
wrote, err := SetProgressIfNewer(db, "p", "m", 1800, 7200, false, time.Now().UTC().Add(time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("SetProgressIfNewer: %v", err)
|
|
}
|
|
if !wrote {
|
|
t.Fatalf("SetProgressIfNewer wrote = false, want true")
|
|
}
|
|
wp := rewatchGetProgress(t, db, "p", "m")
|
|
if !wp.Completed || wp.PositionSeconds != 1800 {
|
|
t.Fatalf("got completed=%v pos=%v, want true/1800 (latch + resume point)", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
})
|
|
|
|
t.Run("stale mark batch does not zero a newer rewatch", func(t *testing.T) {
|
|
db := newRewatchTestDB(t)
|
|
if err := MarkWatched(db, "p", "ep", 7200); err != nil {
|
|
t.Fatalf("MarkWatched: %v", err)
|
|
}
|
|
if err := UpdateProgress(db, "p", "ep", 900, 7200, th); err != nil {
|
|
t.Fatalf("UpdateProgress(rewatch): %v", err)
|
|
}
|
|
// A delayed batch mark carrying an old timestamp must not clobber the
|
|
// live rewatch resume point.
|
|
if err := MarkProgressBatch(db, "p", []string{"ep"}, time.Now().UTC().Add(-time.Hour)); err != nil {
|
|
t.Fatalf("MarkProgressBatch(stale): %v", err)
|
|
}
|
|
wp := rewatchGetProgress(t, db, "p", "ep")
|
|
if !wp.Completed || wp.PositionSeconds != 900 {
|
|
t.Fatalf("got completed=%v pos=%v, want true/900 (stale batch ignored)", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
})
|
|
|
|
t.Run("set progress at completed normalizes position to zero", func(t *testing.T) {
|
|
db := newRewatchTestDB(t)
|
|
// Import path (trakt/jellyfin) reporting a completed watch with a
|
|
// raw end-of-file position.
|
|
if err := SetProgressAt(db, "p", "m", 7200, 7200, true, time.Time{}); err != nil {
|
|
t.Fatalf("SetProgressAt: %v", err)
|
|
}
|
|
wp := rewatchGetProgress(t, db, "p", "m")
|
|
if !wp.Completed || wp.PositionSeconds != 0 {
|
|
t.Fatalf("got completed=%v pos=%v, want true/0", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestMigrateToV11ResetsCompletedPositions verifies the v11 userdb migration:
|
|
// an existing DB (user_version 10) gets its legacy completed rows (position
|
|
// pinned to duration) reset to 0, and the version gate prevents re-runs from
|
|
// wiping rewatch state.
|
|
func TestMigrateToV11ResetsCompletedPositions(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
if err := InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
|
|
// Simulate an existing pre-v11 database: schema already at v10 with a
|
|
// legacy completed row written under the old model.
|
|
if _, err := db.Exec("PRAGMA user_version = 10"); err != nil {
|
|
t.Fatalf("set user_version: %v", err)
|
|
}
|
|
if _, err := db.Exec(`
|
|
INSERT INTO watch_progress (profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at)
|
|
VALUES ('p', 'legacy', 7200, 7200, 1, '2026-01-01T00:00:00Z')`); err != nil {
|
|
t.Fatalf("seed legacy row: %v", err)
|
|
}
|
|
|
|
if err := runMigrations(db); err != nil {
|
|
t.Fatalf("runMigrations: %v", err)
|
|
}
|
|
wp := rewatchGetProgress(t, db, "p", "legacy")
|
|
if !wp.Completed || wp.PositionSeconds != 0 {
|
|
t.Fatalf("after migration: completed=%v pos=%v, want true/0", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
version, err := userVersion(db)
|
|
if err != nil {
|
|
t.Fatalf("userVersion: %v", err)
|
|
}
|
|
if version != schemaVersion {
|
|
t.Fatalf("user_version = %d, want %d", version, schemaVersion)
|
|
}
|
|
|
|
// A rewatch in flight must survive subsequent runs (the version gate
|
|
// blocks re-runs).
|
|
th := userstore.ProgressThresholds{WatchedPct: 90, MinResumePct: 5}
|
|
if err := UpdateProgress(db, "p", "legacy", 900, 7200, th); err != nil {
|
|
t.Fatalf("UpdateProgress(rewatch): %v", err)
|
|
}
|
|
if err := runMigrations(db); err != nil {
|
|
t.Fatalf("runMigrations(again): %v", err)
|
|
}
|
|
wp = rewatchGetProgress(t, db, "p", "legacy")
|
|
if !wp.Completed || wp.PositionSeconds != 900 {
|
|
t.Fatalf("after re-run: completed=%v pos=%v, want true/900 (rewatch preserved)", wp.Completed, wp.PositionSeconds)
|
|
}
|
|
}
|