Files
silo-server/internal/watchstate/identity.go
eb35141ecc 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>
2026-07-01 17:36:47 -04:00

165 lines
5.0 KiB
Go

package watchstate
import (
"context"
"errors"
"strings"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type itemLookup interface {
GetByID(ctx context.Context, contentID string) (*models.MediaItem, error)
}
type episodeLookup interface {
GetByID(ctx context.Context, contentID string) (*models.Episode, error)
GetBySeriesAndNumber(ctx context.Context, seriesID string, season, episode int) (*models.Episode, error)
}
type providerIDLookup interface {
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaItemProviderID, error)
FindContentIDByProviderIDs(ctx context.Context, providerIDs map[string]string, itemType, excludeContentID string) (string, error)
}
// StableIdentityResolver translates volatile local content IDs to provider-ID
// based identities that survive rescans or catalog rebinding.
type StableIdentityResolver struct {
items itemLookup
episodes episodeLookup
providerIDs providerIDLookup
}
func NewStableIdentityResolver(items itemLookup, episodes episodeLookup, providerIDs providerIDLookup) *StableIdentityResolver {
return &StableIdentityResolver{
items: items,
episodes: episodes,
providerIDs: providerIDs,
}
}
func (r *StableIdentityResolver) ResolveHistoryIdentity(ctx context.Context, mediaItemID string) userstore.WatchIdentity {
if r == nil || strings.TrimSpace(mediaItemID) == "" || r.providerIDs == nil {
return userstore.WatchIdentity{}
}
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))
// 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,
}
}
}
if r.items == nil {
return userstore.WatchIdentity{}
}
item, err := r.items.GetByID(ctx, mediaItemID)
if err != nil || item == nil || item.Type != "movie" {
return userstore.WatchIdentity{}
}
itemIDs := providerIDMap(r.loadProviderIDs(ctx, mediaItemID))
if len(itemIDs) == 0 {
return userstore.WatchIdentity{}
}
return userstore.WatchIdentity{
StableType: "movie",
ProviderIDs: itemIDs,
}
}
func (r *StableIdentityResolver) ResolveMovieContentID(ctx context.Context, providerIDs map[string]string) (string, error) {
if r == nil || r.providerIDs == nil {
return "", nil
}
return r.providerIDs.FindContentIDByProviderIDs(ctx, providerIDs, "movie", "")
}
func (r *StableIdentityResolver) ResolveEpisodeContentID(
ctx context.Context,
seriesProviderIDs map[string]string,
seasonNumber, episodeNumber int,
) (string, error) {
if r == nil || r.providerIDs == nil || r.episodes == nil || seasonNumber < 0 || episodeNumber <= 0 {
return "", nil
}
seriesID, err := r.providerIDs.FindContentIDByProviderIDs(ctx, seriesProviderIDs, "series", "")
if err != nil || strings.TrimSpace(seriesID) == "" {
return "", err
}
episode, err := r.episodes.GetBySeriesAndNumber(ctx, seriesID, seasonNumber, episodeNumber)
if err != nil {
if errors.Is(err, catalog.ErrEpisodeNotFound) {
return "", nil
}
return "", err
}
if episode == nil {
return "", nil
}
return episode.ContentID, nil
}
func (r *StableIdentityResolver) loadProviderIDs(ctx context.Context, contentID string) []*models.MediaItemProviderID {
ids, err := r.providerIDs.GetByContentID(ctx, contentID)
if err != nil {
return nil
}
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{}
}
result := make(map[string]string, len(rows))
for _, row := range rows {
if row == nil {
continue
}
provider := strings.TrimSpace(row.Provider)
providerID := strings.TrimSpace(row.ProviderID)
if provider == "" || providerID == "" {
continue
}
result[provider] = providerID
}
return result
}