Files
silo-server/internal/watchstate/service_test.go
T
54507d236a fix(watchstate): make series mark-watched atomic and unload-proof (#645)
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>
2026-08-14 23:15:56 -04:00

836 lines
26 KiB
Go

package watchstate
import (
"context"
"database/sql"
"errors"
"fmt"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type testStoreProvider struct {
store userstore.UserStore
}
func (p testStoreProvider) ForUser(context.Context, int) (userstore.UserStore, error) {
return p.store, nil
}
func (p testStoreProvider) Close() error {
return nil
}
type testItemRepo struct {
items map[string]*models.MediaItem
}
func (r testItemRepo) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
item, ok := r.items[contentID]
if !ok {
return nil, catalog.ErrItemNotFound
}
return item, nil
}
type testEpisodeRepo struct {
episodes map[string]*models.Episode
byKey map[string]*models.Episode
}
func (r testEpisodeRepo) GetByID(_ context.Context, contentID string) (*models.Episode, error) {
episode, ok := r.episodes[contentID]
if !ok {
return nil, catalog.ErrEpisodeNotFound
}
return episode, nil
}
func (r testEpisodeRepo) GetBySeriesAndNumber(_ context.Context, seriesID string, season, episode int) (*models.Episode, error) {
got, ok := r.byKey[seriesID]
if !ok || got.SeasonNumber != season || got.EpisodeNumber != episode {
return nil, catalog.ErrEpisodeNotFound
}
return got, nil
}
type testProviderIDRepo struct {
ids map[string][]*models.MediaItemProviderID
err error
}
func (r testProviderIDRepo) GetByContentID(_ context.Context, contentID string) ([]*models.MediaItemProviderID, error) {
if r.err != nil {
return nil, r.err
}
return r.ids[contentID], nil
}
func (r testProviderIDRepo) FindContentIDByProviderIDs(_ context.Context, providerIDs map[string]string, itemType, _ string) (string, error) {
for contentID, rows := range r.ids {
for _, row := range rows {
if row.ItemType != itemType {
continue
}
if providerIDs[row.Provider] == row.ProviderID {
return contentID, nil
}
}
}
return "", nil
}
func TestRecordPlaybackStopAddsMovieIdentity(t *testing.T) {
store, db := newTestUserStore(t)
defer db.Close()
service := NewService(testStoreProvider{store: store}).WithStableIdentityResolver(NewStableIdentityResolver(
testItemRepo{items: map[string]*models.MediaItem{
"movie-1": {ContentID: "movie-1", Type: "movie"},
}},
testEpisodeRepo{},
testProviderIDRepo{ids: map[string][]*models.MediaItemProviderID{
"movie-1": {
{ContentID: "movie-1", ItemType: "movie", Provider: "tmdb", ProviderID: "603"},
},
}},
))
result, err := service.RecordPlaybackStop(
context.Background(),
1,
"profile-1",
"movie-1",
7200,
7200,
time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC),
userstore.VersionHints{},
userstore.ProgressThresholds{},
)
if err != nil {
t.Fatalf("RecordPlaybackStop: %v", err)
}
if !result.Completed || result.HistoryID == "" {
t.Fatalf("RecordPlaybackStop result = %+v", result)
}
history, err := store.ListHistory(context.Background(), "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 history[0].Identity.StableType != "movie" {
t.Fatalf("Identity.StableType = %q, want movie", history[0].Identity.StableType)
}
if got := history[0].Identity.ProviderIDs["tmdb"]; got != "603" {
t.Fatalf("Identity.ProviderIDs[tmdb] = %q, want 603", got)
}
}
func TestManualMarkWatchedAddsEpisodeIdentity(t *testing.T) {
store, db := newTestUserStore(t)
defer db.Close()
episode := &models.Episode{
ContentID: "episode-1",
SeriesID: "series-1",
SeasonNumber: 2,
EpisodeNumber: 7,
}
service := NewService(testStoreProvider{store: store}).WithStableIdentityResolver(NewStableIdentityResolver(
testItemRepo{},
testEpisodeRepo{episodes: map[string]*models.Episode{"episode-1": episode}},
testProviderIDRepo{ids: map[string][]*models.MediaItemProviderID{
"series-1": {
{ContentID: "series-1", ItemType: "series", Provider: "tvdb", ProviderID: "765"},
},
}},
))
err := service.RecordManualMarkWatched(
context.Background(),
1,
"profile-1",
[]LeafWatchTarget{{MediaItemID: "episode-1", DurationSeconds: 1800}},
time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC),
)
if err != nil {
t.Fatalf("RecordManualMarkWatched: %v", err)
}
history, err := store.ListHistory(context.Background(), "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 history[0].Identity.StableType != "episode" {
t.Fatalf("Identity.StableType = %q, want episode", history[0].Identity.StableType)
}
if len(history[0].Identity.ProviderIDs) != 0 {
t.Fatalf("Identity.ProviderIDs = %#v, want empty", history[0].Identity.ProviderIDs)
}
if got := history[0].Identity.SeriesProviderIDs["tvdb"]; got != "765" {
t.Fatalf("Identity.SeriesProviderIDs[tvdb] = %q, want 765", got)
}
if history[0].Identity.Season == nil || *history[0].Identity.Season != 2 {
t.Fatalf("Identity.Season = %v, want 2", history[0].Identity.Season)
}
if history[0].Identity.Episode == nil || *history[0].Identity.Episode != 7 {
t.Fatalf("Identity.Episode = %v, want 7", history[0].Identity.Episode)
}
}
func TestManualMarkWatchedPreservesVisibleWatchedAt(t *testing.T) {
store, db := newTestUserStore(t)
defer db.Close()
watchedAt := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
service := NewService(testStoreProvider{store: store})
err := service.RecordManualMarkWatched(
context.Background(),
1,
"profile-1",
[]LeafWatchTarget{{MediaItemID: "movie-1", DurationSeconds: 7200}},
watchedAt,
)
if err != nil {
t.Fatalf("RecordManualMarkWatched: %v", err)
}
history, err := store.ListHistory(context.Background(), "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 history[0].WatchedAt != "2026-04-25T12:00:00Z" {
t.Fatalf("history watched_at = %q, want caller watchedAt", history[0].WatchedAt)
}
}
func TestIdentityLookupFailureDoesNotBlockHistory(t *testing.T) {
store, db := newTestUserStore(t)
defer db.Close()
service := NewService(testStoreProvider{store: store}).WithStableIdentityResolver(NewStableIdentityResolver(
testItemRepo{items: map[string]*models.MediaItem{
"movie-1": {ContentID: "movie-1", Type: "movie"},
}},
testEpisodeRepo{},
testProviderIDRepo{err: errors.New("catalog unavailable")},
))
err := service.RecordManualMarkWatched(
context.Background(),
1,
"profile-1",
[]LeafWatchTarget{{MediaItemID: "movie-1", DurationSeconds: 7200}},
time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC),
)
if err != nil {
t.Fatalf("RecordManualMarkWatched: %v", err)
}
history, err := store.ListHistory(context.Background(), "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 history[0].Identity.StableType != "" ||
len(history[0].Identity.ProviderIDs) != 0 ||
len(history[0].Identity.SeriesProviderIDs) != 0 {
t.Fatalf("identity = %+v, want empty", history[0].Identity)
}
}
func TestStableIdentityResolverWithoutItemRepoDoesNotAssumeMovie(t *testing.T) {
resolver := NewStableIdentityResolver(
nil,
testEpisodeRepo{},
testProviderIDRepo{ids: map[string][]*models.MediaItemProviderID{
"unknown-1": {
{ContentID: "unknown-1", ItemType: "movie", Provider: "tmdb", ProviderID: "603"},
},
}},
)
identity := resolver.ResolveHistoryIdentity(context.Background(), "unknown-1")
if identity.StableType != "" ||
len(identity.ProviderIDs) != 0 ||
len(identity.SeriesProviderIDs) != 0 {
t.Fatalf("identity = %+v, want empty", identity)
}
}
func TestStableIdentityResolverResolvesEpisodeContentID(t *testing.T) {
episode := &models.Episode{
ContentID: "episode-1",
SeriesID: "series-1",
SeasonNumber: 2,
EpisodeNumber: 7,
}
resolver := NewStableIdentityResolver(
testItemRepo{},
testEpisodeRepo{byKey: map[string]*models.Episode{"series-1": episode}},
testProviderIDRepo{ids: map[string][]*models.MediaItemProviderID{
"series-1": {
{ContentID: "series-1", ItemType: "series", Provider: "tmdb", ProviderID: "123"},
},
}},
)
contentID, err := resolver.ResolveEpisodeContentID(context.Background(), map[string]string{"tmdb": "123"}, 2, 7)
if err != nil {
t.Fatalf("ResolveEpisodeContentID: %v", err)
}
if contentID != "episode-1" {
t.Fatalf("contentID = %q, want episode-1", contentID)
}
}
func TestStableIdentityResolverResolvesSeasonZeroSpecial(t *testing.T) {
episode := &models.Episode{
ContentID: "special-1",
SeriesID: "series-1",
SeasonNumber: 0,
EpisodeNumber: 1,
}
resolver := NewStableIdentityResolver(
testItemRepo{},
testEpisodeRepo{byKey: map[string]*models.Episode{"series-1": episode}},
testProviderIDRepo{ids: map[string][]*models.MediaItemProviderID{
"series-1": {
{ContentID: "series-1", ItemType: "series", Provider: "tmdb", ProviderID: "123"},
},
}},
)
contentID, err := resolver.ResolveEpisodeContentID(context.Background(), map[string]string{"tmdb": "123"}, 0, 1)
if err != nil {
t.Fatalf("ResolveEpisodeContentID: %v", err)
}
if contentID != "special-1" {
t.Fatalf("contentID = %q, want special-1", contentID)
}
}
func TestManualMarkUnwatchedSuppressesImportedHistoryButReturnsManualHistory(t *testing.T) {
store, db := newTestUserStore(t)
defer db.Close()
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-1", Name: "Profile"}); err != nil {
t.Fatalf("CreateProfile: %v", err)
}
if err := store.SetProgressAt(
context.Background(),
"profile-1",
"movie-1",
0,
7200,
true,
time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC),
); err != nil {
t.Fatalf("SetProgressAt: %v", err)
}
if err := store.AddHistory(context.Background(), userstore.WatchHistoryEntry{
ID: "trakt-history-1",
ProfileID: "profile-1",
MediaItemID: "movie-1",
WatchedAt: "2026-05-04T12:00:00Z",
DurationSeconds: 7200,
Completed: true,
Source: userstore.WatchHistorySourceTrakt,
Identity: userstore.WatchIdentity{
StableType: "movie",
ProviderIDs: map[string]string{"tmdb": "603"},
},
}); err != nil {
t.Fatalf("AddHistory: %v", err)
}
if err := store.AddHistory(context.Background(), userstore.WatchHistoryEntry{
ID: "simkl-history-1",
ProfileID: "profile-1",
MediaItemID: "movie-1",
WatchedAt: "2026-05-04T13:00:00Z",
DurationSeconds: 7200,
Completed: true,
Source: userstore.WatchHistorySourceSimkl,
Identity: userstore.WatchIdentity{
StableType: "movie",
ProviderIDs: map[string]string{"tmdb": "603"},
},
}); err != nil {
t.Fatalf("AddHistory: %v", err)
}
if err := store.AddHistory(context.Background(), userstore.WatchHistoryEntry{
ID: "manual-history-1",
ProfileID: "profile-1",
MediaItemID: "movie-1",
WatchedAt: "2026-05-04T14:00:00Z",
DurationSeconds: 7200,
Completed: true,
Source: userstore.WatchHistorySourceManual,
Identity: userstore.WatchIdentity{
StableType: "movie",
ProviderIDs: map[string]string{"tmdb": "603"},
},
}); err != nil {
t.Fatalf("AddHistory: %v", err)
}
service := NewService(testStoreProvider{store: store})
result, err := service.RecordManualMarkUnwatchedWithResult(context.Background(), 1, "profile-1", []string{"movie-1"})
if err != nil {
t.Fatalf("RecordManualMarkUnwatchedWithResult: %v", err)
}
if len(result.Entries) != 1 || result.Entries[0].Source != userstore.WatchHistorySourceManual {
t.Fatalf("unwatch result entries = %+v, want only manual history for outbound sync", result.Entries)
}
progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1")
if err != nil {
t.Fatalf("GetProgress: %v", err)
}
if progress != nil {
t.Fatalf("progress after unwatch = %+v, want nil", progress)
}
completedItems, err := store.ListCompletedHistoryItems(context.Background(), userstore.CompletedHistoryItemQuery{
ProfileID: "profile-1",
MediaItemIDs: []string{"movie-1"},
})
if err != nil {
t.Fatalf("ListCompletedHistoryItems: %v", err)
}
if len(completedItems) != 0 {
t.Fatalf("completed items after unwatch = %v, want empty", completedItems)
}
}
func TestManualMarkUnwatchedReturnsOneOutboundEntryPerTarget(t *testing.T) {
store, db := newTestUserStore(t)
defer db.Close()
createWatchstateProfile(t, store)
for _, entry := range []userstore.WatchHistoryEntry{
{
ID: "manual-history-older",
ProfileID: "profile-1",
MediaItemID: "movie-1",
WatchedAt: "2026-05-04T12:00:00Z",
DurationSeconds: 7200,
Completed: true,
Source: userstore.WatchHistorySourceManual,
Identity: userstore.WatchIdentity{
StableType: "movie",
ProviderIDs: map[string]string{"tmdb": "603"},
},
},
{
ID: "manual-history-newer",
ProfileID: "profile-1",
MediaItemID: "movie-1",
WatchedAt: "2026-05-04T13:00:00Z",
DurationSeconds: 7200,
Completed: true,
Source: userstore.WatchHistorySourceManual,
Identity: userstore.WatchIdentity{
StableType: "movie",
ProviderIDs: map[string]string{"tmdb": "603"},
},
},
} {
if err := store.AddHistory(context.Background(), entry); err != nil {
t.Fatalf("AddHistory(%s): %v", entry.ID, err)
}
}
service := NewService(testStoreProvider{store: store})
result, err := service.RecordManualMarkUnwatchedWithResult(context.Background(), 1, "profile-1", []string{"movie-1"})
if err != nil {
t.Fatalf("RecordManualMarkUnwatchedWithResult: %v", err)
}
if len(result.Entries) != 1 {
t.Fatalf("unwatch result entries = %+v, want one representative entry", result.Entries)
}
if result.Entries[0].ID != "manual-history-newer" {
t.Fatalf("representative history id = %q, want newest manual history", result.Entries[0].ID)
}
}
func TestManualMarkWatchedAfterHiddenWatermarkIsVisible(t *testing.T) {
store, db := newTestUserStore(t)
defer db.Close()
createWatchstateProfile(t, store)
hiddenBefore := time.Now().UTC().Add(time.Second).Format(time.RFC3339)
if _, err := db.Exec(`
INSERT INTO hidden_history_items (profile_id, media_item_id, hidden_before, updated_at)
VALUES (?, ?, ?, ?)`,
"profile-1",
"movie-1",
hiddenBefore,
hiddenBefore,
); err != nil {
t.Fatalf("seed hidden watermark: %v", err)
}
service := NewService(testStoreProvider{store: store})
result, err := service.RecordManualMarkWatchedWithResult(
context.Background(),
1,
"profile-1",
[]LeafWatchTarget{{MediaItemID: "movie-1", DurationSeconds: 7200}},
time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordManualMarkWatchedWithResult: %v", err)
}
if len(result.Entries) != 1 {
t.Fatalf("result entries = %+v, want one history entry", result.Entries)
}
if result.Entries[0].WatchedAt <= hiddenBefore {
t.Fatalf("history watched_at = %q, want after hidden_before %q", result.Entries[0].WatchedAt, hiddenBefore)
}
progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1")
if err != nil {
t.Fatalf("GetProgress: %v", err)
}
if progress == nil || !progress.Completed {
t.Fatalf("progress = %+v, want visible completed progress", progress)
}
history, err := store.ListHistory(context.Background(), "profile-1", 10, 0)
if err != nil {
t.Fatalf("ListHistory: %v", err)
}
if len(history) != 1 || history[0].WatchedAt <= hiddenBefore {
t.Fatalf("history = %+v, want visible history after hidden watermark %q", history, hiddenBefore)
}
}
func TestImportedWatchIfNewerDoesNotOverwriteNewerResume(t *testing.T) {
store, db := newTestUserStore(t)
defer db.Close()
createWatchstateProfile(t, store)
if err := store.SetProgressAt(
context.Background(),
"profile-1",
"movie-1",
1200,
7200,
false,
time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC),
); err != nil {
t.Fatalf("SetProgressAt: %v", err)
}
watchedAt := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
service := NewService(testStoreProvider{store: store})
created, err := service.RecordImportedWatchIfNewerWithSource(
context.Background(),
1,
"profile-1",
"movie-1",
7200,
0,
true,
watchedAt,
&watchedAt,
userstore.WatchHistorySourceTrakt,
)
if err != nil {
t.Fatalf("RecordImportedWatchIfNewerWithSource: %v", err)
}
if !created {
t.Fatal("created = false, want imported history row recorded")
}
progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1")
if err != nil {
t.Fatalf("GetProgress: %v", err)
}
if progress == nil || progress.Completed || progress.PositionSeconds != 1200 {
t.Fatalf("progress after older import = %+v, want newer resume preserved", progress)
}
}
func TestImportedWatchIfNewerCompletesOlderResume(t *testing.T) {
store, db := newTestUserStore(t)
defer db.Close()
createWatchstateProfile(t, store)
if err := store.SetProgressAt(
context.Background(),
"profile-1",
"movie-1",
1200,
7200,
false,
time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC),
); err != nil {
t.Fatalf("SetProgressAt: %v", err)
}
watchedAt := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC)
service := NewService(testStoreProvider{store: store})
created, err := service.RecordImportedWatchIfNewerWithSource(
context.Background(),
1,
"profile-1",
"movie-1",
7200,
0,
true,
watchedAt,
&watchedAt,
userstore.WatchHistorySourceSimkl,
)
if err != nil {
t.Fatalf("RecordImportedWatchIfNewerWithSource: %v", err)
}
if !created {
t.Fatal("created = false, want imported history row recorded")
}
progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1")
if err != nil {
t.Fatalf("GetProgress: %v", err)
}
if progress == nil || !progress.Completed || progress.PositionSeconds != 0 {
t.Fatalf("progress after newer import = %+v, want completed projection", progress)
}
}
func TestImportedWatchIfNewerSuppressesHiddenOlderWatch(t *testing.T) {
store, db := newTestUserStore(t)
defer db.Close()
createWatchstateProfile(t, store)
hiddenBefore := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC)
if err := store.RemoveHistoryItems(context.Background(), "profile-1", []string{"movie-1"}, hiddenBefore); err != nil {
t.Fatalf("RemoveHistoryItems: %v", err)
}
watchedAt := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
service := NewService(testStoreProvider{store: store})
created, err := service.RecordImportedWatchIfNewerWithSource(
context.Background(),
1,
"profile-1",
"movie-1",
7200,
0,
true,
watchedAt,
&watchedAt,
userstore.WatchHistorySourceTrakt,
)
if err != nil {
t.Fatalf("RecordImportedWatchIfNewerWithSource: %v", err)
}
if created {
t.Fatal("created = true, want hidden imported history skipped")
}
progress, err := store.GetProgress(context.Background(), "profile-1", "movie-1")
if err != nil {
t.Fatalf("GetProgress: %v", err)
}
if progress != nil {
t.Fatalf("progress after hidden import = %+v, want nil", progress)
}
completedItems, err := store.ListCompletedHistoryItems(context.Background(), userstore.CompletedHistoryItemQuery{
ProfileID: "profile-1",
MediaItemIDs: []string{"movie-1"},
})
if err != nil {
t.Fatalf("ListCompletedHistoryItems: %v", err)
}
if len(completedItems) != 0 {
t.Fatalf("completed items = %v, want hidden import skipped", completedItems)
}
}
func newTestUserStore(t *testing.T) (userstore.UserStore, *sql.DB) {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := userdb.InitSchema(db); err != nil {
db.Close()
t.Fatalf("InitSchema: %v", err)
}
return userdb.NewSQLiteUserStore(db), db
}
func createWatchstateProfile(t *testing.T, store userstore.UserStore) {
t.Helper()
if err := store.CreateProfile(context.Background(), userstore.Profile{ID: "profile-1", Name: "Profile"}); err != nil {
t.Fatalf("CreateProfile: %v", err)
}
}
// batchTestEpisodeRepo is testEpisodeRepo plus the GetByIDs batch capability,
// so the bulk identity path is what gets exercised. It counts calls to prove
// a series mark no longer issues one lookup per episode.
type batchTestEpisodeRepo struct {
testEpisodeRepo
batchCalls int
}
func (r *batchTestEpisodeRepo) GetByIDs(_ context.Context, contentIDs []string) ([]*models.Episode, error) {
r.batchCalls++
found := make([]*models.Episode, 0, len(contentIDs))
for _, contentID := range contentIDs {
if episode, ok := r.episodes[contentID]; ok {
found = append(found, episode)
}
}
return found, nil
}
// batchTestProviderIDRepo adds the GetByContentIDs batch capability and counts
// calls, pinning that a whole series resolves in one provider-ID query.
type batchTestProviderIDRepo struct {
testProviderIDRepo
batchCalls int
}
func (r *batchTestProviderIDRepo) GetByContentIDs(_ context.Context, contentIDs []string) (map[string][]*models.MediaItemProviderID, error) {
r.batchCalls++
out := make(map[string][]*models.MediaItemProviderID, len(contentIDs))
for _, contentID := range contentIDs {
if rows, ok := r.ids[contentID]; ok {
out[contentID] = rows
}
}
return out, nil
}
// TestManualMarkWatchedSeriesResolvesIdentitiesInBulk marks a whole series and
// pins both halves of the fix: every episode gets its own history row with a
// resolved identity, and the catalog lookups are batched rather than repeated
// per episode.
func TestManualMarkWatchedSeriesResolvesIdentitiesInBulk(t *testing.T) {
store, db := newTestUserStore(t)
t.Cleanup(func() { _ = db.Close() })
const episodeCount = 25
episodes := make(map[string]*models.Episode, episodeCount)
targets := make([]LeafWatchTarget, 0, episodeCount)
for i := 1; i <= episodeCount; i++ {
contentID := fmt.Sprintf("episode-%d", i)
episodes[contentID] = &models.Episode{
ContentID: contentID,
SeriesID: "series-1",
SeasonNumber: 1,
EpisodeNumber: i,
}
targets = append(targets, LeafWatchTarget{MediaItemID: contentID, DurationSeconds: 1800})
}
episodeRepo := &batchTestEpisodeRepo{testEpisodeRepo: testEpisodeRepo{episodes: episodes}}
providerRepo := &batchTestProviderIDRepo{testProviderIDRepo: testProviderIDRepo{
ids: map[string][]*models.MediaItemProviderID{
"series-1": {{ContentID: "series-1", ItemType: "series", Provider: "tvdb", ProviderID: "765"}},
},
}}
service := NewService(testStoreProvider{store: store}).WithStableIdentityResolver(
NewStableIdentityResolver(testItemRepo{}, episodeRepo, providerRepo),
)
result, err := service.RecordManualMarkWatchedWithResult(
context.Background(), 1, "profile-1", targets,
time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC),
)
if err != nil {
t.Fatalf("RecordManualMarkWatchedWithResult: %v", err)
}
if len(result.Entries) != episodeCount {
t.Fatalf("result entries = %d, want %d; watch-provider dispatch reads these",
len(result.Entries), episodeCount)
}
history, err := store.ListHistory(context.Background(), "profile-1", episodeCount*2, 0)
if err != nil {
t.Fatalf("ListHistory: %v", err)
}
if len(history) != episodeCount {
t.Fatalf("history len = %d, want %d", len(history), episodeCount)
}
for _, entry := range history {
if entry.Identity.StableType != "episode" {
t.Fatalf("%s identity = %q, want episode", entry.MediaItemID, entry.Identity.StableType)
}
if got := entry.Identity.SeriesProviderIDs["tvdb"]; got != "765" {
t.Fatalf("%s SeriesProviderIDs[tvdb] = %q, want 765", entry.MediaItemID, got)
}
}
for _, target := range targets {
progress, err := store.GetProgress(context.Background(), "profile-1", target.MediaItemID)
if err != nil || progress == nil || !progress.Completed {
t.Fatalf("GetProgress(%s) = %+v (%v), want completed", target.MediaItemID, progress, err)
}
}
// The whole point of the bulk path: constant lookups, not O(episodes).
if episodeRepo.batchCalls != 1 {
t.Fatalf("episode batch lookups = %d, want 1", episodeRepo.batchCalls)
}
if providerRepo.batchCalls != 1 {
t.Fatalf("provider-ID batch lookups = %d, want 1", providerRepo.batchCalls)
}
}
// TestManualMarkWatchedSeriesIsAtomicOnCancel is the regression test for the
// reported bug: marking a large series while the client disconnects used to
// leave the series partially watched, because each episode committed on its
// own. The mark must now be all-or-nothing.
func TestManualMarkWatchedSeriesIsAtomicOnCancel(t *testing.T) {
store, db := newTestUserStore(t)
t.Cleanup(func() { _ = db.Close() })
targets := make([]LeafWatchTarget, 0, 40)
for i := 1; i <= 40; i++ {
targets = append(targets, LeafWatchTarget{
MediaItemID: fmt.Sprintf("episode-%d", i),
DurationSeconds: 1800,
})
}
service := NewService(testStoreProvider{store: store})
ctx, cancel := context.WithCancel(context.Background())
cancel() // the tab closed before the write could run
if _, err := service.RecordManualMarkWatchedWithResult(
ctx, 1, "profile-1", targets, time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC),
); err == nil {
t.Fatal("RecordManualMarkWatchedWithResult on a canceled context returned nil error")
}
history, err := store.ListHistory(context.Background(), "profile-1", 100, 0)
if err != nil {
t.Fatalf("ListHistory: %v", err)
}
if len(history) != 0 {
t.Fatalf("history len = %d, want 0 — a canceled series mark must roll back, not leave a partial mark", len(history))
}
for _, target := range targets {
progress, err := store.GetProgress(context.Background(), "profile-1", target.MediaItemID)
if err != nil {
t.Fatalf("GetProgress(%s): %v", target.MediaItemID, err)
}
if progress != nil && progress.Completed {
t.Fatalf("%s is completed after a canceled mark; the write must be all-or-nothing", target.MediaItemID)
}
}
}