fix(watchsync): correct Trakt TV episode sync payload (#254)
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Opus 4.8
parent
cf0db385f3
commit
eb35141ecc
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user