Files
silo-server/internal/metadata/service.go
T
Quick ee54942b01 fix(metadata): break duplicate provider candidate ties
- Score candidate metadata completeness and auto-match the richer duplicate when title/year/type tie
- Enrich near-duplicate candidates via the provider chain before initial match selection
- Seed both movie and series match queues for mixed-type libraries and wait for TV queue settle
- Add taskmanager worker test coverage and a plan doc for the tie-breaker work
2026-05-26 20:07:43 -04:00

5195 lines
161 KiB
Go

package metadata
import (
"context"
"errors"
"fmt"
"log/slog"
"maps"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/idgen"
"github.com/Silo-Server/silo-server/internal/lang"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/naming"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// FileContentUpdater updates content_id on media_files.
type FileContentUpdater interface {
UpdateContentID(ctx context.Context, fileID int, contentID string) error
ReplaceContentID(ctx context.Context, oldContentID, newContentID string) (int, error)
FindContentIDByRootPath(ctx context.Context, folderID int, rootPath, preferredType string) (string, error)
FindContentIDByObservedRootPath(ctx context.Context, folderID int, observedRootPath, preferredType string) (string, error)
FindContentIDByGroupKey(ctx context.Context, folderID int, groupKeyVersion int, contentGroupKey, preferredType string) (string, error)
ListByGroupKey(ctx context.Context, folderID int, groupKeyVersion int, contentGroupKey string) ([]*models.MediaFile, error)
ListByObservedRootPath(ctx context.Context, folderID int, observedRootPath string) ([]*models.MediaFile, error)
UpdateContentIDByObservedRootPath(ctx context.Context, folderID int, observedRootPath, contentID string) (int, error)
}
// EpisodeLinker extends FileContentUpdater with episode linking.
type EpisodeLinker interface {
FileContentUpdater
UpdateEpisodeLink(ctx context.Context, fileID int, episodeID string, seasonNum, episodeNum int) error
ListBySeriesUnlinked(ctx context.Context, seriesContentID string) ([]*models.MediaFile, error)
}
type bulkSeriesEpisodeLinker interface {
BulkLinkEpisodesBySeries(ctx context.Context, seriesContentID string) (int, error)
}
// UnmatchedFileLister lists files that have no content_id.
type UnmatchedFileLister interface {
ClaimUnmatched(ctx context.Context, limit int) ([]*models.MediaFile, error)
ClaimUnmatchedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error)
MarkMatchAttempted(ctx context.Context, fileID int) error
}
// UnmatchedItemLister lists unmatched items scoped to a library subtree.
type UnmatchedItemLister interface {
ListUnmatchedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int) ([]string, error)
}
// metadataItemRepo defines item repository methods used by MetadataService.
// The concrete *catalog.ItemRepository satisfies this interface.
type metadataItemRepo interface {
GetByID(ctx context.Context, contentID string) (*models.MediaItem, error)
GetByExternalID(ctx context.Context, tmdbID, imdbID, tvdbID, itemType string) (*models.MediaItem, error)
GetByTitleYearType(ctx context.Context, title string, year int, itemType string) (*models.MediaItem, error)
Upsert(ctx context.Context, item *models.MediaItem) error
IncrementRefreshFailure(ctx context.Context, contentID string) error
ReplacePeople(ctx context.Context, contentID string, people []models.ItemPerson) error
ListUnmatchedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int) ([]string, error)
}
type metadataProviderIDRepo interface {
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaItemProviderID, error)
ReplaceByContentID(ctx context.Context, contentID string, providerIDs map[string]string) error
FindContentIDByProviderIDs(ctx context.Context, providerIDs map[string]string, itemType, excludeContentID string) (string, error)
}
type metadataStaleIDRepo interface {
GetByContentID(ctx context.Context, contentID string) ([]*models.StaleMediaID, error)
Upsert(ctx context.Context, contentID, provider, providerID string) error
DeleteByContentID(ctx context.Context, contentID string) error
}
type metadataRefreshDebtRepo interface {
Get(ctx context.Context, contentID string) (*models.MetadataRefreshDebt, error)
GetTarget(ctx context.Context, targetType, contentID string) (*models.MetadataRefreshDebt, error)
UpsertDebt(ctx context.Context, contentID string, priority int, reasonMask int64, nextRefreshAt time.Time) error
UpsertTargetDebt(ctx context.Context, targetType, contentID string, priority int, reasonMask int64, nextRefreshAt time.Time) error
RequestDue(ctx context.Context, targetType, contentID string, priority int, reasonMask int64, nextRefreshAt time.Time, cooldown time.Duration) error
MarkFailure(ctx context.Context, contentID string, priority int, reasonMask int64, nextRefreshAt time.Time, attemptCount int, lastError string) error
MarkTargetFailure(ctx context.Context, targetType, contentID string, priority int, reasonMask int64, nextRefreshAt time.Time, attemptCount int, lastError string) error
MarkSuccess(ctx context.Context, contentID string, priority int, reasonMask int64, nextRefreshAt time.Time) error
MarkTargetSuccess(ctx context.Context, targetType, contentID string, priority int, reasonMask int64, nextRefreshAt time.Time) error
DeleteDebt(ctx context.Context, contentID string) error
DeleteTargetDebt(ctx context.Context, targetType, contentID string) error
}
// metadataLibraryRepo defines library membership methods used by
// MetadataService. The concrete *catalog.LibraryItemRepository satisfies this.
type metadataLibraryRepo interface {
Upsert(ctx context.Context, contentID string, folderID int, firstSeenAt time.Time) error
GetFolderIDsForItem(ctx context.Context, contentID string) ([]int, error)
GetDistinctMetadataLanguagesForItem(ctx context.Context, contentID string) ([]string, error)
CountFoldersForItem(ctx context.Context, contentID string) (int, error)
}
// metadataRootClaimRepo defines root-claim methods used by createOrFindSkeleton
// for canonical-root deduplication. The concrete *catalog.RootClaimRepository
// satisfies this through its Get and ClaimRoot methods.
type metadataRootClaimRepo interface {
Get(ctx context.Context, folderID int, rootPath string) (*models.MediaItemRoot, error)
ClaimRoot(ctx context.Context, folderID int, rootPath, contentID string) error
}
type metadataScannedRootRepo interface {
Get(ctx context.Context, folderID int, rootPath string) (*models.ScannedMediaRoot, error)
}
type metadataGroupClaimRepo interface {
Get(ctx context.Context, folderID int, groupKeyVersion int, contentGroupKey string) (*models.MediaItemGroup, error)
ClaimGroup(ctx context.Context, folderID int, groupKeyVersion int, contentGroupKey, contentID string) error
ClaimAndRelinkFiles(ctx context.Context, folderID int, groupKeyVersion int, contentGroupKey, contentID string) (int, error)
}
type metadataScannedGroupRepo interface {
Get(ctx context.Context, folderID int, groupKeyVersion int, contentGroupKey string) (*models.ScannedMediaGroup, error)
}
type metadataGroupOverrideRepo interface {
Get(ctx context.Context, folderID int, groupKeyVersion int, contentGroupKey string) (*models.MediaGroupOverride, error)
}
type metadataObservedLocationRepo interface {
Get(ctx context.Context, folderID int, observedRootPath string) (*models.ObservedMediaLocation, error)
}
// metadataSkippedRootRepo defines skipped-root methods used during skeleton
// creation for legacy diagnostics. The concrete *SkippedRootRepository
// satisfies this interface.
type metadataSkippedRootRepo interface {
Upsert(ctx context.Context, root models.SkippedMediaRoot) error
Delete(ctx context.Context, folderID int, rootPath string) error
}
// metadataEpisodeRepo defines episode repository methods used by MetadataService.
// The concrete *catalog.EpisodeRepository satisfies this interface.
type metadataEpisodeRepo interface {
GetByID(ctx context.Context, contentID string) (*models.Episode, error)
GetBySeriesAndNumber(ctx context.Context, seriesID string, season, episode int) (*models.Episode, error)
ListBySeriesAndAirDates(ctx context.Context, seriesID string, airDates []string) (map[string][]*models.Episode, error)
ListBySeries(ctx context.Context, seriesID string) ([]*models.Episode, error)
ListBySeasonID(ctx context.Context, seasonID string) ([]*models.Episode, error)
Upsert(ctx context.Context, ep *models.Episode) error
BulkUpsert(ctx context.Context, seriesID string, episodes []*models.Episode) error
}
// metadataSeasonRepo defines season repository methods used by MetadataService.
// The concrete *catalog.SeasonRepository satisfies this interface.
type metadataSeasonRepo interface {
GetByID(ctx context.Context, contentID string) (*models.Season, error)
GetBySeriesAndNumber(ctx context.Context, seriesID string, seasonNum int) (*models.Season, error)
Upsert(ctx context.Context, s *models.Season) error
BulkUpsert(ctx context.Context, seasons []*models.Season) error
}
// metadataFolderRepo defines folder repository methods used by MetadataService
// for looking up per-library settings like metadata language.
type metadataFolderRepo interface {
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
}
type metadataContentFileLister interface {
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error)
}
type metadataServiceHooks struct {
process func(ctx context.Context, req ProcessRequest) (*ProcessResult, error)
createOrFindSkeleton func(ctx context.Context, file *models.MediaFile, folderID int) (*skeletonResult, error)
updateItemStatus func(ctx context.Context, contentID, status string) error
linkSeriesFilesToEpisodes func(ctx context.Context, seriesID string)
ensureSeriesEpisodeLinks func(ctx context.Context, seriesID string) error
}
var trustedSearchIDKeys = []string{"tmdb", "tvdb", "imdb"}
var ErrMetadataNotFound = errors.New("no metadata found from any provider")
const (
metadataRefreshNudgeCooldown = time.Hour
metadataOnDemandRefreshTimeout = 2 * time.Minute
)
// isProvider404 returns true if the error string indicates an HTTP 404 from a provider.
func isProvider404(err error) bool {
return err != nil && strings.Contains(err.Error(), "HTTP 404")
}
func isDurableProviderSlug(slug string) bool {
switch strings.ToLower(strings.TrimSpace(slug)) {
case "tmdb", "imdb", "tvdb":
return true
default:
return false
}
}
func dropProviderID(providerIDs map[string]string, provider string) {
if providerIDs == nil {
return
}
delete(providerIDs, strings.ToLower(strings.TrimSpace(provider)))
}
func handleProvider404(
provider404s map[string]string,
providerIDs map[string]string,
provider string,
err error,
attrs ...any,
) bool {
if !isProvider404(err) {
return false
}
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return true
}
logAttrs := append([]any{"provider", provider}, attrs...)
if providerID := strings.TrimSpace(providerIDs[provider]); providerID != "" {
if provider404s != nil && isDurableProviderSlug(provider) {
provider404s[provider] = providerID
}
logAttrs = append(logAttrs, "provider_id", providerID)
dropProviderID(providerIDs, provider)
}
logAttrs = append(logAttrs, "error", err)
slog.Info("metadata: provider returned 404 for stale or invalid external id", logAttrs...)
return true
}
// MetadataService is the unified pipeline orchestrator.
type MetadataService struct {
chainRepo *ChainRepository
pluginResolver pluginMetadataResolver
itemRepo metadataItemRepo
providerIDRepo metadataProviderIDRepo
episodeRepo metadataEpisodeRepo
seasonRepo metadataSeasonRepo
libraryRepo metadataLibraryRepo
folderRepo metadataFolderRepo
itemLocalizationRepo *catalog.MediaItemLocalizationRepository
seasonLocalizationRepo *catalog.SeasonLocalizationRepository
episodeLocalizationRepo *catalog.EpisodeLocalizationRepository
personRepo *catalog.PersonRepository
fileRepo FileContentUpdater
skippedRootRepo metadataSkippedRootRepo
staleIDRepo metadataStaleIDRepo
refreshDebtRepo metadataRefreshDebtRepo
rootClaimRepo metadataRootClaimRepo
scannedRootRepo metadataScannedRootRepo
groupClaimRepo metadataGroupClaimRepo
scannedGroupRepo metadataScannedGroupRepo
groupOverrideRepo metadataGroupOverrideRepo
observedLocationRepo metadataObservedLocationRepo
dbPool *pgxpool.Pool
dedupLocks keyedDedupLocks
onDemandRefresh keyedRefreshSet
seriesWorkMu sync.Mutex
seriesWork map[string]*seriesEpisodeWork
hooks metadataServiceHooks
imageCacher ImageCacher
autoCacheImages bool
imageResolver interface {
ResolveImageURL(ctx context.Context, path string, variant string) string
}
// chainCache caches resolved provider chains keyed by "folderID:contentLevel".
// Chains are static between admin edits, so a short TTL avoids thousands of
// redundant DB queries per batch.
chainCacheMu sync.RWMutex
chainCache map[string]chainCacheEntry
chainCacheTTL time.Duration
}
type chainCacheEntry struct {
providers []Provider
expiresAt time.Time
}
type seriesEpisodeWork struct {
done chan struct{}
err error
}
type keyedDedupLocks struct {
mu sync.Mutex
locks map[string]*dedupLockRef
}
type keyedRefreshSet struct {
mu sync.Mutex
running map[string]struct{}
}
type dedupLockRef struct {
mu sync.Mutex
refs int
}
type localProviderContext struct {
filePath string
representativeFilePath string
observedRootPath string
allGroupFilePaths []string
primarySidecarSearchPaths []string
}
type chainResolver func(contentLevel string) ([]Provider, error)
// NewMetadataService creates a new MetadataService.
func NewMetadataService(
chainRepo *ChainRepository,
pluginResolver pluginMetadataResolver,
itemRepo *catalog.ItemRepository,
providerIDRepo *catalog.ProviderIDRepository,
episodeRepo *catalog.EpisodeRepository,
seasonRepo *catalog.SeasonRepository,
libraryRepo *catalog.LibraryItemRepository,
folderRepo *catalog.FolderRepository,
personRepo *catalog.PersonRepository,
fileRepo FileContentUpdater,
skippedRootRepo *SkippedRootRepository,
staleIDRepo metadataStaleIDRepo,
rootClaimRepo *catalog.RootClaimRepository,
) *MetadataService {
var itemLocalizationRepo *catalog.MediaItemLocalizationRepository
var seasonLocalizationRepo *catalog.SeasonLocalizationRepository
var episodeLocalizationRepo *catalog.EpisodeLocalizationRepository
var scannedRootRepo metadataScannedRootRepo
var scannedGroupRepo metadataScannedGroupRepo
var groupClaimRepo metadataGroupClaimRepo
var groupOverrideRepo metadataGroupOverrideRepo
var observedLocationRepo metadataObservedLocationRepo
var dbPool *pgxpool.Pool
if folderRepo != nil {
pool := folderRepo.Pool()
dbPool = pool
itemLocalizationRepo = catalog.NewMediaItemLocalizationRepository(pool)
seasonLocalizationRepo = catalog.NewSeasonLocalizationRepository(pool)
episodeLocalizationRepo = catalog.NewEpisodeLocalizationRepository(pool)
scannedRootRepo = scanner.NewScannedRootRepository(pool)
scannedGroupRepo = scanner.NewScannedGroupRepository(pool)
groupClaimRepo = catalog.NewGroupClaimRepository(pool)
groupOverrideRepo = scanner.NewMediaGroupOverrideRepository(pool)
observedLocationRepo = scanner.NewObservedLocationRepository(pool)
}
return &MetadataService{
chainRepo: chainRepo,
pluginResolver: pluginResolver,
itemRepo: itemRepo,
providerIDRepo: providerIDRepo,
episodeRepo: episodeRepo,
seasonRepo: seasonRepo,
libraryRepo: libraryRepo,
folderRepo: folderRepo,
itemLocalizationRepo: itemLocalizationRepo,
seasonLocalizationRepo: seasonLocalizationRepo,
episodeLocalizationRepo: episodeLocalizationRepo,
personRepo: personRepo,
fileRepo: fileRepo,
skippedRootRepo: skippedRootRepo,
staleIDRepo: staleIDRepo,
refreshDebtRepo: NewRefreshDebtRepository(dbPool),
rootClaimRepo: rootClaimRepo,
scannedRootRepo: scannedRootRepo,
groupClaimRepo: groupClaimRepo,
scannedGroupRepo: scannedGroupRepo,
groupOverrideRepo: groupOverrideRepo,
observedLocationRepo: observedLocationRepo,
dbPool: dbPool,
chainCache: make(map[string]chainCacheEntry),
chainCacheTTL: 60 * time.Second,
}
}
// SetImageCacher enables S3 image caching during metadata persistence.
func (s *MetadataService) SetImageCacher(c ImageCacher) {
s.imageCacher = c
}
// SetAutoCacheImages controls whether refresh pipelines automatically cache
// provider images into object storage. Explicit admin image applies still use
// the configured image cacher when available.
func (s *MetadataService) SetAutoCacheImages(enabled bool) {
s.autoCacheImages = enabled
}
// SetImageResolver sets the resolver used to convert plugin-prefixed image
// paths (e.g. "tmdb://poster/abc.jpg") to HTTP URLs before caching.
func (s *MetadataService) SetImageResolver(r interface {
ResolveImageURL(ctx context.Context, path string, variant string) string
}) {
s.imageResolver = r
}
// resolveChainCached wraps ResolveChain with a short-lived in-memory cache.
// Provider chains are determined by admin configuration and change rarely, so
// caching avoids repeated DB queries when processing batches of files.
func (s *MetadataService) resolveChainCached(ctx context.Context, folderID int, contentLevel string) ([]Provider, error) {
key := fmt.Sprintf("%d:%s", folderID, contentLevel)
s.chainCacheMu.RLock()
if entry, ok := s.chainCache[key]; ok && time.Now().Before(entry.expiresAt) {
s.chainCacheMu.RUnlock()
return entry.providers, nil
}
s.chainCacheMu.RUnlock()
providers, err := ResolveChain(ctx, folderID, contentLevel, s.chainRepo, s.pluginResolver)
if err != nil {
return nil, err
}
s.chainCacheMu.Lock()
s.chainCache[key] = chainCacheEntry{
providers: providers,
expiresAt: time.Now().Add(s.chainCacheTTL),
}
s.chainCacheMu.Unlock()
return providers, nil
}
// InvalidateChainCache clears the cached provider chains, forcing the next
// resolution to re-query the database. Call this after admin chain edits.
func (s *MetadataService) InvalidateChainCache() {
s.chainCacheMu.Lock()
s.chainCache = make(map[string]chainCacheEntry)
s.chainCacheMu.Unlock()
}
// Process runs the unified metadata pipeline with per-level chain resolution.
func (s *MetadataService) Process(ctx context.Context, req ProcessRequest) (*ProcessResult, error) {
if s != nil && s.hooks.process != nil {
return s.hooks.process(ctx, req)
}
var err error
req, err = s.prepareProcessRequest(ctx, req)
if err != nil {
return nil, err
}
folderID := parseProcessFolderID(req.FolderID)
languages, err := s.resolveProcessLanguages(ctx, req, folderID)
if err != nil {
return nil, err
}
resolveChain := func(contentLevel string) ([]Provider, error) {
chain, err := s.resolveChainCached(ctx, folderID, contentLevel)
if err != nil {
return nil, fmt.Errorf("resolve %s provider chain: %w", contentLevel, err)
}
return chain, nil
}
var final ProcessResult
for _, language := range languages {
langReq := req
langReq.Language = language
result, err := s.processInternal(ctx, langReq, resolveChain)
if err != nil {
return nil, err
}
if result == nil {
continue
}
if result.ContentID != "" {
final.ContentID = result.ContentID
req.ContentID = result.ContentID
}
final.IsNew = final.IsNew || result.IsNew
final.Updated = final.Updated || result.Updated
}
return &final, nil
}
func parseProcessFolderID(raw string) int {
folderID := 0
if raw != "" {
fmt.Sscanf(raw, "%d", &folderID)
}
return folderID
}
func (s *MetadataService) resolveProcessLanguages(ctx context.Context, req ProcessRequest, folderID int) ([]string, error) {
if strings.TrimSpace(req.Language) != "" {
return []string{strings.TrimSpace(req.Language)}, nil
}
if folderID > 0 {
return []string{s.resolveFolderLanguage(ctx, folderID)}, nil
}
if req.ContentID != "" {
defaultLanguage := ""
if s.itemRepo != nil {
if item, err := s.itemRepo.GetByID(ctx, req.ContentID); err == nil && strings.TrimSpace(item.DefaultMetadataLanguage) != "" {
defaultLanguage = strings.TrimSpace(item.DefaultMetadataLanguage)
}
}
var languages []string
if s.libraryRepo != nil {
langs, err := s.libraryRepo.GetDistinctMetadataLanguagesForItem(ctx, req.ContentID)
if err != nil {
return nil, err
}
languages = append(languages, langs...)
}
if defaultLanguage != "" {
languages = prependUniqueString(languages, defaultLanguage)
}
languages = compactMetadataLanguages(languages)
if len(languages) > 0 {
return languages, nil
}
}
return []string{"en"}, nil
}
func (s *MetadataService) resolveFolderLanguage(ctx context.Context, folderID int) string {
if folderID <= 0 || s.folderRepo == nil {
return "en"
}
folder, err := s.folderRepo.GetByID(ctx, folderID)
if err != nil {
slog.Warn("metadata: failed to look up folder language, defaulting to English",
"folder_id", folderID, "error", err)
return "en"
}
if strings.TrimSpace(folder.MetadataLanguage) == "" {
return "en"
}
return strings.TrimSpace(folder.MetadataLanguage)
}
func prependUniqueString(values []string, value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return values
}
for _, existing := range values {
if strings.EqualFold(strings.TrimSpace(existing), value) {
return values
}
}
return append([]string{value}, values...)
}
func compactMetadataLanguages(values []string) []string {
result := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if seen[key] {
continue
}
seen[key] = true
result = append(result, trimmed)
}
return result
}
func (s *MetadataService) localProviderContextForContent(ctx context.Context, contentID string, folderID int) localProviderContext {
if s == nil || s.fileRepo == nil || contentID == "" {
return localProviderContext{}
}
lister, ok := s.fileRepo.(metadataContentFileLister)
if !ok {
return localProviderContext{}
}
files, err := lister.GetByContentID(ctx, contentID)
if err != nil || len(files) == 0 {
return localProviderContext{}
}
if folderID > 0 {
filtered := files[:0]
for _, file := range files {
if file != nil && file.MediaFolderID == folderID {
filtered = append(filtered, file)
}
}
files = filtered
if len(files) == 0 {
return localProviderContext{}
}
}
groupFilePaths := make([]string, 0, len(files))
representativeFilePath := ""
observedRootPath := ""
var representativeFile *models.MediaFile
for _, file := range files {
if file == nil {
continue
}
if representativeFilePath == "" {
representativeFilePath = file.FilePath
representativeFile = file
}
groupFilePaths = append(groupFilePaths, file.FilePath)
}
if representativeFilePath == "" {
return localProviderContext{}
}
if representativeFile != nil && representativeFile.ObservedRootPath != "" &&
s.canUseObservedRootForDirectorySidecars(
ctx,
representativeFile.MediaFolderID,
representativeFile.ObservedRootPath,
representativeFile.GroupKeyVersion,
representativeFile.ContentGroupKey,
) {
observedRootPath = representativeFile.ObservedRootPath
}
return localProviderContext{
filePath: representativeFilePath,
representativeFilePath: representativeFilePath,
observedRootPath: observedRootPath,
allGroupFilePaths: compactUniqueFilePaths(groupFilePaths),
primarySidecarSearchPaths: s.directorySidecarSearchPathsForFiles(ctx, files),
}
}
func (s *MetadataService) directorySidecarSearchPathsForFiles(ctx context.Context, files []*models.MediaFile) []string {
if len(files) == 0 {
return nil
}
paths := make([]string, 0, len(files))
for _, file := range files {
if file == nil || file.ObservedRootPath == "" {
continue
}
if !s.canUseObservedRootForDirectorySidecars(
ctx,
file.MediaFolderID,
file.ObservedRootPath,
file.GroupKeyVersion,
file.ContentGroupKey,
) {
continue
}
paths = append(paths, file.ObservedRootPath)
}
return compactUniqueFilePaths(paths)
}
func (s *MetadataService) canUseObservedRootForDirectorySidecars(
ctx context.Context,
folderID int,
observedRootPath string,
groupKeyVersion int,
contentGroupKey string,
) bool {
cleanRoot := filepath.Clean(strings.TrimSpace(observedRootPath))
if cleanRoot == "" || cleanRoot == "." {
return false
}
if s == nil || s.observedLocationRepo == nil || folderID <= 0 {
return true
}
location, err := s.observedLocationRepo.Get(ctx, folderID, cleanRoot)
if err != nil {
slog.Warn("metadata: observed location lookup failed",
"folder_id", folderID,
"observed_root_path", cleanRoot,
"error", err,
)
return false
}
if location == nil || location.ContentGroupCount > 1 {
return false
}
if groupKeyVersion != 0 && location.PrimaryGroupKeyVersion != 0 && location.PrimaryGroupKeyVersion != groupKeyVersion {
return false
}
if contentGroupKey != "" && location.PrimaryContentGroupKey != "" && location.PrimaryContentGroupKey != contentGroupKey {
return false
}
return true
}
func compactUniqueFilePaths(paths []string) []string {
if len(paths) == 0 {
return nil
}
seen := make(map[string]struct{}, len(paths))
out := make([]string, 0, len(paths))
for _, path := range paths {
clean := filepath.Clean(strings.TrimSpace(path))
if clean == "" || clean == "." {
continue
}
if _, ok := seen[clean]; ok {
continue
}
seen[clean] = struct{}{}
out = append(out, clean)
}
sort.Strings(out)
return out
}
func (s *MetadataService) loadDurableProviderIDs(ctx context.Context, contentID string) (map[string]string, error) {
if s == nil || s.providerIDRepo == nil || contentID == "" {
return nil, nil
}
ids, err := s.providerIDRepo.GetByContentID(ctx, contentID)
if err != nil {
return nil, err
}
return providerIDMapFromRows(ids), nil
}
func (s *MetadataService) suppressRecordedStaleProviderIDs(
ctx context.Context,
contentID string,
providerIDs map[string]string,
) error {
if s == nil || s.staleIDRepo == nil || strings.TrimSpace(contentID) == "" || len(providerIDs) == 0 {
return nil
}
staleIDs, err := s.staleIDRepo.GetByContentID(ctx, contentID)
if err != nil {
return fmt.Errorf("loading stale provider ids for %s: %w", contentID, err)
}
for _, staleID := range staleIDs {
if staleID == nil {
continue
}
provider := strings.ToLower(strings.TrimSpace(staleID.Provider))
if provider == "" {
continue
}
if providerIDs[provider] != strings.TrimSpace(staleID.ProviderID) {
continue
}
delete(providerIDs, provider)
}
return nil
}
// ProcessWithProviders runs the pipeline with explicit providers (for testing).
// All phases use the same chain regardless of content level.
func (s *MetadataService) ProcessWithProviders(ctx context.Context, req ProcessRequest, chain []Provider) (*ProcessResult, error) {
var err error
req, err = s.prepareProcessRequest(ctx, req)
if err != nil {
return nil, err
}
return s.processInternal(ctx, req, func(string) ([]Provider, error) { return chain, nil })
}
func (s *MetadataService) prepareProcessRequest(ctx context.Context, req ProcessRequest) (ProcessRequest, error) {
durableIDs, err := s.loadDurableProviderIDs(ctx, req.ContentID)
if err != nil {
return req, err
}
if len(durableIDs) == 0 {
return req, nil
}
if req.ProviderIDs == nil {
req.ProviderIDs = make(map[string]string, len(durableIDs))
}
for key, value := range durableIDs {
if _, exists := req.ProviderIDs[key]; !exists {
req.ProviderIDs[key] = value
}
}
return req, nil
}
// processInternal contains the core pipeline logic. The resolveChain function
// returns providers for a given content level, enabling per-level resolution.
func (s *MetadataService) processInternal(ctx context.Context, req ProcessRequest, resolveChain chainResolver) (*ProcessResult, error) {
scopeFolderID := parseProcessFolderID(req.FolderID)
// Determine content type from hints or existing item.
contentType := ""
if req.Hints != nil {
contentType = req.Hints.Type
}
// Determine the item-level content level for phases 1-3.
itemLevel := "series"
if contentType == "movie" || contentType == "movies" {
itemLevel = "movie"
}
itemChain, err := resolveChain(itemLevel)
if err != nil {
return nil, err
}
// Phase 1: Search — find provider IDs.
accumulatedIDs := make(map[string]string)
maps.Copy(accumulatedIDs, req.ProviderIDs)
// Track provider 404s as stale external IDs. This applies to initial match
// as well so bad embedded folder/file IDs can be recorded and dropped
// without surfacing as generic provider failures.
var provider404s map[string]string
if req.ContentID != "" {
provider404s = make(map[string]string)
}
switch req.Mode {
case ModeInitialMatch:
if req.Hints == nil {
return nil, fmt.Errorf("initial match requires hints")
}
// Seed external IDs from hints. Hints.ContentID is Silo's local
// skeleton item ID, not a searchable provider ID.
if req.Hints.FileHash != "" {
accumulatedIDs["oshash"] = req.Hints.FileHash
}
if req.Hints.TmdbID != "" {
accumulatedIDs["tmdb"] = req.Hints.TmdbID
}
if req.Hints.TvdbID != "" {
accumulatedIDs["tvdb"] = req.Hints.TvdbID
}
if req.Hints.ImdbID != "" {
accumulatedIDs["imdb"] = req.Hints.ImdbID
}
// Add filepath for local providers.
if req.Hints.RepresentativeFilePath != "" {
accumulatedIDs["_filepath"] = req.Hints.RepresentativeFilePath
} else if req.Hints.FilePath != "" {
accumulatedIDs["_filepath"] = req.Hints.FilePath
}
if err := s.suppressRecordedStaleProviderIDs(ctx, req.ContentID, accumulatedIDs); err != nil {
return nil, err
}
// Run search providers and choose a decisive normalized winner instead of
// letting the first non-empty result win.
searchQuery := SearchQuery{
Title: req.Hints.Title,
Year: req.Hints.Year,
ContentType: contentType,
ProviderIDs: accumulatedIDs,
Language: req.Language,
FilePath: req.Hints.FilePath,
RepresentativeFilePath: req.Hints.RepresentativeFilePath,
ObservedRootPath: req.Hints.ObservedRootPath,
AllGroupFilePaths: append([]string(nil), req.Hints.AllGroupFilePaths...),
PrimarySidecarSearchPaths: append([]string(nil), req.Hints.PrimarySidecarSearchPaths...),
}
searchQuery = suppressTitleYearFallbackForTrustedIDs(searchQuery)
allResults := make([]SearchResult, 0)
for _, p := range itemChain {
sp, ok := p.(SearchProvider)
if !ok {
continue
}
results, err := sp.Search(ctx, searchQuery)
if err != nil {
if handleProvider404(provider404s, accumulatedIDs, p.Slug(), err,
"title", req.Hints.Title,
"year", req.Hints.Year,
) {
continue
}
slog.Warn("metadata: search provider error",
"provider", p.Slug(), "error", err)
continue
}
for _, result := range results {
if searchResultConflictsWithTrustedIDs(accumulatedIDs, result.ProviderIDs) {
slog.Warn("metadata: skipping conflicting search result",
"provider", p.Slug(),
"title", req.Hints.Title,
"year", req.Hints.Year,
"hinted_ids", accumulatedIDs,
"candidate_ids", result.ProviderIDs,
)
continue
}
allResults = append(allResults, result)
}
}
candidates := NormalizeCandidates(allResults, contentType)
if winner, ok := selectInitialMatchCandidate(req.Hints, candidates); ok && winner != nil {
for k, v := range winner.ProviderIDs {
if v != "" {
accumulatedIDs[k] = v
}
}
}
case ModeIdentify:
// Use user-provided IDs directly.
maps.Copy(accumulatedIDs, req.ProviderIDs)
if contentType == "" && req.ContentID != "" {
if existing, err := s.itemRepo.GetByID(ctx, req.ContentID); err == nil {
contentType = existing.Type
itemLevel = "series"
if contentType == "movie" || contentType == "movies" {
itemLevel = "movie"
}
itemChain, err = resolveChain(itemLevel)
if err != nil {
return nil, err
}
}
}
case ModeScheduledRefresh, ModeManualRefresh:
// Load existing item's IDs from DB.
if req.ContentID == "" {
return nil, fmt.Errorf("refresh requires content_id")
}
existing, err := s.itemRepo.GetByID(ctx, req.ContentID)
if err != nil {
return nil, fmt.Errorf("loading existing item: %w", err)
}
if existing.TmdbID != "" {
accumulatedIDs["tmdb"] = existing.TmdbID
}
if existing.TvdbID != "" {
accumulatedIDs["tvdb"] = existing.TvdbID
}
if existing.ImdbID != "" {
accumulatedIDs["imdb"] = existing.ImdbID
}
contentType = existing.Type
if err := s.suppressRecordedStaleProviderIDs(ctx, req.ContentID, accumulatedIDs); err != nil {
return nil, err
}
// Re-resolve itemChain now that contentType is known.
itemLevel = "series"
if contentType == "movie" || contentType == "movies" {
itemLevel = "movie"
}
itemChain, err = resolveChain(itemLevel)
if err != nil {
return nil, err
}
// Run search providers to refresh provider IDs before fetching full metadata.
searchQuery := SearchQuery{
Title: existing.Title,
Year: existing.Year,
ContentType: contentType,
ProviderIDs: accumulatedIDs,
Language: req.Language,
}
localCtx := s.localProviderContextForContent(ctx, req.ContentID, scopeFolderID)
searchQuery.FilePath = localCtx.filePath
searchQuery.RepresentativeFilePath = localCtx.representativeFilePath
searchQuery.ObservedRootPath = localCtx.observedRootPath
searchQuery.AllGroupFilePaths = append([]string(nil), localCtx.allGroupFilePaths...)
searchQuery.PrimarySidecarSearchPaths = append([]string(nil), localCtx.primarySidecarSearchPaths...)
searchQuery = suppressTitleYearFallbackForTrustedIDs(searchQuery)
allResults := make([]SearchResult, 0)
for _, p := range itemChain {
sp, ok := p.(SearchProvider)
if !ok {
continue
}
results, searchErr := sp.Search(ctx, searchQuery)
if searchErr != nil {
if handleProvider404(provider404s, accumulatedIDs, p.Slug(), searchErr,
"content_id", req.ContentID,
) {
continue
}
slog.Warn("metadata: refresh search error",
"provider", p.Slug(), "error", searchErr)
continue
}
for _, result := range results {
if searchResultConflictsWithTrustedIDs(accumulatedIDs, result.ProviderIDs) {
continue
}
allResults = append(allResults, result)
}
}
candidates := NormalizeCandidates(allResults, contentType)
if winner, ok := selectRefreshMatchCandidate(existing, candidates); ok && winner != nil {
for k, v := range winner.ProviderIDs {
if v != "" {
accumulatedIDs[k] = v
}
}
}
}
if req.Mode != ModeIdentify {
if err := s.suppressRecordedStaleProviderIDs(ctx, req.ContentID, accumulatedIDs); err != nil {
return nil, err
}
}
// Phase 2: Metadata — all MetadataProviders run, results merge into accumulator.
accumulator := &MetadataResult{ProviderIDs: copyMap(accumulatedIDs)}
filePath := ""
representativeFilePath := ""
observedRootPath := ""
allGroupFilePaths := []string{}
primarySidecarSearchPaths := []string{}
groupTitle := ""
groupYear := 0
if req.Hints != nil {
filePath = req.Hints.FilePath
representativeFilePath = req.Hints.RepresentativeFilePath
observedRootPath = req.Hints.ObservedRootPath
allGroupFilePaths = req.Hints.AllGroupFilePaths
primarySidecarSearchPaths = req.Hints.PrimarySidecarSearchPaths
groupTitle = req.Hints.Title
groupYear = req.Hints.Year
} else if req.ContentID != "" {
localCtx := s.localProviderContextForContent(ctx, req.ContentID, scopeFolderID)
filePath = localCtx.filePath
representativeFilePath = localCtx.representativeFilePath
observedRootPath = localCtx.observedRootPath
allGroupFilePaths = localCtx.allGroupFilePaths
primarySidecarSearchPaths = localCtx.primarySidecarSearchPaths
}
for _, p := range itemChain {
mp, ok := p.(MetadataProvider)
if !ok {
continue
}
result, err := mp.GetMetadata(ctx, MetadataRequest{
ProviderIDs: accumulatedIDs,
ContentType: contentType,
Language: req.Language,
FilePath: filePath,
RepresentativeFilePath: representativeFilePath,
ObservedRootPath: observedRootPath,
AllGroupFilePaths: allGroupFilePaths,
PrimarySidecarSearchPaths: primarySidecarSearchPaths,
GroupTitle: groupTitle,
GroupYear: groupYear,
})
if err != nil {
if handleProvider404(provider404s, accumulatedIDs, p.Slug(), err,
"content_id", req.ContentID,
) {
continue
}
slog.Warn("metadata: provider error",
"provider", p.Slug(), "error", err)
continue
}
if result == nil || !result.HasMetadata {
continue
}
// Bootstrap: feed new IDs to subsequent providers.
mergeProviderIDs(accumulator, result)
accumulatedIDs = accumulator.ProviderIDs
// Merge fields into accumulator (FillEmpty — first provider wins).
MergeMetadata(result, accumulator, nil, MergeFillEmpty)
}
// Phase 3: Images — all ImageProviders run, collect all available images.
var allImages []RemoteImage
for _, p := range itemChain {
ip, ok := p.(ImageProvider)
if !ok {
continue
}
images, err := ip.GetImages(ctx, ImageRequest{
ProviderIDs: accumulatedIDs,
ContentType: contentType,
Language: req.Language,
})
if err != nil {
if handleProvider404(provider404s, accumulatedIDs, p.Slug(), err,
"content_id", req.ContentID,
) {
continue
}
slog.Warn("metadata: image provider error",
"provider", p.Slug(), "error", err)
continue
}
allImages = append(allImages, images...)
}
// Phase 4a: Seasons — resolve with "season" content level.
var allSeasons []SeasonResult
var allEpisodes []EpisodeResult
if contentType == "series" {
seasonChain, err := resolveChain("season")
if err != nil {
return nil, err
}
seasonResults := make(map[int]*SeasonResult)
for _, p := range seasonChain {
ep, ok := p.(EpisodeProvider)
if !ok {
continue
}
seasons, err := ep.GetSeasons(ctx, SeasonsRequest{
ProviderIDs: accumulatedIDs,
ContentType: contentType,
Language: req.Language,
})
if err != nil {
if handleProvider404(provider404s, accumulatedIDs, p.Slug(), err,
"content_id", req.ContentID,
"season", 0,
) {
continue
}
slog.Warn("metadata: season provider error",
"provider", p.Slug(), "error", err)
continue
}
accumulateSeasonResults(seasonResults, seasons)
}
allSeasons = flattenSeasonResults(seasonResults)
// Phase 4b: Episodes — resolve with "episode" content level.
if len(allSeasons) > 0 {
episodeChain, err := resolveChain("episode")
if err != nil {
return nil, err
}
episodeResults := make(map[episodeResultKey]*EpisodeResult)
for _, season := range allSeasons {
for _, p := range episodeChain {
ep, ok := p.(EpisodeProvider)
if !ok {
continue
}
episodes, err := ep.GetEpisodes(ctx, EpisodesRequest{
ProviderIDs: accumulatedIDs,
SeasonNumber: season.SeasonNumber,
Language: req.Language,
})
if err != nil {
if handleProvider404(provider404s, accumulatedIDs, p.Slug(), err,
"content_id", req.ContentID,
"season", season.SeasonNumber,
) {
continue
}
slog.Warn("metadata: episode provider error",
"provider", p.Slug(), "season", season.SeasonNumber, "error", err)
continue
}
accumulateEpisodeResults(episodeResults, episodes)
}
}
allEpisodes = flattenEpisodeResults(episodeResults)
}
}
// Phase 5: Merge & Persist.
if !accumulator.HasMetadata && accumulator.Title == "" {
// Record stale IDs for providers that returned 404.
if s.staleIDRepo != nil && req.ContentID != "" && provider404s != nil {
for slug, providerID := range provider404s {
if providerID != "" {
if err := s.staleIDRepo.Upsert(ctx, req.ContentID, slug, providerID); err != nil {
slog.Warn("metadata: failed to record stale ID",
"content_id", req.ContentID, "provider", slug, "provider_id", providerID, "error", err)
}
}
}
}
return &ProcessResult{Updated: false}, nil
}
result, err := s.mergeAndPersist(ctx, req, accumulator, allImages, allSeasons, allEpisodes, contentType)
if err != nil {
return nil, err
}
// Refresh stale ID records on successful refresh: clear anything resolved,
// then keep only the providers that still 404ed during this run.
if s.staleIDRepo != nil && req.ContentID != "" {
if delErr := s.staleIDRepo.DeleteByContentID(ctx, req.ContentID); delErr != nil {
slog.Warn("metadata: failed to clear stale IDs after refresh",
"content_id", req.ContentID, "error", delErr)
}
for slug, providerID := range provider404s {
if providerID == "" {
continue
}
if upsertErr := s.staleIDRepo.Upsert(ctx, req.ContentID, slug, providerID); upsertErr != nil {
slog.Warn("metadata: failed to persist stale provider ID after partial refresh",
"content_id", req.ContentID,
"provider", slug,
"provider_id", providerID,
"error", upsertErr)
}
}
}
if result != nil && strings.TrimSpace(result.ContentID) != "" {
if syncErr := s.syncRefreshDebtForItem(ctx, result.ContentID); syncErr != nil {
slog.Warn("metadata: failed to sync refresh debt after successful metadata refresh",
"content_id", result.ContentID,
"error", syncErr)
}
}
return result, nil
}
// mergeAndPersist handles the final merge into existing item and DB persistence.
func (s *MetadataService) mergeAndPersist(
ctx context.Context,
req ProcessRequest,
accumulator *MetadataResult,
images []RemoteImage,
seasons []SeasonResult,
episodes []EpisodeResult,
contentType string,
) (*ProcessResult, error) {
// Determine merge mode.
var mergeMode MergeMode
switch req.Mode {
case ModeInitialMatch, ModeScheduledRefresh:
mergeMode = MergeFillEmpty
case ModeManualRefresh, ModeIdentify:
mergeMode = MergeReplaceUnlocked
}
isNew := req.ContentID == "" && req.Mode == ModeInitialMatch
contentID := req.ContentID
var existingItem *models.MediaItem
if isNew {
// Check for existing item by provider IDs (dedup).
unlockDedup := s.lockDedupKey(dedupKeyFromProviderIDs(contentType, accumulator.ProviderIDs))
existing, _ := s.findExistingByProviderIDs(ctx, accumulator.ProviderIDs, contentType, contentID)
if existing != nil && isConfirmedOwnershipStatus(existing.Status) {
contentID = existing.ContentID
isNew = false
existingItem = existing
}
unlockDedup()
}
// Hold the provider-ID dedup lock through canonicalization and durable-ID
// persistence so concurrent versions with the same provider IDs cannot both
// slip past rebind and race on the unique provider constraint.
unlockProviderDedup := func() {}
if key := dedupKeyFromProviderIDs(contentType, accumulator.ProviderIDs); key != "" {
unlockProviderDedup = s.lockDedupKey(key)
}
providerDedupReleased := false
defer func() {
if !providerDedupReleased {
unlockProviderDedup()
}
}()
// Load locked fields and durable provider IDs if refreshing an existing item.
var locked []MetadataField
var durableIDs map[string]string
if !isNew && contentID != "" {
existing, err := s.itemRepo.GetByID(ctx, contentID)
if err == nil {
existingItem = existing
locked = intSliceToFields(existing.LockedFields)
}
durableIDs, err = s.loadDurableProviderIDs(ctx, contentID)
if err != nil {
return nil, err
}
}
if !isNew && contentID != "" && existingItem != nil && (isProvisionalOwnershipStatus(existingItem.Status) || len(durableIDs) == 0) {
reboundTo, err := s.rebindItemByProviderIDsLocked(ctx, contentID, accumulator.ProviderIDs, contentType, len(durableIDs) == 0)
if err != nil {
return nil, err
}
if reboundTo != "" {
contentID = reboundTo
existingItem, err = s.itemRepo.GetByID(ctx, contentID)
if err != nil {
return nil, fmt.Errorf("loading rebound item: %w", err)
}
locked = intSliceToFields(existingItem.LockedFields)
durableIDs, err = s.loadDurableProviderIDs(ctx, contentID)
if err != nil {
return nil, err
}
}
}
if len(durableIDs) > 0 {
if accumulator.ProviderIDs == nil {
accumulator.ProviderIDs = make(map[string]string, len(durableIDs))
}
for key, value := range durableIDs {
if _, exists := accumulator.ProviderIDs[key]; !exists {
accumulator.ProviderIDs[key] = value
}
}
}
canonicalLanguage := strings.TrimSpace(req.Language)
if existingItem != nil && strings.TrimSpace(existingItem.DefaultMetadataLanguage) != "" {
canonicalLanguage = strings.TrimSpace(existingItem.DefaultMetadataLanguage)
}
if canonicalLanguage == "" {
canonicalLanguage = "en"
}
isCanonicalWrite := existingItem == nil || strings.EqualFold(req.Language, canonicalLanguage)
if existingItem != nil {
existingResult := itemToMetadataResult(existingItem)
if req.Mode == ModeInitialMatch && isSkeletonLikeStatus(existingItem.Status) {
existingResult.Title = ""
existingResult.SortTitle = ""
existingResult.Year = 0
}
if isCanonicalWrite {
MergeMetadata(accumulator, existingResult, locked, mergeMode)
} else {
MergeGlobalMetadata(accumulator, existingResult, locked, mergeMode)
}
accumulator = existingResult
}
ApplyDefaultSortTitle(accumulator, isFieldLocked(locked, FieldName))
// Build the item for persistence.
now := time.Now()
item := metadataResultToItem(accumulator, contentType)
item.DefaultMetadataLanguage = canonicalLanguage
item.MatchedAt = &now
item.LastRefreshed = &now
item.RefreshFailures = 0
item.Status = "matched"
// Apply best images.
if isCanonicalWrite {
applyBestImages(item, images, mergeMode, req.Language)
item.PosterThumbhash = mergedImageThumbhash(
existingImagePath(existingItem, ImagePoster),
existingImageThumbhash(existingItem, ImagePoster),
item.PosterPath,
"",
)
item.BackdropThumbhash = mergedImageThumbhash(
existingImagePath(existingItem, ImageBackdrop),
existingImageThumbhash(existingItem, ImageBackdrop),
item.BackdropPath,
"",
)
}
// Cache images to S3 if enabled.
if s.autoCacheImages && s.imageCacher != nil && isCanonicalWrite {
s.cacheItemImages(ctx, item, images)
}
if isNew && contentID == "" {
var genErr error
contentID, genErr = generateContentID()
if genErr != nil {
return nil, fmt.Errorf("generate content id: %w", genErr)
}
}
item.ContentID = contentID
persistedContentID, err := s.persistItemAndProviderIDs(
ctx,
item,
accumulator.ProviderIDs,
isNew,
existingItem,
len(durableIDs) == 0,
contentType,
)
if err != nil {
return nil, err
}
contentID = persistedContentID
item.ContentID = contentID
unlockProviderDedup()
providerDedupReleased = true
if !isCanonicalWrite && s.itemLocalizationRepo != nil {
existingLoc, err := s.itemLocalizationRepo.Get(ctx, contentID, req.Language)
if err != nil {
return nil, fmt.Errorf("loading item localization: %w", err)
}
loc := buildItemLocalizationRecord(
existingLoc, contentID, req.Language, contentType, accumulator, images, mergeMode, req.Language,
isFieldLocked(locked, FieldName),
)
if err := s.itemLocalizationRepo.Upsert(ctx, loc); err != nil {
return nil, fmt.Errorf("upserting item localization: %w", err)
}
}
// Persist people to the unified people table.
if len(item.People) > 0 && s.personRepo != nil {
persons := make([]models.Person, len(item.People))
for i := range item.People {
persons[i] = item.People[i].Person
}
personIDs, err := s.personRepo.BatchFindOrCreate(ctx, persons)
if err != nil {
slog.Warn("metadata: batch find/create people failed", "error", err)
} else {
for i := range item.People {
item.People[i].Person.ID = personIDs[i]
}
}
// Filter out entries where FindOrCreate failed (Person.ID stayed 0).
valid := item.People[:0]
for _, p := range item.People {
if p.Person.ID != 0 {
valid = append(valid, p)
}
}
if err := s.itemRepo.ReplacePeople(ctx, contentID, valid); err != nil {
slog.Warn("metadata: failed to replace people", "content_id", contentID, "error", err)
}
}
// Ensure library membership exists after successful enrichment. This call
// site runs inside mergeAndPersist, so the item is known to be matched at
// this point; it uses the matched-only upsert as a secondary confirmation.
if req.Mode == ModeInitialMatch && req.FolderID != "" {
folderID := 0
fmt.Sscanf(req.FolderID, "%d", &folderID)
if err := s.upsertLibraryMembershipIfMatched(ctx, contentID, folderID); err != nil {
s.logLibraryMembershipError("ensuring matched library membership", contentID, folderID, err)
}
}
s.cacheSeriesChildImages(ctx, item, accumulator.ProviderIDs, seasons, episodes)
// Persist seasons and episodes for series.
if contentType == "series" {
if len(seasons) > 0 {
s.persistSeasonsAndEpisodes(ctx, contentID, canonicalLanguage, req.Language, seasons, episodes, mergeMode)
} else if err := s.SynthesizeFallbackEpisodes(ctx, contentID); err != nil {
slog.Warn("metadata: failed to synthesize fallback series structure",
"content_id", contentID, "error", err)
}
}
return &ProcessResult{
ContentID: contentID,
IsNew: isNew,
Updated: true,
}, nil
}
func (s *MetadataService) persistItemAndProviderIDs(
ctx context.Context,
item *models.MediaItem,
providerIDs map[string]string,
isNew bool,
existingItem *models.MediaItem,
missingDurableIDs bool,
contentType string,
) (string, error) {
if item == nil || strings.TrimSpace(item.ContentID) == "" {
return "", fmt.Errorf("content_id is required")
}
if itemRepo, ok := s.itemRepo.(*catalog.ItemRepository); ok && s.dbPool != nil {
if providerRepo, ok := s.providerIDRepo.(*catalog.ProviderIDRepository); ok && providerRepo != nil {
return s.persistItemAndProviderIDsTx(ctx, itemRepo, providerRepo, item, providerIDs, isNew, existingItem, missingDurableIDs, contentType)
}
}
return s.persistItemAndProviderIDsLegacy(ctx, item, providerIDs, isNew, existingItem, missingDurableIDs, contentType)
}
func (s *MetadataService) persistItemAndProviderIDsTx(
ctx context.Context,
itemRepo *catalog.ItemRepository,
providerRepo *catalog.ProviderIDRepository,
item *models.MediaItem,
providerIDs map[string]string,
isNew bool,
existingItem *models.MediaItem,
missingDurableIDs bool,
contentType string,
) (string, error) {
contentID := item.ContentID
err := s.persistItemAndProviderIDsOnceTx(ctx, itemRepo, providerRepo, item, providerIDs)
if err == nil {
return contentID, nil
}
if !isProviderIDUniqueConflict(err) {
return "", fmt.Errorf("persisting provider IDs for %s: %w", contentID, err)
}
reboundTo, recoverErr := s.recoverProviderIDConflict(ctx, contentID, providerIDs, contentType, true)
if recoverErr != nil {
return "", fmt.Errorf("recovering provider ID conflict for %s: %w", contentID, recoverErr)
}
if reboundTo == "" {
return "", fmt.Errorf("persisting provider IDs for %s: %w", contentID, err)
}
item.ContentID = reboundTo
if retryErr := s.persistItemAndProviderIDsOnceTx(ctx, itemRepo, providerRepo, item, providerIDs); retryErr != nil {
return "", fmt.Errorf("persisting provider IDs after conflict recovery for %s: %w", reboundTo, retryErr)
}
return reboundTo, nil
}
func (s *MetadataService) persistItemAndProviderIDsOnceTx(
ctx context.Context,
itemRepo *catalog.ItemRepository,
providerRepo *catalog.ProviderIDRepository,
item *models.MediaItem,
providerIDs map[string]string,
) error {
tx, err := s.dbPool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin metadata item/provider transaction: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
if err := itemRepo.UpsertTx(ctx, tx, item); err != nil {
return fmt.Errorf("upserting item: %w", err)
}
if providerRepo != nil {
if err := providerRepo.ReplaceByContentIDTx(ctx, tx, item.ContentID, item.Type, providerIDs); err != nil {
return err
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit metadata item/provider transaction: %w", err)
}
return nil
}
func (s *MetadataService) persistItemAndProviderIDsLegacy(
ctx context.Context,
item *models.MediaItem,
providerIDs map[string]string,
isNew bool,
existingItem *models.MediaItem,
missingDurableIDs bool,
contentType string,
) (string, error) {
contentID := item.ContentID
if err := s.itemRepo.Upsert(ctx, item); err != nil {
if isNew {
return "", fmt.Errorf("creating item: %w", err)
}
return "", fmt.Errorf("updating item: %w", err)
}
if s.providerIDRepo == nil {
return contentID, nil
}
if err := s.providerIDRepo.ReplaceByContentID(ctx, contentID, providerIDs); err != nil {
if isProviderIDUniqueConflict(err) {
reboundTo, reboundErr := s.recoverProviderIDConflict(ctx, contentID, providerIDs, contentType, true)
if reboundErr != nil {
return "", fmt.Errorf("recovering provider ID conflict for %s: %w", contentID, reboundErr)
}
if reboundTo == "" {
return "", fmt.Errorf("persisting provider IDs for %s: %w", contentID, err)
}
item.ContentID = reboundTo
if err := s.itemRepo.Upsert(ctx, item); err != nil {
return "", fmt.Errorf("updating rebound item after provider conflict: %w", err)
}
if err := s.providerIDRepo.ReplaceByContentID(ctx, reboundTo, providerIDs); err != nil {
return "", fmt.Errorf("persisting provider IDs after conflict recovery for %s: %w", reboundTo, err)
}
return reboundTo, nil
}
return "", fmt.Errorf("persisting provider IDs for %s: %w", contentID, err)
}
return contentID, nil
}
// RefreshItem re-fetches metadata for a single content item.
func (s *MetadataService) RefreshItem(ctx context.Context, contentID string) error {
return s.refreshItemTarget(ctx, contentID, 0, ModeManualRefresh, true)
}
func (s *MetadataService) refreshItemTarget(ctx context.Context, contentID string, folderID int, mode RefreshMode, incrementDebtAttempt bool) error {
result, err := s.Process(ctx, ProcessRequest{
ContentID: contentID,
FolderID: formatFolderID(folderID),
Mode: mode,
})
if err != nil {
s.recordRefreshFailure(ctx, contentID, err, incrementDebtAttempt)
return err
}
if result == nil || !result.Updated {
s.recordRefreshFailure(ctx, contentID, ErrMetadataNotFound, incrementDebtAttempt)
return ErrMetadataNotFound
}
return nil
}
// RefreshScheduledItem re-fetches metadata for a single content item using the
// background refresh merge policy.
func (s *MetadataService) RefreshScheduledItem(ctx context.Context, contentID string) error {
return s.RefreshScheduledTarget(ctx, RefreshTargetItem, contentID)
}
// RefreshScheduledTarget re-fetches metadata for a queued item, season, or
// episode target using the background refresh merge policy.
func (s *MetadataService) RefreshScheduledTarget(ctx context.Context, targetType, contentID string) error {
return s.refreshTarget(ctx, targetType, contentID, 0, ModeScheduledRefresh, false)
}
// RefreshItemForLibrary re-fetches metadata for an item using a specific
// library's provider chain and metadata language preferences.
func (s *MetadataService) RefreshItemForLibrary(ctx context.Context, contentID string, folderID int) error {
return s.RefreshTargetForLibrary(ctx, RefreshTargetItem, contentID, folderID)
}
// RefreshTargetForLibrary re-fetches metadata for a specific target using a
// specific library's provider chain and metadata language preferences.
func (s *MetadataService) RefreshTargetForLibrary(ctx context.Context, targetType, contentID string, folderID int) error {
return s.refreshTarget(ctx, targetType, contentID, folderID, ModeManualRefresh, true)
}
func (s *MetadataService) refreshTarget(ctx context.Context, targetType, contentID string, folderID int, mode RefreshMode, incrementDebtAttempt bool) error {
targetType = NormalizeRefreshTargetType(targetType)
if targetType == "" {
return fmt.Errorf("unsupported metadata refresh target type")
}
switch targetType {
case RefreshTargetItem:
return s.refreshItemTarget(ctx, contentID, folderID, mode, incrementDebtAttempt)
case RefreshTargetSeason:
err := s.refreshSeasonTarget(ctx, contentID, folderID, mode)
if err != nil {
s.recordRefreshTargetFailure(ctx, targetType, contentID, err, incrementDebtAttempt)
return err
}
return s.syncRefreshDebtForTarget(ctx, targetType, contentID)
case RefreshTargetEpisode:
err := s.refreshEpisodeTarget(ctx, contentID, folderID, mode)
if err != nil {
s.recordRefreshTargetFailure(ctx, targetType, contentID, err, incrementDebtAttempt)
return err
}
return s.syncRefreshDebtForTarget(ctx, targetType, contentID)
default:
return fmt.Errorf("unsupported metadata refresh target type %q", targetType)
}
}
// RequestStaleMetadataRefresh nudges a stale target into the durable refresh
// queue and starts a detached refresh when the target is due. Provider work is
// never done inline on the caller's request path.
func (s *MetadataService) RequestStaleMetadataRefresh(ctx context.Context, targetType, contentID string) error {
if s == nil || s.refreshDebtRepo == nil {
return nil
}
targetType = NormalizeRefreshTargetType(targetType)
contentID = strings.TrimSpace(contentID)
if targetType == "" || contentID == "" {
return nil
}
now := time.Now().UTC()
reasonMask := RefreshDebtReasonEpisodeIncomplete
if err := s.refreshDebtRepo.RequestDue(
ctx,
targetType,
contentID,
refreshDebtPriority(reasonMask),
reasonMask,
now,
metadataRefreshNudgeCooldown,
); err != nil {
return err
}
due, err := s.refreshDebtTargetIsDue(ctx, targetType, contentID, now)
if err != nil {
return err
}
if due {
s.startOnDemandMetadataRefresh(targetType, contentID)
}
return nil
}
func (s *MetadataService) refreshDebtTargetIsDue(ctx context.Context, targetType, contentID string, now time.Time) (bool, error) {
if s == nil || s.refreshDebtRepo == nil {
return false, nil
}
debt, err := s.refreshDebtRepo.GetTarget(ctx, targetType, contentID)
if err != nil {
if errors.Is(err, ErrRefreshDebtNotFound) {
return false, nil
}
return false, err
}
if debt == nil {
return false, nil
}
if debt.LeaseExpiresAt != nil && !debt.LeaseExpiresAt.Before(now) {
return false, nil
}
return !debt.NextRefreshAt.After(now), nil
}
func (s *MetadataService) startOnDemandMetadataRefresh(targetType, contentID string) {
if !s.claimOnDemandMetadataRefresh(targetType, contentID) {
return
}
go func() {
defer s.releaseOnDemandMetadataRefresh(targetType, contentID)
ctx, cancel := context.WithTimeout(context.Background(), metadataOnDemandRefreshTimeout)
defer cancel()
slog.Info("metadata: starting on-demand stale refresh",
"target_type", targetType,
"content_id", contentID)
if err := s.refreshTarget(ctx, targetType, contentID, 0, ModeScheduledRefresh, false); err != nil {
slog.Warn("metadata: on-demand stale refresh failed",
"target_type", targetType,
"content_id", contentID,
"error", err)
return
}
slog.Info("metadata: completed on-demand stale refresh",
"target_type", targetType,
"content_id", contentID)
}()
}
func (s *MetadataService) claimOnDemandMetadataRefresh(targetType, contentID string) bool {
if s == nil {
return false
}
key := refreshTargetKey(targetType, contentID)
if key == "" {
return false
}
s.onDemandRefresh.mu.Lock()
defer s.onDemandRefresh.mu.Unlock()
if s.onDemandRefresh.running == nil {
s.onDemandRefresh.running = make(map[string]struct{})
}
if _, ok := s.onDemandRefresh.running[key]; ok {
return false
}
s.onDemandRefresh.running[key] = struct{}{}
return true
}
func (s *MetadataService) releaseOnDemandMetadataRefresh(targetType, contentID string) {
if s == nil {
return
}
key := refreshTargetKey(targetType, contentID)
if key == "" {
return
}
s.onDemandRefresh.mu.Lock()
defer s.onDemandRefresh.mu.Unlock()
delete(s.onDemandRefresh.running, key)
}
func refreshTargetKey(targetType, contentID string) string {
targetType = NormalizeRefreshTargetType(targetType)
contentID = strings.TrimSpace(contentID)
if targetType == "" || contentID == "" {
return ""
}
return targetType + ":" + contentID
}
func (s *MetadataService) recordRefreshFailure(ctx context.Context, contentID string, refreshErr error, incrementDebtAttempt bool) {
if s == nil || s.itemRepo == nil || strings.TrimSpace(contentID) == "" || refreshErr == nil {
return
}
if errors.Is(refreshErr, context.Canceled) || errors.Is(refreshErr, context.DeadlineExceeded) {
return
}
if err := s.itemRepo.IncrementRefreshFailure(ctx, contentID); err != nil && !errors.Is(err, catalog.ErrItemNotFound) {
slog.Warn("metadata: failed to record refresh failure",
"content_id", contentID,
"refresh_error", refreshErr,
"error", err)
return
}
if err := s.syncRefreshDebtFailure(ctx, contentID, refreshErr, incrementDebtAttempt); err != nil {
slog.Warn("metadata: failed to sync refresh debt after refresh failure",
"content_id", contentID,
"refresh_error", refreshErr,
"error", err)
}
}
func (s *MetadataService) recordRefreshTargetFailure(ctx context.Context, targetType, contentID string, refreshErr error, incrementDebtAttempt bool) {
if NormalizeRefreshTargetType(targetType) == RefreshTargetItem {
s.recordRefreshFailure(ctx, contentID, refreshErr, incrementDebtAttempt)
return
}
if s == nil || s.refreshDebtRepo == nil || strings.TrimSpace(contentID) == "" || refreshErr == nil {
return
}
if errors.Is(refreshErr, context.Canceled) || errors.Is(refreshErr, context.DeadlineExceeded) {
return
}
if err := s.syncRefreshDebtTargetFailure(ctx, targetType, contentID, refreshErr, incrementDebtAttempt); err != nil {
slog.Warn("metadata: failed to sync refresh debt after target refresh failure",
"target_type", targetType,
"content_id", contentID,
"refresh_error", refreshErr,
"error", err)
}
}
func (s *MetadataService) syncRefreshDebtForItem(ctx context.Context, contentID string) error {
if s == nil || s.refreshDebtRepo == nil || s.itemRepo == nil || strings.TrimSpace(contentID) == "" {
return nil
}
item, err := s.itemRepo.GetByID(ctx, contentID)
if err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
return s.refreshDebtRepo.DeleteDebt(ctx, contentID)
}
return err
}
reasonMask := refreshDebtReasonsForItem(item)
existingReasonMask, err := s.currentRefreshDebtTargetReasonMask(ctx, RefreshTargetItem, contentID)
if err != nil {
return err
}
if itemHasEpisodeMetadataDebt(item) && hasRefreshDebtReason(existingReasonMask, RefreshDebtReasonEpisodeIncomplete) {
reasonMask |= RefreshDebtReasonEpisodeIncomplete
}
if staleReason, err := s.currentStaleRefreshDebtReason(ctx, contentID); err != nil {
return err
} else {
reasonMask |= staleReason
}
if reasonMask == 0 {
return s.refreshDebtRepo.DeleteDebt(ctx, contentID)
}
attemptCount, err := s.currentRefreshDebtAttemptCount(ctx, contentID)
if err != nil {
return err
}
now := time.Now().UTC()
return s.refreshDebtRepo.MarkSuccess(
ctx,
contentID,
refreshDebtPriority(reasonMask),
reasonMask,
nextRefreshAtForDebt(reasonMask, attemptCount, now),
)
}
func (s *MetadataService) syncRefreshDebtForTarget(ctx context.Context, targetType, contentID string) error {
targetType = NormalizeRefreshTargetType(targetType)
switch targetType {
case "":
return nil
case RefreshTargetItem:
return s.syncRefreshDebtForItem(ctx, contentID)
case RefreshTargetSeason:
return s.syncRefreshDebtForSeason(ctx, contentID)
case RefreshTargetEpisode:
return s.syncRefreshDebtForEpisode(ctx, contentID)
default:
return nil
}
}
func (s *MetadataService) syncRefreshDebtForSeason(ctx context.Context, seasonID string) error {
if s == nil || s.refreshDebtRepo == nil || s.episodeRepo == nil || strings.TrimSpace(seasonID) == "" {
return nil
}
episodes, err := s.episodeRepo.ListBySeasonID(ctx, seasonID)
if err != nil {
if errors.Is(err, catalog.ErrSeasonNotFound) {
return s.refreshDebtRepo.DeleteTargetDebt(ctx, RefreshTargetSeason, seasonID)
}
return err
}
reasonMask := int64(0)
now := time.Now().UTC()
for _, episode := range episodes {
if EpisodeHasIncompleteMetadata(episode, now) {
reasonMask = RefreshDebtReasonEpisodeIncomplete
break
}
}
if reasonMask == 0 {
return s.refreshDebtRepo.DeleteTargetDebt(ctx, RefreshTargetSeason, seasonID)
}
attemptCount, err := s.currentRefreshDebtTargetAttemptCount(ctx, RefreshTargetSeason, seasonID)
if err != nil {
return err
}
return s.refreshDebtRepo.MarkTargetSuccess(
ctx,
RefreshTargetSeason,
seasonID,
refreshDebtPriority(reasonMask),
reasonMask,
nextRefreshAtForDebt(reasonMask, attemptCount, now),
)
}
func (s *MetadataService) syncRefreshDebtForEpisode(ctx context.Context, episodeID string) error {
if s == nil || s.refreshDebtRepo == nil || s.episodeRepo == nil || strings.TrimSpace(episodeID) == "" {
return nil
}
episode, err := s.episodeRepo.GetByID(ctx, episodeID)
if err != nil {
if errors.Is(err, catalog.ErrEpisodeNotFound) {
return s.refreshDebtRepo.DeleteTargetDebt(ctx, RefreshTargetEpisode, episodeID)
}
return err
}
reasonMask := int64(0)
now := time.Now().UTC()
if EpisodeHasIncompleteMetadata(episode, now) {
reasonMask = RefreshDebtReasonEpisodeIncomplete
}
if reasonMask == 0 {
return s.refreshDebtRepo.DeleteTargetDebt(ctx, RefreshTargetEpisode, episodeID)
}
attemptCount, err := s.currentRefreshDebtTargetAttemptCount(ctx, RefreshTargetEpisode, episodeID)
if err != nil {
return err
}
return s.refreshDebtRepo.MarkTargetSuccess(
ctx,
RefreshTargetEpisode,
episodeID,
refreshDebtPriority(reasonMask),
reasonMask,
nextRefreshAtForDebt(reasonMask, attemptCount, now),
)
}
func (s *MetadataService) syncRefreshDebtFailure(ctx context.Context, contentID string, refreshErr error, incrementAttempt bool) error {
if s == nil || s.refreshDebtRepo == nil || s.itemRepo == nil || strings.TrimSpace(contentID) == "" {
return nil
}
item, err := s.itemRepo.GetByID(ctx, contentID)
if err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
return s.refreshDebtRepo.DeleteDebt(ctx, contentID)
}
return err
}
reasonMask := refreshDebtReasonsForItem(item)
existingReasonMask, err := s.currentRefreshDebtTargetReasonMask(ctx, RefreshTargetItem, contentID)
if err != nil {
return err
}
if itemHasEpisodeMetadataDebt(item) && hasRefreshDebtReason(existingReasonMask, RefreshDebtReasonEpisodeIncomplete) {
reasonMask |= RefreshDebtReasonEpisodeIncomplete
}
staleReason, err := s.currentStaleRefreshDebtReason(ctx, contentID)
if err != nil {
return err
}
reasonMask |= staleReason
if strings.EqualFold(strings.TrimSpace(item.Status), "matched") &&
!hasRefreshDebtReason(reasonMask, RefreshDebtReasonProviderIDIncomplete) {
// Items missing provider IDs fail for that reason, not because the
// refresh itself errored. Tagging them with RefreshFailure muddies
// the metrics — the priority logic already prefers ProviderIDIncomplete.
reasonMask |= RefreshDebtReasonRefreshFailure
}
if reasonMask == 0 {
return s.refreshDebtRepo.DeleteDebt(ctx, contentID)
}
attemptCount, err := s.currentRefreshDebtAttemptCount(ctx, contentID)
if err != nil {
return err
}
if incrementAttempt {
attemptCount++
}
now := time.Now().UTC()
return s.refreshDebtRepo.MarkFailure(
ctx,
contentID,
refreshDebtPriority(reasonMask),
reasonMask,
nextRefreshAtForDebt(reasonMask, attemptCount, now),
attemptCount,
refreshErr.Error(),
)
}
func (s *MetadataService) syncRefreshDebtTargetFailure(ctx context.Context, targetType, contentID string, refreshErr error, incrementAttempt bool) error {
if s == nil || s.refreshDebtRepo == nil || strings.TrimSpace(contentID) == "" {
return nil
}
targetType = NormalizeRefreshTargetType(targetType)
if targetType == "" || targetType == RefreshTargetItem {
return s.syncRefreshDebtFailure(ctx, contentID, refreshErr, incrementAttempt)
}
reasonMask := RefreshDebtReasonEpisodeIncomplete | RefreshDebtReasonRefreshFailure
attemptCount, err := s.currentRefreshDebtTargetAttemptCount(ctx, targetType, contentID)
if err != nil {
return err
}
if incrementAttempt {
attemptCount++
}
now := time.Now().UTC()
return s.refreshDebtRepo.MarkTargetFailure(
ctx,
targetType,
contentID,
refreshDebtPriority(reasonMask),
reasonMask,
nextRefreshAtForDebt(reasonMask, attemptCount, now),
attemptCount,
refreshErr.Error(),
)
}
func (s *MetadataService) currentRefreshDebtAttemptCount(ctx context.Context, contentID string) (int, error) {
return s.currentRefreshDebtTargetAttemptCount(ctx, RefreshTargetItem, contentID)
}
func (s *MetadataService) currentRefreshDebtTargetAttemptCount(ctx context.Context, targetType, contentID string) (int, error) {
if s == nil || s.refreshDebtRepo == nil || strings.TrimSpace(contentID) == "" {
return 0, nil
}
debt, err := s.refreshDebtRepo.GetTarget(ctx, targetType, contentID)
if err != nil {
if errors.Is(err, ErrRefreshDebtNotFound) {
return 0, nil
}
return 0, err
}
if debt == nil {
return 0, nil
}
return debt.AttemptCount, nil
}
func (s *MetadataService) currentRefreshDebtTargetReasonMask(ctx context.Context, targetType, contentID string) (int64, error) {
if s == nil || s.refreshDebtRepo == nil || strings.TrimSpace(contentID) == "" {
return 0, nil
}
debt, err := s.refreshDebtRepo.GetTarget(ctx, targetType, contentID)
if err != nil {
if errors.Is(err, ErrRefreshDebtNotFound) {
return 0, nil
}
return 0, err
}
if debt == nil {
return 0, nil
}
return debt.ReasonMask, nil
}
func (s *MetadataService) currentStaleRefreshDebtReason(ctx context.Context, contentID string) (int64, error) {
if s == nil || s.staleIDRepo == nil || strings.TrimSpace(contentID) == "" {
return 0, nil
}
ids, err := s.staleIDRepo.GetByContentID(ctx, contentID)
if err != nil {
return 0, err
}
if len(ids) == 0 {
return 0, nil
}
return RefreshDebtReasonStaleProviderID, nil
}
func itemHasEpisodeMetadataDebt(item *models.MediaItem) bool {
return item != nil &&
strings.EqualFold(strings.TrimSpace(item.Type), "series") &&
item.EpisodeMetadataIncomplete
}
func (s *MetadataService) refreshSeasonTarget(ctx context.Context, seasonID string, folderID int, mode RefreshMode) error {
if s == nil || s.seasonRepo == nil {
return ErrMetadataNotFound
}
season, err := s.seasonRepo.GetByID(ctx, seasonID)
if err != nil {
return err
}
return s.refreshSeriesChildTarget(ctx, season.SeriesID, season.SeasonNumber, 0, folderID, mode)
}
func (s *MetadataService) refreshEpisodeTarget(ctx context.Context, episodeID string, folderID int, mode RefreshMode) error {
if s == nil || s.episodeRepo == nil {
return ErrMetadataNotFound
}
episode, err := s.episodeRepo.GetByID(ctx, episodeID)
if err != nil {
return err
}
return s.refreshSeriesChildTarget(ctx, episode.SeriesID, episode.SeasonNumber, episode.EpisodeNumber, folderID, mode)
}
func (s *MetadataService) refreshSeriesChildTarget(
ctx context.Context,
seriesID string,
seasonNumber int,
episodeNumber int,
folderID int,
mode RefreshMode,
) error {
if s == nil || s.itemRepo == nil || s.seasonRepo == nil || s.episodeRepo == nil {
return ErrMetadataNotFound
}
series, err := s.itemRepo.GetByID(ctx, seriesID)
if err != nil {
return err
}
if !strings.EqualFold(strings.TrimSpace(series.Type), "series") {
return ErrMetadataNotFound
}
languages, err := s.resolveProcessLanguages(ctx, ProcessRequest{
ContentID: seriesID,
FolderID: formatFolderID(folderID),
}, folderID)
if err != nil {
return err
}
mergeMode := MergeFillEmpty
if mode == ModeScheduledRefresh || mode == ModeManualRefresh || mode == ModeIdentify {
mergeMode = MergeReplaceUnlocked
}
updated := false
for _, language := range languages {
canonicalLanguage := strings.TrimSpace(series.DefaultMetadataLanguage)
if canonicalLanguage == "" {
canonicalLanguage = strings.TrimSpace(language)
}
if canonicalLanguage == "" {
canonicalLanguage = "en"
}
providerIDs, err := s.resolveSeriesRefreshProviderIDs(ctx, series, folderID, language)
if err != nil {
return err
}
seasons, err := s.fetchTargetSeasonResults(ctx, providerIDs, folderID, language, seasonNumber)
if err != nil {
return err
}
episodes, err := s.fetchTargetEpisodeResults(ctx, providerIDs, folderID, language, seasonNumber, episodeNumber)
if err != nil {
return err
}
if len(seasons) == 0 && len(episodes) == 0 {
continue
}
s.cacheSeriesChildImages(ctx, series, providerIDs, seasons, episodes)
s.persistSeasonsAndEpisodes(ctx, seriesID, canonicalLanguage, language, seasons, episodes, mergeMode)
updated = true
}
if !updated {
return ErrMetadataNotFound
}
return nil
}
func (s *MetadataService) resolveSeriesRefreshProviderIDs(ctx context.Context, series *models.MediaItem, folderID int, language string) (map[string]string, error) {
accumulatedIDs := make(map[string]string)
if series == nil {
return accumulatedIDs, nil
}
if series.TmdbID != "" {
accumulatedIDs["tmdb"] = series.TmdbID
}
if series.TvdbID != "" {
accumulatedIDs["tvdb"] = series.TvdbID
}
if series.ImdbID != "" {
accumulatedIDs["imdb"] = series.ImdbID
}
durableIDs, err := s.loadDurableProviderIDs(ctx, series.ContentID)
if err != nil {
return nil, err
}
maps.Copy(accumulatedIDs, durableIDs)
if err := s.suppressRecordedStaleProviderIDs(ctx, series.ContentID, accumulatedIDs); err != nil {
return nil, err
}
itemChain, err := s.resolveChainCached(ctx, folderID, "series")
if err != nil {
return nil, fmt.Errorf("resolve series provider chain: %w", err)
}
searchQuery := SearchQuery{
Title: series.Title,
Year: series.Year,
ContentType: series.Type,
ProviderIDs: accumulatedIDs,
Language: language,
}
localCtx := s.localProviderContextForContent(ctx, series.ContentID, folderID)
searchQuery.FilePath = localCtx.filePath
searchQuery.RepresentativeFilePath = localCtx.representativeFilePath
searchQuery.ObservedRootPath = localCtx.observedRootPath
searchQuery.AllGroupFilePaths = append([]string(nil), localCtx.allGroupFilePaths...)
searchQuery.PrimarySidecarSearchPaths = append([]string(nil), localCtx.primarySidecarSearchPaths...)
searchQuery = suppressTitleYearFallbackForTrustedIDs(searchQuery)
allResults := make([]SearchResult, 0)
provider404s := make(map[string]string)
for _, p := range itemChain {
sp, ok := p.(SearchProvider)
if !ok {
continue
}
results, searchErr := sp.Search(ctx, searchQuery)
if searchErr != nil {
if handleProvider404(provider404s, accumulatedIDs, p.Slug(), searchErr,
"content_id", series.ContentID,
) {
continue
}
slog.Warn("metadata: target refresh search error",
"provider", p.Slug(), "error", searchErr)
continue
}
for _, result := range results {
if searchResultConflictsWithTrustedIDs(accumulatedIDs, result.ProviderIDs) {
continue
}
allResults = append(allResults, result)
}
}
candidates := NormalizeCandidates(allResults, series.Type)
if winner, ok := selectRefreshMatchCandidate(series, candidates); ok && winner != nil {
for k, v := range winner.ProviderIDs {
if v != "" {
accumulatedIDs[k] = v
}
}
}
if err := s.suppressRecordedStaleProviderIDs(ctx, series.ContentID, accumulatedIDs); err != nil {
return nil, err
}
return accumulatedIDs, nil
}
func (s *MetadataService) fetchTargetSeasonResults(ctx context.Context, providerIDs map[string]string, folderID int, language string, seasonNumber int) ([]SeasonResult, error) {
seasonChain, err := s.resolveChainCached(ctx, folderID, "season")
if err != nil {
return nil, fmt.Errorf("resolve season provider chain: %w", err)
}
seasonResults := make(map[int]*SeasonResult)
for _, p := range seasonChain {
ep, ok := p.(EpisodeProvider)
if !ok {
continue
}
seasons, err := ep.GetSeasons(ctx, SeasonsRequest{
ProviderIDs: providerIDs,
ContentType: "series",
Language: language,
})
if err != nil {
if handleProvider404(nil, providerIDs, p.Slug(), err, "season", seasonNumber) {
continue
}
slog.Warn("metadata: target season provider error",
"provider", p.Slug(), "season", seasonNumber, "error", err)
continue
}
for _, season := range seasons {
if season.SeasonNumber != seasonNumber {
continue
}
accumulateSeasonResults(seasonResults, []SeasonResult{season})
}
}
return flattenSeasonResults(seasonResults), nil
}
func (s *MetadataService) fetchTargetEpisodeResults(ctx context.Context, providerIDs map[string]string, folderID int, language string, seasonNumber int, episodeNumber int) ([]EpisodeResult, error) {
episodeChain, err := s.resolveChainCached(ctx, folderID, "episode")
if err != nil {
return nil, fmt.Errorf("resolve episode provider chain: %w", err)
}
episodeResults := make(map[episodeResultKey]*EpisodeResult)
for _, p := range episodeChain {
ep, ok := p.(EpisodeProvider)
if !ok {
continue
}
episodes, err := ep.GetEpisodes(ctx, EpisodesRequest{
ProviderIDs: providerIDs,
SeasonNumber: seasonNumber,
Language: language,
})
if err != nil {
if handleProvider404(nil, providerIDs, p.Slug(), err, "season", seasonNumber) {
continue
}
slog.Warn("metadata: target episode provider error",
"provider", p.Slug(), "season", seasonNumber, "error", err)
continue
}
for _, episode := range episodes {
if episode.SeasonNumber != seasonNumber {
continue
}
if episodeNumber > 0 && episode.EpisodeNumber != episodeNumber {
continue
}
accumulateEpisodeResults(episodeResults, []EpisodeResult{episode})
}
}
return flattenEpisodeResults(episodeResults), nil
}
// cacheSeriesChildImages downloads, processes, and uploads season poster
// and episode still images to S3 concurrently (max 6 in flight). Plugin-
// prefixed URLs (e.g. tmdb://, tvdb://) are resolved via s.imageResolver
// before downloading. The provider used to construct each S3 key is
// derived from that image's source URL scheme so the key reflects the
// actual contributing provider (e.g. a TVDB-sourced season poster lands
// under tvdb/series/{tvdbID}/seasons/{n}/poster/...), not whichever
// provider primaryProviderID happens to prefer. Failures are logged and
// the original path is kept.
func (s *MetadataService) cacheSeriesChildImages(ctx context.Context, series *models.MediaItem, providerIDs map[string]string, seasons []SeasonResult, episodes []EpisodeResult) {
if s == nil || !s.autoCacheImages || s.imageCacher == nil || series == nil {
return
}
fallbackProvider := primaryProviderID(providerIDs)
sem := make(chan struct{}, 6)
var wg sync.WaitGroup
// keyAttribution returns the (providerID, contentID) pair to use when
// building the S3 key for an image with the given source path. The
// provider is derived from the plugin URL scheme when present and falls
// back to the series-level primary provider for raw HTTP URLs.
keyAttribution := func(sourcePath string) (string, string) {
providerID := providerIDFromPluginURL(sourcePath)
if providerID == "" {
providerID = fallbackProvider
}
return providerID, findContentID(series, providerID)
}
for i := range seasons {
downloadURL, ok := s.resolveImageURLForCache(ctx, seasons[i].PosterPath)
if !ok {
continue
}
providerID, contentID := keyAttribution(seasons[i].PosterPath)
seasonNum := seasons[i].SeasonNumber
wg.Add(1)
go func(idx int, sourceURL, providerID, contentID string, seasonNum int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
result, err := s.imageCacher.CacheImage(ctx, CacheImageRequest{
SourceURL: sourceURL,
ProviderID: providerID,
ContentType: "series",
ContentID: contentID,
ImageType: ImagePoster,
SeasonNumber: &seasonNum,
})
if err != nil {
slog.Warn("metadata: season poster cache failed", "season", seasonNum, "error", err)
return
}
seasons[idx].PosterPath = cachedOriginalImagePath(result.BasePath, result.Ext)
seasons[idx].PosterThumbhash = result.Thumbhash
}(i, downloadURL, providerID, contentID, seasonNum)
}
for i := range episodes {
downloadURL, ok := s.resolveImageURLForCache(ctx, episodes[i].StillPath)
if !ok {
continue
}
providerID, contentID := keyAttribution(episodes[i].StillPath)
seasonNum := episodes[i].SeasonNumber
episodeNum := episodes[i].EpisodeNumber
wg.Add(1)
go func(idx int, sourceURL, providerID, contentID string, seasonNum, episodeNum int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
result, err := s.imageCacher.CacheImage(ctx, CacheImageRequest{
SourceURL: sourceURL,
ProviderID: providerID,
ContentType: "series",
ContentID: contentID,
ImageType: ImageStill,
SeasonNumber: &seasonNum,
EpisodeNumber: &episodeNum,
})
if err != nil {
slog.Warn("metadata: episode still cache failed",
"season", seasonNum, "episode", episodeNum, "error", err)
return
}
episodes[idx].StillPath = cachedOriginalImagePath(result.BasePath, result.Ext)
episodes[idx].StillThumbhash = result.Thumbhash
}(i, downloadURL, providerID, contentID, seasonNum, episodeNum)
}
wg.Wait()
}
// providerIDFromPluginURL extracts the plugin slug from a plugin-scheme URL
// (e.g. "tvdb://banners/..." -> "tvdb"). Returns "" for HTTP(S) URLs and
// any other input lacking a scheme.
func providerIDFromPluginURL(url string) string {
if url == "" {
return ""
}
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
return ""
}
i := strings.Index(url, "://")
if i <= 0 {
return ""
}
return url[:i]
}
// resolveImageURLForCache normalizes a stored image path into a downloadable
// HTTP(S) URL suitable for the image cacher. Returns ok=false if the path is
// empty, has no scheme, or a plugin-prefixed URL cannot be resolved.
func (s *MetadataService) resolveImageURLForCache(ctx context.Context, path string) (string, bool) {
if path == "" || !strings.Contains(path, "://") {
return "", false
}
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path, true
}
if s.imageResolver == nil {
slog.Warn("metadata: cannot cache plugin image without resolver", "url", path)
return "", false
}
resolved := s.imageResolver.ResolveImageURL(ctx, path, "original")
if resolved == "" {
slog.Warn("metadata: resolver returned empty URL for image", "url", path)
return "", false
}
return resolved, true
}
type episodeResultKey struct {
seasonNumber int
episodeNumber int
}
func accumulateSeasonResults(accumulator map[int]*SeasonResult, seasons []SeasonResult) {
for _, season := range seasons {
existing := accumulator[season.SeasonNumber]
if existing == nil {
cp := season
accumulator[season.SeasonNumber] = &cp
continue
}
MergeSeasonResult(&season, existing, MergeFillEmpty)
}
}
func flattenSeasonResults(accumulator map[int]*SeasonResult) []SeasonResult {
if len(accumulator) == 0 {
return nil
}
seasonNumbers := make([]int, 0, len(accumulator))
for seasonNumber := range accumulator {
seasonNumbers = append(seasonNumbers, seasonNumber)
}
sort.Ints(seasonNumbers)
results := make([]SeasonResult, 0, len(seasonNumbers))
for _, seasonNumber := range seasonNumbers {
results = append(results, *accumulator[seasonNumber])
}
return results
}
func accumulateEpisodeResults(accumulator map[episodeResultKey]*EpisodeResult, episodes []EpisodeResult) {
for _, episode := range episodes {
key := episodeResultKey{
seasonNumber: episode.SeasonNumber,
episodeNumber: episode.EpisodeNumber,
}
existing := accumulator[key]
if existing == nil {
cp := episode
accumulator[key] = &cp
continue
}
MergeEpisodeResult(&episode, existing, MergeFillEmpty)
}
}
func flattenEpisodeResults(accumulator map[episodeResultKey]*EpisodeResult) []EpisodeResult {
if len(accumulator) == 0 {
return nil
}
keys := make([]episodeResultKey, 0, len(accumulator))
for key := range accumulator {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].seasonNumber != keys[j].seasonNumber {
return keys[i].seasonNumber < keys[j].seasonNumber
}
return keys[i].episodeNumber < keys[j].episodeNumber
})
results := make([]EpisodeResult, 0, len(keys))
for _, key := range keys {
results = append(results, *accumulator[key])
}
return results
}
func buildItemLocalizationRecord(
existing *models.MediaItemLocalization,
contentID string,
language string,
contentType string,
accumulator *MetadataResult,
images []RemoteImage,
mergeMode MergeMode,
preferredLanguage string,
titleLocked bool,
) *models.MediaItemLocalization {
loc := &models.MediaItemLocalization{
ContentID: contentID,
Language: language,
}
if existing != nil {
*loc = *existing
loc.ContentID = contentID
loc.Language = language
}
mergeScalar(&loc.Title, accumulator.Title, mergeMode)
mergeScalar(&loc.SortTitle, accumulator.SortTitle, mergeMode)
mergeScalar(&loc.Overview, accumulator.Overview, mergeMode)
mergeScalar(&loc.Tagline, accumulator.Tagline, mergeMode)
previousPosterPath := loc.PosterPath
previousPosterThumbhash := loc.PosterThumbhash
previousBackdropPath := loc.BackdropPath
previousBackdropThumbhash := loc.BackdropThumbhash
locItem := &models.MediaItem{
Type: contentType,
PosterPath: loc.PosterPath,
PosterThumbhash: loc.PosterThumbhash,
BackdropPath: loc.BackdropPath,
BackdropThumbhash: loc.BackdropThumbhash,
LogoPath: loc.LogoPath,
}
applyBestImages(locItem, images, mergeMode, preferredLanguage)
loc.PosterPath = locItem.PosterPath
loc.PosterThumbhash = mergedImageThumbhash(previousPosterPath, previousPosterThumbhash, locItem.PosterPath, "")
loc.BackdropPath = locItem.BackdropPath
loc.BackdropThumbhash = mergedImageThumbhash(previousBackdropPath, previousBackdropThumbhash, locItem.BackdropPath, "")
loc.LogoPath = locItem.LogoPath
ApplyDefaultSortTitleToLocalization(loc, titleLocked)
return loc
}
func buildSeasonLocalizationRecord(
existing *models.SeasonLocalization,
seasonContentID string,
language string,
season SeasonResult,
mergeMode MergeMode,
) *models.SeasonLocalization {
loc := &models.SeasonLocalization{
SeasonContentID: seasonContentID,
Language: language,
}
if existing != nil {
*loc = *existing
loc.SeasonContentID = seasonContentID
loc.Language = language
}
previousPosterPath := loc.PosterPath
previousPosterThumbhash := loc.PosterThumbhash
mergeScalar(&loc.Title, season.Title, mergeMode)
mergeScalar(&loc.Overview, season.Overview, mergeMode)
mergeScalar(&loc.PosterPath, season.PosterPath, mergeMode)
loc.PosterThumbhash = mergedImageThumbhash(
previousPosterPath,
previousPosterThumbhash,
loc.PosterPath,
season.PosterThumbhash,
)
return loc
}
func buildEpisodeLocalizationRecord(
existing *models.EpisodeLocalization,
episodeContentID string,
language string,
episode EpisodeResult,
mergeMode MergeMode,
) *models.EpisodeLocalization {
loc := &models.EpisodeLocalization{
EpisodeContentID: episodeContentID,
Language: language,
}
if existing != nil {
*loc = *existing
loc.EpisodeContentID = episodeContentID
loc.Language = language
}
mergeScalar(&loc.Title, episode.Title, mergeMode)
mergeScalar(&loc.Overview, episode.Overview, mergeMode)
return loc
}
func seasonResultFromModel(season *models.Season) SeasonResult {
if season == nil {
return SeasonResult{}
}
result := SeasonResult{
SeasonNumber: season.SeasonNumber,
Title: season.Title,
Overview: season.Overview,
PosterPath: season.PosterPath,
PosterThumbhash: season.PosterThumbhash,
}
if season.AirDate != nil {
result.AirDate = season.AirDate.Format("2006-01-02")
}
return result
}
func episodeResultFromModel(episode *models.Episode) EpisodeResult {
if episode == nil {
return EpisodeResult{}
}
result := EpisodeResult{
SeasonNumber: episode.SeasonNumber,
EpisodeNumber: episode.EpisodeNumber,
Title: episode.Title,
Overview: episode.Overview,
Runtime: episode.Runtime,
StillPath: episode.StillPath,
StillThumbhash: episode.StillThumbhash,
ProviderIDs: map[string]string{
"imdb": episode.ImdbID,
"tmdb": episode.TmdbID,
"tvdb": episode.TvdbID,
},
}
if episode.AirDate != nil {
result.AirDate = episode.AirDate.Format("2006-01-02")
}
if episode.RatingIMDB != nil {
result.Ratings.IMDB = *episode.RatingIMDB
}
if episode.RatingTMDB != nil {
result.Ratings.TMDB = *episode.RatingTMDB
}
return result
}
func existingImagePath(item *models.MediaItem, imageType ImageType) string {
if item == nil {
return ""
}
switch imageType {
case ImagePoster:
return item.PosterPath
case ImageBackdrop:
return item.BackdropPath
default:
return item.LogoPath
}
}
func existingImageThumbhash(item *models.MediaItem, imageType ImageType) string {
if item == nil {
return ""
}
switch imageType {
case ImagePoster:
return item.PosterThumbhash
case ImageBackdrop:
return item.BackdropThumbhash
default:
return ""
}
}
func mergedImageThumbhash(previousPath, previousThumbhash, nextPath, nextThumbhash string) string {
if nextThumbhash != "" {
return nextThumbhash
}
if nextPath != "" && nextPath == previousPath {
return previousThumbhash
}
return ""
}
// SearchProviders runs only the search phase and returns results for the UI.
func (s *MetadataService) SearchProviders(ctx context.Context, query SearchQuery, folderID int) ([]SearchResult, error) {
if strings.TrimSpace(query.Language) == "" && folderID > 0 {
query.Language = s.resolveFolderLanguage(ctx, folderID)
}
// Search uses the item-level chain (movie or series).
contentLevel := "series"
if query.ContentType == "movie" || query.ContentType == "movies" {
contentLevel = "movie"
}
chain, err := s.resolveChainCached(ctx, folderID, contentLevel)
if err != nil {
return nil, fmt.Errorf("resolving provider chain: %w", err)
}
var allResults []SearchResult
for _, p := range chain {
sp, ok := p.(SearchProvider)
if !ok {
continue
}
results, err := sp.Search(ctx, query)
if err != nil {
continue
}
allResults = append(allResults, results...)
}
return allResults, nil
}
// persistSeasonsAndEpisodes creates/updates seasons and episodes in the DB.
func (s *MetadataService) persistSeasonsAndEpisodes(
ctx context.Context,
seriesID string,
canonicalLanguage string,
language string,
seasons []SeasonResult,
episodes []EpisodeResult,
mergeMode MergeMode,
) {
seasonIDs := make(map[int]string, len(seasons))
isCanonicalWrite := strings.EqualFold(canonicalLanguage, language)
// Phase 1: Upsert explicit seasons.
if len(seasons) > 0 {
for _, season := range seasons {
existingSeason, err := s.seasonRepo.GetBySeriesAndNumber(ctx, seriesID, season.SeasonNumber)
if err != nil && !errors.Is(err, catalog.ErrSeasonNotFound) {
slog.Warn("metadata: failed to load season before upsert",
"series_id", seriesID, "season", season.SeasonNumber, "error", err)
continue
}
providerSeason := season
if existingSeason != nil && isCanonicalWrite {
mergedSeason := seasonResultFromModel(existingSeason)
MergeSeasonResult(&providerSeason, &mergedSeason, mergeMode)
mergedSeason.PosterThumbhash = mergedImageThumbhash(
existingSeason.PosterPath,
existingSeason.PosterThumbhash,
mergedSeason.PosterPath,
providerSeason.PosterThumbhash,
)
providerSeason = mergedSeason
}
dbSeason := &models.Season{
SeriesID: seriesID,
SeasonNumber: providerSeason.SeasonNumber,
Title: providerSeason.Title,
DefaultMetadataLanguage: canonicalLanguage,
Overview: providerSeason.Overview,
PosterPath: providerSeason.PosterPath,
PosterThumbhash: providerSeason.PosterThumbhash,
MetadataSource: "provider",
}
if existingSeason != nil {
dbSeason.ContentID = existingSeason.ContentID
if !isCanonicalWrite {
dbSeason.Title = existingSeason.Title
dbSeason.Overview = existingSeason.Overview
dbSeason.PosterPath = existingSeason.PosterPath
dbSeason.PosterThumbhash = existingSeason.PosterThumbhash
dbSeason.DefaultMetadataLanguage = existingSeason.DefaultMetadataLanguage
}
} else {
sid, genErr := idgen.NextID()
if genErr != nil {
slog.Warn("metadata: failed to generate season id",
"series_id", seriesID, "season", season.SeasonNumber, "error", genErr)
continue
}
dbSeason.ContentID = sid
}
if providerSeason.AirDate != "" {
if t, parseErr := time.Parse("2006-01-02", providerSeason.AirDate); parseErr == nil {
dbSeason.AirDate = &t
}
}
if err := s.seasonRepo.Upsert(ctx, dbSeason); err != nil {
slog.Warn("metadata: failed to upsert season",
"series_id", seriesID, "season", season.SeasonNumber, "error", err)
continue
}
seasonIDs[dbSeason.SeasonNumber] = dbSeason.ContentID
if !isCanonicalWrite && s.seasonLocalizationRepo != nil {
existingLoc, locErr := s.seasonLocalizationRepo.Get(ctx, dbSeason.ContentID, language)
if locErr != nil {
slog.Warn("metadata: failed to load season localization",
"series_id", seriesID, "season", season.SeasonNumber, "error", locErr)
}
if err := s.seasonLocalizationRepo.Upsert(ctx, buildSeasonLocalizationRecord(
existingLoc,
dbSeason.ContentID,
language,
providerSeason,
mergeMode,
)); err != nil {
slog.Warn("metadata: failed to upsert season localization",
"series_id", seriesID, "season", season.SeasonNumber, "error", err)
}
}
}
}
// Phase 2: Identify implicit seasons referenced by episodes but not returned
// explicitly by the provider.
if len(episodes) > 0 {
implicitSeen := make(map[int]bool)
for _, ep := range episodes {
if _, ok := seasonIDs[ep.SeasonNumber]; ok || implicitSeen[ep.SeasonNumber] {
continue
}
implicitSeen[ep.SeasonNumber] = true
title := fmt.Sprintf("Season %d", ep.SeasonNumber)
if ep.SeasonNumber == 0 {
title = "Specials"
}
seasonModel := &models.Season{
SeriesID: seriesID,
SeasonNumber: ep.SeasonNumber,
Title: title,
DefaultMetadataLanguage: canonicalLanguage,
MetadataSource: "provider",
}
if existingSeason, err := s.seasonRepo.GetBySeriesAndNumber(ctx, seriesID, ep.SeasonNumber); err == nil {
seasonModel.ContentID = existingSeason.ContentID
seasonModel.DefaultMetadataLanguage = existingSeason.DefaultMetadataLanguage
if isCanonicalWrite {
mergedSeason := seasonResultFromModel(existingSeason)
MergeSeasonResult(&SeasonResult{
SeasonNumber: ep.SeasonNumber,
Title: title,
}, &mergedSeason, mergeMode)
mergedSeason.PosterThumbhash = mergedImageThumbhash(
existingSeason.PosterPath,
existingSeason.PosterThumbhash,
mergedSeason.PosterPath,
"",
)
seasonModel.Title = mergedSeason.Title
seasonModel.Overview = mergedSeason.Overview
seasonModel.PosterPath = mergedSeason.PosterPath
seasonModel.PosterThumbhash = mergedSeason.PosterThumbhash
} else {
seasonModel.Title = existingSeason.Title
seasonModel.Overview = existingSeason.Overview
seasonModel.PosterPath = existingSeason.PosterPath
seasonModel.PosterThumbhash = existingSeason.PosterThumbhash
}
} else {
sid, genErr := idgen.NextID()
if genErr != nil {
slog.Warn("metadata: failed to generate implicit season id",
"series_id", seriesID, "season", ep.SeasonNumber, "error", genErr)
continue
}
seasonModel.ContentID = sid
}
if err := s.seasonRepo.Upsert(ctx, seasonModel); err != nil {
slog.Warn("metadata: failed to upsert implicit season",
"series_id", seriesID, "season", ep.SeasonNumber, "error", err)
continue
}
seasonIDs[seasonModel.SeasonNumber] = seasonModel.ContentID
}
}
// Phase 3: Upsert episodes.
if len(episodes) > 0 {
for _, ep := range episodes {
existingEpisode, err := s.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, ep.SeasonNumber, ep.EpisodeNumber)
if err != nil && !errors.Is(err, catalog.ErrEpisodeNotFound) {
slog.Warn("metadata: failed to load episode before upsert",
"series_id", seriesID, "season", ep.SeasonNumber, "episode", ep.EpisodeNumber, "error", err)
continue
}
providerEpisode := ep
if existingEpisode != nil && isCanonicalWrite {
mergedEpisode := episodeResultFromModel(existingEpisode)
MergeEpisodeResult(&providerEpisode, &mergedEpisode, mergeMode)
mergedEpisode.StillThumbhash = mergedImageThumbhash(
existingEpisode.StillPath,
existingEpisode.StillThumbhash,
mergedEpisode.StillPath,
providerEpisode.StillThumbhash,
)
providerEpisode = mergedEpisode
}
dbEp := &models.Episode{
SeriesID: seriesID,
SeasonID: seasonIDs[providerEpisode.SeasonNumber],
SeasonNumber: providerEpisode.SeasonNumber,
EpisodeNumber: providerEpisode.EpisodeNumber,
Title: providerEpisode.Title,
DefaultMetadataLanguage: canonicalLanguage,
Overview: providerEpisode.Overview,
Runtime: providerEpisode.Runtime,
ImdbID: providerEpisode.ProviderIDs["imdb"],
TmdbID: providerEpisode.ProviderIDs["tmdb"],
TvdbID: providerEpisode.ProviderIDs["tvdb"],
StillPath: providerEpisode.StillPath,
StillThumbhash: providerEpisode.StillThumbhash,
MetadataSource: "provider",
}
if existingEpisode != nil {
dbEp.ContentID = existingEpisode.ContentID
if !isCanonicalWrite {
dbEp.Title = existingEpisode.Title
dbEp.Overview = existingEpisode.Overview
dbEp.DefaultMetadataLanguage = existingEpisode.DefaultMetadataLanguage
dbEp.StillPath = existingEpisode.StillPath
dbEp.StillThumbhash = existingEpisode.StillThumbhash
}
} else {
eid, genErr := idgen.NextID()
if genErr != nil {
slog.Warn("metadata: failed to generate episode id",
"series_id", seriesID, "season", ep.SeasonNumber,
"episode", ep.EpisodeNumber, "error", genErr)
continue
}
dbEp.ContentID = eid
}
if providerEpisode.AirDate != "" {
if t, parseErr := time.Parse("2006-01-02", providerEpisode.AirDate); parseErr == nil {
dbEp.AirDate = &t
}
}
if providerEpisode.Ratings.TMDB > 0 {
v := providerEpisode.Ratings.TMDB
dbEp.RatingTMDB = &v
}
if providerEpisode.Ratings.IMDB > 0 {
v := providerEpisode.Ratings.IMDB
dbEp.RatingIMDB = &v
}
if err := s.episodeRepo.Upsert(ctx, dbEp); err != nil {
slog.Warn("metadata: failed to upsert episode",
"series_id", seriesID, "season", ep.SeasonNumber,
"episode", ep.EpisodeNumber, "error", err)
continue
}
if !isCanonicalWrite && s.episodeLocalizationRepo != nil {
existingLoc, locErr := s.episodeLocalizationRepo.Get(ctx, dbEp.ContentID, language)
if locErr != nil {
slog.Warn("metadata: failed to load episode localization",
"series_id", seriesID, "season", ep.SeasonNumber,
"episode", ep.EpisodeNumber, "error", locErr)
}
if err := s.episodeLocalizationRepo.Upsert(ctx, buildEpisodeLocalizationRecord(
existingLoc,
dbEp.ContentID,
language,
providerEpisode,
mergeMode,
)); err != nil {
slog.Warn("metadata: failed to upsert episode localization",
"series_id", seriesID, "season", ep.SeasonNumber,
"episode", ep.EpisodeNumber, "error", err)
}
}
}
}
if err := s.ensureSeriesEpisodeLinks(ctx, seriesID); err != nil {
slog.Warn("metadata: failed to ensure series episode links",
"series_id", seriesID, "error", err)
}
s.refreshSeriesEpisodeMetadataState(ctx, seriesID, time.Now())
}
// SynthesizeFallbackEpisodes is the stable public API for external callers
// (worker, admin jobs) to create scanner-derived season and episode rows for a
// series with unlinked files that have parseable SxxEyy numbers. It is safe to
// call for both unmatched and partially matched series. The private
// synthesizeFallbackSeriesStructure implementation may evolve; callers should
// use this method so their call-sites remain stable.
func (s *MetadataService) SynthesizeFallbackEpisodes(ctx context.Context, seriesID string) error {
return s.withSeriesEpisodeWork(ctx, seriesID, func() error {
return s.synthesizeFallbackSeriesStructure(ctx, seriesID)
})
}
func (s *MetadataService) synthesizeFallbackSeriesStructure(ctx context.Context, seriesID string) error {
item, err := s.itemRepo.GetByID(ctx, seriesID)
if err != nil {
return fmt.Errorf("loading series item: %w", err)
}
if item.Type != "series" {
return nil
}
linker, ok := s.fileRepo.(EpisodeLinker)
if !ok {
return nil
}
files, err := linker.ListBySeriesUnlinked(ctx, seriesID)
if err != nil {
return fmt.Errorf("listing unlinked series files: %w", err)
}
seasonIDs := make(map[int]string)
for _, file := range files {
seasonNum, episodeNum, ok := fallbackEpisodeNumbers(file)
if !ok {
continue
}
seasonID, ok := seasonIDs[seasonNum]
if !ok {
existingSeason, err := s.seasonRepo.GetBySeriesAndNumber(ctx, seriesID, seasonNum)
switch {
case err == nil:
seasonID = existingSeason.ContentID
seasonIDs[seasonNum] = seasonID
case errors.Is(err, catalog.ErrSeasonNotFound):
seasonID, err = idgen.NextID()
if err != nil {
return fmt.Errorf("generating fallback season id: %w", err)
}
season := &models.Season{
ContentID: seasonID,
SeriesID: seriesID,
SeasonNumber: seasonNum,
Title: fallbackSeasonTitle(seasonNum),
MetadataSource: "scanner_fallback",
}
if err := s.seasonRepo.Upsert(ctx, season); err != nil {
return fmt.Errorf("upserting fallback season: %w", err)
}
seasonID = season.ContentID
seasonIDs[seasonNum] = seasonID
default:
return fmt.Errorf("loading fallback season: %w", err)
}
}
existingEpisode, err := s.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, seasonNum, episodeNum)
switch {
case err == nil:
if existingEpisode.SeasonID == seasonID {
continue
}
existingEpisode.SeasonID = seasonID
if err := s.episodeRepo.Upsert(ctx, existingEpisode); err != nil {
return fmt.Errorf("updating fallback episode season link: %w", err)
}
continue
case errors.Is(err, catalog.ErrEpisodeNotFound):
episodeID, err := idgen.NextID()
if err != nil {
return fmt.Errorf("generating fallback episode id: %w", err)
}
episode := &models.Episode{
ContentID: episodeID,
SeriesID: seriesID,
SeasonID: seasonID,
SeasonNumber: seasonNum,
EpisodeNumber: episodeNum,
Title: fallbackEpisodeTitle(episodeNum),
MetadataSource: "scanner_fallback",
}
if err := s.episodeRepo.Upsert(ctx, episode); err != nil {
return fmt.Errorf("upserting fallback episode: %w", err)
}
default:
return fmt.Errorf("loading fallback episode: %w", err)
}
}
if err := s.linkSeriesFilesToEpisodesWithOptions(ctx, seriesID, true); err != nil {
return err
}
s.refreshSeriesEpisodeMetadataState(ctx, seriesID, time.Now())
return nil
}
func (s *MetadataService) linkSeriesFilesToEpisodes(ctx context.Context, seriesID string) {
if err := s.linkSeriesFilesToEpisodesWithOptions(ctx, seriesID, false); err == nil {
s.refreshSeriesEpisodeMetadataState(ctx, seriesID, time.Now())
}
}
func (s *MetadataService) ensureSeriesEpisodeLinks(ctx context.Context, seriesID string) error {
return s.withSeriesEpisodeWork(ctx, seriesID, func() error {
return s.ensureSeriesEpisodeLinksCore(ctx, seriesID)
})
}
func (s *MetadataService) ensureSeriesEpisodeLinksCore(ctx context.Context, seriesID string) error {
if s != nil && s.hooks.ensureSeriesEpisodeLinks != nil {
return s.hooks.ensureSeriesEpisodeLinks(ctx, seriesID)
}
if s != nil && s.hooks.linkSeriesFilesToEpisodes != nil &&
(s.itemRepo == nil || s.episodeRepo == nil || s.fileRepo == nil) {
s.hooks.linkSeriesFilesToEpisodes(ctx, seriesID)
return nil
}
item, err := s.itemRepo.GetByID(ctx, seriesID)
if err != nil {
return fmt.Errorf("loading series item: %w", err)
}
linker, ok := s.fileRepo.(EpisodeLinker)
if !ok {
return nil
}
files, err := linker.ListBySeriesUnlinked(ctx, seriesID)
if err != nil {
return fmt.Errorf("listing unlinked series files: %w", err)
}
if len(files) == 0 {
return nil
}
needsSynthesis := false
for _, file := range files {
seasonNum, episodeNum, ok := fallbackEpisodeNumbers(file)
if !ok {
continue
}
if _, err := s.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, seasonNum, episodeNum); errors.Is(err, catalog.ErrEpisodeNotFound) {
needsSynthesis = true
break
}
}
if !needsSynthesis {
if err := s.linkSeriesFilesToEpisodesWithOptions(ctx, seriesID, item.EpisodeMetadataIncomplete); err != nil {
return err
}
s.refreshSeriesEpisodeMetadataState(ctx, seriesID, time.Now())
return nil
}
slog.Info("metadata: entering fallback episode synthesis for incomplete series", "series_id", seriesID)
if err := s.synthesizeFallbackSeriesStructure(ctx, seriesID); err != nil {
return err
}
return nil
}
func (s *MetadataService) withSeriesEpisodeWork(ctx context.Context, seriesID string, fn func() error) error {
if s == nil || seriesID == "" {
return fn()
}
s.seriesWorkMu.Lock()
if s.seriesWork == nil {
s.seriesWork = make(map[string]*seriesEpisodeWork)
}
if work, ok := s.seriesWork[seriesID]; ok {
s.seriesWorkMu.Unlock()
select {
case <-work.done:
return work.err
case <-ctx.Done():
return ctx.Err()
}
}
work := &seriesEpisodeWork{done: make(chan struct{})}
s.seriesWork[seriesID] = work
s.seriesWorkMu.Unlock()
work.err = fn()
close(work.done)
s.seriesWorkMu.Lock()
delete(s.seriesWork, seriesID)
s.seriesWorkMu.Unlock()
return work.err
}
func (s *MetadataService) linkSeriesFilesToEpisodesWithOptions(ctx context.Context, seriesID string, suppressMissing bool) error {
if s != nil && s.hooks.linkSeriesFilesToEpisodes != nil {
s.hooks.linkSeriesFilesToEpisodes(ctx, seriesID)
return nil
}
linker, ok := s.fileRepo.(EpisodeLinker)
if !ok {
return nil
}
if bulkLinker, ok := s.fileRepo.(bulkSeriesEpisodeLinker); ok {
if _, err := bulkLinker.BulkLinkEpisodesBySeries(ctx, seriesID); err != nil {
return fmt.Errorf("bulk-linking series files to episodes: %w", err)
}
}
files, err := linker.ListBySeriesUnlinked(ctx, seriesID)
if err != nil {
return fmt.Errorf("loading unlinked series files: %w", err)
}
hints := make(map[int]episodeLinkHint, len(files))
airDateSet := make(map[string]struct{})
for _, file := range files {
hint := parseEpisodeLinkHint(file)
if !hint.ok {
continue
}
hints[file.ID] = hint
if hint.airDate != "" {
airDateSet[hint.airDate] = struct{}{}
}
}
episodesByAirDate := map[string][]*models.Episode{}
var seriesItem *models.MediaItem
if len(airDateSet) > 0 {
airDates := make([]string, 0, len(airDateSet))
for airDate := range airDateSet {
airDates = append(airDates, airDate)
}
sort.Strings(airDates)
var lookupErr error
episodesByAirDate, lookupErr = s.episodeRepo.ListBySeriesAndAirDates(ctx, seriesID, airDates)
if lookupErr != nil {
return fmt.Errorf("loading episodes by air date: %w", lookupErr)
}
if s.itemRepo != nil {
if item, itemErr := s.itemRepo.GetByID(ctx, seriesID); itemErr == nil {
seriesItem = item
} else if !errors.Is(itemErr, catalog.ErrItemNotFound) {
slog.Warn("metadata: failed to load series item for air-date episode preference",
"series_id", seriesID,
"error", itemErr)
}
}
}
for _, file := range files {
hint, ok := hints[file.ID]
if !ok {
continue
}
var episode *models.Episode
seasonNum := hint.seasonNum
episodeNum := hint.episodeNum
if hint.airDate != "" {
candidates := episodesByAirDate[hint.airDate]
selected, ok := selectAirDateEpisodeCandidate(candidates, seriesItem)
if !ok {
if len(candidates) > 1 {
slog.Warn("metadata: skipped ambiguous air-date episode link",
"series_id", seriesID,
"file_id", file.ID,
"file_path", file.FilePath,
"air_date", hint.airDate,
"matches", len(candidates))
}
continue
}
episode = selected
seasonNum = episode.SeasonNumber
episodeNum = episode.EpisodeNumber
} else {
var err error
episode, err = s.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, seasonNum, episodeNum)
if err != nil {
if suppressMissing && errors.Is(err, catalog.ErrEpisodeNotFound) {
continue
}
slog.Warn("metadata: failed to resolve episode for file",
"series_id", seriesID,
"file_id", file.ID,
"file_path", file.FilePath,
"season", seasonNum,
"episode", episodeNum,
"error", err)
continue
}
}
if err := linker.UpdateEpisodeLink(ctx, file.ID, episode.ContentID, seasonNum, episodeNum); err != nil {
slog.Warn("metadata: failed to link file to episode",
"series_id", seriesID,
"file_id", file.ID,
"file_path", file.FilePath,
"episode_id", episode.ContentID,
"error", err)
}
}
return nil
}
func selectAirDateEpisodeCandidate(candidates []*models.Episode, seriesItem *models.MediaItem) (*models.Episode, bool) {
if len(candidates) == 0 {
return nil, false
}
if len(candidates) == 1 {
return candidates[0], true
}
for _, provider := range preferredEpisodeProviders(seriesItem) {
filtered := filterEpisodesByProviderID(candidates, provider)
if len(filtered) == 1 {
return filtered[0], true
}
if len(filtered) > 1 {
return nil, false
}
}
return nil, false
}
func preferredEpisodeProviders(seriesItem *models.MediaItem) []string {
if seriesItem == nil {
return nil
}
providers := make([]string, 0, 3)
if strings.TrimSpace(seriesItem.TvdbID) != "" {
providers = append(providers, "tvdb")
}
if strings.TrimSpace(seriesItem.TmdbID) != "" {
providers = append(providers, "tmdb")
}
if strings.TrimSpace(seriesItem.ImdbID) != "" {
providers = append(providers, "imdb")
}
return providers
}
func filterEpisodesByProviderID(candidates []*models.Episode, provider string) []*models.Episode {
filtered := make([]*models.Episode, 0, len(candidates))
for _, candidate := range candidates {
if candidate == nil {
continue
}
switch provider {
case "tvdb":
if strings.TrimSpace(candidate.TvdbID) != "" {
filtered = append(filtered, candidate)
}
case "tmdb":
if strings.TrimSpace(candidate.TmdbID) != "" {
filtered = append(filtered, candidate)
}
case "imdb":
if strings.TrimSpace(candidate.ImdbID) != "" {
filtered = append(filtered, candidate)
}
}
}
return filtered
}
type episodeLinkHint struct {
seasonNum int
episodeNum int
airDate string
ok bool
}
func (s *MetadataService) updateEpisodeMetadataState(ctx context.Context, seriesID string, incomplete bool, lastCheckedAt *time.Time) {
item, err := s.itemRepo.GetByID(ctx, seriesID)
if err != nil {
slog.Warn("metadata: failed to load series item for episode metadata state",
"series_id", seriesID, "error", err)
return
}
item.EpisodeMetadataIncomplete = incomplete
item.EpisodeMetadataLastCheckedAt = lastCheckedAt
if err := s.itemRepo.Upsert(ctx, item); err != nil {
slog.Warn("metadata: failed to update episode metadata state",
"series_id", seriesID, "error", err)
}
}
func (s *MetadataService) refreshSeriesEpisodeMetadataState(ctx context.Context, seriesID string, now time.Time) {
if s == nil || s.episodeRepo == nil {
return
}
episodes, err := s.episodeRepo.ListBySeries(ctx, seriesID)
if err != nil {
slog.Warn("metadata: failed to list series episodes for completeness check",
"series_id", seriesID, "error", err)
return
}
incomplete := false
for _, episode := range episodes {
if EpisodeHasIncompleteMetadata(episode, now) {
incomplete = true
}
if err := s.syncVisibleEpisodeRefreshDebt(ctx, episode, now); err != nil {
slog.Warn("metadata: failed to sync episode refresh debt",
"series_id", seriesID,
"episode_id", episode.ContentID,
"error", err)
}
}
lastCheckedAt := now
s.updateEpisodeMetadataState(ctx, seriesID, incomplete, &lastCheckedAt)
}
func (s *MetadataService) syncVisibleEpisodeRefreshDebt(ctx context.Context, episode *models.Episode, now time.Time) error {
if s == nil || s.refreshDebtRepo == nil || episode == nil || strings.TrimSpace(episode.ContentID) == "" {
return nil
}
if !EpisodeHasIncompleteMetadata(episode, now) {
return s.refreshDebtRepo.DeleteTargetDebt(ctx, RefreshTargetEpisode, episode.ContentID)
}
reasonMask := RefreshDebtReasonEpisodeIncomplete
attemptCount, err := s.currentRefreshDebtTargetAttemptCount(ctx, RefreshTargetEpisode, episode.ContentID)
if err != nil {
return err
}
return s.refreshDebtRepo.MarkTargetSuccess(
ctx,
RefreshTargetEpisode,
episode.ContentID,
refreshDebtPriority(reasonMask),
reasonMask,
nextRefreshAtForDebt(reasonMask, attemptCount, now.UTC()),
)
}
func fallbackEpisodeNumbers(file *models.MediaFile) (seasonNum int, episodeNum int, ok bool) {
hint := parseEpisodeLinkHint(file)
if !hint.ok || hint.airDate != "" {
return 0, 0, false
}
return hint.seasonNum, hint.episodeNum, true
}
func parseEpisodeLinkHint(file *models.MediaFile) episodeLinkHint {
if file == nil {
return episodeLinkHint{}
}
if file.SeasonNumber != 0 && file.EpisodeNumber != 0 {
return episodeLinkHint{seasonNum: file.SeasonNumber, episodeNum: file.EpisodeNumber, ok: true}
}
fnh := naming.ParseFilename(file.FilePath, "series")
if fnh == nil {
return episodeLinkHint{}
}
if fnh.EpisodeNum != 0 {
return episodeLinkHint{seasonNum: fnh.SeasonNum, episodeNum: fnh.EpisodeNum, ok: true}
}
if fnh.AirDate != "" {
return episodeLinkHint{airDate: fnh.AirDate, ok: true}
}
return episodeLinkHint{}
}
func fallbackSeasonTitle(seasonNum int) string {
if seasonNum == 0 {
return "Specials"
}
return fmt.Sprintf("Season %d", seasonNum)
}
func fallbackEpisodeTitle(episodeNum int) string {
return fmt.Sprintf("Episode %d", episodeNum)
}
// findExistingByProviderIDs checks if an item with matching provider IDs exists.
func (s *MetadataService) findExistingByProviderIDs(
ctx context.Context,
ids map[string]string,
itemType string,
excludeContentID string,
) (*models.MediaItem, error) {
if s == nil || s.itemRepo == nil {
return nil, nil
}
if s != nil && s.providerIDRepo != nil {
contentID, err := s.providerIDRepo.FindContentIDByProviderIDs(ctx, ids, itemType, excludeContentID)
if err != nil {
return nil, err
}
if contentID != "" {
item, err := s.itemRepo.GetByID(ctx, contentID)
if err != nil {
return nil, err
}
return item, nil
}
}
tmdbID := ids["tmdb"]
imdbID := ids["imdb"]
tvdbID := ids["tvdb"]
if tmdbID != "" || imdbID != "" || tvdbID != "" {
if item, err := s.itemRepo.GetByExternalID(ctx, tmdbID, imdbID, tvdbID, itemType); err == nil {
if excludeContentID != "" && item.ContentID == excludeContentID {
return nil, nil
}
return item, nil
}
}
return nil, nil
}
// skeletonResult carries the outcome of skeleton creation plus parsed hints so
// the caller can seed them into the enrichment pipeline.
type skeletonResult struct {
ContentID string
IsNew bool
ItemStatus string
RootPath string
ObservedRootPath string
GroupKeyVersion int
ContentGroupKey string
Title string
Year int
Type string
TmdbID string
ImdbID string
TvdbID string
}
func hasFolderIDHints(hints *naming.FolderIDHints) bool {
return hints != nil && (hints.TmdbID != "" || hints.ImdbID != "" || hints.TvdbID != "")
}
func mergeFolderIDHints(base, override *naming.FolderIDHints) *naming.FolderIDHints {
if !hasFolderIDHints(base) && !hasFolderIDHints(override) {
return nil
}
merged := &naming.FolderIDHints{}
if base != nil {
merged.TmdbID = base.TmdbID
merged.ImdbID = base.ImdbID
merged.TvdbID = base.TvdbID
}
if override != nil {
if override.TmdbID != "" {
merged.TmdbID = override.TmdbID
}
if override.ImdbID != "" {
merged.ImdbID = override.ImdbID
}
if override.TvdbID != "" {
merged.TvdbID = override.TvdbID
}
}
if !hasFolderIDHints(merged) {
return nil
}
return merged
}
func applyFolderIDHints(res *skeletonResult, hints *naming.FolderIDHints) {
if res == nil || hints == nil {
return
}
if hints.TmdbID != "" {
res.TmdbID = hints.TmdbID
}
if hints.ImdbID != "" {
res.ImdbID = hints.ImdbID
}
if hints.TvdbID != "" {
res.TvdbID = hints.TvdbID
}
}
func providerIDsFromSkeletonResult(res *skeletonResult) map[string]string {
if res == nil {
return nil
}
providerIDs := make(map[string]string, 3)
if value := strings.TrimSpace(res.TmdbID); value != "" {
providerIDs["tmdb"] = value
}
if value := strings.TrimSpace(res.ImdbID); value != "" {
providerIDs["imdb"] = value
}
if value := strings.TrimSpace(res.TvdbID); value != "" {
providerIDs["tvdb"] = value
}
if len(providerIDs) == 0 {
return nil
}
return providerIDs
}
func trustedStructuredIDsForSkeleton(filePath, observedRootPath, contentRootPath string) *naming.FolderIDHints {
folderIDs := naming.ParseStructuredFolderIDs(filepath.Base(observedRootPath))
if folderIDs == nil && contentRootPath != "" && contentRootPath != observedRootPath {
folderIDs = naming.ParseStructuredFolderIDs(filepath.Base(contentRootPath))
}
fileIDs := naming.ParseStructuredFolderIDs(strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)))
return mergeFolderIDHints(folderIDs, fileIDs)
}
// createOrFindSkeleton creates a skeleton media_items row or finds an existing
// item to link the file to. Returns a skeletonResult with parsed hints.
//
// Dedup order: group claim, then root claim, then explicit external IDs, then
// title/year fallback. When a winner is found its current ownership is reused
// and the relevant group/root claims are refreshed.
//
// Missing folder IDs: roots without embedded provider IDs (e.g. {tmdb-12345})
// are still recorded in skipped_media_roots for admin diagnostics, but they
// are no longer skipped — a skeleton item is created and entered the match
// queue with status "pending".
func (s *MetadataService) createOrFindSkeleton(ctx context.Context, file *models.MediaFile, folderID int) (*skeletonResult, error) {
if s != nil && s.hooks.createOrFindSkeleton != nil {
return s.hooks.createOrFindSkeleton(ctx, file, folderID)
}
libraryType := ""
if s != nil && s.folderRepo != nil && folderID > 0 {
folder, err := s.folderRepo.GetByID(ctx, folderID)
if err != nil {
slog.Warn("metadata: failed to load folder context for path classification",
"folder_id", folderID,
"file_path", file.FilePath,
"error", err,
)
} else if folder != nil {
libraryType = folder.Type
}
}
contentRootPath := filepath.Dir(file.FilePath)
if file.CanonicalRootPath != "" {
contentRootPath = filepath.Clean(file.CanonicalRootPath)
}
observedRootPath := filepath.Dir(file.FilePath)
if file.ObservedRootPath != "" {
observedRootPath = filepath.Clean(file.ObservedRootPath)
}
groupKeyVersion := file.GroupKeyVersion
if groupKeyVersion == 0 {
groupKeyVersion = naming.ContentGroupKeyVersion
}
contentGroupKey := strings.TrimSpace(file.ContentGroupKey)
// Build result with parsed hints (always returned regardless of dedup outcome).
res := &skeletonResult{
RootPath: contentRootPath,
ObservedRootPath: observedRootPath,
GroupKeyVersion: groupKeyVersion,
ContentGroupKey: contentGroupKey,
ItemStatus: "pending",
Title: file.BaseTitle,
Year: file.BaseYear,
Type: file.BaseType,
}
if s.scannedGroupRepo != nil && contentGroupKey != "" {
scannedGroup, err := s.scannedGroupRepo.Get(ctx, folderID, groupKeyVersion, contentGroupKey)
if err != nil {
slog.Warn("metadata: scanned group lookup failed",
"folder_id", folderID,
"group_key_version", groupKeyVersion,
"content_group_key", contentGroupKey,
"error", err,
)
} else if scannedGroup != nil {
if scannedGroup.BaseTitle != "" {
res.Title = scannedGroup.BaseTitle
}
if scannedGroup.BaseYear != 0 {
res.Year = scannedGroup.BaseYear
}
if scannedGroup.InferredType != "" {
res.Type = scannedGroup.InferredType
}
if scannedGroup.TmdbID != "" {
res.TmdbID = scannedGroup.TmdbID
}
if scannedGroup.ImdbID != "" {
res.ImdbID = scannedGroup.ImdbID
}
if scannedGroup.TvdbID != "" {
res.TvdbID = scannedGroup.TvdbID
}
if scannedGroup.State == "ambiguous" {
res.ItemStatus = "ambiguous"
}
if scannedGroup.SampleObservedRootPath != "" {
res.ObservedRootPath = scannedGroup.SampleObservedRootPath
}
}
}
if s.groupOverrideRepo != nil && contentGroupKey != "" {
override, err := s.groupOverrideRepo.Get(ctx, folderID, groupKeyVersion, contentGroupKey)
if err != nil {
slog.Warn("metadata: group override lookup failed",
"folder_id", folderID,
"group_key_version", groupKeyVersion,
"content_group_key", contentGroupKey,
"error", err,
)
} else if override != nil {
if override.ForcedType != "" {
res.Type = override.ForcedType
}
if override.ForcedTitle != "" {
res.Title = override.ForcedTitle
}
if override.ForcedYear > 0 {
res.Year = override.ForcedYear
}
if override.ForcedTmdbID != "" {
res.TmdbID = override.ForcedTmdbID
}
if override.ForcedImdbID != "" {
res.ImdbID = override.ForcedImdbID
}
if override.ForcedTvdbID != "" {
res.TvdbID = override.ForcedTvdbID
}
res.ItemStatus = "pending"
}
}
if res.Type == "" {
switch strings.ToLower(strings.TrimSpace(libraryType)) {
case "series", "tv", "show", "tvshows":
res.Type = "series"
case "movie", "movies":
res.Type = "movie"
case "mixed":
if file.EpisodeID != "" || file.SeasonNumber != 0 || file.EpisodeNumber != 0 {
res.Type = "series"
}
}
}
if res.Type == "" {
res.Type = "movie"
}
folderType := res.Type
if folderType == "" {
folderType = "movie"
}
// Explicit structured IDs from the file or folder are treated as trusted.
// When present, they override scanner ambiguity and become the authoritative
// external IDs used for dedup/link/create. Filename tags take precedence
// over folder tags because the file is generally the freshest artifact.
trustedIDs := trustedStructuredIDsForSkeleton(file.FilePath, observedRootPath, contentRootPath)
if trustedIDs != nil {
applyFolderIDHints(res, trustedIDs)
res.ItemStatus = "pending"
}
// Parse observed location names for external IDs before falling back to the
// legacy canonical root path. This preserves existing heuristic behavior for
// roots without explicit structured tags.
folderIDs := naming.ParseFolderIDs(filepath.Base(observedRootPath), folderType)
if folderIDs == nil && contentRootPath != "" && contentRootPath != observedRootPath {
folderIDs = naming.ParseFolderIDs(filepath.Base(contentRootPath), folderType)
}
effectiveExternalIDs := folderIDs
if trustedIDs != nil {
effectiveExternalIDs = trustedIDs
}
if effectiveExternalIDs != nil {
if trustedIDs == nil {
applyFolderIDHints(res, folderIDs)
}
// Clear any legacy skipped-root record since we now have provider IDs.
if s != nil && s.skippedRootRepo != nil {
if err := s.skippedRootRepo.Delete(ctx, folderID, contentRootPath); err != nil {
slog.Warn("metadata: failed to clear skipped root",
"folder_id", folderID,
"root_path", observedRootPath,
"error", err)
}
}
}
if effectiveExternalIDs == nil {
// Record for admin diagnostics only — no longer bail out.
if s != nil && s.skippedRootRepo != nil {
if err := s.skippedRootRepo.Upsert(ctx, models.SkippedMediaRoot{
MediaFolderID: folderID,
RootPath: observedRootPath,
Reason: "missing_folder_ids",
SampleFilePath: file.FilePath,
FileCount: 1,
}); err != nil {
slog.Warn("metadata: failed to record skipped root",
"folder_id", folderID,
"root_path", observedRootPath,
"error", err)
}
}
}
unlockDedup := s.lockDedupKey(dedupKeyForSkeleton(
res.Type,
providerIDsFromSkeletonResult(res),
res.Title,
res.Year,
folderID,
groupKeyVersion,
contentGroupKey,
contentRootPath,
file.FilePath,
))
defer unlockDedup()
// Dedup 1: confirmed content-group ownership always wins, including for
// movies. Provisional claims are intentionally ignored.
if contentGroupKey != "" && s.groupClaimRepo != nil {
claimedGroup, err := s.groupClaimRepo.Get(ctx, folderID, groupKeyVersion, contentGroupKey)
if err != nil {
return nil, fmt.Errorf("loading claimed content group: %w", err)
}
if claimedGroup != nil {
if existing, ok := s.confirmedOwnershipItem(ctx, claimedGroup.ContentID); ok {
if _, linkErr := s.claimGroupAndRelink(ctx, folderID, groupKeyVersion, contentGroupKey, contentRootPath, existing.ContentID); linkErr != nil {
return nil, fmt.Errorf("claiming confirmed content group: %w", linkErr)
}
if linkErr := s.fileRepo.UpdateContentID(ctx, file.ID, existing.ContentID); linkErr != nil {
return nil, fmt.Errorf("linking file to confirmed group item: %w", linkErr)
}
if err := s.upsertLibraryMembership(ctx, existing.ContentID, folderID); err != nil {
s.logLibraryMembershipError("upserting confirmed group membership", existing.ContentID, folderID, err)
}
res.ContentID = existing.ContentID
return res, nil
}
}
}
// Dedup 2: confirmed root ownership also wins for both movies and series.
if contentRootPath != "" && s.rootClaimRepo != nil {
claimedRoot, err := s.rootClaimRepo.Get(ctx, folderID, contentRootPath)
if err != nil {
return nil, fmt.Errorf("loading claimed root path: %w", err)
}
if claimedRoot != nil {
if existing, ok := s.confirmedOwnershipItem(ctx, claimedRoot.ContentID); ok {
s.claimRoot(ctx, folderID, contentRootPath, existing.ContentID)
if linkErr := s.fileRepo.UpdateContentID(ctx, file.ID, existing.ContentID); linkErr != nil {
return nil, fmt.Errorf("linking file to confirmed root item: %w", linkErr)
}
if err := s.upsertLibraryMembership(ctx, existing.ContentID, folderID); err != nil {
s.logLibraryMembershipError("upserting confirmed root membership", existing.ContentID, folderID, err)
}
res.ContentID = existing.ContentID
return res, nil
}
}
}
// Dedup 3: same observed TV root reuses the already-linked root-scoped item.
if res.Type == "series" {
res.Type = "series"
existingContentID, err := s.fileRepo.FindContentIDByObservedRootPath(ctx, folderID, observedRootPath, "series")
if err != nil {
return nil, fmt.Errorf("finding existing item by observed root path: %w", err)
}
if existingContentID != "" {
if linkErr := s.fileRepo.UpdateContentID(ctx, file.ID, existingContentID); linkErr != nil {
return nil, fmt.Errorf("linking file to existing root item: %w", linkErr)
}
if err := s.upsertLibraryMembership(ctx, existingContentID, folderID); err != nil {
s.logLibraryMembershipError("upserting existing root item membership", existingContentID, folderID, err)
}
res.ContentID = existingContentID
return res, nil
}
}
// Dedup 4: Check by external IDs from trusted file/folder tags first, then
// fall back to the legacy folder/root parsing behavior.
if effectiveExternalIDs != nil {
existing, err := s.itemRepo.GetByExternalID(ctx, effectiveExternalIDs.TmdbID, effectiveExternalIDs.ImdbID, effectiveExternalIDs.TvdbID, res.Type)
if err == nil && existing != nil {
if (res.Type == "movie" || res.Type == "series") && !isConfirmedOwnershipStatus(existing.Status) {
existing = nil
}
}
if existing != nil {
if isConfirmedOwnershipStatus(existing.Status) {
if _, linkErr := s.claimGroupAndRelink(ctx, folderID, groupKeyVersion, contentGroupKey, contentRootPath, existing.ContentID); linkErr != nil {
return nil, fmt.Errorf("claiming existing group: %w", linkErr)
}
}
if linkErr := s.fileRepo.UpdateContentID(ctx, file.ID, existing.ContentID); linkErr != nil {
return nil, fmt.Errorf("linking file to existing item: %w", linkErr)
}
if err := s.upsertLibraryMembership(ctx, existing.ContentID, folderID); err != nil {
s.logLibraryMembershipError("upserting existing external-id membership", existing.ContentID, folderID, err)
}
res.ContentID = existing.ContentID
return res, nil
}
}
// Scanner skeleton creation intentionally avoids fuzzy title/year dedup and
// pre-confirmation ownership claims. Matching is driven by explicit IDs or
// later provider confirmation to avoid cross-root false merges.
// No existing item — create skeleton.
contentID, err := generateContentID()
if err != nil {
return nil, fmt.Errorf("generate content id: %w", err)
}
item := &models.MediaItem{
ContentID: contentID,
Status: res.ItemStatus,
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
Keywords: []string{},
}
item.Title = res.Title
item.Year = res.Year
item.Type = res.Type
item.TmdbID = res.TmdbID
item.ImdbID = res.ImdbID
item.TvdbID = res.TvdbID
if err := s.itemRepo.Upsert(ctx, item); err != nil {
return nil, fmt.Errorf("creating skeleton item: %w", err)
}
// Link file, claim root, and create library membership.
if err := s.fileRepo.UpdateContentID(ctx, file.ID, contentID); err != nil {
return nil, fmt.Errorf("linking file to skeleton: %w", err)
}
if err := s.upsertLibraryMembership(ctx, contentID, folderID); err != nil {
s.logLibraryMembershipError("upserting skeleton membership", contentID, folderID, err)
}
res.ContentID = contentID
res.IsNew = true
return res, nil
}
// claimRoot records a canonical root path claim for dedup. Failures are logged
// but not fatal — the root claim is an optimization, not a hard requirement.
func (s *MetadataService) claimRoot(ctx context.Context, folderID int, rootPath, contentID string) {
if s == nil || s.rootClaimRepo == nil {
return
}
if err := s.rootClaimRepo.ClaimRoot(ctx, folderID, rootPath, contentID); err != nil {
slog.Warn("metadata: failed to claim root path",
"folder_id", folderID,
"root_path", rootPath,
"content_id", contentID,
"error", err)
}
}
func (s *MetadataService) claimConfirmedSeriesRootOwnership(
ctx context.Context,
folderID int,
rootPath string,
contentID string,
files []*models.MediaFile,
) {
if s == nil || strings.TrimSpace(contentID) == "" {
return
}
if rootPath != "" {
s.claimRoot(ctx, folderID, rootPath, contentID)
}
if s.groupClaimRepo == nil {
return
}
seenGroups := make(map[string]struct{})
for _, file := range files {
if file == nil || file.MediaFolderID != folderID {
continue
}
groupKey := strings.TrimSpace(file.ContentGroupKey)
if file.GroupKeyVersion <= 0 || groupKey == "" {
continue
}
claimKey := fmt.Sprintf("%d:%s", file.GroupKeyVersion, groupKey)
if _, ok := seenGroups[claimKey]; ok {
continue
}
seenGroups[claimKey] = struct{}{}
if err := s.groupClaimRepo.ClaimGroup(ctx, folderID, file.GroupKeyVersion, groupKey, contentID); err != nil {
slog.Warn("metadata: failed to claim confirmed series group",
"folder_id", folderID,
"group_key_version", file.GroupKeyVersion,
"content_group_key", groupKey,
"content_id", contentID,
"error", err)
}
}
}
func (s *MetadataService) claimGroupAndRelink(
ctx context.Context,
folderID int,
groupKeyVersion int,
contentGroupKey string,
rootPath string,
contentID string,
) (int, error) {
if s != nil && s.rootClaimRepo != nil && rootPath != "" {
s.claimRoot(ctx, folderID, rootPath, contentID)
}
if s == nil || s.groupClaimRepo == nil || contentGroupKey == "" {
return 0, nil
}
return s.groupClaimRepo.ClaimAndRelinkFiles(ctx, folderID, groupKeyVersion, contentGroupKey, contentID)
}
// updateItemStatus sets the status field on a media_items row.
func (s *MetadataService) updateItemStatus(ctx context.Context, contentID, status string) error {
if s != nil && s.hooks.updateItemStatus != nil {
return s.hooks.updateItemStatus(ctx, contentID, status)
}
if s == nil || s.itemRepo == nil {
return fmt.Errorf("metadata item repository is not configured")
}
if strings.TrimSpace(contentID) == "" {
return fmt.Errorf("content id is required to update item status")
}
existing, err := s.itemRepo.GetByID(ctx, contentID)
if err != nil {
return fmt.Errorf("loading item %s before status update: %w", contentID, err)
}
existing.Status = status
if err := s.itemRepo.Upsert(ctx, existing); err != nil {
return fmt.Errorf("upserting item %s with status %s: %w", contentID, status, err)
}
return nil
}
// upsertLibraryMembership creates a library membership for the given item
// regardless of status. Pending and unmatched items are now visible in library
// browse results so users can see and manually match them.
func (s *MetadataService) upsertLibraryMembership(ctx context.Context, contentID string, folderID int) error {
if s == nil || s.libraryRepo == nil || folderID <= 0 || contentID == "" {
return nil
}
return s.libraryRepo.Upsert(ctx, contentID, folderID, time.Now())
}
// upsertLibraryMembershipIfMatched creates a library membership only when the
// item has status "matched". This is kept for the mergeAndPersist flow where
// the item was just enriched and is known to be matched.
func (s *MetadataService) upsertLibraryMembershipIfMatched(ctx context.Context, contentID string, folderID int) error {
if s == nil || s.libraryRepo == nil || s.itemRepo == nil || folderID <= 0 || contentID == "" {
return nil
}
item, err := s.itemRepo.GetByID(ctx, contentID)
if err != nil {
return err
}
if item.Status != "matched" {
return nil
}
return s.libraryRepo.Upsert(ctx, contentID, folderID, time.Now())
}
func (s *MetadataService) logLibraryMembershipError(action, contentID string, folderID int, err error) {
if err == nil {
return
}
slog.Warn("metadata: library membership upsert failed",
"action", action,
"content_id", contentID,
"folder_id", folderID,
"error", err,
)
}
func isSkeletonLikeStatus(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "pending", "unmatched":
return true
default:
return false
}
}
func isProvisionalOwnershipStatus(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "pending", "unmatched", "ambiguous":
return true
default:
return false
}
}
func isConfirmedOwnershipStatus(status string) bool {
return strings.EqualFold(strings.TrimSpace(status), "matched")
}
func (s *MetadataService) confirmedOwnershipItem(ctx context.Context, contentID string) (*models.MediaItem, bool) {
if s == nil || s.itemRepo == nil || strings.TrimSpace(contentID) == "" {
return nil, false
}
item, err := s.itemRepo.GetByID(ctx, contentID)
if err != nil || item == nil || !isConfirmedOwnershipStatus(item.Status) {
return nil, false
}
return item, true
}
func (s *MetadataService) rebindSkeletonByProviderIDs(
ctx context.Context,
skeletonContentID string,
providerIDs map[string]string,
itemType string,
) (string, error) {
if s == nil || skeletonContentID == "" {
return "", nil
}
unlockDedup := s.lockDedupKey(dedupKeyFromProviderIDs(itemType, providerIDs))
defer unlockDedup()
return s.rebindSkeletonByProviderIDsLocked(ctx, skeletonContentID, providerIDs, itemType)
}
func (s *MetadataService) rebindSkeletonByProviderIDsLocked(
ctx context.Context,
skeletonContentID string,
providerIDs map[string]string,
itemType string,
) (string, error) {
return s.rebindItemByProviderIDsLocked(ctx, skeletonContentID, providerIDs, itemType, false)
}
func (s *MetadataService) rebindItemByProviderIDsLocked(
ctx context.Context,
sourceContentID string,
providerIDs map[string]string,
itemType string,
allowMatchedSource bool,
) (string, error) {
if s == nil || sourceContentID == "" {
return "", nil
}
existing, err := s.findExistingByProviderIDs(ctx, providerIDs, itemType, sourceContentID)
if err != nil {
return "", fmt.Errorf("finding existing item for skeleton rebind: %w", err)
}
if existing != nil && !isConfirmedOwnershipStatus(existing.Status) {
return "", nil
}
if existing == nil || existing.ContentID == "" || existing.ContentID == sourceContentID {
return "", nil
}
if err := s.rebindItemToExistingItem(ctx, sourceContentID, existing.ContentID, allowMatchedSource); err != nil {
return "", err
}
slog.Info("metadata: rebound skeleton to existing item",
"from_content_id", sourceContentID,
"to_content_id", existing.ContentID,
"item_type", itemType,
)
return existing.ContentID, nil
}
func (s *MetadataService) recoverProviderIDConflict(
ctx context.Context,
sourceContentID string,
providerIDs map[string]string,
itemType string,
allowMatchedSource bool,
) (string, error) {
existing, err := s.findExistingByProviderIDs(ctx, providerIDs, itemType, sourceContentID)
if err != nil {
return "", fmt.Errorf("finding existing item for provider conflict recovery: %w", err)
}
if existing == nil || existing.ContentID == "" || existing.ContentID == sourceContentID {
return "", nil
}
if !isConfirmedOwnershipStatus(existing.Status) {
if s.dbPool == nil {
return "", nil
}
if err := clearProvisionalProviderIDsLocked(ctx, s.dbPool, existing.ContentID); err != nil {
return "", err
}
return sourceContentID, nil
}
if s.dbPool != nil {
var sourceStatus string
statusErr := s.dbPool.QueryRow(ctx, `SELECT status FROM media_items WHERE content_id = $1`, sourceContentID).Scan(&sourceStatus)
if errors.Is(statusErr, pgx.ErrNoRows) {
return existing.ContentID, nil
}
if statusErr != nil {
return "", fmt.Errorf("loading source status for provider conflict recovery: %w", statusErr)
}
if isProvisionalOwnershipStatus(sourceStatus) && isConfirmedOwnershipStatus(existing.Status) {
canonicalID, err := canonicalizeProviderIDDuplicateInto(ctx, s.dbPool, sourceContentID, existing.ContentID, false)
if err != nil {
return "", err
}
return canonicalID, nil
}
canonicalID, err := s.canonicalizeProviderIDDuplicate(ctx, sourceContentID, existing.ContentID, allowMatchedSource)
if err != nil {
return "", err
}
if canonicalID != "" {
return canonicalID, nil
}
}
return s.rebindItemByProviderIDsLocked(ctx, sourceContentID, providerIDs, itemType, allowMatchedSource)
}
func (s *MetadataService) rebindSkeletonToExistingItem(ctx context.Context, fromContentID, toContentID string) error {
return s.rebindItemToExistingItem(ctx, fromContentID, toContentID, false)
}
func (s *MetadataService) rebindItemToExistingItem(ctx context.Context, fromContentID, toContentID string, allowMatchedSource bool) error {
if s == nil || fromContentID == "" || toContentID == "" || fromContentID == toContentID {
return nil
}
if s.dbPool == nil {
return fmt.Errorf("rebind skeleton requires database pool")
}
tx, err := s.dbPool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin skeleton rebind transaction: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
steps := []struct {
name string
sql string
args []any
}{
{
name: "dedupe library memberships",
sql: `
DELETE FROM media_item_libraries src
USING media_item_libraries dest
WHERE src.content_id = $1
AND dest.content_id = $2
AND src.media_folder_id = dest.media_folder_id
`,
args: []any{fromContentID, toContentID},
},
{
name: "move library memberships",
sql: `
UPDATE media_item_libraries
SET content_id = $2
WHERE content_id = $1
`,
args: []any{fromContentID, toContentID},
},
// Legacy claims owned by provisional shells remain quarantined. They are
// ignored for reuse elsewhere, and confirmation writes fresh claims
// explicitly rather than promoting provisional ones during rebind.
{
name: "move file links",
sql: `
UPDATE media_files
SET content_id = $2,
episode_id = NULL,
updated_at = NOW()
WHERE content_id = $1
`,
args: []any{fromContentID, toContentID},
},
{
name: "clear episode library memberships for rebound source series",
sql: `
DELETE FROM episode_libraries el
USING episodes e
WHERE e.content_id = el.episode_id
AND e.series_id = $1
`,
args: []any{fromContentID},
},
{
name: "dedupe provider ids",
sql: `
DELETE FROM media_item_provider_ids src
USING media_item_provider_ids dest
WHERE src.content_id = $1
AND dest.content_id = $2
AND src.provider = dest.provider
`,
args: []any{fromContentID, toContentID},
},
{
name: "move provider ids",
sql: `
UPDATE media_item_provider_ids src
SET content_id = $2,
item_type = (SELECT type FROM media_items WHERE content_id = $2),
updated_at = NOW()
WHERE src.content_id = $1
AND NOT EXISTS (
SELECT 1
FROM media_item_provider_ids other
WHERE other.content_id <> $1
AND other.provider = src.provider
AND other.provider_id = src.provider_id
AND other.item_type = (SELECT type FROM media_items WHERE content_id = $2)
)
`,
args: []any{fromContentID, toContentID},
},
{
name: "delete orphaned skeleton",
sql: `
DELETE FROM media_items
WHERE content_id = $1
AND lower(trim(status)) = ANY($2::text[])
`,
args: []any{fromContentID, rebindDeletableStatuses(allowMatchedSource)},
},
}
for _, step := range steps {
if _, err := tx.Exec(ctx, step.sql, step.args...); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit skeleton rebind transaction: %w", err)
}
return nil
}
func rebindDeletableStatuses(allowMatchedSource bool) []string {
statuses := []string{"pending", "unmatched", "ambiguous"}
if allowMatchedSource {
statuses = append(statuses, "matched")
}
return statuses
}
func isProviderIDUniqueConflict(err error) bool {
if err == nil {
return false
}
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
return pgErr.Code == "23505" && pgErr.ConstraintName == "media_item_provider_ids_provider_provider_id_item_type_key"
}
func (s *MetadataService) repairMatchedDuplicateProviderOwnersByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string) (int, error) {
if s == nil || s.dbPool == nil || folderID <= 0 || strings.TrimSpace(pathPrefix) == "" {
return 0, nil
}
pathPrefix = filepath.Clean(pathPrefix)
scopeLike := pathPrefix + "/%"
rows, err := s.dbPool.Query(ctx, `
WITH source_candidates AS (
SELECT DISTINCT
src.content_id,
src.type,
COALESCE(NULLIF(src.tmdb_id, ''), '') AS tmdb_id,
COALESCE(NULLIF(src.imdb_id, ''), '') AS imdb_id,
COALESCE(NULLIF(src.tvdb_id, ''), '') AS tvdb_id
FROM media_items src
JOIN media_item_libraries mil
ON mil.content_id = src.content_id
JOIN media_folders folders
ON folders.id = mil.media_folder_id
JOIN media_files mf
ON mf.content_id = src.content_id
AND mf.media_folder_id = mil.media_folder_id
WHERE mil.media_folder_id = $1
AND folders.enabled = true
AND lower(trim(src.status)) = 'matched'
AND mf.missing_since IS NULL
AND (mf.file_path = $2 OR mf.file_path LIKE $3)
AND NOT EXISTS (
SELECT 1
FROM media_item_provider_ids pid
WHERE pid.content_id = src.content_id
)
AND (
COALESCE(NULLIF(src.tmdb_id, ''), '') <> '' OR
COALESCE(NULLIF(src.imdb_id, ''), '') <> '' OR
COALESCE(NULLIF(src.tvdb_id, ''), '') <> ''
)
),
ranked_targets AS (
SELECT
src.content_id AS source_content_id,
dest.content_id AS target_content_id,
ROW_NUMBER() OVER (
PARTITION BY src.content_id
ORDER BY dest.updated_at DESC, dest.content_id ASC
) AS rn
FROM source_candidates src
JOIN media_items dest
ON dest.content_id <> src.content_id
AND dest.type = src.type
AND lower(trim(dest.status)) = 'matched'
AND (
(src.tmdb_id <> '' AND dest.tmdb_id = src.tmdb_id) OR
(src.imdb_id <> '' AND dest.imdb_id = src.imdb_id) OR
(src.tvdb_id <> '' AND dest.tvdb_id = src.tvdb_id)
)
WHERE EXISTS (
SELECT 1
FROM media_item_provider_ids pid
WHERE pid.content_id = dest.content_id
)
)
SELECT source_content_id, target_content_id
FROM ranked_targets
WHERE rn = 1
`, folderID, pathPrefix, scopeLike)
if err != nil {
return 0, fmt.Errorf("querying scoped matched duplicate repairs: %w", err)
}
defer rows.Close()
type repairPair struct {
source string
target string
}
pairs := make([]repairPair, 0)
for rows.Next() {
var pair repairPair
if err := rows.Scan(&pair.source, &pair.target); err != nil {
return 0, fmt.Errorf("scanning scoped matched duplicate repair: %w", err)
}
pairs = append(pairs, pair)
}
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("iterating scoped matched duplicate repairs: %w", err)
}
repaired := 0
for _, pair := range pairs {
if pair.source == "" || pair.target == "" || pair.source == pair.target {
continue
}
if err := s.rebindItemToExistingItem(ctx, pair.source, pair.target, true); err != nil {
return repaired, fmt.Errorf("repairing duplicate matched item %s -> %s: %w", pair.source, pair.target, err)
}
repaired++
}
return repaired, nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
var ephemeralProviderIDKeys = map[string]struct{}{
"metadb": {},
"_filepath": {},
"oshash": {},
}
func isEphemeralProviderIDKey(key string) bool {
_, ok := ephemeralProviderIDKeys[strings.ToLower(strings.TrimSpace(key))]
return ok
}
func copyMap(m map[string]string) map[string]string {
out := make(map[string]string, len(m))
maps.Copy(out, m)
return out
}
func providerIDMapFromRows(rows []*models.MediaItemProviderID) map[string]string {
if len(rows) == 0 {
return nil
}
out := make(map[string]string, len(rows))
for _, row := range rows {
if row == nil {
continue
}
provider := strings.TrimSpace(row.Provider)
providerID := strings.TrimSpace(row.ProviderID)
if provider == "" || providerID == "" || isEphemeralProviderIDKey(provider) {
continue
}
out[provider] = providerID
}
return out
}
func (s *MetadataService) lockDedupKey(key string) func() {
if s == nil || strings.TrimSpace(key) == "" {
return func() {}
}
s.dedupLocks.mu.Lock()
if s.dedupLocks.locks == nil {
s.dedupLocks.locks = make(map[string]*dedupLockRef)
}
ref := s.dedupLocks.locks[key]
if ref == nil {
ref = &dedupLockRef{}
s.dedupLocks.locks[key] = ref
}
ref.refs++
s.dedupLocks.mu.Unlock()
ref.mu.Lock()
return func() {
ref.mu.Unlock()
s.dedupLocks.mu.Lock()
defer s.dedupLocks.mu.Unlock()
ref.refs--
if ref.refs == 0 {
delete(s.dedupLocks.locks, key)
}
}
}
func dedupKeyFromProviderIDs(itemType string, providerIDs map[string]string) string {
if len(providerIDs) == 0 {
return ""
}
keys := make([]string, 0, len(providerIDs))
for key, value := range providerIDs {
key = strings.ToLower(strings.TrimSpace(key))
value = strings.TrimSpace(value)
if key == "" || value == "" || isEphemeralProviderIDKey(key) {
continue
}
keys = append(keys, key)
}
if len(keys) == 0 {
return ""
}
sort.Strings(keys)
parts := make([]string, 0, len(keys)+1)
parts = append(parts, "provider", strings.ToLower(strings.TrimSpace(itemType)))
for _, key := range keys {
parts = append(parts, key+"="+strings.TrimSpace(providerIDs[key]))
}
return strings.Join(parts, "|")
}
func dedupKeyFromTitleYear(itemType, title string, year int) string {
if strings.TrimSpace(title) == "" || year <= 0 {
return ""
}
return strings.Join([]string{
"title",
strings.ToLower(strings.TrimSpace(itemType)),
strings.ToLower(strings.TrimSpace(title)),
fmt.Sprintf("%04d", year),
}, "|")
}
func dedupKeyFromGroup(folderID, groupKeyVersion int, contentGroupKey string) string {
if folderID <= 0 || strings.TrimSpace(contentGroupKey) == "" {
return ""
}
return strings.Join([]string{
"group",
strconv.Itoa(folderID),
strconv.Itoa(groupKeyVersion),
strings.TrimSpace(contentGroupKey),
}, "|")
}
func dedupKeyFromRoot(folderID int, rootPath string) string {
if folderID <= 0 || strings.TrimSpace(rootPath) == "" {
return ""
}
return strings.Join([]string{
"root",
strconv.Itoa(folderID),
filepath.Clean(rootPath),
}, "|")
}
func dedupKeyForSkeleton(
itemType string,
providerIDs map[string]string,
title string,
year int,
folderID int,
groupKeyVersion int,
contentGroupKey string,
rootPath string,
fallbackPath string,
) string {
if key := dedupKeyFromProviderIDs(itemType, providerIDs); key != "" {
return key
}
if key := dedupKeyFromTitleYear(itemType, title, year); key != "" {
return key
}
if key := dedupKeyFromGroup(folderID, groupKeyVersion, contentGroupKey); key != "" {
return key
}
if key := dedupKeyFromRoot(folderID, rootPath); key != "" {
return key
}
if strings.TrimSpace(fallbackPath) == "" {
return ""
}
return strings.Join([]string{"file", strconv.Itoa(folderID), filepath.Clean(fallbackPath)}, "|")
}
func intSliceToFields(ints []int) []MetadataField {
fields := make([]MetadataField, len(ints))
for i, v := range ints {
fields[i] = MetadataField(v)
}
return fields
}
func itemToMetadataResult(item *models.MediaItem) *MetadataResult {
result := &MetadataResult{
HasMetadata: true,
Title: item.Title,
OriginalTitle: item.OriginalTitle,
SortTitle: item.SortTitle,
Overview: item.Overview,
Tagline: item.Tagline,
Year: item.Year,
Runtime: item.Runtime,
ContentRating: item.ContentRating,
Genres: item.Genres,
Studios: item.Studios,
Networks: item.Networks,
Countries: item.Countries,
Keywords: item.Keywords,
OriginalLanguage: item.OriginalLanguage,
People: item.People,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
BackdropPath: item.BackdropPath,
BackdropThumbhash: item.BackdropThumbhash,
LogoPath: item.LogoPath,
ProviderIDs: make(map[string]string),
}
if item.TmdbID != "" {
result.ProviderIDs["tmdb"] = item.TmdbID
}
if item.TvdbID != "" {
result.ProviderIDs["tvdb"] = item.TvdbID
}
if item.ImdbID != "" {
result.ProviderIDs["imdb"] = item.ImdbID
}
result.ProviderIDs["metadb"] = item.ContentID
if item.RatingIMDB != nil {
result.Ratings.IMDB = *item.RatingIMDB
}
if item.RatingTMDB != nil {
result.Ratings.TMDB = *item.RatingTMDB
}
if item.RatingRTCritic != nil {
result.Ratings.RTCritic = float64(*item.RatingRTCritic)
}
if item.RatingRTAudience != nil {
result.Ratings.RTAudience = float64(*item.RatingRTAudience)
}
if item.FirstAirDate != nil {
result.FirstAirDate = *item.FirstAirDate
}
if item.LastAirDate != nil {
result.LastAirDate = *item.LastAirDate
}
if item.AirTime != nil {
result.AirTime = *item.AirTime
}
if item.ReleaseDate != nil {
result.ReleaseDate = *item.ReleaseDate
}
if item.SeasonCount != nil {
result.SeasonCount = *item.SeasonCount
}
return result
}
func metadataResultToItem(r *MetadataResult, contentType string) *models.MediaItem {
item := &models.MediaItem{
Type: contentType,
Title: r.Title,
OriginalTitle: r.OriginalTitle,
SortTitle: r.SortTitle,
Overview: r.Overview,
Tagline: r.Tagline,
Year: r.Year,
Runtime: r.Runtime,
ContentRating: r.ContentRating,
Genres: r.Genres,
Studios: r.Studios,
Networks: r.Networks,
Countries: lang.CanonicalCountries(r.Countries),
Keywords: r.Keywords,
OriginalLanguage: lang.Canonical(r.OriginalLanguage),
People: r.People,
TmdbID: r.ProviderIDs["tmdb"],
TvdbID: r.ProviderIDs["tvdb"],
ImdbID: r.ProviderIDs["imdb"],
PosterPath: r.PosterPath,
PosterThumbhash: r.PosterThumbhash,
BackdropPath: r.BackdropPath,
BackdropThumbhash: r.BackdropThumbhash,
LogoPath: r.LogoPath,
}
if r.Ratings.IMDB > 0 {
v := r.Ratings.IMDB
item.RatingIMDB = &v
}
if r.Ratings.TMDB > 0 {
v := r.Ratings.TMDB
item.RatingTMDB = &v
}
if r.Ratings.RTCritic > 0 {
v := int(r.Ratings.RTCritic)
item.RatingRTCritic = &v
}
if r.Ratings.RTAudience > 0 {
v := int(r.Ratings.RTAudience)
item.RatingRTAudience = &v
}
if r.FirstAirDate != "" {
item.FirstAirDate = &r.FirstAirDate
}
if r.LastAirDate != "" {
item.LastAirDate = &r.LastAirDate
}
if r.AirTime != "" {
item.AirTime = &r.AirTime
}
if r.ReleaseDate != "" {
item.ReleaseDate = &r.ReleaseDate
}
if r.SeasonCount > 0 {
item.SeasonCount = &r.SeasonCount
}
return item
}
func applyBestImages(item *models.MediaItem, images []RemoteImage, mode MergeMode, preferredLang string) {
// Images arrive in provider-chain order (highest priority first).
// For each image type, pick the best image using a fallback chain:
// 1. Preferred language or language-neutral
// 2. English or language-neutral (if preferred != "en")
// 3. Any language
// Within each pass, the first provider with a match wins; within
// that provider, pick the highest-rated image.
type best struct {
url string
rating float64
providerID string
}
filters := []func(string) bool{
func(l string) bool { return l == "" || l == preferredLang },
}
if preferredLang != "en" {
filters = append(filters, func(l string) bool { return l == "" || l == "en" })
}
filters = append(filters, func(string) bool { return true })
bestByType := map[ImageType]*best{
ImagePoster: {},
ImageBackdrop: {},
ImageLogo: {},
}
for _, accept := range filters {
for _, img := range images {
if img.URL == "" || !accept(img.Language) {
continue
}
b := bestByType[img.Type]
if b == nil {
continue
}
if b.url == "" {
b.url = img.URL
b.rating = img.Rating
b.providerID = img.ProviderID
} else if img.ProviderID == b.providerID && img.Rating > b.rating {
b.url = img.URL
b.rating = img.Rating
}
}
// Stop if every type has a candidate.
allFilled := true
for _, b := range bestByType {
if b.url == "" {
allFilled = false
break
}
}
if allFilled {
break
}
}
applyIfBetter := func(current *string, b *best) {
if b.url == "" {
return
}
if *current == "" || mode == MergeReplaceUnlocked || b.rating > 0 {
*current = b.url
}
}
applyIfBetter(&item.PosterPath, bestByType[ImagePoster])
applyIfBetter(&item.BackdropPath, bestByType[ImageBackdrop])
applyIfBetter(&item.LogoPath, bestByType[ImageLogo])
}
// cacheItemImages downloads, processes, and uploads item images to S3
// concurrently. Failures are logged and the original CDN URL is preserved.
func (s *MetadataService) cacheItemImages(ctx context.Context, item *models.MediaItem, images []RemoteImage) {
type imageField struct {
path *string
thumbhash *string
imageType ImageType
}
fields := []imageField{
{&item.PosterPath, &item.PosterThumbhash, ImagePoster},
{&item.BackdropPath, &item.BackdropThumbhash, ImageBackdrop},
{&item.LogoPath, nil, ImageLogo},
}
type cacheJob struct {
field imageField
url string
downloadURL string
providerID string
}
// Prepare jobs, resolving plugin URLs upfront.
var jobs []cacheJob
for _, f := range fields {
url := *f.path
downloadURL, ok := s.resolveImageURLForCache(ctx, url)
if !ok {
continue
}
providerID := findProviderID(images, url)
if providerID == "" {
providerID = "unknown"
}
jobs = append(jobs, cacheJob{field: f, url: url, downloadURL: downloadURL, providerID: providerID})
}
// Cache all images concurrently.
type cacheResult struct {
idx int
result *CacheImageResult
}
results := make(chan cacheResult, len(jobs))
var wg sync.WaitGroup
for i, job := range jobs {
wg.Add(1)
go func(idx int, j cacheJob) {
defer wg.Done()
r, err := s.imageCacher.CacheImage(ctx, CacheImageRequest{
SourceURL: j.downloadURL,
ProviderID: j.providerID,
ContentType: pluralContentType(item.Type),
ContentID: findContentID(item, j.providerID),
ImageType: j.field.imageType,
})
if err != nil {
slog.Warn("metadata: image cache failed, keeping CDN URL",
"url", j.url, "error", err)
return
}
results <- cacheResult{idx: idx, result: r}
}(i, job)
}
wg.Wait()
close(results)
for cr := range results {
j := jobs[cr.idx]
*j.field.path = cachedOriginalImagePath(cr.result.BasePath, cr.result.Ext)
if j.field.thumbhash != nil && cr.result.Thumbhash != "" {
*j.field.thumbhash = cr.result.Thumbhash
}
}
}
// findProviderID returns the ProviderID of the RemoteImage matching the given URL.
func findProviderID(images []RemoteImage, url string) string {
for _, img := range images {
if img.URL == url {
return img.ProviderID
}
}
return ""
}
// pluralContentType converts "movie"/"series" to "movies"/"series" for S3 keys.
func pluralContentType(ct string) string {
if ct == "movie" {
return "movies"
}
return ct
}
// findContentID returns the best durable identifier available for S3 key
// construction, preferring the image provider's native ID when present.
func findContentID(item *models.MediaItem, providerID string) string {
if item == nil {
return ""
}
for _, key := range contentIDPreferenceOrder(providerID) {
switch key {
case "tmdb":
if item.TmdbID != "" {
return item.TmdbID
}
case "tvdb":
if item.TvdbID != "" {
return item.TvdbID
}
case "imdb":
if item.ImdbID != "" {
return item.ImdbID
}
case "metadb":
if item.ContentID != "" {
return item.ContentID
}
}
}
return ""
}
func contentIDPreferenceOrder(providerID string) []string {
switch providerID {
case "tmdb":
return []string{"tmdb", "tvdb", "imdb", "metadb"}
case "tvdb":
return []string{"tvdb", "tmdb", "imdb", "metadb"}
case "imdb":
return []string{"imdb", "tmdb", "tvdb", "metadb"}
default:
return []string{"tmdb", "tvdb", "imdb", "metadb"}
}
}
// primaryProviderID picks the most common provider ID for S3 key construction.
func primaryProviderID(ids map[string]string) string {
for _, key := range []string{"tmdb", "tvdb", "imdb"} {
if ids[key] != "" {
return key
}
}
for k := range ids {
return k
}
return "unknown"
}
// FetchItemImages queries all enabled ImageProviders for available images.
// providerIDs should come from the parent MediaItem (for seasons/episodes,
// the caller resolves up to the series item). contentType is "movie" or "series".
func (s *MetadataService) FetchItemImages(ctx context.Context, providerIDs map[string]string, contentType string, language string, folderID int) ([]RemoteImage, map[string]string, error) {
contentLevel := contentType
chain, err := s.resolveChainCached(ctx, folderID, contentLevel)
if err != nil {
return nil, nil, fmt.Errorf("resolving provider chain: %w", err)
}
var allImages []RemoteImage
providerErrors := make(map[string]string)
for _, p := range chain {
ip, ok := p.(ImageProvider)
if !ok {
continue
}
images, err := ip.GetImages(ctx, ImageRequest{
ProviderIDs: providerIDs,
ContentType: contentType,
Language: language,
})
if err != nil {
slog.Warn("fetch item images: provider error",
"provider", p.Slug(), "error", err)
providerErrors[p.Slug()] = err.Error()
continue
}
allImages = append(allImages, images...)
}
// Sort by rating descending (popularity).
sort.Slice(allImages, func(i, j int) bool {
return allImages[i].Rating > allImages[j].Rating
})
return allImages, providerErrors, nil
}
// ApplyItemImage downloads a single image, caches it to S3, and returns
// the stored path and thumbhash. The caller is responsible for persisting
// the result to the correct table and locking FieldImages.
func (s *MetadataService) ApplyItemImage(ctx context.Context, req ApplyItemImageRequest) (*ApplyItemImageResult, error) {
if s.imageCacher == nil {
return nil, fmt.Errorf("image caching is not configured")
}
// Resolve plugin-prefixed URLs to downloadable HTTP URLs.
downloadURL := req.OriginalURL
if s.imageResolver != nil && !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
resolved := s.imageResolver.ResolveImageURL(ctx, downloadURL, "original")
if resolved == "" {
return nil, fmt.Errorf("failed to resolve image URL: %s", downloadURL)
}
downloadURL = resolved
}
result, err := s.imageCacher.CacheImage(ctx, CacheImageRequest{
SourceURL: downloadURL,
ProviderID: req.ProviderID,
ContentType: pluralContentType(req.ContentType),
ContentID: req.ContentID,
ImageType: req.ImageType,
SeasonNumber: req.SeasonNumber,
EpisodeNumber: req.EpisodeNumber,
})
if err != nil {
return nil, fmt.Errorf("caching image: %w", err)
}
storedPath := cachedOriginalImagePath(result.BasePath, result.Ext)
return &ApplyItemImageResult{
StoredPath: storedPath,
Thumbhash: result.Thumbhash,
}, nil
}
// ApplyItemImageRequest describes an image to download and cache. For
// season posters and episode stills, ContentID is the parent series's
// provider ID and SeasonNumber / EpisodeNumber scope the S3 key.
type ApplyItemImageRequest struct {
OriginalURL string // Plugin-prefixed path or HTTP URL
ProviderID string // Plugin slug (e.g. "tmdb")
ContentType string // "movie" or "series"
ContentID string // Provider-specific ID for S3 key construction
ImageType ImageType // Which image field to update
SeasonNumber *int // nil for item-level images
EpisodeNumber *int // nil for season poster; set for episode still
}
// ApplyItemImageResult contains the stored S3 path and thumbhash.
type ApplyItemImageResult struct {
StoredPath string
Thumbhash string
}
// ImageTypeToString converts an ImageType to its string representation.
func ImageTypeToString(t ImageType) string {
switch t {
case ImageBackdrop:
return "backdrop"
case ImageLogo:
return "logo"
case ImageStill:
return "still"
default:
return "poster"
}
}
// ImageTypeFromString converts a string to an ImageType.
func ImageTypeFromString(s string) ImageType {
switch s {
case "backdrop":
return ImageBackdrop
case "logo":
return ImageLogo
case "still":
return ImageStill
default:
return ImagePoster
}
}
func generateContentID() (string, error) {
return idgen.NextID()
}
func searchResultConflictsWithTrustedIDs(hintedIDs, candidateIDs map[string]string) bool {
for _, key := range trustedSearchIDKeys {
hinted := strings.TrimSpace(hintedIDs[key])
candidate := strings.TrimSpace(candidateIDs[key])
if hinted != "" && candidate != "" && hinted != candidate {
return true
}
}
return false
}
func suppressTitleYearFallbackForTrustedIDs(query SearchQuery) SearchQuery {
for _, key := range trustedSearchIDKeys {
if strings.TrimSpace(query.ProviderIDs[key]) != "" {
query.Title = ""
query.Year = 0
return query
}
}
return query
}