Files
silo-server/internal/metadata/provider_id_persistence_test.go
99d205676f fix(metadata): prevent stale cross-provider IDs (#480)
* fix(metadata): prevent stale cross-provider IDs

* fix(metadata): address stale ID review findings

* fix(migrations): build the stale-ID primary key concurrently

ALTER TABLE ... ADD PRIMARY KEY builds the index under ACCESS EXCLUSIVE,
blocking reads and writes on stale_media_ids for the whole build. Create the
wider unique index with CREATE UNIQUE INDEX CONCURRENTLY and attach it with
ADD CONSTRAINT ... PRIMARY KEY USING INDEX instead; all three key columns are
already NOT NULL, so the attach is metadata-only. Same treatment on the
rollback path, plus the repo's INVALID-remnant cleanup so a failed concurrent
build is not silently accepted by IF NOT EXISTS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:19:33 -04:00

1014 lines
30 KiB
Go

package metadata
import (
"context"
"errors"
"sync"
"testing"
"github.com/Silo-Server/silo-server/internal/contentid"
"github.com/Silo-Server/silo-server/internal/models"
)
const (
staleRecoveryContentID = "legacy-item-1"
staleRecoveryTitle = "Recovered Movie"
staleRecoveryIMDbID = "tt1234567"
)
type fakeProviderIDRepo struct {
mu sync.Mutex
byContentID map[string][]*models.MediaItemProviderID
lastReplace map[string]map[string]string
}
func newFakeProviderIDRepo() *fakeProviderIDRepo {
return &fakeProviderIDRepo{
byContentID: make(map[string][]*models.MediaItemProviderID),
lastReplace: make(map[string]map[string]string),
}
}
func (r *fakeProviderIDRepo) GetByContentID(_ context.Context, contentID string) ([]*models.MediaItemProviderID, error) {
r.mu.Lock()
defer r.mu.Unlock()
rows := r.byContentID[contentID]
out := make([]*models.MediaItemProviderID, 0, len(rows))
for _, row := range rows {
if row == nil {
continue
}
cp := *row
out = append(out, &cp)
}
return out, nil
}
func (r *fakeProviderIDRepo) ReplaceByContentID(_ context.Context, contentID string, providerIDs map[string]string) error {
r.mu.Lock()
defer r.mu.Unlock()
cp := make(map[string]string, len(providerIDs))
for k, v := range providerIDs {
if isEphemeralProviderIDKey(k) {
continue
}
cp[k] = v
}
r.lastReplace[contentID] = cp
return nil
}
func (r *fakeProviderIDRepo) FindContentIDByProviderIDs(_ context.Context, providerIDs map[string]string, itemType, excludeContentID string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
for contentID, rows := range r.byContentID {
if contentID == excludeContentID {
continue
}
for _, row := range rows {
if row == nil || row.ProviderID == "" {
continue
}
if itemType != "" && row.ItemType != "" && row.ItemType != itemType {
continue
}
if isEphemeralProviderIDKey(row.Provider) {
continue
}
if providerIDs[row.Provider] == row.ProviderID {
return contentID, nil
}
}
}
return "", nil
}
func (r *fakeProviderIDRepo) set(contentID string, ids ...*models.MediaItemProviderID) {
r.mu.Lock()
defer r.mu.Unlock()
cp := make([]*models.MediaItemProviderID, 0, len(ids))
for _, id := range ids {
if id == nil {
continue
}
row := *id
cp = append(cp, &row)
}
r.byContentID[contentID] = cp
}
type fakeStaleIDRepo struct {
mu sync.Mutex
byContentID map[string][]*models.StaleMediaID
}
func newFakeStaleIDRepo() *fakeStaleIDRepo {
return &fakeStaleIDRepo{byContentID: make(map[string][]*models.StaleMediaID)}
}
func (r *fakeStaleIDRepo) GetByContentID(_ context.Context, contentID string) ([]*models.StaleMediaID, error) {
r.mu.Lock()
defer r.mu.Unlock()
rows := r.byContentID[contentID]
out := make([]*models.StaleMediaID, 0, len(rows))
for _, row := range rows {
if row == nil {
continue
}
cp := *row
out = append(out, &cp)
}
return out, nil
}
func (r *fakeStaleIDRepo) Upsert(_ context.Context, contentID, provider, providerID string) error {
r.mu.Lock()
defer r.mu.Unlock()
for _, row := range r.byContentID[contentID] {
if row == nil || row.Provider != provider || row.ProviderID != providerID {
continue
}
return nil
}
r.byContentID[contentID] = append(r.byContentID[contentID], &models.StaleMediaID{
ContentID: contentID,
Provider: provider,
ProviderID: providerID,
})
return nil
}
func (r *fakeStaleIDRepo) DeleteByContentID(_ context.Context, contentID string) error {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.byContentID, contentID)
return nil
}
func (r *fakeStaleIDRepo) set(contentID string, ids ...*models.StaleMediaID) {
r.mu.Lock()
defer r.mu.Unlock()
cp := make([]*models.StaleMediaID, 0, len(ids))
for _, id := range ids {
if id == nil {
continue
}
row := *id
cp = append(cp, &row)
}
r.byContentID[contentID] = cp
}
type capturingMetadataProvider struct {
mu sync.Mutex
lastReq MetadataRequest
response *MetadataResult
}
type searchMetadataProvider struct {
slug string
searchResults []SearchResult
metadataResult *MetadataResult
metadataCalls int
}
type image404MetadataProvider struct {
*searchMetadataProvider
}
type searchThen404MetadataProvider struct {
slug string
searchResults []SearchResult
}
type capturingEpisodeBootstrapProvider struct {
providerIDs map[string]string
}
func (p *image404MetadataProvider) GetImages(_ context.Context, _ ImageRequest) ([]RemoteImage, error) {
return nil, errors.New(p.slug + ": HTTP 404: not found")
}
func (p *searchThen404MetadataProvider) Slug() string { return p.slug }
func (p *searchThen404MetadataProvider) Name() string { return p.slug }
func (p *searchThen404MetadataProvider) ForTypes() []string {
return []string{anchoredItemTypeMovie, anchoredItemTypeSeries}
}
func (p *searchThen404MetadataProvider) Search(_ context.Context, _ SearchQuery) ([]SearchResult, error) {
return append([]SearchResult(nil), p.searchResults...), nil
}
func (p *searchThen404MetadataProvider) GetMetadata(_ context.Context, _ MetadataRequest) (*MetadataResult, error) {
return nil, errors.New(p.slug + ": HTTP 404: not found")
}
func (p *capturingEpisodeBootstrapProvider) Slug() string { return contentid.ProviderTVDB }
func (p *capturingEpisodeBootstrapProvider) Name() string { return "TVDB episodes" }
func (p *capturingEpisodeBootstrapProvider) ForTypes() []string {
return []string{anchoredItemTypeSeries}
}
func (p *capturingEpisodeBootstrapProvider) GetSeasons(_ context.Context, req SeasonsRequest) ([]SeasonResult, error) {
p.providerIDs = copyMap(req.ProviderIDs)
return nil, nil
}
func (p *capturingEpisodeBootstrapProvider) GetEpisodes(context.Context, EpisodesRequest) ([]EpisodeResult, error) {
return nil, nil
}
func (p *searchMetadataProvider) Slug() string { return p.slug }
func (p *searchMetadataProvider) Name() string { return p.slug }
func (p *searchMetadataProvider) ForTypes() []string {
return []string{anchoredItemTypeMovie, anchoredItemTypeSeries}
}
func (p *searchMetadataProvider) Search(_ context.Context, _ SearchQuery) ([]SearchResult, error) {
results := make([]SearchResult, len(p.searchResults))
for i := range p.searchResults {
results[i] = p.searchResults[i]
results[i].ProviderIDs = copyMap(p.searchResults[i].ProviderIDs)
}
return results, nil
}
func (p *searchMetadataProvider) GetMetadata(_ context.Context, _ MetadataRequest) (*MetadataResult, error) {
p.metadataCalls++
if p.metadataResult == nil {
return nil, nil
}
result := *p.metadataResult
result.ProviderIDs = copyMap(p.metadataResult.ProviderIDs)
return &result, nil
}
func (p *capturingMetadataProvider) Slug() string { return "capture" }
func (p *capturingMetadataProvider) Name() string { return "capture" }
func (p *capturingMetadataProvider) ForTypes() []string { return []string{"movie", "series"} }
func (p *capturingMetadataProvider) GetMetadata(_ context.Context, req MetadataRequest) (*MetadataResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.lastReq = req
if p.response != nil {
cp := *p.response
cp.ProviderIDs = copyMap(p.response.ProviderIDs)
return &cp, nil
}
return &MetadataResult{HasMetadata: false}, nil
}
func (p *capturingMetadataProvider) lastRequest() MetadataRequest {
p.mu.Lock()
defer p.mu.Unlock()
return p.lastReq
}
func TestProcess_ScheduledRefreshReplacesRecordedStaleCurrentID(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: staleRecoveryContentID,
Type: anchoredItemTypeMovie,
Title: staleRecoveryTitle,
Year: 2020,
Status: string(MatchOutcomeMatched),
TmdbID: "111",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
providerRepo := newFakeProviderIDRepo()
providerRepo.set(staleRecoveryContentID, &models.MediaItemProviderID{
ContentID: staleRecoveryContentID, ItemType: anchoredItemTypeMovie,
Provider: contentid.ProviderTMDB, ProviderID: "111",
})
h.service.providerIDRepo = providerRepo
staleRepo := newFakeStaleIDRepo()
staleRepo.set(staleRecoveryContentID, &models.StaleMediaID{
ContentID: staleRecoveryContentID, Provider: contentid.ProviderTMDB, ProviderID: "111",
})
h.service.staleIDRepo = staleRepo
tmdb := &searchMetadataProvider{
slug: contentid.ProviderTMDB,
searchResults: []SearchResult{{
Name: staleRecoveryTitle, Year: 2020, Provider: contentid.ProviderTMDB,
ProviderIDs: map[string]string{contentid.ProviderTMDB: "222", contentid.ProviderIMDB: staleRecoveryIMDbID},
}},
metadataResult: &MetadataResult{
HasMetadata: true, Title: staleRecoveryTitle, Year: 2020,
ProviderIDs: map[string]string{contentid.ProviderTMDB: "222", contentid.ProviderIMDB: staleRecoveryIMDbID},
},
}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: staleRecoveryContentID,
Language: "en",
Mode: ModeScheduledRefresh,
}, []Provider{tmdb})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
providerRepo.mu.Lock()
persisted := providerRepo.lastReplace[staleRecoveryContentID]
providerRepo.mu.Unlock()
if persisted[contentid.ProviderTMDB] != "222" {
t.Fatalf("persisted tmdb id = %q, want replacement 222", persisted[contentid.ProviderTMDB])
}
if persisted[contentid.ProviderIMDB] != staleRecoveryIMDbID {
t.Fatalf("persisted imdb id = %q, want %s", persisted[contentid.ProviderIMDB], staleRecoveryIMDbID)
}
if tmdb.metadataCalls != 1 {
t.Fatalf("metadata calls = %d, want 1", tmdb.metadataCalls)
}
}
func TestProcess_InitialMatchPreservesAggregatorProviderIdentity(t *testing.T) {
const (
aggregatorTitle = "Aggregator Movie"
aggregatorIMDbID = "tt7654321"
)
h := newTestHarness()
provider := &searchMetadataProvider{
slug: testMetaDBProvider,
searchResults: []SearchResult{{
Name: aggregatorTitle, Year: 2020, Provider: testMetaDBProvider,
ProviderIDs: map[string]string{
contentid.ProviderTMDB: "999",
contentid.ProviderIMDB: aggregatorIMDbID,
},
}},
metadataResult: &MetadataResult{
HasMetadata: true, Title: aggregatorTitle, Year: 2020,
ProviderIDs: map[string]string{
contentid.ProviderTMDB: "999",
contentid.ProviderIMDB: aggregatorIMDbID,
},
},
}
result, err := h.service.ProcessWithProviders(context.Background(), ProcessRequest{
Hints: &MatchHints{Title: aggregatorTitle, Year: 2020, Type: anchoredItemTypeMovie},
Mode: ModeInitialMatch,
}, []Provider{provider})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || result.ContentID != "movie-tmdb-999" {
t.Fatalf("result = %#v, want movie-tmdb-999", result)
}
item, err := h.itemRepo.GetByID(context.Background(), result.ContentID)
if err != nil {
t.Fatalf("load matched item: %v", err)
}
if item.TmdbID != "999" || item.ImdbID != aggregatorIMDbID {
t.Fatalf("persisted ids = tmdb:%q imdb:%q", item.TmdbID, item.ImdbID)
}
}
func TestProcess_ScheduledRefreshDoesNotReanchorRecordedStaleContentID(t *testing.T) {
const (
contentID = "movie-tmdb-777"
imdbID = "tt0000777"
)
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: contentID, Type: anchoredItemTypeMovie, Title: "Stable Identity",
Status: string(MatchOutcomeMatched), TmdbID: "777", ImdbID: imdbID,
Studios: []string{}, Networks: []string{}, Countries: []string{}, Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
providerRepo := newFakeProviderIDRepo()
providerRepo.set(contentID,
&models.MediaItemProviderID{ContentID: contentID, ItemType: anchoredItemTypeMovie, Provider: contentid.ProviderTMDB, ProviderID: "777"},
&models.MediaItemProviderID{ContentID: contentID, ItemType: anchoredItemTypeMovie, Provider: contentid.ProviderIMDB, ProviderID: imdbID},
)
h.service.providerIDRepo = providerRepo
staleRepo := newFakeStaleIDRepo()
staleRepo.set(contentID, &models.StaleMediaID{
ContentID: contentID, Provider: contentid.ProviderTMDB, ProviderID: "777",
})
h.service.staleIDRepo = staleRepo
provider := &capturingMetadataProvider{response: &MetadataResult{
HasMetadata: true, Title: "Stable Identity",
ProviderIDs: map[string]string{contentid.ProviderIMDB: imdbID},
}}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: contentID, Language: "en", Mode: ModeScheduledRefresh,
}, []Provider{provider})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || result.ContentID != contentID {
t.Fatalf("result content id = %#v, want %s preserved", result, contentID)
}
}
func TestProcess_ScheduledRefreshDoesNotRestoreProviderIDThat404sDuringSameRun(t *testing.T) {
const (
itemID = "legacy-item-new-404"
badTMDB = "333"
goodTVDB = "444"
)
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: itemID,
Type: anchoredItemTypeSeries,
Title: "Still Available",
Status: string(MatchOutcomeMatched),
TmdbID: badTMDB,
TvdbID: goodTVDB,
Studios: []string{}, Networks: []string{}, Countries: []string{}, Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
providerRepo := newFakeProviderIDRepo()
providerRepo.set(itemID,
&models.MediaItemProviderID{ContentID: itemID, ItemType: anchoredItemTypeSeries, Provider: contentid.ProviderTMDB, ProviderID: badTMDB},
&models.MediaItemProviderID{ContentID: itemID, ItemType: anchoredItemTypeSeries, Provider: contentid.ProviderTVDB, ProviderID: goodTVDB},
)
h.service.providerIDRepo = providerRepo
h.service.staleIDRepo = newFakeStaleIDRepo()
tvdb := &searchMetadataProvider{
slug: contentid.ProviderTVDB,
metadataResult: &MetadataResult{
HasMetadata: true,
Title: "Still Available",
ProviderIDs: map[string]string{contentid.ProviderTVDB: goodTVDB},
},
}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: itemID,
Language: "en",
Mode: ModeScheduledRefresh,
}, []Provider{&notFoundMetadataProvider{slug: contentid.ProviderTMDB}, tvdb})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
providerRepo.mu.Lock()
persisted := providerRepo.lastReplace[itemID]
providerRepo.mu.Unlock()
if persisted[contentid.ProviderTMDB] != "" {
t.Fatalf("same-run 404 tmdb id was restored: %#v", persisted)
}
if persisted[contentid.ProviderTVDB] != goodTVDB {
t.Fatalf("persisted tvdb id = %q, want %s", persisted[contentid.ProviderTVDB], goodTVDB)
}
}
func TestProcess_ScheduledRefreshSuppressesRecordedAndReplacement404Values(t *testing.T) {
const (
itemID = "legacy-item-two-stale-values"
oldTMDBID = "111"
replacementTMDB = "222"
goodTVDBID = "444"
title = "Two Bad IDs"
)
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: itemID, Type: anchoredItemTypeSeries, Title: title, Year: 2020,
Status: string(MatchOutcomeMatched), TmdbID: oldTMDBID,
Studios: []string{}, Networks: []string{}, Countries: []string{}, Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
providerRepo := newFakeProviderIDRepo()
providerRepo.set(itemID, &models.MediaItemProviderID{
ContentID: itemID, ItemType: anchoredItemTypeSeries,
Provider: contentid.ProviderTMDB, ProviderID: oldTMDBID,
})
h.service.providerIDRepo = providerRepo
staleRepo := newFakeStaleIDRepo()
staleRepo.set(itemID, &models.StaleMediaID{
ContentID: itemID, Provider: contentid.ProviderTMDB, ProviderID: oldTMDBID,
})
h.service.staleIDRepo = staleRepo
tmdb := &searchThen404MetadataProvider{
slug: contentid.ProviderTMDB,
searchResults: []SearchResult{{
Name: title, Year: 2020, Provider: contentid.ProviderTMDB,
ProviderIDs: map[string]string{contentid.ProviderTMDB: replacementTMDB},
}},
}
tvdb := &capturingMetadataProvider{response: &MetadataResult{
HasMetadata: true, Title: title,
ProviderIDs: map[string]string{contentid.ProviderTVDB: goodTVDBID},
}}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: itemID, Language: "en", Mode: ModeScheduledRefresh,
}, []Provider{tmdb, tvdb})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
providerRepo.mu.Lock()
persisted := providerRepo.lastReplace[itemID]
providerRepo.mu.Unlock()
if persisted[contentid.ProviderTMDB] != "" {
t.Fatalf("stale tmdb value was restored: %#v", persisted)
}
if persisted[contentid.ProviderTVDB] != goodTVDBID {
t.Fatalf("persisted tvdb id = %q, want %s", persisted[contentid.ProviderTVDB], goodTVDBID)
}
staleRows, err := staleRepo.GetByContentID(ctx, itemID)
if err != nil {
t.Fatalf("load stale rows: %v", err)
}
staleTMDBIDs := make(map[string]bool)
for _, row := range staleRows {
if row != nil && row.Provider == contentid.ProviderTMDB {
staleTMDBIDs[row.ProviderID] = true
}
}
if !staleTMDBIDs[oldTMDBID] || !staleTMDBIDs[replacementTMDB] {
t.Fatalf("stale tmdb IDs = %#v, want both %s and %s", staleTMDBIDs, oldTMDBID, replacementTMDB)
}
}
func TestProcess_ScheduledRefreshPreservesProviderIDWhenOnlyImages404(t *testing.T) {
const (
itemID = "legacy-item-image-404"
tmdbID = "555"
title = "Metadata Without Images"
)
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: itemID,
Type: anchoredItemTypeMovie,
Title: title,
Status: string(MatchOutcomeMatched),
TmdbID: tmdbID,
Studios: []string{}, Networks: []string{}, Countries: []string{}, Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
providerRepo := newFakeProviderIDRepo()
providerRepo.set(itemID, &models.MediaItemProviderID{
ContentID: itemID, ItemType: anchoredItemTypeMovie,
Provider: contentid.ProviderTMDB, ProviderID: tmdbID,
})
h.service.providerIDRepo = providerRepo
h.service.staleIDRepo = newFakeStaleIDRepo()
provider := &image404MetadataProvider{searchMetadataProvider: &searchMetadataProvider{
slug: contentid.ProviderTMDB,
metadataResult: &MetadataResult{
HasMetadata: true,
Title: title,
ProviderIDs: map[string]string{contentid.ProviderTMDB: tmdbID},
},
}}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: itemID,
Language: "en",
Mode: ModeScheduledRefresh,
}, []Provider{provider})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
providerRepo.mu.Lock()
persisted := providerRepo.lastReplace[itemID]
providerRepo.mu.Unlock()
if persisted[contentid.ProviderTMDB] != tmdbID {
t.Fatalf("image-phase 404 removed healthy tmdb id: %#v", persisted)
}
stale, err := h.service.staleIDRepo.GetByContentID(ctx, itemID)
if err != nil {
t.Fatalf("get stale ids: %v", err)
}
if len(stale) != 0 {
t.Fatalf("image-phase 404 recorded stale identity: %#v", stale)
}
}
func TestProcess_IdentifyBootstrapsEpisodeProviderFromDetailCrossReference(t *testing.T) {
const (
itemID = "series-bootstrap-detail-id"
tmdbID = "123"
tvdbID = "456"
)
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: itemID, Type: anchoredItemTypeSeries, Title: "Bootstrap Show",
Status: string(MatchOutcomeMatched), TmdbID: tmdbID,
Studios: []string{}, Networks: []string{}, Countries: []string{}, Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
h.service.providerIDRepo = newFakeProviderIDRepo()
h.service.staleIDRepo = newFakeStaleIDRepo()
metadataProvider := &capturingMetadataProvider{response: &MetadataResult{
HasMetadata: true, Title: "Bootstrap Show",
ProviderIDs: map[string]string{
contentid.ProviderTMDB: tmdbID,
contentid.ProviderTVDB: tvdbID,
},
}}
episodeProvider := &capturingEpisodeBootstrapProvider{}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: itemID,
ProviderIDs: map[string]string{contentid.ProviderTMDB: tmdbID},
Language: "en",
Mode: ModeIdentify,
}, []Provider{metadataProvider, episodeProvider})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
if episodeProvider.providerIDs[contentid.ProviderTVDB] != tvdbID {
t.Fatalf("season bootstrap ids = %#v, want tvdb=%s", episodeProvider.providerIDs, tvdbID)
}
}
func TestFindExistingByProviderIDsUsesDurableRepository(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "existing-1",
Type: "movie",
Title: "Existing Item",
Year: 2020,
Status: "matched",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
providerRepo := newFakeProviderIDRepo()
providerRepo.set("existing-1", &models.MediaItemProviderID{
ContentID: "existing-1",
ItemType: "movie",
Provider: "custom",
ProviderID: "custom-123",
})
h.service.providerIDRepo = providerRepo
item, err := h.service.findExistingByProviderIDs(ctx, map[string]string{"custom": "custom-123"}, "movie", "")
if err != nil {
t.Fatalf("findExistingByProviderIDs: %v", err)
}
if item == nil || item.ContentID != "existing-1" {
t.Fatalf("found item = %#v, want existing-1", item)
}
}
func TestFindExistingByProviderIDsRespectsDurableProviderItemType(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
for _, item := range []*models.MediaItem{
{
ContentID: "movie-1",
Type: "movie",
Title: "Shared ID Movie",
Year: 1992,
Status: "matched",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
},
{
ContentID: "series-1",
Type: "series",
Title: "Shared ID Series",
Year: 2008,
Status: "matched",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
},
} {
if err := h.itemRepo.Upsert(ctx, item); err != nil {
t.Fatalf("upsert item %s: %v", item.ContentID, err)
}
}
providerRepo := newFakeProviderIDRepo()
providerRepo.set("movie-1", &models.MediaItemProviderID{
ContentID: "movie-1",
ItemType: "movie",
Provider: "tmdb",
ProviderID: "37264",
})
providerRepo.set("series-1", &models.MediaItemProviderID{
ContentID: "series-1",
ItemType: "series",
Provider: "tmdb",
ProviderID: "37264",
})
h.service.providerIDRepo = providerRepo
movie, err := h.service.findExistingByProviderIDs(ctx, map[string]string{"tmdb": "37264"}, "movie", "")
if err != nil {
t.Fatalf("findExistingByProviderIDs movie: %v", err)
}
if movie == nil || movie.ContentID != "movie-1" {
t.Fatalf("movie match = %#v, want movie-1", movie)
}
series, err := h.service.findExistingByProviderIDs(ctx, map[string]string{"tmdb": "37264"}, "series", "")
if err != nil {
t.Fatalf("findExistingByProviderIDs series: %v", err)
}
if series == nil || series.ContentID != "series-1" {
t.Fatalf("series match = %#v, want series-1", series)
}
}
func TestProcess_LoadsAndPersistsDurableProviderIDs(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "existing-1",
Type: "movie",
Title: "Old Title",
Status: "matched",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
providerRepo := newFakeProviderIDRepo()
providerRepo.set("existing-1", &models.MediaItemProviderID{
ContentID: "existing-1",
ItemType: "movie",
Provider: "custom",
ProviderID: "custom-123",
})
h.service.providerIDRepo = providerRepo
provider := &capturingMetadataProvider{
response: &MetadataResult{
HasMetadata: true,
Title: "Updated Title",
ProviderIDs: map[string]string{
"custom": "custom-123",
"metadb": "existing-1",
"_filepath": "/media/existing-1.mkv",
"oshash": "deadbeef",
},
},
}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: "existing-1",
Language: "en",
Mode: ModeManualRefresh,
}, []Provider{provider})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
req := provider.lastRequest()
if got := req.ProviderIDs["custom"]; got != "custom-123" {
t.Fatalf("provider request custom id = %q, want custom-123", got)
}
providerRepo.mu.Lock()
replace := providerRepo.lastReplace["existing-1"]
providerRepo.mu.Unlock()
if got := replace["custom"]; got != "custom-123" {
t.Fatalf("persisted custom id = %q, want custom-123", got)
}
if _, ok := replace["metadb"]; ok {
t.Fatal("persisted metadb id unexpectedly")
}
if _, ok := replace["_filepath"]; ok {
t.Fatal("persisted _filepath unexpectedly")
}
if _, ok := replace["oshash"]; ok {
t.Fatal("persisted oshash unexpectedly")
}
item, err := h.itemRepo.GetByID(ctx, "existing-1")
if err != nil {
t.Fatalf("get updated item: %v", err)
}
if item.Title != "Updated Title" {
t.Fatalf("item title = %q, want Updated Title", item.Title)
}
}
func TestProcess_InitialMatchSuppressesRecordedStaleProviderIDs(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "existing-1",
Type: "movie",
Title: "Old Title",
Year: 2001,
Status: "pending",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
staleRepo := newFakeStaleIDRepo()
staleRepo.set("existing-1", &models.StaleMediaID{
ContentID: "existing-1",
Provider: "tmdb",
ProviderID: "dead-tmdb-id",
})
h.service.staleIDRepo = staleRepo
provider := &capturingMetadataProvider{
response: &MetadataResult{
HasMetadata: true,
Title: "Recovered Title",
ProviderIDs: map[string]string{"metadb": "existing-1"},
},
}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: "existing-1",
Language: "en",
Mode: ModeInitialMatch,
Hints: &MatchHints{
Title: "Old Title",
Year: 2001,
Type: "movie",
TmdbID: "dead-tmdb-id",
},
}, []Provider{provider})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
req := provider.lastRequest()
if got := req.ProviderIDs["tmdb"]; got != "" {
t.Fatalf("provider request tmdb id = %q, want empty", got)
}
}
func TestMergeAndPersist_DoesNotReusePendingProviderMatch(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "pending-existing",
Type: "series",
Title: "Example Show",
Year: 2024,
Status: "pending",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
providerRepo := newFakeProviderIDRepo()
providerRepo.set("pending-existing", &models.MediaItemProviderID{
ContentID: "pending-existing",
ItemType: "series",
Provider: "custom",
ProviderID: "series-123",
})
h.service.providerIDRepo = providerRepo
result, err := h.service.mergeAndPersist(ctx, ProcessRequest{
Mode: ModeInitialMatch,
}, &MetadataResult{
HasMetadata: true,
Title: "Example Show",
Year: 2024,
ProviderIDs: map[string]string{"custom": "series-123"},
}, nil, nil, nil, "series")
if err != nil {
t.Fatalf("mergeAndPersist: %v", err)
}
if result == nil || result.ContentID == "" {
t.Fatalf("result = %#v, want non-empty content id", result)
}
if result.ContentID == "pending-existing" {
t.Fatal("expected pending provider-id match to be ignored")
}
}
func TestMergeAndPersist_DoesNotRebindSkeletonToPendingProviderMatch(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "pending-source",
Type: "series",
Title: "Example Show Alt Root",
Year: 2024,
Status: "pending",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert source item: %v", err)
}
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "pending-target",
Type: "series",
Title: "Example Show",
Year: 2024,
Status: "pending",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert target item: %v", err)
}
providerRepo := newFakeProviderIDRepo()
providerRepo.set("pending-target", &models.MediaItemProviderID{
ContentID: "pending-target",
ItemType: "series",
Provider: "custom",
ProviderID: "series-123",
})
h.service.providerIDRepo = providerRepo
result, err := h.service.mergeAndPersist(ctx, ProcessRequest{
ContentID: "pending-source",
Mode: ModeInitialMatch,
}, &MetadataResult{
HasMetadata: true,
Title: "Example Show",
Year: 2024,
ProviderIDs: map[string]string{"custom": "series-123"},
}, nil, nil, nil, "series")
if err != nil {
t.Fatalf("mergeAndPersist: %v", err)
}
if result == nil || result.ContentID != "pending-source" {
t.Fatalf("result = %#v, want content_id pending-source", result)
}
}
func TestIsProvisionalOwnershipStatus_IncludesAmbiguous(t *testing.T) {
if !isProvisionalOwnershipStatus("ambiguous") {
t.Fatal("expected ambiguous items to be treated as provisional ownership")
}
}