Files
silo-server/internal/metadata/nfo_identity_test.go
383973ec22 feat(metadata): improve match accuracy and localized titles (#461)
* feat(metadata): improve match accuracy and localized titles

* fix(metadata): address matching review findings

* test(catalog): align empty alias snapshot scope

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-24 12:02:52 -04:00

563 lines
19 KiB
Go

//nolint:goconst // Repeated titles and provider IDs keep NFO scenarios recognizable.
package metadata
import (
"context"
"strings"
"sync"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
// localHintStubProvider mimics the built-in NFO provider's shape in the chain:
// it implements SearchProvider + MetadataProvider + IdentityHintProvider with
// canned data, exactly like a parsed sidecar NFO would supply.
type localHintStubProvider struct {
mu sync.Mutex
hints map[string]string
searchResults []SearchResult
metadata *MetadataResult
hintCalls int
searchCalls int
metadataCalls int
}
func (p *localHintStubProvider) Slug() string { return "nfo" }
func (p *localHintStubProvider) Name() string { return "NFO Files" }
func (p *localHintStubProvider) ForTypes() []string { return []string{"movie", "series"} }
func (p *localHintStubProvider) IdentityHints(_ context.Context, _ SearchQuery) map[string]string {
p.mu.Lock()
defer p.mu.Unlock()
p.hintCalls++
return copyMap(p.hints)
}
func (p *localHintStubProvider) Search(_ context.Context, _ SearchQuery) ([]SearchResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.searchCalls++
out := make([]SearchResult, len(p.searchResults))
copy(out, p.searchResults)
return out, nil
}
func (p *localHintStubProvider) GetMetadata(_ context.Context, _ MetadataRequest) (*MetadataResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.metadataCalls++
if p.metadata != nil {
cp := *p.metadata
cp.ProviderIDs = copyMap(p.metadata.ProviderIDs)
return &cp, nil
}
return &MetadataResult{}, nil
}
// remoteStubProvider is a canned remote search+metadata provider that records
// the provider IDs it was asked to fetch metadata for.
type remoteStubProvider struct {
mu sync.Mutex
slug string
searchResults []SearchResult
metadata *MetadataResult
lastSearchIDs map[string]string
lastMetaIDs map[string]string
metadataCalls int
}
func (p *remoteStubProvider) Slug() string { return p.slug }
func (p *remoteStubProvider) Name() string { return p.slug }
func (p *remoteStubProvider) ForTypes() []string { return []string{"movie", "series"} }
func (p *remoteStubProvider) Search(_ context.Context, query SearchQuery) ([]SearchResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.lastSearchIDs = copyMap(query.ProviderIDs)
out := make([]SearchResult, len(p.searchResults))
copy(out, p.searchResults)
return out, nil
}
func (p *remoteStubProvider) GetMetadata(_ context.Context, req MetadataRequest) (*MetadataResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.metadataCalls++
p.lastMetaIDs = copyMap(req.ProviderIDs)
if p.metadata != nil {
cp := *p.metadata
cp.ProviderIDs = copyMap(p.metadata.ProviderIDs)
return &cp, nil
}
return &MetadataResult{}, nil
}
func (p *remoteStubProvider) lastMetadataIDs() map[string]string {
p.mu.Lock()
defer p.mu.Unlock()
return copyMap(p.lastMetaIDs)
}
func (p *remoteStubProvider) lastSearchProviderIDs() map[string]string {
p.mu.Lock()
defer p.mu.Unlock()
return copyMap(p.lastSearchIDs)
}
func TestInitialMatch_MalformedNFOIdentityDoesNotSuppressTitleSearch(t *testing.T) {
t.Parallel()
h := newTestHarness()
nfo := &localHintStubProvider{
hints: map[string]string{"tmdb": "not-a-number", "imdb": "nm1234567"},
searchResults: []SearchResult{{Name: "The Matrix", Year: 1999, Provider: "nfo", ProviderIDs: map[string]string{"tmdb": "not-a-number"}}},
metadata: &MetadataResult{HasMetadata: true, Title: "The Matrix"},
}
remote := &remoteStubProvider{
slug: "tmdb",
searchResults: []SearchResult{{
Name: "The Matrix", Year: 1999, Provider: "tmdb", ProviderIDs: map[string]string{"tmdb": "603", "imdb": "tt0133093"},
}},
metadata: &MetadataResult{HasMetadata: true, Title: "The Matrix", Year: 1999, ProviderIDs: map[string]string{"tmdb": "603", "imdb": "tt0133093"}},
}
result, err := h.service.ProcessWithProviders(context.Background(), ProcessRequest{
Hints: &MatchHints{Title: "The Matrix", Year: 1999, Type: "movie"},
Mode: ModeInitialMatch,
}, []Provider{nfo, remote})
if err != nil {
t.Fatalf("ProcessWithProviders() error = %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want title-based remote match", result)
}
if ids := remote.lastSearchProviderIDs(); ids["tmdb"] != "" || ids["imdb"] != "" {
t.Fatalf("remote search received malformed trusted IDs: %#v", ids)
}
}
func TestInitialMatch_MalformedScannerIdentityDoesNotSuppressTitleSearch(t *testing.T) {
t.Parallel()
h := newTestHarness()
remote := &remoteStubProvider{
slug: "tmdb",
searchResults: []SearchResult{{
Name: "The Matrix", Year: 1999, Provider: "tmdb",
ProviderIDs: map[string]string{"tmdb": "603", "imdb": "tt0133093"},
}},
metadata: &MetadataResult{HasMetadata: true, Title: "The Matrix", Year: 1999, ProviderIDs: map[string]string{"tmdb": "603", "imdb": "tt0133093"}},
}
result, err := h.service.ProcessWithProviders(context.Background(), ProcessRequest{
Hints: &MatchHints{
Title: "The Matrix", Year: 1999, Type: "movie",
TmdbID: "not-a-number", ImdbID: "nm1234567",
},
Mode: ModeInitialMatch,
}, []Provider{remote})
if err != nil {
t.Fatalf("ProcessWithProviders() error = %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want title-based remote match", result)
}
if ids := remote.lastSearchProviderIDs(); ids["tmdb"] != "" || ids["imdb"] != "" {
t.Fatalf("remote search received malformed scanner IDs: %#v", ids)
}
}
func seedMovieItem(t *testing.T, h *testHarness, contentID, title string, year int) {
t.Helper()
if err := h.itemRepo.Upsert(context.Background(), &models.MediaItem{
ContentID: contentID,
Type: "movie",
Title: title,
Year: year,
Status: "matched",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("seed item: %v", err)
}
}
// A title-only NFO with no remote search results must still produce an
// accepted match: the item stays on its path-deterministic local: content id,
// is persisted as matched, and gains no durable provider IDs (#216).
func TestInitialMatch_TitleOnlyNFO_NoRemoteResults_MatchesUnderLocalID(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "local-abc123",
Type: "movie",
Title: "My Home Movie",
Status: "pending_match",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("seed skeleton: %v", err)
}
nfo := &localHintStubProvider{
searchResults: []SearchResult{{Name: "My Home Movie", Year: 2021, Provider: "nfo"}},
metadata: &MetadataResult{HasMetadata: true, Title: "My Home Movie", Year: 2021, Overview: "Curated."},
}
remote := &remoteStubProvider{slug: "tmdb"}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: "local-abc123",
Hints: &MatchHints{
Title: "My Home Movie",
Year: 2021,
Type: "movie",
},
Language: "en",
Mode: ModeInitialMatch,
}, []Provider{nfo, remote})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
item, err := h.itemRepo.GetByID(ctx, "local-abc123")
if err != nil {
t.Fatalf("item not found under local id after title-only NFO match: %v", err)
}
if item.Status != "matched" {
t.Errorf("item status = %q, want matched", item.Status)
}
if item.Title != "My Home Movie" {
t.Errorf("item title = %q, want My Home Movie", item.Title)
}
if item.TmdbID != "" || item.TvdbID != "" || item.ImdbID != "" {
t.Errorf("title-only NFO match must carry no provider ids, got tmdb=%q tvdb=%q imdb=%q",
item.TmdbID, item.TvdbID, item.ImdbID)
}
}
// The same title-only flow at the worker level: the queued movie file must
// leave the match queue (row deleted) with the item matched under local:.
func TestWorker_TitleOnlyNFO_MovieLeavesMatchQueueMatched(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeWorkerFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "movies", Enabled: true},
},
}
nfo := &localHintStubProvider{
searchResults: []SearchResult{{Name: "My Home Movie", Year: 2021, Provider: "nfo"}},
metadata: &MetadataResult{HasMetadata: true, Title: "My Home Movie", Year: 2021},
}
remote := &remoteStubProvider{slug: "tmdb"}
h.service.hooks.process = func(ctx context.Context, req ProcessRequest) (*ProcessResult, error) {
return h.service.ProcessWithProviders(ctx, req, []Provider{nfo, remote})
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/My Home Movie (2021)/My Home Movie (2021).mkv",
}
movieRepo := newFakeMovieQueueRepo(file)
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.movieClaimer = movieRepo
worker.processUnmatched(ctx)
if _, deleted := movieRepo.deleted[file.ID]; !deleted {
t.Fatalf("movie queue row not deleted; errors=%v", movieRepo.errors)
}
contentID := h.fileRepo.contentIDs[file.ID]
if contentID == "" {
t.Fatal("file was not linked to a content id")
}
if !strings.HasPrefix(contentID, "local-") {
t.Errorf("content id = %q, want local- prefix", contentID)
}
item, err := h.itemRepo.GetByID(ctx, contentID)
if err != nil {
t.Fatalf("load matched item: %v", err)
}
if item.Status != "matched" {
t.Errorf("item status = %q, want matched", item.Status)
}
}
// A curated <uniqueid> must anchor identity even when remote search-by-title
// returns nothing: the NFO's tmdb id is seeded as a trusted hint and the
// remote provider's GetMetadata runs with it (full enrichment by ID).
func TestInitialMatch_NFOUniqueIDAnchorsWhenRemoteSearchEmpty(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
nfo := &localHintStubProvider{
hints: map[string]string{"tmdb": "424242"},
searchResults: []SearchResult{{Name: "Obscure Film", Year: 2019, ProviderIDs: map[string]string{"tmdb": "424242"}, Provider: "nfo"}},
metadata: &MetadataResult{HasMetadata: true, Title: "Obscure Film", Year: 2019},
}
remote := &remoteStubProvider{
slug: "tmdb",
metadata: &MetadataResult{HasMetadata: true, Title: "Obscure Film: Remote Cut", Year: 2019, Overview: "From TMDB.", ProviderIDs: map[string]string{"tmdb": "424242", "imdb": "tt0424242"}},
}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
Hints: &MatchHints{
Title: "Obscure Film",
Year: 2019,
Type: "movie",
},
Language: "en",
Mode: ModeInitialMatch,
}, []Provider{remote, nfo})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
if got := remote.lastMetadataIDs()["tmdb"]; got != "424242" {
t.Errorf("remote GetMetadata tmdb id = %q, want 424242 (NFO hint must anchor identity)", got)
}
item, err := h.itemRepo.GetByID(ctx, result.ContentID)
if err != nil {
t.Fatalf("load matched item: %v", err)
}
if item.TmdbID != "424242" {
t.Errorf("item tmdb id = %q, want 424242", item.TmdbID)
}
if item.Overview != "From TMDB." {
t.Errorf("item overview = %q, want remote enrichment applied", item.Overview)
}
}
// When a title-only NFO coexists with a successful remote search, the remote
// match must win: no downgrade of a found movie to an unenriched local: item,
// and the winning candidate keeps the remote provider IDs.
func TestInitialMatch_TitleOnlyNFO_RemoteSearchWins(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
nfo := &localHintStubProvider{
searchResults: []SearchResult{{Name: "Inception", Year: 2010, Provider: "nfo"}},
metadata: &MetadataResult{HasMetadata: true, Title: "Inception", Year: 2010},
}
remote := &remoteStubProvider{
slug: "tmdb",
searchResults: []SearchResult{{Name: "Inception", Year: 2010, ProviderIDs: map[string]string{"tmdb": "27205"}, Provider: "tmdb"}},
metadata: &MetadataResult{HasMetadata: true, Title: "Inception", Year: 2010, ProviderIDs: map[string]string{"tmdb": "27205"}},
}
// NFO deliberately first in the chain: chain priority must not let the
// ID-less local candidate beat the ID-bearing remote candidate.
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
Hints: &MatchHints{
Title: "Inception",
Year: 2010,
Type: "movie",
},
Language: "en",
Mode: ModeInitialMatch,
}, []Provider{nfo, remote})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
item, err := h.itemRepo.GetByID(ctx, result.ContentID)
if err != nil {
t.Fatalf("load matched item: %v", err)
}
if item.TmdbID != "27205" {
t.Errorf("item tmdb id = %q, want 27205 (remote match must not be downgraded to local:)", item.TmdbID)
}
}
// Initial match: NFO hints beat folder-name-derived hints on conflict.
func TestInitialMatch_NFOHintBeatsFolderNameHint(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
nfo := &localHintStubProvider{
hints: map[string]string{"tmdb": "222"},
metadata: &MetadataResult{HasMetadata: true, Title: "Right Movie"},
}
remote := &remoteStubProvider{
slug: "tmdb",
metadata: &MetadataResult{HasMetadata: true, Title: "Right Movie", ProviderIDs: map[string]string{"tmdb": "222"}},
}
_, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
Hints: &MatchHints{
Title: "Right Movie",
Year: 2020,
Type: "movie",
TmdbID: "111", // folder-name-derived hint
},
Language: "en",
Mode: ModeInitialMatch,
}, []Provider{nfo, remote})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if got := remote.lastMetadataIDs()["tmdb"]; got != "222" {
t.Errorf("remote GetMetadata tmdb id = %q, want 222 (NFO hint beats folder-name hint)", got)
}
}
// Scheduled refresh: stored durable IDs beat NFO hints (no background
// identity flips).
func TestScheduledRefresh_StoredIDBeatsNFOHint(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
seedMovieItem(t, h, "movie:tmdb:100", "Stable Movie", 2018)
h.itemRepo.items["movie:tmdb:100"].TmdbID = "100"
nfo := &localHintStubProvider{
hints: map[string]string{"tmdb": "999"},
metadata: &MetadataResult{HasMetadata: true, Title: "Stable Movie"},
}
remote := &remoteStubProvider{
slug: "tmdb",
metadata: &MetadataResult{HasMetadata: true, Title: "Stable Movie", ProviderIDs: map[string]string{"tmdb": "100"}},
}
_, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: "movie:tmdb:100",
Language: "en",
Mode: ModeScheduledRefresh,
}, []Provider{nfo, remote})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if got := remote.lastMetadataIDs()["tmdb"]; got != "100" {
t.Errorf("scheduled refresh tmdb id = %q, want 100 (stored id beats NFO hint)", got)
}
}
// Manual refresh: NFO hints beat stored IDs — fixing a wrong NFO id and
// refreshing re-anchors the item (recovery path).
func TestManualRefresh_NFOHintBeatsStoredID(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
seedMovieItem(t, h, "movie:tmdb:100", "Misidentified Movie", 2018)
h.itemRepo.items["movie:tmdb:100"].TmdbID = "100"
nfo := &localHintStubProvider{
hints: map[string]string{"tmdb": "200"},
metadata: &MetadataResult{HasMetadata: true, Title: "Corrected Movie"},
}
remote := &remoteStubProvider{
slug: "tmdb",
metadata: &MetadataResult{HasMetadata: true, Title: "Corrected Movie", ProviderIDs: map[string]string{"tmdb": "200"}},
}
_, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: "movie:tmdb:100",
Language: "en",
Mode: ModeManualRefresh,
}, []Provider{nfo, remote})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if got := remote.lastMetadataIDs()["tmdb"]; got != "200" {
t.Errorf("manual refresh tmdb id = %q, want 200 (NFO hint beats stored id)", got)
}
}
// Manual Identify: the user's explicit identification wins and the NFO
// provider is skipped entirely — no hints, no metadata overlay.
func TestIdentify_SkipsNFOProviderEntirely(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
seedMovieItem(t, h, "movie:tmdb:100", "Old Title", 2018)
nfo := &localHintStubProvider{
hints: map[string]string{"tmdb": "999"},
metadata: &MetadataResult{HasMetadata: true, Title: "Stale NFO Title"},
}
remote := &remoteStubProvider{
slug: "tmdb",
metadata: &MetadataResult{HasMetadata: true, Title: "User Chosen Title", ProviderIDs: map[string]string{"tmdb": "555"}},
}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
ContentID: "movie:tmdb:100",
ProviderIDs: map[string]string{"tmdb": "555"},
Language: "en",
Mode: ModeIdentify,
}, []Provider{nfo, remote})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
if result == nil || !result.Updated {
t.Fatalf("result = %#v, want Updated=true", result)
}
if nfo.hintCalls != 0 || nfo.searchCalls != 0 || nfo.metadataCalls != 0 {
t.Errorf("NFO provider consulted during identify: hints=%d search=%d metadata=%d, want all 0",
nfo.hintCalls, nfo.searchCalls, nfo.metadataCalls)
}
if got := remote.lastMetadataIDs()["tmdb"]; got != "555" {
t.Errorf("identify tmdb id = %q, want 555 (user identification wins)", got)
}
item, err := h.itemRepo.GetByID(ctx, "movie:tmdb:100")
if err != nil {
t.Fatalf("load item: %v", err)
}
if item.Title != "User Chosen Title" {
t.Errorf("item title = %q, want User Chosen Title (stale NFO title must not overlay identify)", item.Title)
}
}
// Phase-2: NFO GetMetadata results merge fields but never inject provider-id
// keys that conflict with or extend a remotely-anchored identity.
func TestPhase2_NFOMetadataDoesNotInjectProviderIDs(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
nfo := &localHintStubProvider{
metadata: &MetadataResult{
HasMetadata: true,
Title: "Anchored Movie",
ProviderIDs: map[string]string{"tmdb": "999", "imdb": "tt0000001"},
},
}
remote := &remoteStubProvider{
slug: "tmdb",
searchResults: []SearchResult{{Name: "Anchored Movie", Year: 2015, ProviderIDs: map[string]string{"tmdb": "42"}, Provider: "tmdb"}},
metadata: &MetadataResult{HasMetadata: true, Title: "Anchored Movie", Year: 2015, ProviderIDs: map[string]string{"tmdb": "42"}},
}
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
Hints: &MatchHints{
Title: "Anchored Movie",
Year: 2015,
Type: "movie",
},
Language: "en",
Mode: ModeInitialMatch,
}, []Provider{remote, nfo})
if err != nil {
t.Fatalf("ProcessWithProviders: %v", err)
}
item, err := h.itemRepo.GetByID(ctx, result.ContentID)
if err != nil {
t.Fatalf("load item: %v", err)
}
if item.TmdbID != "42" {
t.Errorf("item tmdb id = %q, want 42 (NFO metadata ids must not overwrite the anchor)", item.TmdbID)
}
if item.ImdbID != "" {
t.Errorf("item imdb id = %q, want empty (NFO must not extend a remotely-anchored identity)", item.ImdbID)
}
}