* fix(playback): last-write-wins progress and DV P7 RPU strip on remux Progress: UpdateProgress (the live playback-session path) clamped position_seconds to GREATEST(new, old), so a deliberate backward seek could never persist — "rewind and stop" resumed at the stale later position on every client. Position is now last-write-wins, matching the /sync/progress path that was always unconditional. The completed latch and rewatch re-entry semantics are unchanged. Remux: profile 7 Dolby Vision remuxes drop the enhancement-layer track (-map 0:v:0 keeps only the base layer) but previously left the dangling dual-layer RPUs on the BL — broken metadata that a DV-honoring display can mis-render. Remuxes of P7 files now strip DV RPUs via the dovi_rpu bitstream filter, yielding a clean HDR10 stream (the same fallback presentation the Apple client's P7 HDR10 toggle produces). Profile 8 RPUs are kept: the BL is self-contained and DV clients render it. Adds MediaFile.PrimaryDVProfile() and threads the profile through ServeRemux callers; the proxy path (no track metadata in claims) keeps prior behavior. True P7->8.1 DV conversion needs dovi_tool alongside FFmpeg (the dovi_rpu bsf only strips/recompresses); the remux plumbing now carries the DV profile so that can slot in later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(userdb): apply last-write-wins progress to the SQLite backend too Review follow-up (P2): the LWW change only covered pgstore; the SQLite userdb UpdateProgress kept the MAX clamp, so rewind-and-stop still resumed at the stale later position for sqlite-backed installs. The conflict clause now matches Postgres (position last-write-wins, completed latch and rewatch re-entry unchanged), with a backward-seek regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): harden LWW progress and DV RPU strip from review - Probe ffmpeg for the dovi_rpu bitstream filter once per process and fall back to a no-strip remux (the pre-existing behavior) when it is missing: on pre-7.1 ffmpeg the unknown filter aborted the process, turning every Dolby Vision profile 7 remux into a hard playback failure. - Skip zero-position heartbeats in persistProgress, mirroring the stop path and the jellycompat report path. Under last-write-wins an early zero heartbeat (e.g. before the client seeks to its resume point) would wipe the stored resume position; GREATEST previously masked this. - Carry the DV profile in stream token claims (dvp, omitempty) so standalone proxy nodes strip profile 7 RPUs the same way integrated mode does. Old tokens decode as 0 and keep prior behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
474 lines
15 KiB
Go
474 lines
15 KiB
Go
package userdb
|
|
|
|
import (
|
|
"database/sql"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
func TestWatchHistoryIdentityRoundTrip(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
|
|
seasonNumber := 2
|
|
episodeNumber := 7
|
|
entry := userstore.WatchHistoryEntry{
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "episode-1",
|
|
WatchedAt: "2026-04-25T12:00:00Z",
|
|
DurationSeconds: 1800,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "episode",
|
|
SeriesProviderIDs: map[string]string{"tmdb": "123", "tvdb": "456"},
|
|
Season: &seasonNumber,
|
|
Episode: &episodeNumber,
|
|
},
|
|
}
|
|
if err := AddHistory(db, entry); err != nil {
|
|
t.Fatalf("AddHistory: %v", err)
|
|
}
|
|
|
|
history, err := ListHistory(db, "profile-1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListHistory: %v", err)
|
|
}
|
|
if len(history) != 1 {
|
|
t.Fatalf("history len = %d, want 1", len(history))
|
|
}
|
|
got := history[0]
|
|
if got.Identity.StableType != "episode" {
|
|
t.Fatalf("Identity.StableType = %q, want episode", got.Identity.StableType)
|
|
}
|
|
if len(got.Identity.ProviderIDs) != 0 {
|
|
t.Fatalf("Identity.ProviderIDs = %#v, want empty", got.Identity.ProviderIDs)
|
|
}
|
|
if !reflect.DeepEqual(got.Identity.SeriesProviderIDs, entry.Identity.SeriesProviderIDs) {
|
|
t.Fatalf("Identity.SeriesProviderIDs = %#v, want %#v", got.Identity.SeriesProviderIDs, entry.Identity.SeriesProviderIDs)
|
|
}
|
|
if got.Identity.Season == nil || *got.Identity.Season != seasonNumber {
|
|
t.Fatalf("Identity.Season = %v, want %d", got.Identity.Season, seasonNumber)
|
|
}
|
|
if got.Identity.Episode == nil || *got.Identity.Episode != episodeNumber {
|
|
t.Fatalf("Identity.Episode = %v, want %d", got.Identity.Episode, episodeNumber)
|
|
}
|
|
}
|
|
|
|
func TestAddHistoryIfMissingKeepsExistingSemantics(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
|
|
entry := userstore.WatchHistoryEntry{
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-1",
|
|
WatchedAt: "2026-04-25T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourceImport,
|
|
Identity: userstore.WatchIdentity{
|
|
StableType: "movie",
|
|
ProviderIDs: map[string]string{"tmdb": "603"},
|
|
},
|
|
}
|
|
created, err := AddHistoryIfMissing(db, entry)
|
|
if err != nil {
|
|
t.Fatalf("AddHistoryIfMissing(first): %v", err)
|
|
}
|
|
if !created {
|
|
t.Fatal("AddHistoryIfMissing(first) created = false, want true")
|
|
}
|
|
entry.Identity.ProviderIDs = map[string]string{"tmdb": "999"}
|
|
created, err = AddHistoryIfMissing(db, entry)
|
|
if err != nil {
|
|
t.Fatalf("AddHistoryIfMissing(second): %v", err)
|
|
}
|
|
if created {
|
|
t.Fatal("AddHistoryIfMissing(second) created = true, want false")
|
|
}
|
|
|
|
history, err := ListHistory(db, "profile-1", 10, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListHistory: %v", err)
|
|
}
|
|
if len(history) != 1 {
|
|
t.Fatalf("history len = %d, want 1", len(history))
|
|
}
|
|
if got := history[0].Identity.ProviderIDs["tmdb"]; got != "603" {
|
|
t.Fatalf("stored tmdb id = %q, want 603", got)
|
|
}
|
|
}
|
|
|
|
func TestListCompletedHistoryAppliesScopedFilters(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
|
|
entries := []userstore.WatchHistoryEntry{
|
|
{
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-1",
|
|
WatchedAt: "2026-04-25T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
},
|
|
{
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-2",
|
|
WatchedAt: "2026-04-26T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourceTrakt,
|
|
},
|
|
{
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-3",
|
|
WatchedAt: "2026-04-27T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourceImport,
|
|
},
|
|
}
|
|
for _, entry := range entries {
|
|
if err := AddHistory(db, entry); err != nil {
|
|
t.Fatalf("AddHistory(%s): %v", entry.MediaItemID, err)
|
|
}
|
|
}
|
|
|
|
history, err := ListCompletedHistory(db, userstore.CompletedHistoryQuery{
|
|
ProfileID: "profile-1",
|
|
MediaItemIDs: []string{"movie-1", "movie-2"},
|
|
IncludeSources: []userstore.WatchHistorySource{userstore.WatchHistorySourcePlayback, userstore.WatchHistorySourceTrakt},
|
|
ExcludeSources: []userstore.WatchHistorySource{userstore.WatchHistorySourceTrakt},
|
|
Limit: 10,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListCompletedHistory: %v", err)
|
|
}
|
|
if len(history) != 1 {
|
|
t.Fatalf("history len = %d, want 1: %+v", len(history), history)
|
|
}
|
|
if history[0].MediaItemID != "movie-1" {
|
|
t.Fatalf("media item = %q, want movie-1", history[0].MediaItemID)
|
|
}
|
|
}
|
|
|
|
func TestListCompletedHistoryItemsAppliesScopedFilters(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
|
|
entries := []userstore.WatchHistoryEntry{
|
|
{
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-history-only",
|
|
WatchedAt: "2026-04-25T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourceTrakt,
|
|
},
|
|
{
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-hidden",
|
|
WatchedAt: "2026-04-25T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourceSimkl,
|
|
},
|
|
{
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-future-hidden",
|
|
WatchedAt: "2026-04-25T12:10:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourceTrakt,
|
|
},
|
|
{
|
|
ProfileID: "profile-2",
|
|
MediaItemID: "movie-other-profile",
|
|
WatchedAt: "2026-04-25T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourceTrakt,
|
|
},
|
|
{
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-incomplete",
|
|
WatchedAt: "2026-04-25T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: false,
|
|
Source: userstore.WatchHistorySourceTrakt,
|
|
},
|
|
{
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-playback",
|
|
WatchedAt: "2026-04-25T12:00:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
},
|
|
}
|
|
for _, entry := range entries {
|
|
if err := AddHistory(db, entry); err != nil {
|
|
t.Fatalf("AddHistory(%s): %v", entry.MediaItemID, err)
|
|
}
|
|
}
|
|
if err := RemoveHistoryItems(db, "profile-1", []string{"movie-hidden"}, time.Date(2026, 4, 25, 12, 5, 0, 0, time.UTC)); err != nil {
|
|
t.Fatalf("RemoveHistoryItems: %v", err)
|
|
}
|
|
if err := RemoveHistoryItems(db, "profile-1", []string{"movie-future-hidden"}, time.Date(2026, 4, 25, 12, 5, 0, 0, time.UTC)); err != nil {
|
|
t.Fatalf("RemoveHistoryItems(future): %v", err)
|
|
}
|
|
|
|
items, err := ListCompletedHistoryItems(db, userstore.CompletedHistoryItemQuery{
|
|
ProfileID: "profile-1",
|
|
MediaItemIDs: []string{
|
|
"movie-history-only",
|
|
"movie-hidden",
|
|
"movie-future-hidden",
|
|
"movie-other-profile",
|
|
"movie-incomplete",
|
|
"movie-missing",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListCompletedHistoryItems: %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].MediaItemID != "movie-history-only" || items[0].WatchedAt != "2026-04-25T12:00:00Z" {
|
|
t.Fatalf("ListCompletedHistoryItems = %v, want movie-history-only with latest watched_at", items)
|
|
}
|
|
|
|
if err := AddHistory(db, userstore.WatchHistoryEntry{
|
|
ProfileID: "profile-1",
|
|
MediaItemID: "movie-future-hidden",
|
|
WatchedAt: "2026-04-25T12:11:00Z",
|
|
DurationSeconds: 7200,
|
|
Completed: true,
|
|
Source: userstore.WatchHistorySourcePlayback,
|
|
}); err != nil {
|
|
t.Fatalf("AddHistory(future replacement): %v", err)
|
|
}
|
|
items, err = ListCompletedHistoryItems(db, userstore.CompletedHistoryItemQuery{
|
|
ProfileID: "profile-1",
|
|
MediaItemIDs: []string{"movie-future-hidden"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListCompletedHistoryItems(future replacement): %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].MediaItemID != "movie-future-hidden" || items[0].WatchedAt != "2026-04-25T12:11:00Z" {
|
|
t.Fatalf("ListCompletedHistoryItems(future replacement) = %v, want movie-future-hidden with latest watched_at", items)
|
|
}
|
|
|
|
items, err = ListCompletedHistoryItems(db, userstore.CompletedHistoryItemQuery{
|
|
ProfileID: "profile-1",
|
|
MediaItemIDs: []string{"movie-history-only", "movie-playback"},
|
|
IncludeSources: []userstore.WatchHistorySource{userstore.WatchHistorySourceTrakt, userstore.WatchHistorySourcePlayback},
|
|
ExcludeSources: []userstore.WatchHistorySource{userstore.WatchHistorySourcePlayback},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListCompletedHistoryItems(source filters): %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].MediaItemID != "movie-history-only" {
|
|
t.Fatalf("ListCompletedHistoryItems(source filters) = %v, want [movie-history-only]", items)
|
|
}
|
|
}
|
|
|
|
func TestMarkProgressBatch_CompactsDirtyInput(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
|
|
at := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
|
|
if err := MarkProgressBatch(db, "p1", []string{"a", "", "a", " "}, at); err != nil {
|
|
t.Fatalf("MarkProgressBatch: %v", err)
|
|
}
|
|
|
|
wpA, err := GetProgress(db, "p1", "a")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(a): %v", err)
|
|
}
|
|
if wpA == nil || !wpA.Completed {
|
|
t.Fatalf("GetProgress(a) = %+v, want completed", wpA)
|
|
}
|
|
|
|
if wpEmpty, err := GetProgress(db, "p1", ""); err != nil {
|
|
t.Fatalf("GetProgress(empty): %v", err)
|
|
} else if wpEmpty != nil {
|
|
t.Fatalf("GetProgress(empty) = %+v, want nil — empty IDs must be compacted", wpEmpty)
|
|
}
|
|
if wpWS, err := GetProgress(db, "p1", " "); err != nil {
|
|
t.Fatalf("GetProgress(whitespace): %v", err)
|
|
} else if wpWS != nil {
|
|
t.Fatalf("GetProgress(whitespace) = %+v, want nil — whitespace IDs must be compacted", wpWS)
|
|
}
|
|
|
|
// Sanity: only one row should exist for profile p1.
|
|
var count int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM watch_progress WHERE profile_id = ?`, "p1").Scan(&count); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("watch_progress row count = %d, want 1 (dirty IDs leaked into the table)", count)
|
|
}
|
|
}
|
|
|
|
// TestClearProgressBatch_ClearsPartiallyWatchedRows pins that the batch
|
|
// path resets resume position even on rows where completed=false. The
|
|
// prior single-item ClearProgress path DELETEd the row unconditionally;
|
|
// when handlePlayedMutation routes single-item unplayed actions through
|
|
// the batch path, partially-watched rows must still have their resume
|
|
// position cleared. Otherwise "mark unplayed" leaves the item in
|
|
// Continue Watching with the user's last position intact.
|
|
//
|
|
// Regression guard for the post-perf-overhaul code review (Codex P1).
|
|
func TestClearProgressBatch_ClearsPartiallyWatchedRows(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
if err := InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
|
|
// Seed a partially-watched row: completed=false, position=300.
|
|
thresholds := userstore.ProgressThresholds{WatchedPct: 90}
|
|
if err := UpdateProgress(db, "p1", "m1", 300, 1800, thresholds); err != nil {
|
|
t.Fatalf("seed UpdateProgress: %v", err)
|
|
}
|
|
wp, err := GetProgress(db, "p1", "m1")
|
|
if err != nil {
|
|
t.Fatalf("seed GetProgress: %v", err)
|
|
}
|
|
if wp == nil || wp.Completed || wp.PositionSeconds == 0 {
|
|
t.Fatalf("seed expected partially-watched (completed=false, position>0); got %+v", wp)
|
|
}
|
|
|
|
// Mark unplayed via the batch path.
|
|
at := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
|
|
if err := ClearProgressBatch(db, "p1", []string{"m1"}, at); err != nil {
|
|
t.Fatalf("ClearProgressBatch: %v", err)
|
|
}
|
|
|
|
// Resume position must be cleared. Without the fix, position_seconds
|
|
// would still be 300 because the prior `AND completed = 1` filter
|
|
// excluded this row.
|
|
wp, err = GetProgress(db, "p1", "m1")
|
|
if err != nil {
|
|
t.Fatalf("post-clear GetProgress: %v", err)
|
|
}
|
|
if wp == nil {
|
|
t.Fatalf("expected row to still exist after batch clear (UPDATE, not DELETE)")
|
|
}
|
|
if wp.PositionSeconds != 0 {
|
|
t.Fatalf("position_seconds = %v, want 0 (partial-progress not cleared by batch path)", wp.PositionSeconds)
|
|
}
|
|
if wp.Completed {
|
|
t.Fatalf("completed = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestClearProgressBatch_CompactsDirtyInput(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
|
|
at := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
|
|
if err := MarkProgressBatch(db, "p1", []string{"a"}, at); err != nil {
|
|
t.Fatalf("seed MarkProgressBatch: %v", err)
|
|
}
|
|
|
|
if err := ClearProgressBatch(db, "p1", []string{"a", "", "a", " "}, at); err != nil {
|
|
t.Fatalf("ClearProgressBatch: %v", err)
|
|
}
|
|
|
|
wpA, err := GetProgress(db, "p1", "a")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress(a): %v", err)
|
|
}
|
|
if wpA == nil || wpA.Completed {
|
|
t.Fatalf("GetProgress(a) = %+v, want completed=false", wpA)
|
|
}
|
|
|
|
// Sanity: still only one row, and no dirty placeholder leaked in.
|
|
var count int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM watch_progress WHERE profile_id = ?`, "p1").Scan(&count); err != nil {
|
|
t.Fatalf("count: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("watch_progress row count = %d, want 1", count)
|
|
}
|
|
}
|
|
|
|
// Backward seeks are legitimate resume points: UpdateProgress is
|
|
// last-write-wins on position (the old MAX clamp made "rewind and stop"
|
|
// resume at the stale later position). Mirrors the pgstore expectation.
|
|
func TestUpdateProgressBackwardSeekPersists(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
if err := InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
|
|
noThreshold := userstore.ProgressThresholds{}
|
|
if err := UpdateProgress(db, "p1", "movie-1", 2400, 7200, noThreshold); err != nil {
|
|
t.Fatalf("UpdateProgress(forward): %v", err)
|
|
}
|
|
if err := UpdateProgress(db, "p1", "movie-1", 600, 7200, noThreshold); err != nil {
|
|
t.Fatalf("UpdateProgress(backward): %v", err)
|
|
}
|
|
|
|
wp, err := GetProgress(db, "p1", "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("GetProgress: %v", err)
|
|
}
|
|
if wp.PositionSeconds != 600 {
|
|
t.Errorf("PositionSeconds = %v, want 600 (last write wins)", wp.PositionSeconds)
|
|
}
|
|
}
|