Marking a large series as watched only marked some episodes when the user
navigated away or closed the tab. POST /watched/{id} expanded a series to
every episode and then did four sequential DB round-trips per episode — a
duration lookup, a progress upsert, a stable-identity resolution (itself an
episode lookup plus a series provider-ID lookup), and a history insert — all
on the request context with no transaction. A 200-episode series was ~800
sequential queries, each committing on its own, so a disconnect mid-loop left
everything already committed in place.
Server:
- episodeTargets now resolves durations through the existing batched
listEpisodeFiles helper instead of one file query per episode. The shared
mediaFileDurationSeconds keeps the batched and single-item paths in step.
- ResolveHistoryIdentities resolves a whole series in one episode query plus
one provider-ID query per distinct series, behind optional-capability
interfaces with per-ID fallbacks.
- New userstore.WatchedBatchWriter capability, implemented transactionally in
both Postgres and SQLite, with a per-target fallback for stores that lack
it. recordMarkWatched and jellycompat's recordMarkWatchedBatch share it.
A cancelled request now rolls back to "nothing marked" rather than stranding
a half-watched series, which is the correct all-or-nothing semantic and needs
no detached context.
Client: the watched mutation sends keepalive so the request survives
navigation and tab close, plus an optimistic played flip with rollback,
following the existing favorites.ts pattern.
Routing jellycompat through the shared path would have clobbered known
durations with 0, since that caller supplies none; the batch upsert only ever
advances a duration, matching what MarkProgressBatch did before.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
80 lines
2.7 KiB
Go
80 lines
2.7 KiB
Go
package userdb
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
"github.com/Silo-Server/silo-server/internal/userstore/storetest"
|
|
)
|
|
|
|
func newConformanceStore(t *testing.T) userstore.UserStore {
|
|
t.Helper()
|
|
db, err := NewUserDB(filepath.Join(t.TempDir(), "user.db"), 1)
|
|
if err != nil {
|
|
t.Fatalf("open user database: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
return NewSQLiteUserStore(db.DB)
|
|
}
|
|
|
|
// TestSQLiteProgressSince runs the offline-sync progress-reconciliation
|
|
// conformance test (invariant 1) against the real SQLite backend, exercising the
|
|
// synced_seq stamping triggers and event_at LWW comparison.
|
|
func TestSQLiteProgressSince(t *testing.T) {
|
|
storetest.RunProgressSince(t, newConformanceStore)
|
|
}
|
|
|
|
// TestSQLiteMarkWatchedBatch runs the batch mark-watched conformance test
|
|
// (series/season mark-watched) against the real SQLite backend. The Postgres
|
|
// backend runs the same suite in internal/userstore/pgstore, which is what
|
|
// keeps the two transactional implementations from drifting.
|
|
func TestSQLiteMarkWatchedBatch(t *testing.T) {
|
|
storetest.RunMarkWatchedBatch(t, newConformanceStore)
|
|
}
|
|
|
|
// TestSQLiteSettingValues runs the canonical settings-contract storage
|
|
// conformance tests against the per-user SQLite backend. The Postgres backend
|
|
// runs the same suite in internal/userstore/pgstore, which is what keeps the two
|
|
// from drifting on scope identity, partial uniqueness and delete behavior.
|
|
func TestSQLiteSettingValues(t *testing.T) {
|
|
storetest.RunSettingValues(t, newConformanceStore)
|
|
}
|
|
|
|
// TestSQLiteJellycompatDisplayPrefs runs the Jellyfin DisplayPreferences
|
|
// storage conformance tests against the per-user SQLite backend; the Postgres
|
|
// backend runs the same suite in internal/userstore/pgstore.
|
|
func TestSQLiteJellycompatDisplayPrefs(t *testing.T) {
|
|
storetest.RunJellycompatDisplayPrefs(t, newConformanceStore)
|
|
}
|
|
|
|
func TestSQLiteCollectionSortPreferences(t *testing.T) {
|
|
storetest.RunCollectionSortPreferences(t, newConformanceStore)
|
|
}
|
|
|
|
func TestSQLiteAddFavoriteAtReportsInsertion(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := newConformanceStore(t)
|
|
if err := store.CreateProfile(ctx, userstore.Profile{ID: "p1", Name: "Test"}); err != nil {
|
|
t.Fatalf("CreateProfile: %v", err)
|
|
}
|
|
|
|
addedAt := time.Date(2026, time.July, 16, 12, 0, 0, 0, time.UTC)
|
|
inserted, err := store.AddFavoriteAt(ctx, "p1", "movie-1", addedAt)
|
|
if err != nil {
|
|
t.Fatalf("first AddFavoriteAt: %v", err)
|
|
}
|
|
if !inserted {
|
|
t.Fatal("first AddFavoriteAt reported no insertion")
|
|
}
|
|
inserted, err = store.AddFavoriteAt(ctx, "p1", "movie-1", addedAt)
|
|
if err != nil {
|
|
t.Fatalf("duplicate AddFavoriteAt: %v", err)
|
|
}
|
|
if inserted {
|
|
t.Fatal("duplicate AddFavoriteAt reported an insertion")
|
|
}
|
|
}
|