* 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>
165 lines
5.0 KiB
Go
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
|
|
}
|