fix(watchsync): align MDBList API contracts
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user