From eb35141ecc121948ac47ff1aefdb5b4001cd9afb Mon Sep 17 00:00:00 2001 From: Puks The Pirate <120460627+Pukabyte@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:36:47 +1200 Subject: [PATCH] fix(watchsync): correct Trakt TV episode sync payload (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(watchsync): omit empty Trakt episode ids so series mark-watched targets the right show omitempty on a struct value is a no-op in encoding/json, so the history export sent all-zero episode ids ({tmdb:0,tvdb:0}); Trakt matched the degenerate id to one default show, mis-recording every watched series. Make episode IDs a *traktIDs pointer and attach it only when a real id exists, else use the show + season/number fallback (mirroring scrobble). Adds a debug log on the show-fallback path and payload tests. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(watchsync): sync TV episodes to Trakt via valid nested shows payload Episode history exports were dropped by Trakt because they were emitted into the flat episodes[] array with a bogus sibling show object plus season/number keys — a shape the Trakt API does not accept, so it silently discarded them (200/201 with no history recorded). Movies were unaffected since they always carry their own external IDs. Two coordinated changes: - watchstate/identity.go: ResolveHistoryIdentity now carries the episode's own imdb/tmdb/tvdb IDs (already stored on the episodes table) so episodes with real IDs export via the flat episodes[].ids form, matching how movies work. - watchsync/providers/trakt/provider.go: episodes without their own ID now serialize into the correct nested shows[].seasons[].episodes[] structure keyed by the show's IDs, merging episodes by show/season. Same fix applied to the history-remove payload. Empty-payload guards account for the new shows[] list. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(watchstate): keep episode identity when episode has its own IDs Address CodeRabbit review on PR #254: the episode identity builder dropped the whole identity whenever series IDs were empty, so episodes with a valid episode IMDb/TMDB/TVDB ID but no series IDs never reached the flat episodes[].ids Trakt path. Only require series IDs when the episode has no IDs of its own (nested show fallback). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/watchstate/identity.go | 25 +- .../watchsync/providers/trakt/provider.go | 221 ++++++++++++------ .../providers/trakt/provider_test.go | 196 +++++++++++++++- 3 files changed, 366 insertions(+), 76 deletions(-) diff --git a/internal/watchstate/identity.go b/internal/watchstate/identity.go index 9256c325..953db34b 100644 --- a/internal/watchstate/identity.go +++ b/internal/watchstate/identity.go @@ -48,14 +48,19 @@ func (r *StableIdentityResolver) ResolveHistoryIdentity(ctx context.Context, med if r.episodes != nil { episode, err := r.episodes.GetByID(ctx, mediaItemID) if err == nil && episode != nil { + episodeIDs := episodeProviderIDs(episode) seriesIDs := providerIDMap(r.loadProviderIDs(ctx, episode.SeriesID)) - if len(seriesIDs) == 0 { + // Only require series IDs when the episode has no IDs of its own: + // an episode with its own IMDb/TMDB/TVDB ID is addressable on the + // flat episodes[].ids path without needing the nested show fallback. + if len(episodeIDs) == 0 && len(seriesIDs) == 0 { return userstore.WatchIdentity{} } seasonNumber := episode.SeasonNumber episodeNumber := episode.EpisodeNumber return userstore.WatchIdentity{ StableType: "episode", + ProviderIDs: episodeIDs, SeriesProviderIDs: seriesIDs, Season: &seasonNumber, Episode: &episodeNumber, @@ -121,6 +126,24 @@ func (r *StableIdentityResolver) loadProviderIDs(ctx context.Context, contentID return ids } +// episodeProviderIDs extracts the episode's own external IDs (populated by +// metadata enrichment on the episodes table). When present, exports can address +// the play by a real episode ID via the flat Trakt episodes[] form; when absent, +// the caller still carries SeriesProviderIDs + season/episode for the nested form. +func episodeProviderIDs(episode *models.Episode) map[string]string { + ids := map[string]string{} + if v := strings.TrimSpace(episode.ImdbID); v != "" { + ids["imdb"] = v + } + if v := strings.TrimSpace(episode.TmdbID); v != "" { + ids["tmdb"] = v + } + if v := strings.TrimSpace(episode.TvdbID); v != "" { + ids["tvdb"] = v + } + return ids +} + func providerIDMap(rows []*models.MediaItemProviderID) map[string]string { if len(rows) == 0 { return map[string]string{} diff --git a/internal/watchsync/providers/trakt/provider.go b/internal/watchsync/providers/trakt/provider.go index 6d68b84e..0d8236b7 100644 --- a/internal/watchsync/providers/trakt/provider.go +++ b/internal/watchsync/providers/trakt/provider.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net/http" "strconv" "strings" @@ -423,7 +424,7 @@ func (p *Provider) ExportHistory( plays []watchsync.LocalPlay, ) (watchsync.ExportResult, error) { payload := buildHistoryPayload(plays) - if len(payload.Movies) == 0 && len(payload.Episodes) == 0 { + if len(payload.Movies) == 0 && len(payload.Episodes) == 0 && len(payload.Shows) == 0 { return watchsync.ExportResult{}, nil } var body bytes.Buffer @@ -531,7 +532,7 @@ func (p *Provider) RemoveHistory( plays []watchsync.LocalPlay, ) (watchsync.ExportResult, error) { payload := buildHistoryRemovePayload(plays) - if len(payload.Movies) == 0 && len(payload.Episodes) == 0 { + if len(payload.Movies) == 0 && len(payload.Episodes) == 0 && len(payload.Shows) == 0 { return watchsync.ExportResult{}, nil } var body bytes.Buffer @@ -749,11 +750,13 @@ func episodeKey(showIDs traktIDs, season, episode int, episodeIDs traktIDs) stri type traktHistoryPayload struct { Movies []traktHistoryMovie `json:"movies,omitempty"` Episodes []traktHistoryEpisode `json:"episodes,omitempty"` + Shows []traktHistoryShow `json:"shows,omitempty"` } type traktHistoryRemovePayload struct { Movies []traktHistoryRemoveMovie `json:"movies,omitempty"` Episodes []traktHistoryRemoveEpisode `json:"episodes,omitempty"` + Shows []traktHistoryRemoveShow `json:"shows,omitempty"` } type traktFavoritesPayload struct { @@ -782,61 +785,101 @@ type traktHistoryRemoveMovie struct { IDs traktIDs `json:"ids"` } +// traktHistoryEpisode addresses an episode by its OWN episode-level id in the +// flat episodes[] array. Episodes lacking their own id go through the nested +// shows[] structure instead (see traktHistoryShow). type traktHistoryEpisode struct { WatchedAt string `json:"watched_at"` - IDs traktIDs `json:"ids,omitempty"` - Show *struct { - IDs traktIDs `json:"ids"` - } `json:"show,omitempty"` - Season int `json:"season"` - Number int `json:"number"` + IDs traktIDs `json:"ids"` } type traktHistoryRemoveEpisode struct { - IDs traktIDs `json:"ids,omitempty"` - Show *struct { - IDs traktIDs `json:"ids"` - } `json:"show,omitempty"` - Season int `json:"season"` + IDs traktIDs `json:"ids"` +} + +// traktHistoryShow is the nested add form: an episode is addressed by +// show ids -> season number -> episode number, which is the only shape Trakt +// accepts when the episode itself carries no external id. +type traktHistoryShow struct { + IDs traktIDs `json:"ids"` + Seasons []traktHistorySeason `json:"seasons"` +} + +type traktHistorySeason struct { + Number int `json:"number"` + Episodes []traktHistoryShowEpisode `json:"episodes"` +} + +type traktHistoryShowEpisode struct { + Number int `json:"number"` + WatchedAt string `json:"watched_at"` +} + +// traktHistoryRemoveShow mirrors traktHistoryShow for /sync/history/remove, +// where watched_at is not required. +type traktHistoryRemoveShow struct { + IDs traktIDs `json:"ids"` + Seasons []traktHistoryRemoveSeason `json:"seasons"` +} + +type traktHistoryRemoveSeason struct { + Number int `json:"number"` + Episodes []traktHistoryRemoveShowEpisode `json:"episodes"` +} + +type traktHistoryRemoveShowEpisode struct { Number int `json:"number"` } +// playOwnIDs builds the item's OWN external ids (movie or episode level). +func playOwnIDs(play watchsync.LocalPlay) traktIDs { + ids := traktIDs{IMDb: play.IMDbID} + if play.TVDBID != "" { + ids.TVDB, _ = strconv.Atoi(play.TVDBID) + } + if play.TMDBID != "" { + ids.TMDB, _ = strconv.Atoi(play.TMDBID) + } + return ids +} + +// playSeriesIDs builds the parent show's external ids, used for the nested +// shows[] fallback when an episode has no id of its own. +func playSeriesIDs(play watchsync.LocalPlay) traktIDs { + ids := traktIDs{IMDb: play.SeriesIMDbID} + if play.SeriesTVDBID != "" { + ids.TVDB, _ = strconv.Atoi(play.SeriesTVDBID) + } + if play.SeriesTMDBID != "" { + ids.TMDB, _ = strconv.Atoi(play.SeriesTMDBID) + } + return ids +} + +func hasAnyID(ids traktIDs) bool { + return ids.TVDB != 0 || ids.TMDB != 0 || ids.IMDb != "" +} + func buildHistoryPayload(plays []watchsync.LocalPlay) traktHistoryPayload { var payload traktHistoryPayload for _, play := range plays { watchedAt := play.WatchedAt.UTC().Format(time.RFC3339) switch play.Kind { case historyimport.KindMovie: - ids := traktIDs{IMDb: play.IMDbID} - if play.TVDBID != "" { - ids.TVDB, _ = strconv.Atoi(play.TVDBID) - } - if play.TMDBID != "" { - ids.TMDB, _ = strconv.Atoi(play.TMDBID) - } - payload.Movies = append(payload.Movies, traktHistoryMovie{WatchedAt: watchedAt, IDs: ids}) + payload.Movies = append(payload.Movies, traktHistoryMovie{WatchedAt: watchedAt, IDs: playOwnIDs(play)}) case historyimport.KindEpisode: - ids := traktIDs{IMDb: play.IMDbID} - if play.TVDBID != "" { - ids.TVDB, _ = strconv.Atoi(play.TVDBID) + if ids := playOwnIDs(play); hasAnyID(ids) { + payload.Episodes = append(payload.Episodes, traktHistoryEpisode{WatchedAt: watchedAt, IDs: ids}) + continue } - if play.TMDBID != "" { - ids.TMDB, _ = strconv.Atoi(play.TMDBID) - } - row := traktHistoryEpisode{WatchedAt: watchedAt, IDs: ids, Season: play.SeasonNumber, Number: play.EpisodeNumber} - if ids.TVDB == 0 && ids.TMDB == 0 && ids.IMDb == "" { - showIDs := traktIDs{IMDb: play.SeriesIMDbID} - if play.SeriesTVDBID != "" { - showIDs.TVDB, _ = strconv.Atoi(play.SeriesTVDBID) - } - if play.SeriesTMDBID != "" { - showIDs.TMDB, _ = strconv.Atoi(play.SeriesTMDBID) - } - row.Show = &struct { - IDs traktIDs `json:"ids"` - }{IDs: showIDs} - } - payload.Episodes = append(payload.Episodes, row) + showIDs := playSeriesIDs(play) + slog.Debug("trakt history export: episode has no episode ids, using nested show fallback", + "show_tmdb", showIDs.TMDB, "show_tvdb", showIDs.TVDB, "show_imdb", showIDs.IMDb, + "season", play.SeasonNumber, "number", play.EpisodeNumber) + payload.Shows = appendNestedEpisode(payload.Shows, showIDs, play.SeasonNumber, traktHistoryShowEpisode{ + Number: play.EpisodeNumber, + WatchedAt: watchedAt, + }) } } return payload @@ -847,41 +890,81 @@ func buildHistoryRemovePayload(plays []watchsync.LocalPlay) traktHistoryRemovePa for _, play := range plays { switch play.Kind { case historyimport.KindMovie: - ids := traktIDs{IMDb: play.IMDbID} - if play.TVDBID != "" { - ids.TVDB, _ = strconv.Atoi(play.TVDBID) - } - if play.TMDBID != "" { - ids.TMDB, _ = strconv.Atoi(play.TMDBID) - } - payload.Movies = append(payload.Movies, traktHistoryRemoveMovie{IDs: ids}) + payload.Movies = append(payload.Movies, traktHistoryRemoveMovie{IDs: playOwnIDs(play)}) case historyimport.KindEpisode: - ids := traktIDs{IMDb: play.IMDbID} - if play.TVDBID != "" { - ids.TVDB, _ = strconv.Atoi(play.TVDBID) + if ids := playOwnIDs(play); hasAnyID(ids) { + payload.Episodes = append(payload.Episodes, traktHistoryRemoveEpisode{IDs: ids}) + continue } - if play.TMDBID != "" { - ids.TMDB, _ = strconv.Atoi(play.TMDBID) - } - row := traktHistoryRemoveEpisode{IDs: ids, Season: play.SeasonNumber, Number: play.EpisodeNumber} - if ids.TVDB == 0 && ids.TMDB == 0 && ids.IMDb == "" { - showIDs := traktIDs{IMDb: play.SeriesIMDbID} - if play.SeriesTVDBID != "" { - showIDs.TVDB, _ = strconv.Atoi(play.SeriesTVDBID) - } - if play.SeriesTMDBID != "" { - showIDs.TMDB, _ = strconv.Atoi(play.SeriesTMDBID) - } - row.Show = &struct { - IDs traktIDs `json:"ids"` - }{IDs: showIDs} - } - payload.Episodes = append(payload.Episodes, row) + showIDs := playSeriesIDs(play) + slog.Debug("trakt history remove: episode has no episode ids, using nested show fallback", + "show_tmdb", showIDs.TMDB, "show_tvdb", showIDs.TVDB, "show_imdb", showIDs.IMDb, + "season", play.SeasonNumber, "number", play.EpisodeNumber) + payload.Shows = appendNestedRemoveEpisode(payload.Shows, showIDs, play.SeasonNumber, traktHistoryRemoveShowEpisode{ + Number: play.EpisodeNumber, + }) } } return payload } +// appendNestedEpisode inserts an episode under the nested shows[] structure, +// merging by show ids then by season number so repeated episodes of the same +// show/season collapse into a single show + season entry. +func appendNestedEpisode(shows []traktHistoryShow, showIDs traktIDs, season int, episode traktHistoryShowEpisode) []traktHistoryShow { + idx := -1 + for i := range shows { + if shows[i].IDs == showIDs { + idx = i + break + } + } + if idx == -1 { + shows = append(shows, traktHistoryShow{IDs: showIDs}) + idx = len(shows) - 1 + } + sIdx := -1 + for i := range shows[idx].Seasons { + if shows[idx].Seasons[i].Number == season { + sIdx = i + break + } + } + if sIdx == -1 { + shows[idx].Seasons = append(shows[idx].Seasons, traktHistorySeason{Number: season}) + sIdx = len(shows[idx].Seasons) - 1 + } + shows[idx].Seasons[sIdx].Episodes = append(shows[idx].Seasons[sIdx].Episodes, episode) + return shows +} + +func appendNestedRemoveEpisode(shows []traktHistoryRemoveShow, showIDs traktIDs, season int, episode traktHistoryRemoveShowEpisode) []traktHistoryRemoveShow { + idx := -1 + for i := range shows { + if shows[i].IDs == showIDs { + idx = i + break + } + } + if idx == -1 { + shows = append(shows, traktHistoryRemoveShow{IDs: showIDs}) + idx = len(shows) - 1 + } + sIdx := -1 + for i := range shows[idx].Seasons { + if shows[idx].Seasons[i].Number == season { + sIdx = i + break + } + } + if sIdx == -1 { + shows[idx].Seasons = append(shows[idx].Seasons, traktHistoryRemoveSeason{Number: season}) + sIdx = len(shows[idx].Seasons) - 1 + } + shows[idx].Seasons[sIdx].Episodes = append(shows[idx].Seasons[sIdx].Episodes, episode) + return shows +} + func buildFavoritesPayload(favorites []watchsync.LocalFavorite) traktFavoritesPayload { var payload traktFavoritesPayload for _, favorite := range favorites { diff --git a/internal/watchsync/providers/trakt/provider_test.go b/internal/watchsync/providers/trakt/provider_test.go index 20ec3ce2..44f14384 100644 --- a/internal/watchsync/providers/trakt/provider_test.go +++ b/internal/watchsync/providers/trakt/provider_test.go @@ -1,6 +1,7 @@ package trakt import ( + "bytes" "context" "encoding/json" "net/http" @@ -185,13 +186,38 @@ func TestRemoveHistorySendsTraktRemovePayload(t *testing.T) { if len(movies) != 1 { t.Fatalf("movies payload = %#v, want 1 movie", gotBody["movies"]) } - episodes, _ := gotBody["episodes"].([]any) - if len(episodes) != 1 { - t.Fatalf("episodes payload = %#v, want 1 episode", gotBody["episodes"]) + // The episode carries only a series id + season/episode number, so it must + // land in the nested shows[] structure, not the flat episodes[] array. + if _, ok := gotBody["episodes"]; ok { + t.Fatalf("episodes payload unexpectedly present: %#v", gotBody["episodes"]) } - episode, _ := episodes[0].(map[string]any) - if episode["season"] != float64(0) || episode["number"] != float64(2) { - t.Fatalf("episode payload = %#v, want S00E02", episode) + shows, _ := gotBody["shows"].([]any) + if len(shows) != 1 { + t.Fatalf("shows payload = %#v, want 1 show", gotBody["shows"]) + } + show, _ := shows[0].(map[string]any) + showIDs, _ := show["ids"].(map[string]any) + if showIDs["tvdb"] != float64(789) { + t.Fatalf("show ids = %#v, want tvdb 789", show["ids"]) + } + seasons, _ := show["seasons"].([]any) + if len(seasons) != 1 { + t.Fatalf("seasons payload = %#v, want 1 season", show["seasons"]) + } + season, _ := seasons[0].(map[string]any) + if season["number"] != float64(0) { + t.Fatalf("season payload = %#v, want season 0", season) + } + seasonEpisodes, _ := season["episodes"].([]any) + if len(seasonEpisodes) != 1 { + t.Fatalf("season episodes = %#v, want 1 episode", season["episodes"]) + } + seasonEpisode, _ := seasonEpisodes[0].(map[string]any) + if seasonEpisode["number"] != float64(2) { + t.Fatalf("episode payload = %#v, want number 2", seasonEpisode) + } + if _, ok := seasonEpisode["watched_at"]; ok { + t.Fatalf("remove episode unexpectedly included watched_at: %#v", seasonEpisode) } } @@ -322,3 +348,161 @@ func TestHistoryPayloadsIncludeTVDBOnlyMovieIDs(t *testing.T) { t.Fatalf("remove payload movie IDs = %#v, want TVDB 12345", removePayload.Movies) } } + +func TestHistoryEpisodeWithoutOwnIDsUsesNestedShowFallback(t *testing.T) { + play := watchsync.LocalPlay{ + HistoryID: "history-episode-no-ids", + Kind: historyimport.KindEpisode, + SeriesTMDBID: "999", + SeasonNumber: 2, + EpisodeNumber: 5, + WatchedAt: time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC), + } + + addPayload := buildHistoryPayload([]watchsync.LocalPlay{play}) + if len(addPayload.Episodes) != 0 { + t.Fatalf("add payload episodes = %#v, want 0 (episode has no own id)", addPayload.Episodes) + } + if len(addPayload.Shows) != 1 { + t.Fatalf("add payload shows = %#v, want 1", addPayload.Shows) + } + addShow := addPayload.Shows[0] + if addShow.IDs.TMDB != 999 { + t.Fatalf("add payload show IDs = %#v, want show TMDB 999", addShow.IDs) + } + if len(addShow.Seasons) != 1 || addShow.Seasons[0].Number != 2 { + t.Fatalf("add payload seasons = %#v, want 1 season numbered 2", addShow.Seasons) + } + if len(addShow.Seasons[0].Episodes) != 1 || addShow.Seasons[0].Episodes[0].Number != 5 { + t.Fatalf("add payload episodes = %#v, want 1 episode numbered 5", addShow.Seasons[0].Episodes) + } + if addShow.Seasons[0].Episodes[0].WatchedAt != "2026-05-04T12:00:00Z" { + t.Fatalf("add payload episode watched_at = %q", addShow.Seasons[0].Episodes[0].WatchedAt) + } + + addJSON, err := json.Marshal(addPayload) + if err != nil { + t.Fatalf("marshal add payload: %v", err) + } + if bytes.Contains(addJSON, []byte(`"episodes":[{"watched_at"`)) { + t.Fatalf("add payload JSON must not carry a flat episode with show sibling: %s", addJSON) + } + if !bytes.Contains(addJSON, []byte(`"shows":[{"ids":{"trakt":0,"slug":"","imdb":"","tmdb":999,"tvdb":0}`)) { + t.Fatalf("add payload JSON missing nested show: %s", addJSON) + } + + removePayload := buildHistoryRemovePayload([]watchsync.LocalPlay{play}) + if len(removePayload.Episodes) != 0 { + t.Fatalf("remove payload episodes = %#v, want 0", removePayload.Episodes) + } + if len(removePayload.Shows) != 1 { + t.Fatalf("remove payload shows = %#v, want 1", removePayload.Shows) + } + removeShow := removePayload.Shows[0] + if removeShow.IDs.TMDB != 999 { + t.Fatalf("remove payload show IDs = %#v, want show TMDB 999", removeShow.IDs) + } + if len(removeShow.Seasons) != 1 || removeShow.Seasons[0].Number != 2 { + t.Fatalf("remove payload seasons = %#v, want 1 season numbered 2", removeShow.Seasons) + } + if len(removeShow.Seasons[0].Episodes) != 1 || removeShow.Seasons[0].Episodes[0].Number != 5 { + t.Fatalf("remove payload episodes = %#v, want 1 episode numbered 5", removeShow.Seasons[0].Episodes) + } + + removeJSON, err := json.Marshal(removePayload) + if err != nil { + t.Fatalf("marshal remove payload: %v", err) + } + if bytes.Contains(removeJSON, []byte(`"watched_at"`)) { + t.Fatalf("remove payload JSON must not carry watched_at: %s", removeJSON) + } +} + +func TestHistoryNestedFallbackMergesEpisodesBySeason(t *testing.T) { + plays := []watchsync.LocalPlay{ + { + Kind: historyimport.KindEpisode, + SeriesTMDBID: "999", + SeasonNumber: 1, + EpisodeNumber: 3, + WatchedAt: time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC), + }, + { + Kind: historyimport.KindEpisode, + SeriesTMDBID: "999", + SeasonNumber: 1, + EpisodeNumber: 4, + WatchedAt: time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC), + }, + } + + addPayload := buildHistoryPayload(plays) + if len(addPayload.Shows) != 1 { + t.Fatalf("shows = %#v, want 1 merged show", addPayload.Shows) + } + show := addPayload.Shows[0] + if len(show.Seasons) != 1 { + t.Fatalf("seasons = %#v, want 1 merged season", show.Seasons) + } + if len(show.Seasons[0].Episodes) != 2 { + t.Fatalf("episodes = %#v, want 2 episodes under one season", show.Seasons[0].Episodes) + } + if show.Seasons[0].Episodes[0].Number != 3 || show.Seasons[0].Episodes[1].Number != 4 { + t.Fatalf("episode numbers = %#v, want [3 4]", show.Seasons[0].Episodes) + } + + removePayload := buildHistoryRemovePayload(plays) + if len(removePayload.Shows) != 1 || len(removePayload.Shows[0].Seasons) != 1 || len(removePayload.Shows[0].Seasons[0].Episodes) != 2 { + t.Fatalf("remove payload did not merge by show/season: %#v", removePayload.Shows) + } +} + +func TestHistoryEpisodeWithRealIDsKeepsEpisodeIDs(t *testing.T) { + play := watchsync.LocalPlay{ + HistoryID: "history-episode-with-ids", + Kind: historyimport.KindEpisode, + TVDBID: "54321", + SeriesTMDBID: "999", + SeasonNumber: 2, + EpisodeNumber: 5, + WatchedAt: time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC), + } + + addPayload := buildHistoryPayload([]watchsync.LocalPlay{play}) + if len(addPayload.Episodes) != 1 { + t.Fatalf("add payload episodes = %#v, want 1", addPayload.Episodes) + } + if addPayload.Episodes[0].IDs.TVDB != 54321 { + t.Fatalf("add payload episode IDs = %#v, want TVDB 54321", addPayload.Episodes[0].IDs) + } + if len(addPayload.Shows) != 0 { + t.Fatalf("add payload shows = %#v, want none", addPayload.Shows) + } + + addJSON, err := json.Marshal(addPayload) + if err != nil { + t.Fatalf("marshal add payload: %v", err) + } + if !bytes.Contains(addJSON, []byte(`"tvdb":54321`)) { + t.Fatalf("add payload JSON missing real episode id: %s", addJSON) + } + + removePayload := buildHistoryRemovePayload([]watchsync.LocalPlay{play}) + if len(removePayload.Episodes) != 1 { + t.Fatalf("remove payload episodes = %#v, want 1", removePayload.Episodes) + } + if removePayload.Episodes[0].IDs.TVDB != 54321 { + t.Fatalf("remove payload episode IDs = %#v, want TVDB 54321", removePayload.Episodes[0].IDs) + } + if len(removePayload.Shows) != 0 { + t.Fatalf("remove payload shows = %#v, want none", removePayload.Shows) + } + + removeJSON, err := json.Marshal(removePayload) + if err != nil { + t.Fatalf("marshal remove payload: %v", err) + } + if !bytes.Contains(removeJSON, []byte(`"tvdb":54321`)) { + t.Fatalf("remove payload JSON missing real episode id: %s", removeJSON) + } +}