* fix(metadata): re-fetch titles/overviews when a library's metadata language changes An item's default_metadata_language was stamped once at first match and never updated: the canonical-language pin in mergeAndPersist routed any refresh in a different language into the localization tables, the upserts' COALESCE kept the old stamp forever, quick-mode library refresh skipped complete items entirely, and changing the language in HandleUpdateLibrary triggered nothing. Items stayed in the old language no matter how often the admin refreshed (#211). Four coupled changes: - ProcessRequest.AdoptLanguage: folder-scoped manual refreshes adopt the library's language as the item's new canonical language when it differs from the stamp, rewriting the base row instead of localizing to the side. Only ModeManualRefresh adopts — scheduled refreshes merge fill-empty and would restamp without rewriting the text. - Upsert language pins inverted (media_items, seasons, episodes): prefer the incoming non-empty default_metadata_language over the existing stamp. All existing callers send the unchanged stamp or empty, so behavior is unchanged outside adoption; the restamp is atomic with the canonical write. - Quick-mode refresh lister now includes complete items whose stamp differs from the library's configured language. - HandleUpdateLibrary enqueues a quick library metadata refresh when the metadata language changes, mirroring the paths-change rescan trigger. Fixes #211 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(metadata): gate language adoption on field locks and library agreement Two hardening fixes for AdoptLanguage from review of #278: - Locked fields defeat the restamp: title/overview merges honor per-field locks, but the restamp was unconditional. An item with both language- bearing fields locked kept its old-language text yet got stamped the new language, so the quick-refresh mismatch predicate never flagged it again. mergeAndPersist now skips adoption when both name and overview are locked, falling back to the non-adopting behavior: the stamp stays put, isCanonicalWrite goes false, and the fetch routes to the localization tables exactly like a non-adopting refresh in that language does today. One locked field still adopts — the other is actually rewritten. - Multi-library flip-flop: an item in libraries with different metadata languages had its canonical base row rewritten to whichever library refreshed last, oscillating forever. Process now requires every library containing the item (media_item_libraries) to resolve to the adoption target before setting AdoptLanguage, via the existing GetDistinctMetadataLanguagesForItem (which applies the same empty→en default as resolveFolderLanguage). Disagreement or a lookup failure keeps the current stamp — stable beats flip-flopping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
259 lines
9.1 KiB
Go
259 lines
9.1 KiB
Go
package metadata
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
func seedRelanguageItem(t *testing.T, h *testHarness, lockedFields ...MetadataField) {
|
|
t.Helper()
|
|
locked := make([]int, 0, len(lockedFields))
|
|
for _, field := range lockedFields {
|
|
locked = append(locked, int(field))
|
|
}
|
|
if err := h.itemRepo.Upsert(context.Background(), &models.MediaItem{
|
|
ContentID: "existing-1",
|
|
Type: "movie",
|
|
Title: "Gammel Kinesisk Titel",
|
|
Overview: "Old-language overview",
|
|
Year: 2020,
|
|
Status: "matched",
|
|
DefaultMetadataLanguage: "zh",
|
|
LockedFields: locked,
|
|
Studios: []string{},
|
|
Networks: []string{},
|
|
Countries: []string{},
|
|
Genres: []string{},
|
|
}); err != nil {
|
|
t.Fatalf("upsert existing item: %v", err)
|
|
}
|
|
}
|
|
|
|
func newRelanguageProvider() *capturingMetadataProvider {
|
|
return &capturingMetadataProvider{
|
|
response: &MetadataResult{
|
|
HasMetadata: true,
|
|
Title: "Ny Dansk Titel",
|
|
Overview: "Dansk oversigt",
|
|
ProviderIDs: map[string]string{"tmdb": "42"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestProcess_AdoptLanguageRewritesBaseRowAndRestamps covers the core of
|
|
// issue #211: a refresh carrying AdoptLanguage must treat req.Language as the
|
|
// item's new canonical metadata language — replacing the base-row title and
|
|
// overview and restamping default_metadata_language — instead of pinning to
|
|
// the language stamped at first match.
|
|
func TestProcess_AdoptLanguageRewritesBaseRowAndRestamps(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
seedRelanguageItem(t, h)
|
|
|
|
provider := newRelanguageProvider()
|
|
|
|
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
|
|
ContentID: "existing-1",
|
|
Language: "da",
|
|
Mode: ModeManualRefresh,
|
|
AdoptLanguage: true,
|
|
}, []Provider{provider})
|
|
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, "existing-1")
|
|
if err != nil {
|
|
t.Fatalf("get item: %v", err)
|
|
}
|
|
if item.Title != "Ny Dansk Titel" {
|
|
t.Errorf("title = %q, want base row rewritten to %q", item.Title, "Ny Dansk Titel")
|
|
}
|
|
if item.Overview != "Dansk oversigt" {
|
|
t.Errorf("overview = %q, want base row rewritten", item.Overview)
|
|
}
|
|
if item.DefaultMetadataLanguage != "da" {
|
|
t.Errorf("default_metadata_language = %q, want restamped to da", item.DefaultMetadataLanguage)
|
|
}
|
|
}
|
|
|
|
// TestProcess_WithoutAdoptLanguageKeepsCanonicalPin pins the pre-existing
|
|
// behavior: a refresh in a non-canonical language without AdoptLanguage must
|
|
// NOT rewrite the base row or restamp the item (the fetch routes to the
|
|
// localization tables instead).
|
|
func TestProcess_WithoutAdoptLanguageKeepsCanonicalPin(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
seedRelanguageItem(t, h)
|
|
|
|
provider := newRelanguageProvider()
|
|
|
|
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
|
|
ContentID: "existing-1",
|
|
Language: "da",
|
|
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)
|
|
}
|
|
|
|
item, err := h.itemRepo.GetByID(ctx, "existing-1")
|
|
if err != nil {
|
|
t.Fatalf("get item: %v", err)
|
|
}
|
|
if item.Title != "Gammel Kinesisk Titel" {
|
|
t.Errorf("title = %q, want base row untouched", item.Title)
|
|
}
|
|
if item.DefaultMetadataLanguage != "zh" {
|
|
t.Errorf("default_metadata_language = %q, want zh (unchanged)", item.DefaultMetadataLanguage)
|
|
}
|
|
}
|
|
|
|
// TestAdoptableFolderLanguage covers the decision for when a folder-scoped
|
|
// refresh should adopt the folder's language as the item's new canonical
|
|
// language (issue #211). Only manual-refresh passes adopt: scheduled
|
|
// refreshes merge fill-empty, which would restamp the language without
|
|
// rewriting the text.
|
|
func TestAdoptableFolderLanguage(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
mode RefreshMode
|
|
stamp string
|
|
language string
|
|
want bool
|
|
}{
|
|
{"manual refresh with mismatch adopts", ModeManualRefresh, "zh", "da", true},
|
|
{"manual refresh case-insensitive match does not adopt", ModeManualRefresh, "DA", "da", false},
|
|
{"scheduled refresh never adopts", ModeScheduledRefresh, "zh", "da", false},
|
|
{"identify never adopts", ModeIdentify, "zh", "da", false},
|
|
{"initial match never adopts", ModeInitialMatch, "zh", "da", false},
|
|
{"unstamped item does not adopt", ModeManualRefresh, "", "da", false},
|
|
{"empty target language does not adopt", ModeManualRefresh, "zh", "", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := adoptableFolderLanguage(tc.mode, tc.stamp, tc.language); got != tc.want {
|
|
t.Fatalf("adoptableFolderLanguage(%v, %q, %q) = %v, want %v", tc.mode, tc.stamp, tc.language, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestProcess_AdoptLanguageSkippedWhenBothLanguageFieldsLocked: when both
|
|
// language-bearing fields (title, overview) are locked, adoption must not
|
|
// restamp default_metadata_language — nothing would actually be rewritten,
|
|
// so the restamp would permanently mislabel the old-language text and the
|
|
// quick-refresh language-mismatch predicate would never flag the item again.
|
|
// The refresh behaves like a non-adopting refresh in that language instead.
|
|
func TestProcess_AdoptLanguageSkippedWhenBothLanguageFieldsLocked(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
seedRelanguageItem(t, h, FieldName, FieldOverview)
|
|
|
|
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
|
|
ContentID: "existing-1",
|
|
Language: "da",
|
|
Mode: ModeManualRefresh,
|
|
AdoptLanguage: true,
|
|
}, []Provider{newRelanguageProvider()})
|
|
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, "existing-1")
|
|
if err != nil {
|
|
t.Fatalf("get item: %v", err)
|
|
}
|
|
if item.Title != "Gammel Kinesisk Titel" {
|
|
t.Errorf("title = %q, want locked title untouched", item.Title)
|
|
}
|
|
if item.Overview != "Old-language overview" {
|
|
t.Errorf("overview = %q, want locked overview untouched", item.Overview)
|
|
}
|
|
if item.DefaultMetadataLanguage != "zh" {
|
|
t.Errorf("default_metadata_language = %q, want zh (stamp kept when both language fields are locked)",
|
|
item.DefaultMetadataLanguage)
|
|
}
|
|
}
|
|
|
|
// TestProcess_AdoptLanguageWithOnlyTitleLocked: a single locked language
|
|
// field does not block adoption — the unlocked field is rewritten in the new
|
|
// language and the stamp is adopted, while the locked field keeps its text.
|
|
func TestProcess_AdoptLanguageWithOnlyTitleLocked(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
seedRelanguageItem(t, h, FieldName)
|
|
|
|
result, err := h.service.ProcessWithProviders(ctx, ProcessRequest{
|
|
ContentID: "existing-1",
|
|
Language: "da",
|
|
Mode: ModeManualRefresh,
|
|
AdoptLanguage: true,
|
|
}, []Provider{newRelanguageProvider()})
|
|
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, "existing-1")
|
|
if err != nil {
|
|
t.Fatalf("get item: %v", err)
|
|
}
|
|
if item.Title != "Gammel Kinesisk Titel" {
|
|
t.Errorf("title = %q, want locked title untouched", item.Title)
|
|
}
|
|
if item.Overview != "Dansk oversigt" {
|
|
t.Errorf("overview = %q, want base row rewritten", item.Overview)
|
|
}
|
|
if item.DefaultMetadataLanguage != "da" {
|
|
t.Errorf("default_metadata_language = %q, want restamped to da (only one field locked)",
|
|
item.DefaultMetadataLanguage)
|
|
}
|
|
}
|
|
|
|
// TestItemLibrariesAgreeOnLanguage covers the multi-library adoption gate: an
|
|
// item that lives in several libraries (media_item_libraries) must only adopt
|
|
// a language every containing library agrees on — otherwise libraries with
|
|
// different languages would rewrite the canonical base row back and forth on
|
|
// every refresh.
|
|
func TestItemLibrariesAgreeOnLanguage(t *testing.T) {
|
|
ctx := context.Background()
|
|
cases := []struct {
|
|
name string
|
|
languages []string
|
|
lookupErr error
|
|
target string
|
|
want bool
|
|
}{
|
|
{"single library matching target agrees", []string{"da"}, nil, "da", true},
|
|
{"case-insensitive match agrees", []string{"DA"}, nil, "da", true},
|
|
{"disagreeing libraries block adoption", []string{"da", "en"}, nil, "da", false},
|
|
{"single library with other language blocks adoption", []string{"de"}, nil, "da", false},
|
|
{"no membership rows cannot disagree", nil, nil, "da", true},
|
|
{"lookup failure blocks adoption", nil, fmt.Errorf("boom"), "da", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h := newTestHarness()
|
|
h.libraryRepo.setMetadataLanguages("existing-1", tc.languages, tc.lookupErr)
|
|
if got := h.service.itemLibrariesAgreeOnLanguage(ctx, "existing-1", tc.target); got != tc.want {
|
|
t.Fatalf("itemLibrariesAgreeOnLanguage(%v, %q) = %v, want %v", tc.languages, tc.target, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|