* 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>
311 lines
8.9 KiB
Go
311 lines
8.9 KiB
Go
package metadata
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// RefreshMode determines how the pipeline processes an item.
|
|
type RefreshMode int
|
|
|
|
const (
|
|
ModeInitialMatch RefreshMode = iota // New file discovered by scanner
|
|
ModeScheduledRefresh // Background periodic refresh
|
|
ModeManualRefresh // User-triggered refresh
|
|
ModeIdentify // User manually identified item
|
|
)
|
|
|
|
// MergeMode determines how provider results merge into existing items.
|
|
type MergeMode int
|
|
|
|
const (
|
|
MergeFillEmpty MergeMode = iota // Only fill zero/empty fields
|
|
MergeReplaceUnlocked // Replace all unlocked fields
|
|
)
|
|
|
|
// MetadataField identifies a lockable metadata field.
|
|
type MetadataField int
|
|
|
|
const (
|
|
FieldName MetadataField = iota
|
|
FieldOverview
|
|
FieldGenres
|
|
FieldStudios
|
|
FieldCast
|
|
FieldCrew
|
|
FieldRating
|
|
FieldRuntime
|
|
FieldTags
|
|
FieldContentRating
|
|
FieldImages
|
|
FieldAirSchedule
|
|
)
|
|
|
|
// RefreshPriority controls queue ordering.
|
|
type RefreshPriority int
|
|
|
|
const (
|
|
PriorityHigh RefreshPriority = 0 // Manual refresh, identify
|
|
PriorityNormal RefreshPriority = 1 // Newly matched items
|
|
PriorityLow RefreshPriority = 2 // Scheduled background refresh
|
|
)
|
|
|
|
// ProcessRequest is the unified input to MetadataService.Process().
|
|
type ProcessRequest struct {
|
|
ContentID string // Existing item (refresh/identify) or empty (new match)
|
|
Hints *MatchHints // From scanner (initial match only)
|
|
ProviderIDs map[string]string // From user selection (identify only)
|
|
FolderID string // Which library folder
|
|
Language string // ISO 639-1 metadata language (resolved from folder)
|
|
Mode RefreshMode
|
|
// AdoptLanguage makes Language the item's new canonical metadata
|
|
// language: the base row is rewritten in Language and
|
|
// default_metadata_language is restamped, instead of routing a
|
|
// non-canonical language to the localization tables. Set by
|
|
// folder-scoped manual refreshes when the library's configured
|
|
// language differs from the item's stamp, so changing a library's
|
|
// metadata language actually re-fetches titles/overviews.
|
|
AdoptLanguage bool
|
|
}
|
|
|
|
// ProcessResult is the output of MetadataService.Process().
|
|
type ProcessResult struct {
|
|
ContentID string
|
|
IsNew bool // True if this was an initial match (new item created)
|
|
Updated bool // True if any fields were changed
|
|
}
|
|
|
|
// MatchHints carries scanner-extracted data into the pipeline.
|
|
// Adapted from matcher.MatchHints with FilePath added for local providers.
|
|
type MatchHints struct {
|
|
ContentID string
|
|
FileHash string
|
|
TmdbID string
|
|
TvdbID string
|
|
ImdbID string
|
|
Title string
|
|
Year int
|
|
Type string // "movie" or "series"
|
|
SeasonNum int
|
|
EpisodeNum int
|
|
HintSource string // "xattr", "nfo", "folder", "filename"
|
|
FilePath string // Absolute path to media file (legacy local-provider field)
|
|
RepresentativeFilePath string
|
|
ObservedRootPath string
|
|
AllGroupFilePaths []string
|
|
PrimarySidecarSearchPaths []string
|
|
}
|
|
|
|
// SearchQuery is passed to SearchProvider.Search().
|
|
type SearchQuery struct {
|
|
Title string
|
|
Year int
|
|
ContentType string // media_items.type value
|
|
ProviderIDs map[string]string // Accumulated IDs from prior providers
|
|
Language string // ISO 639-1 code from library preference
|
|
FilePath string
|
|
RepresentativeFilePath string
|
|
ObservedRootPath string
|
|
AllGroupFilePaths []string
|
|
PrimarySidecarSearchPaths []string
|
|
}
|
|
|
|
// SearchResult is returned from SearchProvider.Search().
|
|
type SearchResult struct {
|
|
Name string
|
|
Year int
|
|
ProviderIDs map[string]string
|
|
ImageURL string
|
|
Overview string
|
|
Provider string // Slug of the provider that returned this
|
|
}
|
|
|
|
// MetadataRequest is passed to MetadataProvider.GetMetadata().
|
|
type MetadataRequest struct {
|
|
ProviderIDs map[string]string
|
|
ContentType string
|
|
Language string
|
|
FilePath string // Populated for local providers, empty for remote
|
|
RepresentativeFilePath string
|
|
ObservedRootPath string
|
|
AllGroupFilePaths []string
|
|
PrimarySidecarSearchPaths []string
|
|
GroupTitle string
|
|
GroupYear int
|
|
}
|
|
|
|
// PersonDetailRequest is passed to person detail lookups.
|
|
type PersonDetailRequest struct {
|
|
ProviderIDs map[string]string
|
|
Language string
|
|
}
|
|
|
|
// PersonDetailResult carries person-level metadata from a provider.
|
|
type PersonDetailResult struct {
|
|
Name string
|
|
SortName string
|
|
Bio string
|
|
BirthDate string
|
|
DeathDate string
|
|
Birthplace string
|
|
Homepage string
|
|
PhotoPath string
|
|
PhotoSourcePath string
|
|
PhotoThumbhash string
|
|
ProviderIDs map[string]string
|
|
}
|
|
|
|
// MetadataResult carries structured metadata from a single provider.
|
|
type MetadataResult struct {
|
|
HasMetadata bool
|
|
ProviderIDs map[string]string
|
|
Title string
|
|
OriginalTitle string
|
|
SortTitle string
|
|
Overview string
|
|
Tagline string
|
|
Year int
|
|
Runtime int
|
|
Genres []string
|
|
Studios []string
|
|
Networks []string
|
|
Countries []string
|
|
Keywords []string
|
|
OriginalLanguage string
|
|
ContentRating string
|
|
Ratings Ratings
|
|
People []models.ItemPerson
|
|
// Images (S3 paths or URLs).
|
|
PosterPath string
|
|
PosterThumbhash string
|
|
BackdropPath string
|
|
BackdropThumbhash string
|
|
LogoPath string
|
|
// Series-specific
|
|
ReleaseDate string
|
|
SeasonCount int
|
|
FirstAirDate string
|
|
LastAirDate string
|
|
AirTime string
|
|
AirTimezone string
|
|
// ShowStatus is the publication/airing status ("Ongoing", "Completed",
|
|
// "Continuing", "Ended") when the provider reports one.
|
|
ShowStatus string
|
|
}
|
|
|
|
// Ratings holds ratings from multiple sources.
|
|
type Ratings struct {
|
|
IMDB float64
|
|
TMDB float64
|
|
RTCritic float64
|
|
RTAudience float64
|
|
}
|
|
|
|
// ImageRequest is passed to ImageProvider.GetImages().
|
|
type ImageRequest struct {
|
|
ProviderIDs map[string]string
|
|
ContentType string
|
|
Language string
|
|
}
|
|
|
|
// RemoteImage describes an available image from a provider.
|
|
type RemoteImage struct {
|
|
ProviderID string // Slug of the provider that returned this image
|
|
URL string
|
|
Type ImageType
|
|
Language string
|
|
Width int
|
|
Height int
|
|
Rating float64 // Vote average for ordering
|
|
}
|
|
|
|
// ImageType classifies image purpose.
|
|
type ImageType int
|
|
|
|
const (
|
|
ImagePoster ImageType = iota
|
|
ImageBackdrop
|
|
ImageLogo
|
|
ImageStill // Episode stills
|
|
ImageProfile
|
|
)
|
|
|
|
// CacheImageRequest describes an image to be cached. For season posters
|
|
// and episode stills, ContentID is the parent series's ID and the
|
|
// SeasonNumber / EpisodeNumber fields scope the S3 key so siblings do not
|
|
// collide. Both pointers are nil for item-level images.
|
|
type CacheImageRequest struct {
|
|
SourceURL string
|
|
ProviderID string
|
|
ContentType string // "movies" or "series"
|
|
ContentID string
|
|
ImageType ImageType
|
|
SeasonNumber *int
|
|
EpisodeNumber *int
|
|
Language string
|
|
}
|
|
|
|
// CacheImageResult is returned by ImageCacher on success.
|
|
type CacheImageResult struct {
|
|
BasePath string // S3 key prefix
|
|
Thumbhash string // base64-encoded
|
|
Ext string // file extension including dot (e.g. ".jpg", ".png")
|
|
|
|
UploadedVariants int
|
|
ExistingVariants int
|
|
}
|
|
|
|
// ImageCacher caches a remote image to object storage.
|
|
type ImageCacher interface {
|
|
CacheImage(ctx context.Context, req CacheImageRequest) (*CacheImageResult, error)
|
|
}
|
|
|
|
type ImageCacheJobEnqueuer interface {
|
|
Enqueue(ctx context.Context, in EnqueueImageCacheJobInput) error
|
|
EnqueueBatch(ctx context.Context, inputs []EnqueueImageCacheJobInput) (int, error)
|
|
}
|
|
|
|
// SeasonsRequest is passed to EpisodeProvider.GetSeasons().
|
|
type SeasonsRequest struct {
|
|
ProviderIDs map[string]string
|
|
ContentType string
|
|
Language string
|
|
}
|
|
|
|
// EpisodesRequest is passed to EpisodeProvider.GetEpisodes().
|
|
type EpisodesRequest struct {
|
|
ProviderIDs map[string]string
|
|
SeasonNumber int
|
|
Language string
|
|
}
|
|
|
|
// SeasonResult carries season data from a provider.
|
|
type SeasonResult struct {
|
|
ContentID string
|
|
SeasonNumber int
|
|
Title string
|
|
Overview string
|
|
AirDate string
|
|
PosterPath string
|
|
PosterSourcePath string
|
|
PosterThumbhash string
|
|
Episodes []EpisodeResult
|
|
}
|
|
|
|
// EpisodeResult carries episode data from a provider.
|
|
type EpisodeResult struct {
|
|
ContentID string
|
|
ProviderIDs map[string]string
|
|
SeasonNumber int
|
|
EpisodeNumber int
|
|
Title string
|
|
Overview string
|
|
AirDate string
|
|
Runtime int
|
|
Ratings Ratings
|
|
StillPath string
|
|
StillSourcePath string
|
|
StillThumbhash string
|
|
}
|