fix(watchsync): align MDBList API contracts

This commit is contained in:
Quick104
2026-07-20 18:29:55 -04:00
parent 4f249fda8f
commit a0851edef0
3 changed files with 133 additions and 24 deletions
+6
View File
@@ -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,
@@ -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)}
@@ -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) {