diff --git a/docs/architecture/watch-provider-rating-sync.md b/docs/architecture/watch-provider-rating-sync.md index 8d2248fbe4..3d6048776b 100644 --- a/docs/architecture/watch-provider-rating-sync.md +++ b/docs/architecture/watch-provider-rating-sync.md @@ -108,6 +108,11 @@ mark the profile's recommendations stale once per run. A provider that rates only some kinds implements `RatingKindFilter`; items of other kinds are left out of its sync entirely, so they are neither sent nor read as removed. +Plugin providers rate the kinds they list in `supported_media_types`. Removing an +absent rating must answer `APPLIED` or `NO_CHANGE`, so a plugin's `REJECTED` answer to +a rating removal counts as a failure and the removal is retried on the next run. A +complete plugin snapshot with an unreadable rating row covers no kind, because the +row's kind is unknown, and the run records a warning. A provider that records a rated title as watched implements `RatingExportWatchGate`. Silo then sends a new rating of that kind only once the profile has a completed play of diff --git a/go.mod b/go.mod index 6b98f98626..3b4baec977 100644 --- a/go.mod +++ b/go.mod @@ -128,7 +128,7 @@ require ( ) require ( - github.com/Silo-Server/silo-plugin-sdk v0.16.1 + github.com/Silo-Server/silo-plugin-sdk v0.17.0 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect diff --git a/go.sum b/go.sum index 73cc96960c..afd10509fe 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= -github.com/Silo-Server/silo-plugin-sdk v0.16.1 h1:bctPlzlsr75Z9ARQ86VnYY6UhyQ9YPwgoP+mqSRESPA= -github.com/Silo-Server/silo-plugin-sdk v0.16.1/go.mod h1:abwsCEKuPAAgeAqpNGbwoaut2eQlC/Kj97u89Vvg9qM= +github.com/Silo-Server/silo-plugin-sdk v0.17.0 h1:dHA4UbObMVIzAaLQC4clq1bSWuPaim88yptIRR5/f/M= +github.com/Silo-Server/silo-plugin-sdk v0.17.0/go.mod h1:abwsCEKuPAAgeAqpNGbwoaut2eQlC/Kj97u89Vvg9qM= github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc= github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s= github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= diff --git a/internal/watchsync/plugin_provider.go b/internal/watchsync/plugin_provider.go index afc27d232e..c7d56dfdaf 100644 --- a/internal/watchsync/plugin_provider.go +++ b/internal/watchsync/plugin_provider.go @@ -63,11 +63,13 @@ type PluginProvider struct { resolveClient WatchSyncPluginClientResolver resolveConfig WatchSyncPluginConfigResolver repository PluginCredentialRepository + now func() time.Time } const ( watchSyncUnsupportedMovieMediaMessage = "watch sync plugin does not support movie media" watchSyncUnsupportedEpisodeMediaMessage = "watch sync plugin does not support episode media" + watchSyncUnsupportedSeriesMediaMessage = "watch sync plugin does not support series media" watchSyncUnsupportedMediaMessage = "watch sync plugin does not support this media type" watchSyncJSONSchemaNumberType = "number" watchSyncJSONSchemaBooleanType = "boolean" @@ -112,6 +114,7 @@ func NewPluginProvider(options PluginProviderOptions) (*PluginProvider, error) { resolveClient: options.ResolveClient, resolveConfig: options.ResolveConfig, repository: options.Repository, + now: time.Now, }, nil } @@ -173,6 +176,8 @@ func (p *PluginProvider) Capabilities() Capabilities { RemoveWatchlist: p.descriptor.GetRemoveWatchlist(), ProvidesWatchlistOrder: p.descriptor.GetProvidesWatchlistOrder(), ScrobblePlayback: p.descriptor.GetScrobblePlayback(), + ImportRatings: p.descriptor.GetImportRatings(), + ExportRatings: p.descriptor.GetExportRatings(), } } @@ -1152,12 +1157,29 @@ func mediaFromIdentity(mediaItemID, kind, title string, year int, imdbID, tmdbID } } +// SyncsRatingKind reports whether the plugin rates items of kind, from the +// media types it supports. Without it, the host would send a movie-only plugin +// every series rating on each sync and log the rejection. +func (p *PluginProvider) SyncsRatingKind(kind string) bool { + return p.supportsMedia(watchSyncMediaType(kind)) +} + +// mediaFromLocalFavorite builds list and rating media. A series item carries +// its own ids, so its SERIES media has them in external_ids and no series_*. +func mediaFromLocalFavorite(item LocalFavorite) *pluginv1.WatchSyncMedia { + return mediaFromIdentity(item.MediaItemID, item.Kind, item.Title, item.Year, + item.IMDbID, item.TMDBID, item.TVDBID, "", 0, + item.SeriesIMDbID, item.SeriesTMDBID, item.SeriesTVDBID, 0, 0) +} + func watchSyncMediaType(kind string) pluginv1.WatchSyncMediaType { switch strings.ToLower(strings.TrimSpace(kind)) { case historyimport.KindMovie: return pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE case historyimport.KindEpisode: return pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE + case historyimport.KindSeries: + return pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES default: return pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_UNSPECIFIED } @@ -1263,12 +1285,20 @@ func supportedWatchSyncMediaTypes(descriptor *pluginv1.WatchSyncProviderDescript for _, mediaType := range media { switch mediaType { case pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, - pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE: + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE, + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES: supported[mediaType] = struct{}{} - default: + case pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_UNSPECIFIED: return nil, fmt.Errorf("advertises unsupported media type %q", mediaType.String()) + default: + // A media type added by a newer SDK is ignored rather than + // rejecting the plugin, so a plugin release that opts into a new + // type keeps its existing sync on servers that predate the type. } } + if len(supported) == 0 { + return nil, errors.New("advertises no media type this server supports") + } return supported, nil } @@ -1286,6 +1316,8 @@ func unsupportedWatchSyncMediaMessage(mediaType pluginv1.WatchSyncMediaType) str return watchSyncUnsupportedMovieMediaMessage case pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE: return watchSyncUnsupportedEpisodeMediaMessage + case pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES: + return watchSyncUnsupportedSeriesMediaMessage default: return watchSyncUnsupportedMediaMessage } diff --git a/internal/watchsync/plugin_provider_state.go b/internal/watchsync/plugin_provider_state.go index 360ce50282..62fbf11a1f 100644 --- a/internal/watchsync/plugin_provider_state.go +++ b/internal/watchsync/plugin_provider_state.go @@ -17,8 +17,11 @@ const ( pluginProgressCursorKey = "plugin.remote.progress" pluginFavoritesCursorKey = "plugin.remote.favorites" pluginWatchlistCursorKey = "plugin.remote.watchlist" + pluginRatingsCursorKey = "plugin.remote.ratings" maxRemoteStatePages = 10_000 maxRemoteStateItems = 100_000 + + watchSyncIncompleteRatingSnapshotWarning = "watch sync plugin returned unreadable ratings, so ratings missing from this read are left unchanged" ) type pluginRemoteTraversal struct { @@ -138,6 +141,76 @@ func (p *PluginProvider) FetchWatchlistBatch( pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_WATCHLIST) } +// FetchRatings reads the plugin's RATING states. A complete snapshot is the +// full set for every rateable kind the plugin supports; an incremental +// traversal covers no kind, so a rating missing from it stays unknown. A +// complete snapshot with an unreadable rating row also covers no kind: the +// dropped row's kind is unknowable, and its title would otherwise read as +// unrated. +func (p *PluginProvider) FetchRatings( + ctx context.Context, + _ ServerConfig, + conn Connection, +) (RatingImportBatch, error) { + traversal, err := p.listRemoteState(ctx, conn, pluginRatingsCursorKey, + pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_RATING) + if err != nil { + return RatingImportBatch{}, err + } + batch := RatingImportBatch{ + UpdatedCursors: cursorUpdate(pluginRatingsCursorKey, traversal.nextCursor), + Warnings: traversal.warnings, + } + droppedRating := false + for _, state := range traversal.items { + // A RATING traversal must return rating state only. A missing item or + // rating payload may hide a title that is still rated, so it is + // unreadable like a malformed rating. + if state.GetRating() == nil { + batch.Warnings = append(batch.Warnings, "watch sync plugin returned remote state without a rating") + droppedRating = true + continue + } + row, err := remoteRatingFromProto(p.Key(), state) + if err != nil { + batch.Warnings = append(batch.Warnings, err.Error()) + // A dropped tombstone reads as absent, which a complete snapshot + // already means removed. Only a dropped rating can hide a title + // that is still rated. + if !state.GetRating().GetRemoved() { + droppedRating = true + } + continue + } + // Silo rates only movies and series, so an episode rating is not an + // error, just nothing to sync. + if !row.Removed && !ratingSyncKind(row.Kind) { + continue + } + batch.Rows = append(batch.Rows, row) + } + if traversal.completeSnapshot { + if droppedRating { + batch.Warnings = append(batch.Warnings, watchSyncIncompleteRatingSnapshotWarning) + } else { + batch.SnapshotKinds = p.rateableKinds() + } + } + return batch, nil +} + +// rateableKinds lists the rating kinds the plugin supports, movies first. +func (p *PluginProvider) rateableKinds() []string { + var kinds []string + if p.supportsMedia(pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE) { + kinds = append(kinds, historyimport.KindMovie) + } + if p.supportsMedia(pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES) { + kinds = append(kinds, historyimport.KindSeries) + } + return kinds +} + func (p *PluginProvider) fetchListState( ctx context.Context, conn Connection, @@ -295,9 +368,7 @@ func (p *PluginProvider) applyListEvents( events := make([]*pluginv1.WatchSyncEvent, 0, len(items)) keys := make([]string, 0, len(items)) for _, item := range items { - media := mediaFromIdentity(item.MediaItemID, item.Kind, item.Title, item.Year, - item.IMDbID, item.TMDBID, item.TVDBID, "", 0, - item.SeriesIMDbID, item.SeriesTMDBID, item.SeriesTVDBID, 0, 0) + media := mediaFromLocalFavorite(item) if !p.supportsMedia(media.GetMediaType()) { result.Failed[item.MediaItemID] = unsupportedWatchSyncMediaMessage(media.GetMediaType()) continue @@ -321,6 +392,70 @@ func (p *PluginProvider) applyListEvents( return mergeExportFailures(applied, result.Failed), err } +// ExportRatings sends SET_RATING events. The event ID carries the rating and +// the local rating time, so a retry of one change reuses its ID while a later +// re-rate to the same value, which moves the rating time, gets a new one. +func (p *PluginProvider) ExportRatings(ctx context.Context, _ ServerConfig, conn Connection, items []LocalRating) (ExportResult, error) { + return p.applyRatingEvents(ctx, conn, items, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_SET_RATING) +} + +// RemoveRatings sends REMOVE_RATING events. The host does not record when a +// rating was cleared, so the event ID carries the send time instead: it never +// repeats for a later removal, and a retry in a later run gets a fresh ID, +// which is safe because clearing an absent rating is a no-op for the plugin. +// For the same reason a REJECTED removal is reported as failed, not not-found. +func (p *PluginProvider) RemoveRatings(ctx context.Context, _ ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error) { + removals := make([]LocalRating, 0, len(items)) + for _, item := range items { + removals = append(removals, LocalRating{LocalFavorite: item}) + } + return p.applyRatingEvents(ctx, conn, removals, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_REMOVE_RATING) +} + +func (p *PluginProvider) applyRatingEvents( + ctx context.Context, + conn Connection, + items []LocalRating, + operation pluginv1.WatchSyncOperation, +) (ExportResult, error) { + failed := make(map[string]string) + events := make([]*pluginv1.WatchSyncEvent, 0, len(items)) + keys := make([]string, 0, len(items)) + sentAt := p.now().UnixNano() + for _, item := range items { + media := mediaFromLocalFavorite(item.LocalFavorite) + if !p.supportsMedia(media.GetMediaType()) { + failed[item.MediaItemID] = unsupportedWatchSyncMediaMessage(media.GetMediaType()) + continue + } + event := &pluginv1.WatchSyncEvent{ + Operation: operation, + Origin: pluginv1.WatchSyncOrigin_WATCH_SYNC_ORIGIN_MANUAL, + Media: media, + ProviderItemKey: item.ProviderItemKey, + } + if operation == pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_SET_RATING { + if item.Rating < 1 || item.Rating > 10 { + failed[item.MediaItemID] = "watch sync rating must be from 1 to 10" + continue + } + ratedAt := int64(0) + if !item.RatedAt.IsZero() { + ratedAt = item.RatedAt.UnixNano() + } + event.EventId = fmt.Sprintf("%s:%s:%d:%d", operation.String(), item.MediaItemID, item.Rating, ratedAt) + event.OccurredAt = timestampOrNil(item.RatedAt) + event.Rating = int32(item.Rating) + } else { + event.EventId = fmt.Sprintf("%s:%s:%d", operation.String(), item.MediaItemID, sentAt) + } + events = append(events, event) + keys = append(keys, item.MediaItemID) + } + applied, err := p.applyPluginEvents(ctx, conn, events, keys) + return mergeExportFailures(applied, failed), err +} + func (p *PluginProvider) applyPluginEvents( ctx context.Context, conn Connection, @@ -377,7 +512,14 @@ func (p *PluginProvider) applyPluginEventsDetailed( pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_NO_CHANGE: result.Sent = append(result.Sent, key) case pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_REJECTED: - result.NotFound = append(result.NotFound, key) + // Clearing an absent rating must be APPLIED or NO_CHANGE, so a + // rejected removal is a failure to retry, not a missing title: + // reading it as cleared would let the rating come back. + if event.GetOperation() == pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_REMOVE_RATING { + result.Failed[key] = safeApplyMessage(apply, conn.AccessToken, conn.RefreshToken) + } else { + result.NotFound = append(result.NotFound, key) + } case pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_RETRY: fault := apply.GetFault() if fault.GetCode() == pluginv1.WatchSyncFaultCode_WATCH_SYNC_FAULT_CODE_RATE_LIMITED { @@ -411,6 +553,11 @@ func remoteWatchFromProto(provider string, state *pluginv1.WatchSyncRemoteState) if err != nil { return RemoteWatch{}, err } + // The plugin contract defines SERIES for list and rating state only. A + // series-level watched row would otherwise expand to every local episode. + if identity.kind == historyimport.KindSeries { + return RemoteWatch{}, errors.New("watch sync plugin returned series-level watched state") + } watched := state.GetWatched() if watched.GetPlayCount() < 1 { return RemoteWatch{}, errors.New("watch sync plugin returned watched state with no plays") @@ -431,6 +578,9 @@ func remoteProgressFromProto(provider string, state *pluginv1.WatchSyncRemoteSta if err != nil { return RemoteProgress{}, err } + if identity.kind == historyimport.KindSeries { + return RemoteProgress{}, errors.New("watch sync plugin returned series-level progress state") + } progress := state.GetProgress() if progress.GetProgressPercent() < 0 || progress.GetProgressPercent() >= 100 { return RemoteProgress{}, errors.New("watch sync plugin returned progress outside [0,100)") @@ -469,18 +619,47 @@ func remoteFavoriteFromProto(provider string, state *pluginv1.WatchSyncRemoteSta if err != nil { return RemoteFavorite{}, err } - listedAt := time.Now().UTC() + row := identity.favorite(provider, providerItemKey) + row.FavoritedAt = time.Now().UTC() if value := timePointer(listed.GetListedAt()); value != nil { - listedAt = *value + row.FavoritedAt = *value } - return RemoteFavorite{ - Provider: provider, ProviderItemKey: providerItemKey, - Kind: identity.kind, Title: identity.title, Year: identity.year, - IMDbID: identity.imdbID, TMDBID: identity.tmdbID, TVDBID: identity.tvdbID, - SeriesTitle: identity.seriesTitle, SeriesYear: identity.seriesYear, - SeriesIMDbID: identity.seriesIMDbID, SeriesTMDBID: identity.seriesTMDBID, SeriesTVDBID: identity.seriesTVDBID, - SeasonNumber: identity.season, EpisodeNumber: identity.episode, FavoritedAt: listedAt, - }, nil + return row, nil +} + +// remoteRatingFromProto decodes one RATING state. A tombstone needs only its +// provider key; any other state needs media and a rating from 1 to 10. +func remoteRatingFromProto(provider string, state *pluginv1.WatchSyncRemoteState) (RemoteRating, error) { + rating := state.GetRating() + if rating == nil { + return RemoteRating{}, errors.New("watch sync plugin returned remote state without a rating") + } + providerItemKey := strings.TrimSpace(state.GetProviderItemKey()) + if rating.GetRemoved() { + if providerItemKey == "" { + return RemoteRating{}, errors.New("watch sync plugin returned a rating tombstone without provider identity") + } + return RemoteRating{RemoteFavorite: RemoteFavorite{ + Provider: provider, + ProviderItemKey: providerItemKey, + Removed: true, + }}, nil + } + identity, err := remoteIdentityFromProto(state) + if err != nil { + return RemoteRating{}, err + } + if rating.GetRating() < 1 || rating.GetRating() > 10 { + return RemoteRating{}, fmt.Errorf("watch sync plugin returned an out-of-range rating %d", rating.GetRating()) + } + row := RemoteRating{ + RemoteFavorite: identity.favorite(provider, providerItemKey), + Rating: int(rating.GetRating()), + } + if value := timePointer(rating.GetRatedAt()); value != nil { + row.RatedAt = *value + } + return row, nil } type remoteIdentity struct { @@ -489,6 +668,17 @@ type remoteIdentity struct { year, seriesYear, season, episode int } +func (identity remoteIdentity) favorite(provider, providerItemKey string) RemoteFavorite { + return RemoteFavorite{ + Provider: provider, ProviderItemKey: providerItemKey, + Kind: identity.kind, Title: identity.title, Year: identity.year, + IMDbID: identity.imdbID, TMDBID: identity.tmdbID, TVDBID: identity.tvdbID, + SeriesTitle: identity.seriesTitle, SeriesYear: identity.seriesYear, + SeriesIMDbID: identity.seriesIMDbID, SeriesTMDBID: identity.seriesTMDBID, SeriesTVDBID: identity.seriesTVDBID, + SeasonNumber: identity.season, EpisodeNumber: identity.episode, + } +} + func remoteIdentityFromProto(state *pluginv1.WatchSyncRemoteState) (remoteIdentity, error) { if state == nil || state.GetMedia() == nil || strings.TrimSpace(state.GetProviderItemKey()) == "" { return remoteIdentity{}, errors.New("watch sync plugin returned remote state without identity") @@ -500,6 +690,8 @@ func remoteIdentityFromProto(state *pluginv1.WatchSyncRemoteState) (remoteIdenti kind = historyimport.KindMovie case pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE: kind = historyimport.KindEpisode + case pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES: + kind = historyimport.KindSeries default: return remoteIdentity{}, errors.New("watch sync plugin returned unsupported remote media") } diff --git a/internal/watchsync/plugin_provider_test.go b/internal/watchsync/plugin_provider_test.go index ff6249166b..933acfb476 100644 --- a/internal/watchsync/plugin_provider_test.go +++ b/internal/watchsync/plugin_provider_test.go @@ -3,13 +3,17 @@ package watchsync import ( "context" "encoding/base64" + "encoding/json" "errors" + "fmt" + "slices" "strings" "testing" "time" pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" "github.com/Silo-Server/silo-server/internal/historyimport" + hostplugins "github.com/Silo-Server/silo-server/internal/plugins" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -32,6 +36,8 @@ type fakeWatchSyncPluginClient struct { devicePollResponse *pluginv1.WatchSyncDeviceAuthorizationServicePollResponse listResponse *pluginv1.WatchSyncListRemoteStateResponse listResponses []*pluginv1.WatchSyncListRemoteStateResponse + applyStatus pluginv1.WatchSyncApplyStatus // answers every event when applyResponse is nil + applyFault *pluginv1.WatchSyncFault // attached to each applyStatus answer applyErr error applyRequest *pluginv1.WatchSyncApplyEventsRequest exchangeRequest *pluginv1.WatchSyncExchangeAPIKeyRequest @@ -78,6 +84,13 @@ func (f *fakeWatchSyncPluginClient) GetAccount(_ context.Context, req *pluginv1. } func (f *fakeWatchSyncPluginClient) ApplyEvents(_ context.Context, req *pluginv1.WatchSyncApplyEventsRequest) (*pluginv1.WatchSyncApplyEventsResponse, error) { f.applyRequest = req + if f.applyResponse == nil && f.applyStatus != pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_UNSPECIFIED { + response := &pluginv1.WatchSyncApplyEventsResponse{} + for _, event := range req.GetEvents() { + response.Results = append(response.Results, &pluginv1.WatchSyncApplyResult{EventId: event.GetEventId(), Status: f.applyStatus, Fault: f.applyFault}) + } + return response, f.applyErr + } return f.applyResponse, f.applyErr } @@ -1280,6 +1293,7 @@ func TestPluginProviderMapsAllCapabilitiesAndListOperations(t *testing.T) { ImportFavorites: true, ExportFavorites: true, RemoveFavorites: true, ImportWatchlist: true, ExportWatchlist: true, RemoveWatchlist: true, ProvidesWatchlistOrder: true, ScrobblePlayback: true, MaxBatchSize: 25, + ImportRatings: true, ExportRatings: true, } client := &fakeWatchSyncPluginClient{} provider := testPluginProviderWithDescriptor(t, client, descriptor) @@ -1288,6 +1302,7 @@ func TestPluginProviderMapsAllCapabilitiesAndListOperations(t *testing.T) { ImportFavorites: true, ExportFavorites: true, RemoveFavorites: true, ImportWatchlist: true, ExportWatchlist: true, RemoveWatchlist: true, ProvidesWatchlistOrder: true, ScrobblePlayback: true, + ImportRatings: true, ExportRatings: true, }) { t.Fatalf("capabilities = %#v", provider.Capabilities()) } @@ -1411,3 +1426,496 @@ func TestPluginProviderForwardsAuthoritativeScrobbleCompletion(t *testing.T) { t.Fatal("incomplete event = true, want false") } } + +const testSeriesMediaID = "series-1" + +func ratingTestDescriptor(media ...pluginv1.WatchSyncMediaType) *pluginv1.WatchSyncProviderDescriptor { + return &pluginv1.WatchSyncProviderDescriptor{ + AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY}, + ImportRatings: true, + ExportRatings: true, + SupportedMediaTypes: media, + MaxBatchSize: 25, + } +} + +func remoteRatingState(key string, mediaType pluginv1.WatchSyncMediaType, imdbID string, rating int32, ratedAt *timestamppb.Timestamp) *pluginv1.WatchSyncRemoteState { + return &pluginv1.WatchSyncRemoteState{ + ProviderItemKey: key, + Media: &pluginv1.WatchSyncMedia{MediaType: mediaType, Title: "Title", ExternalIds: map[string]string{"imdb": imdbID}}, + Rating: &pluginv1.WatchSyncRemoteRatingState{Rating: rating, RatedAt: ratedAt}, + } +} + +func TestPluginProviderDecodesRatingSnapshot(t *testing.T) { + ratedAt := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + client := &fakeWatchSyncPluginClient{listResponse: &pluginv1.WatchSyncListRemoteStateResponse{ + CompleteSnapshot: true, + NextCursor: "cursor-2", + Items: []*pluginv1.WatchSyncRemoteState{ + remoteRatingState("m1", pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, "tt1", 8, timestamppb.New(ratedAt)), + remoteRatingState("s1", pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES, "tt2", 7, nil), + // Silo does not sync episode ratings. + remoteRatingState("e1", pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE, "tt3", 9, nil), + }, + }} + provider := testPluginProviderWithDescriptor(t, client, ratingTestDescriptor( + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE, + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES, + )) + batch, err := provider.FetchRatings(context.Background(), ServerConfig{}, Connection{ + SyncCursors: map[string]string{pluginRatingsCursorKey: testCursorOne}, + }) + if err != nil { + t.Fatal(err) + } + if len(client.listRequests) != 1 || client.listRequests[0].GetCursor() != testCursorOne || + len(client.listRequests[0].GetStateKinds()) != 1 || + client.listRequests[0].GetStateKinds()[0] != pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_RATING { + t.Fatalf("requests = %#v", client.listRequests) + } + if !slices.Equal(batch.SnapshotKinds, []string{historyimport.KindMovie, historyimport.KindSeries}) || + batch.UpdatedCursors[pluginRatingsCursorKey] != "cursor-2" || len(batch.Warnings) != 0 || len(batch.Rows) != 2 { + t.Fatalf("batch = %#v", batch) + } + movie, series := batch.Rows[0], batch.Rows[1] + if movie.Provider != testPluginProviderKey || movie.ProviderItemKey != "m1" || movie.Kind != historyimport.KindMovie || + movie.IMDbID != "tt1" || movie.Rating != 8 || !movie.RatedAt.Equal(ratedAt) || movie.Removed { + t.Fatalf("movie row = %#v", movie) + } + if series.ProviderItemKey != "s1" || series.Kind != historyimport.KindSeries || series.IMDbID != "tt2" || + series.Rating != 7 || !series.RatedAt.IsZero() { + t.Fatalf("series row = %#v", series) + } + + // The cursor resets with the agreed ratings on an account change. + kept := withoutRatingCursors(map[string]string{pluginRatingsCursorKey: "a", pluginWatchedCursorKey: "b"}) + if _, ok := kept[pluginRatingsCursorKey]; ok || kept[pluginWatchedCursorKey] != "b" { + t.Fatalf("cursors kept after account change = %#v", kept) + } +} + +func TestPluginProviderRatingSnapshotKindsFollowSupportedMedia(t *testing.T) { + for _, tc := range []struct { + name string + media []pluginv1.WatchSyncMediaType + want []string + }{ + {name: "default media", want: []string{historyimport.KindMovie}}, + {name: "series only", media: []pluginv1.WatchSyncMediaType{pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES}, want: []string{historyimport.KindSeries}}, + {name: "episodes only", media: []pluginv1.WatchSyncMediaType{pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE}}, + } { + t.Run(tc.name, func(t *testing.T) { + client := &fakeWatchSyncPluginClient{listResponse: &pluginv1.WatchSyncListRemoteStateResponse{CompleteSnapshot: true}} + provider := testPluginProviderWithDescriptor(t, client, ratingTestDescriptor(tc.media...)) + batch, err := provider.FetchRatings(context.Background(), ServerConfig{}, Connection{}) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(batch.SnapshotKinds, tc.want) { + t.Fatalf("SnapshotKinds = %#v, want %#v", batch.SnapshotKinds, tc.want) + } + }) + } +} + +func TestPluginProviderPaginatesIncrementalRatingsWithTombstone(t *testing.T) { + client := &fakeWatchSyncPluginClient{listResponses: []*pluginv1.WatchSyncListRemoteStateResponse{ + { + Items: []*pluginv1.WatchSyncRemoteState{remoteRatingState("m1", pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, "tt1", 6, nil)}, + NextPageToken: "page-2", + }, + { + Items: []*pluginv1.WatchSyncRemoteState{{ProviderItemKey: "m2", Rating: &pluginv1.WatchSyncRemoteRatingState{Removed: true}}}, + NextCursor: "cursor-2", + }, + }} + provider := testPluginProviderWithDescriptor(t, client, ratingTestDescriptor( + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES, + )) + batch, err := provider.FetchRatings(context.Background(), ServerConfig{}, Connection{ + SyncCursors: map[string]string{pluginRatingsCursorKey: testCursorOne}, + }) + if err != nil { + t.Fatal(err) + } + if len(client.listRequests) != 2 || client.listRequests[1].GetCursor() != testCursorOne || + client.listRequests[1].GetPageToken() != "page-2" { + t.Fatalf("requests = %#v", client.listRequests) + } + // An incremental read is complete for no kind, so absent ratings stay unknown. + if len(batch.SnapshotKinds) != 0 || batch.UpdatedCursors[pluginRatingsCursorKey] != "cursor-2" || len(batch.Rows) != 2 { + t.Fatalf("batch = %#v", batch) + } + tombstone := batch.Rows[1] + if !tombstone.Removed || tombstone.ProviderItemKey != "m2" || tombstone.Kind != "" || tombstone.Rating != 0 { + t.Fatalf("tombstone = %#v", tombstone) + } +} + +func TestPluginProviderWarnsAndSkipsInvalidRatings(t *testing.T) { + client := &fakeWatchSyncPluginClient{listResponse: &pluginv1.WatchSyncListRemoteStateResponse{ + CompleteSnapshot: true, + Items: []*pluginv1.WatchSyncRemoteState{ + remoteRatingState("zero", pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, "tt0", 0, nil), + remoteRatingState("eleven", pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, "tt11", 11, nil), + remoteRatingState("ten", pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, "tt10", 10, nil), + {Rating: &pluginv1.WatchSyncRemoteRatingState{Removed: true}}, + }, + }} + provider := testPluginProviderWithDescriptor(t, client, ratingTestDescriptor()) + batch, err := provider.FetchRatings(context.Background(), ServerConfig{}, Connection{}) + if err != nil { + t.Fatal(err) + } + if len(batch.Rows) != 1 || batch.Rows[0].Rating != 10 { + t.Fatalf("rows = %#v", batch.Rows) + } + want := []string{ + "watch sync plugin returned an out-of-range rating 0", + "watch sync plugin returned an out-of-range rating 11", + "watch sync plugin returned a rating tombstone without provider identity", + watchSyncIncompleteRatingSnapshotWarning, + } + if !slices.Equal(batch.Warnings, want) { + t.Fatalf("warnings = %#v", batch.Warnings) + } +} + +// A complete snapshot that drops an unreadable rating cannot say which kind the +// row was, so it covers no kind and absent ratings stay unknown. A dropped +// tombstone reads as absent, which the snapshot already treats as removed. +func TestPluginProviderRatingSnapshotWithUnreadableRatingCoversNoKind(t *testing.T) { + valid := remoteRatingState("m1", pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, "tt1", 8, nil) + for _, tc := range []struct { + name string + complete bool + bad *pluginv1.WatchSyncRemoteState + wantKinds []string + wantWarn []string + }{ + { + name: "bad media", complete: true, + bad: remoteRatingState("x", pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_UNSPECIFIED, "tt2", 7, nil), + wantWarn: []string{"watch sync plugin returned unsupported remote media", watchSyncIncompleteRatingSnapshotWarning}, + }, + { + name: "missing key", complete: true, + bad: remoteRatingState("", pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES, "tt2", 7, nil), + wantWarn: []string{"watch sync plugin returned remote state without identity", watchSyncIncompleteRatingSnapshotWarning}, + }, + { + name: "out of range", complete: true, + bad: remoteRatingState("x", pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, "tt2", 11, nil), + wantWarn: []string{"watch sync plugin returned an out-of-range rating 11", watchSyncIncompleteRatingSnapshotWarning}, + }, + { + name: "missing rating payload", complete: true, + bad: &pluginv1.WatchSyncRemoteState{ProviderItemKey: "x"}, + wantWarn: []string{"watch sync plugin returned remote state without a rating", watchSyncIncompleteRatingSnapshotWarning}, + }, + { + name: "nil item", complete: true, + bad: nil, + wantWarn: []string{"watch sync plugin returned remote state without a rating", watchSyncIncompleteRatingSnapshotWarning}, + }, + { + name: "bad tombstone keeps the snapshot", complete: true, + bad: &pluginv1.WatchSyncRemoteState{Rating: &pluginv1.WatchSyncRemoteRatingState{Removed: true}}, + wantKinds: []string{historyimport.KindMovie, historyimport.KindSeries}, + wantWarn: []string{"watch sync plugin returned a rating tombstone without provider identity"}, + }, + { + name: "incremental read", + bad: remoteRatingState("", pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, "tt2", 7, nil), + wantWarn: []string{"watch sync plugin returned remote state without identity"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + client := &fakeWatchSyncPluginClient{listResponse: &pluginv1.WatchSyncListRemoteStateResponse{ + CompleteSnapshot: tc.complete, + Items: []*pluginv1.WatchSyncRemoteState{valid, tc.bad}, + }} + provider := testPluginProviderWithDescriptor(t, client, ratingTestDescriptor( + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES, + )) + batch, err := provider.FetchRatings(context.Background(), ServerConfig{}, Connection{}) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(batch.SnapshotKinds, tc.wantKinds) { + t.Fatalf("SnapshotKinds = %#v, want %#v", batch.SnapshotKinds, tc.wantKinds) + } + if !slices.Equal(batch.Warnings, tc.wantWarn) { + t.Fatalf("warnings = %#v, want %#v", batch.Warnings, tc.wantWarn) + } + if len(batch.Rows) != 1 || batch.Rows[0].ProviderItemKey != "m1" { + t.Fatalf("rows = %#v", batch.Rows) + } + }) + } +} + +func TestPluginProviderMapsSeriesMediaBothWays(t *testing.T) { + series := LocalFavorite{ + MediaItemID: testSeriesMediaID, Kind: historyimport.KindSeries, Title: "Show", Year: 2020, + IMDbID: "tt9", TMDBID: "99", TVDBID: "77", ProviderItemKey: "imdb:tt9", + } + media := mediaFromLocalFavorite(series) + if media.GetMediaType() != pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES || + media.GetTitle() != "Show" || media.GetYear() != 2020 || + media.GetExternalIds()["imdb"] != "tt9" || media.GetExternalIds()["tmdb"] != "99" || media.GetExternalIds()["tvdb"] != "77" || + len(media.GetSeriesExternalIds()) != 0 || media.GetSeasonNumber() != 0 || media.GetEpisodeNumber() != 0 { + t.Fatalf("series media = %#v", media) + } + + client := &fakeWatchSyncPluginClient{listResponses: []*pluginv1.WatchSyncListRemoteStateResponse{ + {CompleteSnapshot: true, Items: []*pluginv1.WatchSyncRemoteState{{ProviderItemKey: "s1", Media: media, Favorite: &pluginv1.WatchSyncRemoteListState{}}}}, + {Items: []*pluginv1.WatchSyncRemoteState{{ProviderItemKey: "s1", Media: media, Watched: &pluginv1.WatchSyncRemoteWatchedState{PlayCount: 1}}}}, + }} + provider := testPluginProviderWithDescriptor(t, client, &pluginv1.WatchSyncProviderDescriptor{ + AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY}, + ImportFavorites: true, ImportWatched: true, MaxBatchSize: 25, + SupportedMediaTypes: []pluginv1.WatchSyncMediaType{pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES}, + }) + favorites, err := provider.FetchFavoritesBatch(context.Background(), ServerConfig{}, Connection{}) + if err != nil { + t.Fatal(err) + } + if len(favorites.Rows) != 1 || favorites.Rows[0].Kind != historyimport.KindSeries || favorites.Rows[0].IMDbID != "tt9" || + favorites.Rows[0].TVDBID != "77" || favorites.Rows[0].SeriesIMDbID != "" { + t.Fatalf("favorites = %#v", favorites) + } + // SERIES is not defined for watched state; a series-level row would + // otherwise mark every local episode watched. + watched, err := provider.FetchWatchedBatch(context.Background(), ServerConfig{}, Connection{}) + if err != nil { + t.Fatal(err) + } + if len(watched.Rows) != 0 || len(watched.Warnings) != 1 || !strings.Contains(watched.Warnings[0], "series-level watched") { + t.Fatalf("watched = %#v", watched) + } +} + +func TestPluginProviderSendsSeriesListEventsOnlyWhenSupported(t *testing.T) { + series := LocalFavorite{MediaItemID: testSeriesMediaID, Kind: historyimport.KindSeries, IMDbID: "tt9", ProviderItemKey: "imdb:tt9"} + client := &fakeWatchSyncPluginClient{applyStatus: pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_APPLIED} + provider := testPluginProviderWithDescriptor(t, client, &pluginv1.WatchSyncProviderDescriptor{ + AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY}, + ExportFavorites: true, MaxBatchSize: 25, + SupportedMediaTypes: []pluginv1.WatchSyncMediaType{ + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES, + }, + }) + result, err := provider.ExportFavorites(context.Background(), ServerConfig{}, Connection{}, []LocalFavorite{series}) + if err != nil { + t.Fatal(err) + } + if len(result.Sent) != 1 || client.applyRequest.GetEvents()[0].GetMedia().GetMediaType() != pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES { + t.Fatalf("result=%#v request=%#v", result, client.applyRequest) + } + + // A plugin that does not list SERIES still never receives series items. + client = &fakeWatchSyncPluginClient{} + result, err = testPluginProvider(t, client).ExportFavorites(context.Background(), ServerConfig{}, Connection{}, []LocalFavorite{series}) + if err != nil { + t.Fatal(err) + } + if client.applyRequest != nil || result.Failed[testSeriesMediaID] != watchSyncUnsupportedSeriesMediaMessage { + t.Fatalf("result=%#v request=%#v", result, client.applyRequest) + } +} + +func TestPluginProviderRatingEventsCarryValueAndDistinctIDs(t *testing.T) { + client := &fakeWatchSyncPluginClient{applyStatus: pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_APPLIED} + provider := testPluginProviderWithDescriptor(t, client, ratingTestDescriptor( + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES, + )) + movie := LocalFavorite{MediaItemID: testMovieMediaID, Kind: historyimport.KindMovie, IMDbID: "tt1", ProviderItemKey: "imdb:tt1"} + ratedAt := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + set := func(rating int, at time.Time) *pluginv1.WatchSyncEvent { + t.Helper() + result, err := provider.ExportRatings(context.Background(), ServerConfig{}, Connection{}, []LocalRating{{LocalFavorite: movie, Rating: rating, RatedAt: at}}) + if err != nil || len(result.Sent) != 1 || result.Sent[0] != testMovieMediaID { + t.Fatalf("result=%#v err=%v", result, err) + } + return client.applyRequest.GetEvents()[0] + } + remove := func() *pluginv1.WatchSyncEvent { + t.Helper() + result, err := provider.RemoveRatings(context.Background(), ServerConfig{}, Connection{}, []LocalFavorite{movie}) + if err != nil || len(result.Sent) != 1 || result.Sent[0] != testMovieMediaID { + t.Fatalf("result=%#v err=%v", result, err) + } + return client.applyRequest.GetEvents()[0] + } + + event := set(8, ratedAt) + wantID := fmt.Sprintf("%s:%s:8:%d", pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_SET_RATING, testMovieMediaID, ratedAt.UnixNano()) + if event.GetEventId() != wantID || event.GetOperation() != pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_SET_RATING || + event.GetRating() != 8 || !event.GetOccurredAt().AsTime().Equal(ratedAt) || event.GetProviderItemKey() != "imdb:tt1" || + event.GetMedia().GetMediaType() != pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE || event.GetMedia().GetExternalIds()["imdb"] != "tt1" { + t.Fatalf("set event = %#v", event) + } + if retry := set(8, ratedAt); retry.GetEventId() != event.GetEventId() { + t.Fatalf("retry event ID = %q, want %q", retry.GetEventId(), event.GetEventId()) + } + if rerated := set(8, ratedAt.Add(time.Hour)); rerated.GetEventId() == event.GetEventId() { + t.Fatal("a later re-rate to the same value reused the event ID") + } + if changed := set(6, ratedAt); changed.GetEventId() == event.GetEventId() { + t.Fatal("a different rating reused the event ID") + } + + removedAt := ratedAt.Add(2 * time.Hour) + provider.now = func() time.Time { return removedAt } + removal := remove() + wantID = fmt.Sprintf("%s:%s:%d", pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_REMOVE_RATING, testMovieMediaID, removedAt.UnixNano()) + if removal.GetEventId() != wantID || removal.GetOperation() != pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_REMOVE_RATING || + removal.GetRating() != 0 || removal.GetOccurredAt() != nil || removal.GetProviderItemKey() != "imdb:tt1" { + t.Fatalf("remove event = %#v", removal) + } + provider.now = func() time.Time { return removedAt.Add(time.Hour) } + if later := remove(); later.GetEventId() == removal.GetEventId() { + t.Fatal("a later removal reused the event ID") + } +} + +// Clearing an absent rating must answer APPLIED or NO_CHANGE, so a REJECTED +// removal is a failure the service retries. A rejected SET_RATING, like a +// rejected list or watched event, still means the plugin has no such title. +func TestPluginProviderRejectedRatingRemovalFails(t *testing.T) { + client := &fakeWatchSyncPluginClient{ + applyStatus: pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_REJECTED, + applyFault: &pluginv1.WatchSyncFault{SafeMessage: "rating removal refused for " + testSecretValue}, + } + provider := testPluginProviderWithDescriptor(t, client, ratingTestDescriptor(pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE)) + conn := Connection{AccessToken: testSecretValue} + movie := LocalFavorite{MediaItemID: testMovieMediaID, Kind: historyimport.KindMovie, IMDbID: "tt1", ProviderItemKey: "imdb:tt1"} + + removed, err := provider.RemoveRatings(context.Background(), ServerConfig{}, conn, []LocalFavorite{movie}) + if err != nil { + t.Fatal(err) + } + message := removed.Failed[testMovieMediaID] + if len(removed.Sent) != 0 || len(removed.NotFound) != 0 || !strings.HasPrefix(message, "rating removal refused for ") || + strings.Contains(message, testSecretValue) { + t.Fatalf("remove result = %#v", removed) + } + + set, err := provider.ExportRatings(context.Background(), ServerConfig{}, conn, []LocalRating{{LocalFavorite: movie, Rating: 8}}) + if err != nil { + t.Fatal(err) + } + if len(set.Failed) != 0 || !slices.Equal(set.NotFound, []string{testMovieMediaID}) { + t.Fatalf("set result = %#v", set) + } + + listRemoved, err := provider.RemoveFavorites(context.Background(), ServerConfig{}, conn, []LocalFavorite{movie}) + if err != nil { + t.Fatal(err) + } + if len(listRemoved.Failed) != 0 || !slices.Equal(listRemoved.NotFound, []string{testMovieMediaID}) { + t.Fatalf("favorite removal result = %#v", listRemoved) + } +} + +func TestPluginProviderRatingEventsFailUnsupportedMedia(t *testing.T) { + client := &fakeWatchSyncPluginClient{applyStatus: pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_APPLIED} + provider := testPluginProviderWithDescriptor(t, client, ratingTestDescriptor(pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE)) + series := LocalFavorite{MediaItemID: testSeriesMediaID, Kind: historyimport.KindSeries, IMDbID: "tt9"} + result, err := provider.ExportRatings(context.Background(), ServerConfig{}, Connection{}, []LocalRating{ + {LocalFavorite: LocalFavorite{MediaItemID: testMovieMediaID, Kind: historyimport.KindMovie, IMDbID: "tt1"}, Rating: 8}, + {LocalFavorite: series, Rating: 6}, + {LocalFavorite: LocalFavorite{MediaItemID: "movie-2", Kind: historyimport.KindMovie, IMDbID: "tt2"}, Rating: 0}, + }) + if err != nil { + t.Fatal(err) + } + if len(client.applyRequest.GetEvents()) != 1 || len(result.Sent) != 1 || result.Sent[0] != testMovieMediaID || + result.Failed[testSeriesMediaID] != watchSyncUnsupportedSeriesMediaMessage || result.Failed["movie-2"] == "" { + t.Fatalf("result=%#v request=%#v", result, client.applyRequest) + } + + client.applyRequest = nil + result, err = provider.RemoveRatings(context.Background(), ServerConfig{}, Connection{}, []LocalFavorite{series}) + if err != nil { + t.Fatal(err) + } + if client.applyRequest != nil || result.Failed[testSeriesMediaID] != watchSyncUnsupportedSeriesMediaMessage { + t.Fatalf("result=%#v request=%#v", result, client.applyRequest) + } +} + +func TestPluginProviderRatingCapabilitiesSurviveCapabilityStorage(t *testing.T) { + records, err := hostplugins.CapabilityRecordsFromManifest(&pluginv1.PluginManifest{Capabilities: []*pluginv1.CapabilityDescriptor{{ + Type: "watch_sync_provider.v1", Id: testPluginCapabilityID, DisplayName: "AniList", + WatchSyncProvider: ratingTestDescriptor( + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, + pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES, + ), + }}}) + if err != nil || len(records) != 1 { + t.Fatalf("records=%#v err=%v", records, err) + } + // Capability metadata is stored as JSON and decoded on provider reload. + stored, err := json.Marshal(records[0].Metadata) + if err != nil { + t.Fatal(err) + } + records[0].Metadata = nil + if err := json.Unmarshal(stored, &records[0].Metadata); err != nil { + t.Fatal(err) + } + descriptor, err := hostplugins.DecodeCapability(&records[0]) + if err != nil { + t.Fatal(err) + } + provider := testPluginProviderWithDescriptor(t, &fakeWatchSyncPluginClient{}, descriptor.GetWatchSyncProvider()) + capabilities := provider.Capabilities() + if !capabilities.ImportRatings || !capabilities.ExportRatings || + !provider.supportsMedia(pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_SERIES) { + t.Fatalf("capabilities=%#v descriptor=%#v", capabilities, descriptor.GetWatchSyncProvider()) + } +} + +func TestSupportedWatchSyncMediaTypesIgnoresTypesFromNewerSDKs(t *testing.T) { + future := pluginv1.WatchSyncMediaType(99) + supported, err := supportedWatchSyncMediaTypes(&pluginv1.WatchSyncProviderDescriptor{ + SupportedMediaTypes: []pluginv1.WatchSyncMediaType{pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE, future}, + }) + if err != nil { + t.Fatalf("a future media type must not reject the plugin: %v", err) + } + if _, ok := supported[future]; ok || len(supported) != 1 { + t.Fatalf("supported = %v, want only movie", supported) + } + if _, err := supportedWatchSyncMediaTypes(&pluginv1.WatchSyncProviderDescriptor{ + SupportedMediaTypes: []pluginv1.WatchSyncMediaType{future}, + }); err == nil { + t.Fatal("a plugin with no media type this server supports must be rejected") + } + if _, err := supportedWatchSyncMediaTypes(&pluginv1.WatchSyncProviderDescriptor{ + SupportedMediaTypes: []pluginv1.WatchSyncMediaType{pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_UNSPECIFIED}, + }); err == nil { + t.Fatal("an unspecified media type must still be rejected") + } +} + +func TestPluginProviderSyncsRatingKindFollowsSupportedMedia(t *testing.T) { + supported, err := supportedWatchSyncMediaTypes(&pluginv1.WatchSyncProviderDescriptor{ + SupportedMediaTypes: []pluginv1.WatchSyncMediaType{pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE}, + }) + if err != nil { + t.Fatal(err) + } + provider := &PluginProvider{supportedMedia: supported} + if !provider.SyncsRatingKind(historyimport.KindMovie) || provider.SyncsRatingKind(historyimport.KindSeries) { + t.Fatal("a movie-only plugin must rate movies and skip series") + } +}