diff --git a/internal/api/handlers/playback_test.go b/internal/api/handlers/playback_test.go index 02297f8f..48f93e71 100644 --- a/internal/api/handlers/playback_test.go +++ b/internal/api/handlers/playback_test.go @@ -492,6 +492,12 @@ func TestFinalizeSessionStop_UsesProviderLifecycleSemantics(t *testing.T) { wantPauseCalls: 1, wantEventPosition: 120, }, + { + name: "system teardown pauses persisted incomplete scrobble", + position: 600, + wantPauseCalls: 1, + wantEventPosition: 600, + }, { name: "completed system teardown stops scrobble", position: 3500, diff --git a/internal/watchsync/providers/mdblist/provider.go b/internal/watchsync/providers/mdblist/provider.go index cf5ca598..bdc4f24c 100644 --- a/internal/watchsync/providers/mdblist/provider.go +++ b/internal/watchsync/providers/mdblist/provider.go @@ -424,17 +424,17 @@ func watchlistRowsFromPayload(providerKey string, payload mdblistWatchlistRespon } func (p *Provider) ExportWatchlist(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite) (watchsync.ExportResult, error) { - return p.sendWatchlist(ctx, conn, items, "/watchlist/items/add") + return p.sendWatchlist(ctx, conn, items, "/watchlist/items/add", false) } func (p *Provider) RemoveWatchlist(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite) (watchsync.ExportResult, error) { - return p.sendWatchlist(ctx, conn, items, "/watchlist/items/remove") + return p.sendWatchlist(ctx, conn, items, "/watchlist/items/remove", true) } -func (p *Provider) sendWatchlist(ctx context.Context, conn watchsync.Connection, favorites []watchsync.LocalFavorite, path string) (watchsync.ExportResult, error) { +func (p *Provider) sendWatchlist(ctx context.Context, conn watchsync.Connection, favorites []watchsync.LocalFavorite, path string, removing bool) (watchsync.ExportResult, error) { payload := buildWatchlistPayload(favorites) if len(payload.Movies) == 0 && len(payload.Shows) == 0 { - return watchlistExportResult(favorites, false), nil + return watchlistExportResult(favorites, false, removing), nil } var body bytes.Buffer if err := json.NewEncoder(&body).Encode(payload); err != nil { @@ -444,13 +444,14 @@ func (p *Provider) sendWatchlist(ctx context.Context, conn watchsync.Connection, if err := p.do(ctx, http.MethodPost, path, conn.AccessToken, &body, &response); err != nil { return watchsync.ExportResult{}, err } - return watchlistExportResult(favorites, response.NotFound > 0), nil + return watchlistExportResult(favorites, !emptyJSONValue(response.NotFound), removing), nil } -func watchlistExportResult(favorites []watchsync.LocalFavorite, responseHasNotFound bool) watchsync.ExportResult { +func watchlistExportResult(favorites []watchsync.LocalFavorite, responseHasNotFound, removing bool) watchsync.ExportResult { result := watchsync.ExportResult{ - Sent: make([]string, 0, len(favorites)), - Failed: make(map[string]string), + Sent: make([]string, 0, len(favorites)), + NotFound: make([]string, 0, len(favorites)), + Failed: make(map[string]string), } for _, fav := range favorites { if fav.MediaItemID == "" { @@ -461,8 +462,15 @@ func watchlistExportResult(favorites []watchsync.LocalFavorite, responseHasNotFo continue } if responseHasNotFound { - // The API returns only an aggregate count, so a partial rejection - // cannot be attributed safely to one favorite. + if removing { + // A successful removal leaves both removed and already-absent items + // absent remotely. MDBList returns only aggregate counts, so mark + // the batch reconciled without trying to attribute individual rows. + result.NotFound = append(result.NotFound, fav.MediaItemID) + continue + } + // Adds still need item-level identity that the aggregate response + // does not provide, so never claim that any item in the batch sent. result.Failed[fav.MediaItemID] = "MDBList did not accept one or more watchlist items in the batch" continue } @@ -471,6 +479,9 @@ func watchlistExportResult(favorites []watchsync.LocalFavorite, responseHasNotFo if len(result.Failed) == 0 { result.Failed = nil } + if len(result.NotFound) == 0 { + result.NotFound = nil + } return result } @@ -758,6 +769,7 @@ type mdblistWatchedResponse struct { type mdblistPagination struct { NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` } type mdblistPageState struct { @@ -779,13 +791,22 @@ func (s mdblistPageState) path(base string) string { func (s *mdblistPageState) advance(pagination *mdblistPagination, fetched int) (bool, error) { if pagination != nil { next := strings.TrimSpace(pagination.NextCursor) - if next == "" { + if next != "" { + if next == s.cursor { + return false, errors.New("provider returned the same cursor twice") + } + s.cursor = next + return false, nil + } + if !pagination.HasMore { return true, nil } - if next == s.cursor { - return false, errors.New("provider returned the same cursor twice") + if fetched == 0 { + return false, errors.New("provider reported more pages without returning items") } - s.cursor = next + s.cursor = "" + s.legacyOffset = true + s.offset += fetched return false, nil } if fetched < syncPageLimit { @@ -804,7 +825,7 @@ type mdblistWatchedWriteResponse struct { } type mdblistWatchlistWriteResponse struct { - NotFound int `json:"not_found"` + NotFound json.RawMessage `json:"not_found"` } func emptyJSONValue(raw json.RawMessage) bool { @@ -1108,9 +1129,13 @@ func buildScrobblePayload(event watchsync.ScrobbleEvent) map[string]any { showIDs = idsFromLocal(event.IMDbID, event.TMDBID, event.TVDBID) } payload["show"] = map[string]any{ - "ids": showIDs, - "season": event.SeasonNumber, - "episode": event.EpisodeNumber, + "ids": showIDs, + "season": map[string]any{ + "number": event.SeasonNumber, + "episode": map[string]any{ + "number": event.EpisodeNumber, + }, + }, } default: payload["movie"] = map[string]any{"ids": idsFromLocal(event.IMDbID, event.TMDBID, event.TVDBID)} diff --git a/internal/watchsync/providers/mdblist/provider_test.go b/internal/watchsync/providers/mdblist/provider_test.go index 537779e0..9316e515 100644 --- a/internal/watchsync/providers/mdblist/provider_test.go +++ b/internal/watchsync/providers/mdblist/provider_test.go @@ -148,6 +148,32 @@ func TestFetchWatchedUsesCursorPagination(t *testing.T) { } } +func TestFetchWatchedContinuesOffsetPaginationWhenHasMore(t *testing.T) { + var offsets []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + offsets = append(offsets, r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("offset") == "1" { + _, _ = w.Write([]byte(`{"movies":[],"episodes":[],"pagination":{"has_more":false}}`)) + return + } + _, _ = w.Write([]byte(`{"movies":[{"watched_at":"2025-11-01T08:30:00Z","movie":{"ids":{"imdb":"tt0111161"}}}],"episodes":[],"pagination":{"has_more":true}}`)) + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + rows, err := p.FetchWatched(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}) + if err != nil { + t.Fatalf("fetch watched: %v", err) + } + if len(rows) != 1 { + t.Fatalf("got %d rows, want 1", len(rows)) + } + if len(offsets) != 2 || offsets[0] != "" || offsets[1] != "1" { + t.Fatalf("unexpected offsets: %#v", offsets) + } +} + func TestFetchProgressParsesFlatArray(t *testing.T) { pausedAt := time.Date(2025, time.November, 1, 8, 30, 0, 0, time.UTC) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -349,15 +375,20 @@ func TestScrobbleStartUsesEpisodeShape(t *testing.T) { t.Fatalf("expected progress 25, got %v", gotBody["progress"]) } show, _ := gotBody["show"].(map[string]any) - if season, _ := show["season"].(float64); int(season) != 3 { - t.Fatalf("expected show season 3, got %v", show["season"]) + season, _ := show["season"].(map[string]any) + if number, _ := season["number"].(float64); int(number) != 3 { + t.Fatalf("expected show season number 3, got %v", season["number"]) } - if episode, _ := show["episode"].(float64); int(episode) != 7 { - t.Fatalf("expected show episode 7, got %v", show["episode"]) + episode, _ := season["episode"].(map[string]any) + if number, _ := episode["number"].(float64); int(number) != 7 { + t.Fatalf("expected show episode number 7, got %v", episode["number"]) } if _, exists := gotBody["season"]; exists { t.Fatalf("season must be nested under show: %#v", gotBody) } + if _, exists := show["episode"]; exists { + t.Fatalf("episode must be nested under show season: %#v", show) + } ids, _ := show["ids"].(map[string]any) if ids["imdb"] != "tt0903747" { t.Fatalf("expected show imdb tt0903747, got %v", ids["imdb"]) @@ -397,7 +428,7 @@ func TestExportWatchlistSendsToWatchlistAdd(t *testing.T) { body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &gotBody) w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"added":1,"existing":0,"not_found":0}`)) + _, _ = w.Write([]byte(`{"added":{"movies":1,"shows":1},"existing":{"movies":0,"shows":0},"not_found":{"movies":0,"shows":0}}`)) })) defer server.Close() @@ -427,7 +458,7 @@ func TestExportWatchlistSendsToWatchlistAdd(t *testing.T) { func TestExportWatchlistDoesNotClaimNotFoundBatchWasSent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"added":1,"existing":0,"not_found":1}`)) + _, _ = w.Write([]byte(`{"added":{"movies":0,"shows":0},"existing":{"movies":0,"shows":0},"not_found":{"movies":1,"shows":0}}`)) })) defer server.Close() @@ -442,6 +473,27 @@ func TestExportWatchlistDoesNotClaimNotFoundBatchWasSent(t *testing.T) { } } +func TestRemoveWatchlistTreatsNotFoundBatchAsReconciled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/watchlist/items/remove" { + t.Fatalf("path = %q, want /watchlist/items/remove", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"added":{"movies":0,"shows":0},"existing":{"movies":0,"shows":0},"not_found":{"movies":1,"shows":0}}`)) + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + favorites := []watchsync.LocalFavorite{{MediaItemID: "m1", Kind: historyimport.KindMovie, IMDbID: "tt0111161"}} + result, err := p.RemoveWatchlist(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}, favorites) + if err != nil { + t.Fatalf("remove watchlist: %v", err) + } + if len(result.NotFound) != 1 || result.NotFound[0] != "m1" || len(result.Sent) != 0 || len(result.Failed) != 0 { + t.Fatalf("unexpected result: %#v", result) + } +} + func TestExportWatchlistReportsUnsupportedItemWithoutCallingMDBList(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Fatal("MDBList should not be called for an unsupported item") @@ -481,6 +533,32 @@ func TestFetchWatchlistUsesCursorPagination(t *testing.T) { } } +func TestFetchWatchlistContinuesOffsetPaginationWhenHasMore(t *testing.T) { + var offsets []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + offsets = append(offsets, r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("offset") == "1" { + _, _ = w.Write([]byte(`{"movies":[],"shows":[],"pagination":{"has_more":false}}`)) + return + } + _, _ = w.Write([]byte(`{"movies":[{"title":"The Shawshank Redemption","release_year":1994,"ids":{"imdb":"tt0111161"}}],"shows":[],"pagination":{"has_more":true}}`)) + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + rows, err := p.FetchWatchlist(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}) + if err != nil { + t.Fatalf("fetch watchlist: %v", err) + } + if len(rows) != 1 { + t.Fatalf("got %d rows, want 1", len(rows)) + } + if len(offsets) != 2 || offsets[0] != "" || offsets[1] != "1" { + t.Fatalf("unexpected offsets: %#v", offsets) + } +} + func TestDoRetriesShortRateLimitInPlace(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {