* feat(nodeconfig): harden config watcher for integrated-mode use - RequestReload(): non-blocking, coalescing reload nudge that runs on the poll goroutine, so concurrent requests can never swap a stale snapshot over a newer one (unlike ForceReload from request handlers) - Skip OnChange callbacks when the reloaded config is deep-equal to the previous one, so the 60s poll doesn't fire rebuild/log callbacks on no-op reloads - Add RedisURL to BootstrapOverrides; previously a reload clobbered an env-provided Redis URL in the live config - Split reload into fetchSettings/applySettings and add unit tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(config): hot-reload config watcher in integrated mode Start nodeconfig.Watcher in integrated/api mode (previously only proxy/ transcode worker modes hot-reloaded). Expose the live config to the API and jellycompat routers via func-typed LiveConfig/OnConfigChange fields with nil fallbacks to the startup snapshot, and wire the admin settings update hook to RequestReload so same-process changes apply immediately even without Redis. No consumer reads the live config yet — conversions land separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(admin): truthful restart-required banner for settings saves The settings UI showed 'restart required' after every save regardless of the key. The backend now classifies each key via a central registry (internal/config/restart_keys.go) and PUT /admin/settings/{key} reports restart_required per key; useSettingsForm only raises the banner when a saved key actually needs a restart (and keeps it raised until restart). The registry is conservative: every currently startup-frozen key is marked restart-required; subsequent hot-reload conversions shrink it. Settings read live from the settings repo (branding, overlays, markers, download.*, ...) default to no-restart. DownloadSettings/OverlaySettings drop their hardcoded restartRequired={false} special-casing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(logging): hot-reload server.log_level and server.log_quiet Share one slog.LevelVar across the handler chain and make logfilter.Handler's quiet-prefix list an atomic pointer shared with WithAttrs/WithGroup clones (New previously returned the inner handler unwrapped when the quiet list was empty, leaving nothing to update). The integrated-mode config watcher now applies both settings live; their keys leave the restart-required registry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(auth): hot-reload access/refresh token expiries JWTService stores expiries as atomics with a SetExpiries hook; all three instances (main API, ABS compat, jellycompat) re-apply them on config reload. Applies to newly issued tokens; outstanding tokens keep their original expiry. The JWT secret stays fixed for the process lifetime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(playback): read transcode config live at session start The playback and stream handlers pull ffmpeg path / hwaccel / transcode dir from the live config when starting a transcode or extracting subtitles, instead of values frozen at router construction. Each session snapshots the config once so its output dir and binary stay consistent. Also fixes a real bug: playback.hw_device was parsed into the config but never wired into the integrated-mode handler, so local transcodes always ran with an empty HWDevice while transcode nodes honored it. playback.transcode_dir leaves the restart-required registry (the handler is its only consumer); ffmpeg_path/hw_accel stay restart-required until scanner/chapterthumbs/audiobook consumers convert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(jellycompat): read compat identity settings live per request System/Auth handlers take a config provider instead of the startup snapshot, so jellyfin_compat.public_url, .server_name, and .emulated_server_version apply without restart. server_id stays restart-required (generate-once, baked into the resource mapper), as do the session-store TTLs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(scanner,metadata,mdblist): hot-reload worker pools and API key scanner.workers, matcher.workers/batch_size, metadata.cache_images, and mdblist.api_key convert to atomic fields with setters wired to the config watcher. Worker counts apply on the next scan/match cycle (the loops read them per cycle); the MDBList key applies to the next request. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(ai): hot-reload AI connection, models, toggles, and quotas The shared llm.Client holds its config behind an atomic pointer (UpdateConfig; each request snapshots once), and the subtitle/metadata AI services gain UpdateConfig plus setters on the translator (batching) and Whisper transcriber (ffmpeg path, chunk seconds). The router derives their configs from shared helpers used both at construction and in OnConfigChange callbacks, re-evaluating the chat-only-gateway transcribe guard on each reload and warning only when it newly fires. Everything on the AI Services page now applies without restart except ai.max_concurrent_jobs (fixed-capacity dispatch semaphore). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): wire transcode_enabled; remove dead playback/scanner knobs playback.transcode_enabled was parsed into the config but the resolver always received a hardcoded true — the admin toggle did nothing. It now reads the live config per playback start, so disabling transcodes applies without restart. Remove settings that were wired to nothing so 'save + restart' stops pretending: playback.allow_hevc_encoding (resolver field never assigned), playback.transcode_ahead_segments and playback.segment_duration (parsed, never consumed — segment duration is per-session from the client), scanner.file_removal_grace (DeleteMissing is never called). UI fields removed and the config struct fields pruned so they don't resurrect; YAML import still tolerates the legacy keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1376 lines
43 KiB
Go
1376 lines
43 KiB
Go
package metadata
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/notifications"
|
|
)
|
|
|
|
// MatchWorker processes unmatched files in the background.
|
|
type MatchWorker struct {
|
|
service *MetadataService
|
|
fileLister UnmatchedFileLister
|
|
itemLister UnmatchedItemLister
|
|
movieClaimer MovieFileClaimer
|
|
seriesClaimer SeriesRootClaimer
|
|
enableTVSeriesRootQueue bool
|
|
realtimeHub *notifications.Hub
|
|
// workers/batchSize are atomics so admin settings changes can resize the
|
|
// pool while the long-running match loop reads them each cycle.
|
|
workers atomic.Int32
|
|
batchSize atomic.Int32
|
|
interval time.Duration
|
|
}
|
|
|
|
// SetConcurrency updates the worker pool size and claim batch size. Safe for
|
|
// concurrent use; the next match cycle picks the new values up. Out-of-range
|
|
// values are ignored.
|
|
func (w *MatchWorker) SetConcurrency(workers, batchSize int) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
if workers >= 1 {
|
|
w.workers.Store(int32(workers))
|
|
}
|
|
if batchSize > 0 {
|
|
w.batchSize.Store(int32(batchSize))
|
|
}
|
|
}
|
|
|
|
func (w *MatchWorker) workerCount() int { return int(w.workers.Load()) }
|
|
func (w *MatchWorker) claimBatchSize() int { return int(w.batchSize.Load()) }
|
|
|
|
type NonSeriesFileClaimer interface {
|
|
ClaimUnmatchedNonSeries(ctx context.Context, limit int) ([]*models.MediaFile, error)
|
|
ClaimUnmatchedNonSeriesByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error)
|
|
}
|
|
|
|
type MixedFileClaimer interface {
|
|
ClaimUnmatchedMixed(ctx context.Context, limit int) ([]*models.MediaFile, error)
|
|
ClaimUnmatchedMixedByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error)
|
|
}
|
|
|
|
type MatchSuppressionChecker interface {
|
|
IsMatchSuppressed(ctx context.Context, fileID int) (bool, error)
|
|
}
|
|
|
|
type MovieFileClaimer interface {
|
|
Claim(ctx context.Context, limit int) ([]*models.MediaFile, error)
|
|
ClaimByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error)
|
|
Delete(ctx context.Context, mediaFileID int) error
|
|
UpdateError(ctx context.Context, mediaFileID int, errText string) error
|
|
}
|
|
|
|
type SeriesRootClaimer interface {
|
|
Claim(ctx context.Context, limit int) ([]models.SeriesRootMatchJob, error)
|
|
ClaimByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]models.SeriesRootMatchJob, error)
|
|
Delete(ctx context.Context, folderID int, observedRootPath string) error
|
|
UpdateError(ctx context.Context, folderID int, observedRootPath string, errText string) error
|
|
ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.SeriesRootMatchQueueEntry, int, error)
|
|
CountByFolder(ctx context.Context, folderID int) (int, error)
|
|
}
|
|
|
|
// NewMatchWorker creates a new background match worker.
|
|
func NewMatchWorker(service *MetadataService, fileLister UnmatchedFileLister, workers, batchSize int, interval time.Duration) *MatchWorker {
|
|
if workers < 1 {
|
|
workers = 8
|
|
}
|
|
if batchSize <= 0 {
|
|
batchSize = 500
|
|
}
|
|
if interval == 0 {
|
|
interval = 30 * time.Second
|
|
}
|
|
var itemLister UnmatchedItemLister
|
|
if service != nil {
|
|
itemLister = service.itemRepo
|
|
}
|
|
w := &MatchWorker{
|
|
service: service,
|
|
fileLister: fileLister,
|
|
itemLister: itemLister,
|
|
interval: interval,
|
|
}
|
|
w.SetConcurrency(workers, batchSize)
|
|
return w
|
|
}
|
|
|
|
// SetSeriesRootClaimer enables native TV root-backed matching when enabled is true.
|
|
func (w *MatchWorker) SetSeriesRootClaimer(claimer SeriesRootClaimer, enabled bool) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
w.seriesClaimer = claimer
|
|
w.enableTVSeriesRootQueue = enabled
|
|
}
|
|
|
|
// SetMovieFileClaimer enables queue-backed movie matching when claimer is non-nil.
|
|
func (w *MatchWorker) SetMovieFileClaimer(claimer MovieFileClaimer) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
w.movieClaimer = claimer
|
|
}
|
|
|
|
// SetRealtimeHub publishes catalog item changes as metadata enrichment commits.
|
|
func (w *MatchWorker) SetRealtimeHub(hub *notifications.Hub) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
w.realtimeHub = hub
|
|
}
|
|
|
|
// Run starts the match worker loop. It blocks until ctx is cancelled.
|
|
func (w *MatchWorker) Run(ctx context.Context) {
|
|
ticker := time.NewTicker(w.interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
w.processUnmatched(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// processUnmatched fetches a batch of unmatched files and processes them.
|
|
func (w *MatchWorker) processUnmatched(ctx context.Context) {
|
|
if w.enableTVSeriesRootQueue && w.seriesClaimer != nil {
|
|
jobs, err := w.seriesClaimer.Claim(ctx, w.claimBatchSize())
|
|
if err != nil {
|
|
slog.Error("metadata: failed to claim unmatched series roots", "error", err)
|
|
} else if len(jobs) > 0 {
|
|
slog.Info("metadata: processing unmatched series roots", "count", len(jobs))
|
|
if _, err := w.processSeriesRoots(ctx, jobs); err != nil {
|
|
slog.Error("metadata: failed to process unmatched series roots", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if w.movieClaimer != nil {
|
|
files, err := w.movieClaimer.Claim(ctx, w.claimBatchSize())
|
|
if err != nil {
|
|
slog.Error("metadata: failed to claim queued movie files", "error", err)
|
|
} else if len(files) > 0 {
|
|
slog.Info("metadata: processing queued movie files", "count", len(files))
|
|
w.processQueuedMovieFiles(ctx, files)
|
|
}
|
|
}
|
|
|
|
files, err := w.claimBackgroundFiles(ctx)
|
|
if err != nil {
|
|
slog.Error("metadata: failed to list unmatched files", "error", err)
|
|
return
|
|
}
|
|
if len(files) == 0 {
|
|
return
|
|
}
|
|
|
|
slog.Info("metadata: processing unmatched files", "count", len(files))
|
|
w.processFiles(ctx, files)
|
|
}
|
|
|
|
func (w *MatchWorker) processFile(ctx context.Context, file *models.MediaFile) {
|
|
if w.fileLister != nil {
|
|
defer func() {
|
|
stampCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := w.fileLister.MarkMatchAttempted(stampCtx, file.ID); err != nil {
|
|
slog.Warn("metadata: failed to record match attempt",
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
"error", err)
|
|
}
|
|
}()
|
|
}
|
|
w.processFileWithFolderCache(ctx, file, nil, nil)
|
|
}
|
|
|
|
func (w *MatchWorker) processFileWithFolderCache(ctx context.Context, file *models.MediaFile, folderEnabledCache *sync.Map, deferredSeriesLinks *sync.Map) {
|
|
if !w.folderEnabled(ctx, file.MediaFolderID, folderEnabledCache) {
|
|
slog.Info("metadata: skipping file in disabled library",
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
"folder_id", file.MediaFolderID,
|
|
)
|
|
return
|
|
}
|
|
|
|
// Phase 0: Create skeleton item or find existing.
|
|
skeleton, err := w.service.createOrFindSkeleton(ctx, file, file.MediaFolderID)
|
|
if err != nil {
|
|
slog.Warn("metadata: skeleton creation failed",
|
|
"file_id", file.ID, "path", file.FilePath, "error", err)
|
|
return
|
|
}
|
|
// If the file was linked to an existing item via dedup, skip enrichment.
|
|
// The first file for each series creates the skeleton (IsNew=true) and runs
|
|
// the full provider pipeline. Subsequent files only need linking.
|
|
if !skeleton.IsNew {
|
|
// For series files, defer episode linking to a single call per series
|
|
// after the batch completes, rather than calling per-file.
|
|
if skeleton.Type == "series" && deferredSeriesLinks != nil {
|
|
deferredSeriesLinks.Store(skeleton.ContentID, struct{}{})
|
|
} else if skeleton.Type == "series" {
|
|
// Fallback for callers that don't provide a deferred map.
|
|
if err := w.service.ensureSeriesEpisodeLinks(ctx, skeleton.ContentID); err != nil {
|
|
slog.Warn("metadata: failed to ensure series episode links",
|
|
"content_id", skeleton.ContentID,
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
"error", err)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if skeleton.ItemStatus == "ambiguous" {
|
|
return
|
|
}
|
|
|
|
req := w.buildProcessRequestForGroup(ctx, file, skeleton, nil)
|
|
result, err := w.service.Process(ctx, req)
|
|
if err != nil {
|
|
slog.Warn("metadata: enrichment failed",
|
|
"file_id", file.ID, "path", file.FilePath, "error", err)
|
|
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched", "content_id", skeleton.ContentID, "file_id", file.ID, "path", file.FilePath)
|
|
// For series items, synthesize fallback episode structure so episodes
|
|
// are visible even when no provider match was found.
|
|
if skeleton.Type == "series" {
|
|
if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil {
|
|
slog.Warn("metadata: fallback episode synthesis failed after enrichment error",
|
|
"content_id", skeleton.ContentID, "error", fbErr)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if result != nil && !result.Updated {
|
|
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched", "content_id", skeleton.ContentID, "file_id", file.ID, "path", file.FilePath)
|
|
// Same fallback synthesis for series when no provider data was returned.
|
|
if skeleton.Type == "series" {
|
|
if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil {
|
|
slog.Warn("metadata: fallback episode synthesis failed for unmatched series",
|
|
"content_id", skeleton.ContentID, "error", fbErr)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
w.publishCatalogItemChanged(ctx, file.MediaFolderID, resultContentID(result, skeleton.ContentID), "metadata_updated")
|
|
}
|
|
|
|
func (w *MatchWorker) buildProcessRequestForGroup(ctx context.Context, representative *models.MediaFile, skeleton *skeletonResult, preloadedGroupFiles []*models.MediaFile) ProcessRequest {
|
|
groupFiles := preloadedGroupFiles
|
|
if len(groupFiles) == 0 {
|
|
groupFiles = []*models.MediaFile{representative}
|
|
if skeleton != nil && skeleton.Type == "series" && w.service != nil && w.service.fileRepo != nil {
|
|
loadedFiles, err := w.service.fileRepo.ListByObservedRootPath(ctx, representative.MediaFolderID, skeleton.ObservedRootPath)
|
|
if err != nil {
|
|
slog.Warn("metadata: failed to load observed-root files",
|
|
"folder_id", representative.MediaFolderID,
|
|
"observed_root_path", skeleton.ObservedRootPath,
|
|
"error", err,
|
|
)
|
|
} else if len(loadedFiles) > 0 {
|
|
groupFiles = loadedFiles
|
|
}
|
|
}
|
|
}
|
|
|
|
groupFilePaths := make([]string, 0, len(groupFiles))
|
|
groupFilesForSidecars := make([]*models.MediaFile, 0, len(groupFiles))
|
|
for _, groupFile := range groupFiles {
|
|
if groupFile == nil {
|
|
continue
|
|
}
|
|
groupFilePaths = append(groupFilePaths, groupFile.FilePath)
|
|
groupFilesForSidecars = append(groupFilesForSidecars, groupFile)
|
|
}
|
|
if len(groupFilesForSidecars) == 0 {
|
|
groupFilePaths = []string{representative.FilePath}
|
|
groupFilesForSidecars = []*models.MediaFile{representative}
|
|
}
|
|
slices.Sort(groupFilePaths)
|
|
sidecarSearchPaths := w.service.directorySidecarSearchPathsForFiles(ctx, groupFilesForSidecars)
|
|
safeObservedRootPath := ""
|
|
if w.service.canUseObservedRootForDirectorySidecars(
|
|
ctx,
|
|
representative.MediaFolderID,
|
|
skeleton.ObservedRootPath,
|
|
skeleton.GroupKeyVersion,
|
|
skeleton.ContentGroupKey,
|
|
) {
|
|
safeObservedRootPath = skeleton.ObservedRootPath
|
|
}
|
|
|
|
hints := &MatchHints{
|
|
FileHash: representative.FileHash,
|
|
FilePath: representative.FilePath,
|
|
RepresentativeFilePath: representative.FilePath,
|
|
ObservedRootPath: safeObservedRootPath,
|
|
AllGroupFilePaths: groupFilePaths,
|
|
PrimarySidecarSearchPaths: sidecarSearchPaths,
|
|
Title: skeleton.Title,
|
|
Year: skeleton.Year,
|
|
Type: skeleton.Type,
|
|
TmdbID: skeleton.TmdbID,
|
|
ImdbID: skeleton.ImdbID,
|
|
TvdbID: skeleton.TvdbID,
|
|
HintSource: "scanner",
|
|
}
|
|
|
|
return ProcessRequest{
|
|
ContentID: skeleton.ContentID,
|
|
Hints: hints,
|
|
FolderID: formatFolderID(representative.MediaFolderID),
|
|
Mode: ModeInitialMatch,
|
|
}
|
|
}
|
|
|
|
func compactUniquePaths(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(path)
|
|
if clean == "." || clean == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[clean]; ok {
|
|
continue
|
|
}
|
|
seen[clean] = struct{}{}
|
|
out = append(out, clean)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ProcessFile applies the normal unmatched-file pipeline to a single file.
|
|
func (w *MatchWorker) ProcessFile(ctx context.Context, file *models.MediaFile) {
|
|
w.processFile(ctx, file)
|
|
}
|
|
|
|
// ProcessBatch fetches and processes one batch of unmatched files. Returns the
|
|
// number of files processed.
|
|
func (w *MatchWorker) ProcessBatch(ctx context.Context) (processed int, err error) {
|
|
if w.enableTVSeriesRootQueue && w.seriesClaimer != nil {
|
|
jobs, err := w.seriesClaimer.Claim(ctx, w.claimBatchSize())
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(jobs) > 0 {
|
|
return w.processSeriesRoots(ctx, jobs)
|
|
}
|
|
}
|
|
if w.movieClaimer != nil {
|
|
files, err := w.movieClaimer.Claim(ctx, w.claimBatchSize())
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(files) > 0 {
|
|
return w.processQueuedMovieFiles(ctx, files), nil
|
|
}
|
|
}
|
|
|
|
files, err := w.claimBackgroundFiles(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(files) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
return w.processFiles(ctx, files), nil
|
|
}
|
|
|
|
// ProcessBatchByFolderAndPathPrefix processes unmatched files within a single
|
|
// library subtree immediately instead of waiting for the periodic worker loop.
|
|
func (w *MatchWorker) ProcessBatchByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, attemptBefore time.Time) (processed int, err error) {
|
|
useSeriesQueue, useMovieQueue, err := w.queueUsageForFolder(ctx, folderID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if useSeriesQueue {
|
|
jobs, err := w.seriesClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
processed, err := w.processSeriesRoots(ctx, jobs)
|
|
if err != nil || processed > 0 {
|
|
return processed, err
|
|
}
|
|
}
|
|
if useSeriesQueue && !useMovieQueue {
|
|
return processed, nil
|
|
}
|
|
if useMovieQueue {
|
|
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
processed := w.processQueuedMovieFiles(ctx, files)
|
|
if processed > 0 {
|
|
return processed, nil
|
|
}
|
|
if !useSeriesQueue {
|
|
return processed, nil
|
|
}
|
|
}
|
|
|
|
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, scopedFallbackMode(useSeriesQueue, useMovieQueue))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return w.processFiles(ctx, files), nil
|
|
}
|
|
|
|
// ProcessAllByFolderAndPathPrefix keeps draining scoped unmatched files until
|
|
// the subtree is empty.
|
|
func (w *MatchWorker) ProcessAllByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string, attemptBefore time.Time) (processed int, err error) {
|
|
if attemptBefore.IsZero() {
|
|
attemptBefore = time.Now().UTC()
|
|
}
|
|
useSeriesQueue, useMovieQueue, err := w.queueUsageForFolder(ctx, folderID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
slog.Info("metadata: scoped matcher selected",
|
|
"folder_id", folderID,
|
|
"path_prefix", pathPrefix,
|
|
"matcher_path", scopedMatcherPath(useSeriesQueue, useMovieQueue),
|
|
)
|
|
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return processed, ctx.Err()
|
|
}
|
|
|
|
if useSeriesQueue {
|
|
jobs, err := w.seriesClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
|
if err != nil {
|
|
return processed, err
|
|
}
|
|
if len(jobs) > 0 {
|
|
batchProcessed, err := w.processSeriesRoots(ctx, jobs)
|
|
if err != nil {
|
|
return processed, err
|
|
}
|
|
processed += batchProcessed
|
|
continue
|
|
}
|
|
}
|
|
if useMovieQueue {
|
|
files, err := w.movieClaimer.ClaimByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
|
if err != nil {
|
|
return processed, err
|
|
}
|
|
if len(files) > 0 {
|
|
batchProcessed := w.processQueuedMovieFiles(ctx, files)
|
|
processed += batchProcessed
|
|
continue
|
|
}
|
|
}
|
|
if useSeriesQueue != useMovieQueue {
|
|
return processed, nil
|
|
}
|
|
|
|
files, err := w.claimScopedFiles(ctx, folderID, pathPrefix, attemptBefore, scopedFallbackMode(useSeriesQueue, useMovieQueue))
|
|
if err != nil {
|
|
return processed, err
|
|
}
|
|
batchProcessed := w.processFiles(ctx, files)
|
|
processed += batchProcessed
|
|
if batchProcessed == 0 {
|
|
return processed, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *MatchWorker) processFiles(ctx context.Context, files []*models.MediaFile) int {
|
|
if len(files) == 0 {
|
|
return 0
|
|
}
|
|
|
|
claimedCount := len(files)
|
|
folders := sync.Map{}
|
|
selectedFiles := w.collapseClaimedSeriesBatch(ctx, files)
|
|
|
|
fileChan := make(chan *models.MediaFile, len(selectedFiles))
|
|
for _, f := range selectedFiles {
|
|
fileChan <- f
|
|
}
|
|
close(fileChan)
|
|
|
|
var (
|
|
wg sync.WaitGroup
|
|
processed atomic.Int64
|
|
deferredSeriesLinks sync.Map
|
|
)
|
|
for i := 0; i < w.workerCount(); i++ {
|
|
wg.Go(func() {
|
|
for file := range fileChan {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if w.isMatchSuppressed(ctx, file) {
|
|
continue
|
|
}
|
|
w.processFileWithFolderCache(ctx, file, &folders, &deferredSeriesLinks)
|
|
processed.Add(1)
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
|
|
// Run deferred series episode linking once per unique series.
|
|
if ctx.Err() == nil {
|
|
deferredSeriesLinks.Range(func(key, _ any) bool {
|
|
if ctx.Err() != nil {
|
|
return false
|
|
}
|
|
contentID := key.(string)
|
|
if err := w.service.ensureSeriesEpisodeLinks(ctx, contentID); err != nil {
|
|
slog.Warn("metadata: deferred series episode link failed",
|
|
"content_id", contentID, "error", err)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
return claimedCount
|
|
}
|
|
|
|
func (w *MatchWorker) isMatchSuppressed(ctx context.Context, file *models.MediaFile) bool {
|
|
if file == nil {
|
|
return true
|
|
}
|
|
checker, ok := w.fileLister.(MatchSuppressionChecker)
|
|
if !ok {
|
|
return false
|
|
}
|
|
suppressed, err := checker.IsMatchSuppressed(ctx, file.ID)
|
|
if err != nil {
|
|
slog.Warn("metadata: failed to check match suppression",
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
"error", err,
|
|
)
|
|
return true
|
|
}
|
|
if suppressed {
|
|
slog.Info("metadata: skipping suppressed unmatched file",
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
)
|
|
}
|
|
return suppressed
|
|
}
|
|
|
|
func (w *MatchWorker) processQueuedMovieFiles(ctx context.Context, files []*models.MediaFile) int {
|
|
if len(files) == 0 {
|
|
return 0
|
|
}
|
|
|
|
fileChan := make(chan *models.MediaFile, len(files))
|
|
for _, f := range files {
|
|
fileChan <- f
|
|
}
|
|
close(fileChan)
|
|
|
|
var (
|
|
wg sync.WaitGroup
|
|
processed atomic.Int64
|
|
folders sync.Map
|
|
)
|
|
for i := 0; i < w.workerCount(); i++ {
|
|
wg.Go(func() {
|
|
for file := range fileChan {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if w.processQueuedMovieFile(ctx, file, &folders) {
|
|
processed.Add(1)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
|
|
return int(processed.Load())
|
|
}
|
|
|
|
func (w *MatchWorker) processQueuedMovieFile(ctx context.Context, file *models.MediaFile, folderEnabledCache *sync.Map) bool {
|
|
if file == nil || w == nil || w.service == nil || w.movieClaimer == nil {
|
|
return false
|
|
}
|
|
if !w.folderEnabled(ctx, file.MediaFolderID, folderEnabledCache) {
|
|
return false
|
|
}
|
|
|
|
skeleton, reusedLinkedItem, err := w.queuedMovieSkeleton(ctx, file)
|
|
if err != nil {
|
|
queueErr := truncateSeriesQueueError(err.Error())
|
|
if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, queueErr); updateErr != nil {
|
|
slog.Warn("metadata: failed to update movie queue error",
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
"error", updateErr,
|
|
)
|
|
}
|
|
slog.Warn("metadata: movie queue skeleton creation failed",
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
"error", err,
|
|
)
|
|
return false
|
|
}
|
|
if skeleton != nil && skeleton.ItemStatus == "skipped" {
|
|
// Deliberately skipped during skeleton creation (e.g. a misplaced TV
|
|
// episode inside a movie library): no item is created on purpose.
|
|
// Dequeue immediately; the recorded skipped root keeps the file out of
|
|
// future enqueues (see movieQueueFileEligibleCond), so this drains rows
|
|
// claimed before the skipped root was recorded.
|
|
if err := w.movieClaimer.Delete(ctx, file.ID); err != nil {
|
|
slog.Warn("metadata: failed to delete skipped movie queue row",
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
"error", err,
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
if skeleton == nil || strings.TrimSpace(skeleton.ContentID) == "" {
|
|
if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, truncateSeriesQueueError("movie queue claimed without a content id")); updateErr != nil {
|
|
slog.Warn("metadata: failed to update movie queue error",
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
"error", updateErr,
|
|
)
|
|
}
|
|
return false
|
|
}
|
|
if skeleton.ItemStatus == "ambiguous" {
|
|
if err := w.movieClaimer.Delete(ctx, file.ID); err != nil {
|
|
slog.Warn("metadata: failed to delete ambiguous movie queue row",
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
"error", err,
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
if skeleton.IsNew || reusedLinkedItem {
|
|
req := w.buildProcessRequestForGroup(ctx, file, skeleton, nil)
|
|
result, processErr := w.service.Process(ctx, req)
|
|
if processErr != nil {
|
|
queueErr := truncateSeriesQueueError(processErr.Error())
|
|
if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, queueErr); updateErr != nil {
|
|
slog.Warn("metadata: failed to update movie queue error",
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
"error", updateErr,
|
|
)
|
|
}
|
|
slog.Warn("metadata: enrichment failed",
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
"error", processErr,
|
|
)
|
|
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched", "content_id", skeleton.ContentID, "file_id", file.ID, "path", file.FilePath)
|
|
return false
|
|
} else if result != nil && !result.Updated {
|
|
if updateErr := w.movieClaimer.UpdateError(ctx, file.ID, truncateSeriesQueueError(ErrMetadataNotFound.Error())); updateErr != nil {
|
|
slog.Warn("metadata: failed to update movie queue error",
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
"error", updateErr,
|
|
)
|
|
}
|
|
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched", "content_id", skeleton.ContentID, "file_id", file.ID, "path", file.FilePath)
|
|
return false
|
|
}
|
|
w.publishCatalogItemChanged(ctx, file.MediaFolderID, resultContentID(result, skeleton.ContentID), "metadata_updated")
|
|
}
|
|
|
|
if err := w.movieClaimer.Delete(ctx, file.ID); err != nil {
|
|
slog.Warn("metadata: failed to delete movie queue row",
|
|
"file_id", file.ID,
|
|
"path", file.FilePath,
|
|
"error", err,
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (w *MatchWorker) queuedMovieSkeleton(ctx context.Context, file *models.MediaFile) (*skeletonResult, bool, error) {
|
|
if skeleton, ok := w.reusableQueuedMovieSkeleton(ctx, file); ok {
|
|
return skeleton, true, nil
|
|
}
|
|
|
|
skeleton, err := w.service.createOrFindSkeleton(ctx, file, file.MediaFolderID)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return skeleton, false, nil
|
|
}
|
|
|
|
func (w *MatchWorker) reusableQueuedMovieSkeleton(ctx context.Context, file *models.MediaFile) (*skeletonResult, bool) {
|
|
if w == nil || w.service == nil || w.service.itemRepo == nil || file == nil {
|
|
return nil, false
|
|
}
|
|
contentID := strings.TrimSpace(file.ContentID)
|
|
if contentID == "" {
|
|
return nil, false
|
|
}
|
|
|
|
item, err := w.service.itemRepo.GetByID(ctx, contentID)
|
|
if err != nil || item == nil {
|
|
return nil, false
|
|
}
|
|
status := strings.ToLower(strings.TrimSpace(item.Status))
|
|
if !isSkeletonLikeStatus(status) && status != "ambiguous" {
|
|
return nil, false
|
|
}
|
|
|
|
rootPath := filepath.Dir(file.FilePath)
|
|
if file.CanonicalRootPath != "" {
|
|
rootPath = filepath.Clean(file.CanonicalRootPath)
|
|
}
|
|
observedRootPath := filepath.Dir(file.FilePath)
|
|
if file.ObservedRootPath != "" {
|
|
observedRootPath = filepath.Clean(file.ObservedRootPath)
|
|
}
|
|
title := strings.TrimSpace(item.Title)
|
|
if title == "" {
|
|
title = file.BaseTitle
|
|
}
|
|
itemType := strings.TrimSpace(item.Type)
|
|
if itemType == "" {
|
|
itemType = file.BaseType
|
|
}
|
|
if itemType == "" {
|
|
itemType = "movie"
|
|
}
|
|
year := item.Year
|
|
if year == 0 {
|
|
year = file.BaseYear
|
|
}
|
|
|
|
return &skeletonResult{
|
|
ContentID: item.ContentID,
|
|
ItemStatus: item.Status,
|
|
RootPath: rootPath,
|
|
ObservedRootPath: observedRootPath,
|
|
GroupKeyVersion: file.GroupKeyVersion,
|
|
ContentGroupKey: strings.TrimSpace(file.ContentGroupKey),
|
|
Title: title,
|
|
Year: year,
|
|
Type: itemType,
|
|
TmdbID: item.TmdbID,
|
|
ImdbID: item.ImdbID,
|
|
TvdbID: item.TvdbID,
|
|
}, true
|
|
}
|
|
|
|
func scopedMatcherPath(useSeriesQueue bool, useMovieQueue bool) string {
|
|
switch {
|
|
case useSeriesQueue && useMovieQueue:
|
|
return "series_root_queue+movie_file_queue"
|
|
case useSeriesQueue:
|
|
return "series_root_queue"
|
|
case useMovieQueue:
|
|
return "movie_file_queue"
|
|
default:
|
|
return "file"
|
|
}
|
|
}
|
|
|
|
func (w *MatchWorker) processSeriesRoots(ctx context.Context, jobs []models.SeriesRootMatchJob) (int, error) {
|
|
if len(jobs) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
jobChan := make(chan models.SeriesRootMatchJob, len(jobs))
|
|
for _, job := range jobs {
|
|
jobChan <- job
|
|
}
|
|
close(jobChan)
|
|
|
|
var (
|
|
wg sync.WaitGroup
|
|
processed atomic.Int64
|
|
firstErr error
|
|
firstErrMu sync.Mutex
|
|
folders sync.Map
|
|
)
|
|
for i := 0; i < w.workerCount(); i++ {
|
|
wg.Go(func() {
|
|
for job := range jobChan {
|
|
if runCtx.Err() != nil {
|
|
return
|
|
}
|
|
count, err := w.processSeriesRoot(runCtx, job, &folders)
|
|
if err != nil {
|
|
firstErrMu.Lock()
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
cancel()
|
|
}
|
|
firstErrMu.Unlock()
|
|
return
|
|
}
|
|
processed.Add(int64(count))
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
|
|
return int(processed.Load()), firstErr
|
|
}
|
|
|
|
func (w *MatchWorker) processSeriesRoot(ctx context.Context, job models.SeriesRootMatchJob, folderEnabledCache *sync.Map) (int, error) {
|
|
if !w.folderEnabled(ctx, job.MediaFolderID, folderEnabledCache) {
|
|
return 0, nil
|
|
}
|
|
if w.service == nil || w.service.fileRepo == nil || w.seriesClaimer == nil {
|
|
return 0, fmt.Errorf("series root matching requires file repo and queue claimer")
|
|
}
|
|
|
|
groupFiles, err := w.service.fileRepo.ListByObservedRootPath(ctx, job.MediaFolderID, job.ObservedRootPath)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("loading files for series root %d/%s: %w", job.MediaFolderID, job.ObservedRootPath, err)
|
|
}
|
|
if len(groupFiles) == 0 {
|
|
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath); err != nil {
|
|
return 0, err
|
|
}
|
|
slog.Info("metadata: series root dropped because files disappeared",
|
|
"folder_id", job.MediaFolderID,
|
|
"observed_root_path", job.ObservedRootPath,
|
|
)
|
|
return 0, nil
|
|
}
|
|
|
|
representative := selectRepresentativeGroupFile(groupFiles)
|
|
if representative == nil {
|
|
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath); err != nil {
|
|
return 0, err
|
|
}
|
|
return 0, nil
|
|
}
|
|
if !hasUnlinkedGroupFile(groupFiles) {
|
|
if strings.TrimSpace(representative.ContentID) != "" {
|
|
if skeleton, ok := w.reusableQueuedMovieSkeleton(ctx, representative); ok && skeleton.ItemStatus != "ambiguous" {
|
|
req := w.buildProcessRequestForGroup(ctx, representative, skeleton, groupFiles)
|
|
result, processErr := w.service.Process(ctx, req)
|
|
if processErr != nil {
|
|
queueErr := truncateSeriesQueueError(processErr.Error())
|
|
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, queueErr); updateErr != nil {
|
|
return 0, updateErr
|
|
}
|
|
slog.Warn("metadata: enrichment failed",
|
|
"file_id", representative.ID,
|
|
"path", representative.FilePath,
|
|
"error", processErr)
|
|
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched",
|
|
"content_id", skeleton.ContentID,
|
|
"file_id", representative.ID,
|
|
"path", representative.FilePath)
|
|
if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil {
|
|
slog.Warn("metadata: fallback episode synthesis failed after series root enrichment error",
|
|
"content_id", skeleton.ContentID, "error", fbErr)
|
|
}
|
|
return 0, nil
|
|
}
|
|
if result != nil && !result.Updated {
|
|
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(ErrMetadataNotFound.Error())); updateErr != nil {
|
|
return 0, updateErr
|
|
}
|
|
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched",
|
|
"content_id", skeleton.ContentID,
|
|
"file_id", representative.ID,
|
|
"path", representative.FilePath)
|
|
if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil {
|
|
slog.Warn("metadata: fallback episode synthesis failed for unmatched series root",
|
|
"content_id", skeleton.ContentID, "error", fbErr)
|
|
}
|
|
return 0, nil
|
|
}
|
|
w.publishCatalogItemChanged(ctx, job.MediaFolderID, resultContentID(result, skeleton.ContentID), "metadata_updated")
|
|
}
|
|
if err := w.service.ensureSeriesEpisodeLinks(ctx, representative.ContentID); err != nil {
|
|
if errors.Is(err, catalog.ErrItemNotFound) {
|
|
// The series item was concurrently merged into another (provider-ID
|
|
// dedup moves its seasons+episodes to the survivor, then deletes the
|
|
// source row). The episodes are already reattached, so there is nothing
|
|
// to link here — benign; finish normally instead of failing the batch.
|
|
slog.Info("metadata: series item gone during episode-link ensure (likely concurrent merge); skipping",
|
|
"content_id", representative.ContentID,
|
|
"folder_id", job.MediaFolderID,
|
|
"observed_root_path", job.ObservedRootPath)
|
|
} else {
|
|
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
|
|
return 0, updateErr
|
|
}
|
|
return 0, fmt.Errorf("ensuring series episode links for %s: %w", representative.ContentID, err)
|
|
}
|
|
}
|
|
}
|
|
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(groupFiles), nil
|
|
}
|
|
|
|
skeleton, err := w.service.createOrFindSkeleton(ctx, representative, job.MediaFolderID)
|
|
if err != nil {
|
|
queueErr := truncateSeriesQueueError(err.Error())
|
|
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, queueErr); updateErr != nil {
|
|
return 0, updateErr
|
|
}
|
|
slog.Warn("metadata: series root skeleton creation failed",
|
|
"folder_id", job.MediaFolderID,
|
|
"observed_root_path", job.ObservedRootPath,
|
|
"sample_file_path", job.SampleFilePath,
|
|
"error", err,
|
|
)
|
|
return 0, nil
|
|
}
|
|
if skeleton == nil || strings.TrimSpace(skeleton.ContentID) == "" {
|
|
return 0, nil
|
|
}
|
|
|
|
if _, err := w.service.fileRepo.UpdateContentIDByObservedRootPath(ctx, job.MediaFolderID, job.ObservedRootPath, skeleton.ContentID); err != nil {
|
|
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
|
|
return 0, updateErr
|
|
}
|
|
return 0, fmt.Errorf("relinking series root %d/%s: %w", job.MediaFolderID, job.ObservedRootPath, err)
|
|
}
|
|
|
|
if skeleton.IsNew && skeleton.ItemStatus != "ambiguous" {
|
|
req := w.buildProcessRequestForGroup(ctx, representative, skeleton, groupFiles)
|
|
result, processErr := w.service.Process(ctx, req)
|
|
if processErr != nil {
|
|
queueErr := truncateSeriesQueueError(processErr.Error())
|
|
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, queueErr); updateErr != nil {
|
|
return 0, updateErr
|
|
}
|
|
slog.Warn("metadata: enrichment failed",
|
|
"file_id", representative.ID,
|
|
"path", representative.FilePath,
|
|
"error", processErr)
|
|
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched",
|
|
"content_id", skeleton.ContentID,
|
|
"file_id", representative.ID,
|
|
"path", representative.FilePath,
|
|
"folder_id", job.MediaFolderID,
|
|
"observed_root_path", job.ObservedRootPath,
|
|
)
|
|
if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil {
|
|
slog.Warn("metadata: fallback episode synthesis failed after enrichment error",
|
|
"content_id", skeleton.ContentID,
|
|
"error", fbErr,
|
|
)
|
|
}
|
|
return 0, nil
|
|
} else if result != nil && !result.Updated {
|
|
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(ErrMetadataNotFound.Error())); updateErr != nil {
|
|
return 0, updateErr
|
|
}
|
|
w.logStatusUpdateFailure(ctx, skeleton.ContentID, "unmatched",
|
|
"content_id", skeleton.ContentID,
|
|
"file_id", representative.ID,
|
|
"path", representative.FilePath,
|
|
"folder_id", job.MediaFolderID,
|
|
"observed_root_path", job.ObservedRootPath,
|
|
)
|
|
if fbErr := w.service.SynthesizeFallbackEpisodes(ctx, skeleton.ContentID); fbErr != nil {
|
|
slog.Warn("metadata: fallback episode synthesis failed for unmatched series",
|
|
"content_id", skeleton.ContentID,
|
|
"error", fbErr,
|
|
)
|
|
}
|
|
return 0, nil
|
|
}
|
|
w.publishCatalogItemChanged(ctx, job.MediaFolderID, resultContentID(result, skeleton.ContentID), "metadata_updated")
|
|
}
|
|
|
|
finalContentID, err := w.service.fileRepo.FindContentIDByObservedRootPath(ctx, job.MediaFolderID, job.ObservedRootPath, "series")
|
|
if err != nil {
|
|
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
|
|
return 0, updateErr
|
|
}
|
|
return 0, fmt.Errorf("resolving final content for series root %d/%s: %w", job.MediaFolderID, job.ObservedRootPath, err)
|
|
}
|
|
if finalContentID == "" {
|
|
finalContentID = skeleton.ContentID
|
|
}
|
|
if strings.TrimSpace(finalContentID) != "" {
|
|
if err := w.service.ensureSeriesEpisodeLinks(ctx, finalContentID); err != nil {
|
|
if errors.Is(err, catalog.ErrItemNotFound) {
|
|
slog.Info("metadata: series item gone during episode-link ensure (likely concurrent merge); skipping",
|
|
"content_id", finalContentID,
|
|
"folder_id", job.MediaFolderID,
|
|
"observed_root_path", job.ObservedRootPath)
|
|
} else {
|
|
if updateErr := w.seriesClaimer.UpdateError(ctx, job.MediaFolderID, job.ObservedRootPath, truncateSeriesQueueError(err.Error())); updateErr != nil {
|
|
return 0, updateErr
|
|
}
|
|
return 0, fmt.Errorf("ensuring series episode links for %s: %w", finalContentID, err)
|
|
}
|
|
}
|
|
if _, ok := w.service.confirmedOwnershipItem(ctx, finalContentID); ok {
|
|
w.service.claimConfirmedSeriesRootOwnership(ctx, job.MediaFolderID, job.ObservedRootPath, finalContentID, groupFiles)
|
|
}
|
|
}
|
|
|
|
if err := w.seriesClaimer.Delete(ctx, job.MediaFolderID, job.ObservedRootPath); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
slog.Info("metadata: series root completed",
|
|
"folder_id", job.MediaFolderID,
|
|
"observed_root_path", job.ObservedRootPath,
|
|
"content_id", finalContentID,
|
|
"file_count", len(groupFiles),
|
|
)
|
|
return len(groupFiles), nil
|
|
}
|
|
|
|
func (w *MatchWorker) logStatusUpdateFailure(ctx context.Context, contentID, status string, attrs ...any) {
|
|
if w == nil || w.service == nil {
|
|
return
|
|
}
|
|
if err := w.service.updateItemStatus(ctx, contentID, status); err != nil {
|
|
args := append([]any{"content_id", contentID, "status", status, "error", err}, attrs...)
|
|
slog.Warn("metadata: failed to update item status", args...)
|
|
}
|
|
}
|
|
|
|
func (w *MatchWorker) collapseClaimedSeriesBatch(ctx context.Context, files []*models.MediaFile) []*models.MediaFile {
|
|
if len(files) == 0 || w == nil || w.service == nil || w.service.folderRepo == nil {
|
|
return files
|
|
}
|
|
|
|
out := make([]*models.MediaFile, 0, len(files))
|
|
folderTypes := make(map[int]string)
|
|
seenGroups := make(map[string]struct{})
|
|
|
|
for _, file := range files {
|
|
if file == nil {
|
|
continue
|
|
}
|
|
folderType, ok := folderTypes[file.MediaFolderID]
|
|
if !ok {
|
|
folder, err := w.service.folderRepo.GetByID(ctx, file.MediaFolderID)
|
|
if err != nil {
|
|
slog.Warn("metadata: failed to load folder type for batch compaction",
|
|
"folder_id", file.MediaFolderID,
|
|
"file_id", file.ID,
|
|
"error", err,
|
|
)
|
|
}
|
|
if folder != nil {
|
|
folderType = strings.ToLower(strings.TrimSpace(folder.Type))
|
|
}
|
|
folderTypes[file.MediaFolderID] = folderType
|
|
}
|
|
|
|
switch folderType {
|
|
case "series", "tv", "show", "tvshows":
|
|
default:
|
|
out = append(out, file)
|
|
continue
|
|
}
|
|
if file.ContentGroupKey == "" {
|
|
out = append(out, file)
|
|
continue
|
|
}
|
|
|
|
groupKey := fmt.Sprintf("%d:%d:%s", file.MediaFolderID, file.GroupKeyVersion, file.ContentGroupKey)
|
|
if _, ok := seenGroups[groupKey]; ok {
|
|
continue
|
|
}
|
|
seenGroups[groupKey] = struct{}{}
|
|
out = append(out, file)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func (w *MatchWorker) folderEnabled(ctx context.Context, folderID int, cache *sync.Map) bool {
|
|
if folderID <= 0 || w == nil || w.service == nil || w.service.folderRepo == nil {
|
|
return true
|
|
}
|
|
|
|
if cache != nil {
|
|
if cached, ok := cache.Load(folderID); ok {
|
|
return cached.(bool)
|
|
}
|
|
}
|
|
|
|
enabled := true
|
|
folder, err := w.service.folderRepo.GetByID(ctx, folderID)
|
|
if err != nil {
|
|
slog.Warn("metadata: failed to load folder state during match",
|
|
"folder_id", folderID,
|
|
"error", err,
|
|
)
|
|
} else if folder != nil {
|
|
enabled = folder.Enabled
|
|
}
|
|
|
|
if cache != nil {
|
|
cache.Store(folderID, enabled)
|
|
}
|
|
|
|
return enabled
|
|
}
|
|
|
|
func (w *MatchWorker) folderType(ctx context.Context, folderID int) (string, error) {
|
|
if folderID <= 0 || w == nil || w.service == nil || w.service.folderRepo == nil {
|
|
return "", nil
|
|
}
|
|
folder, err := w.service.folderRepo.GetByID(ctx, folderID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if folder == nil {
|
|
return "", nil
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(folder.Type)), nil
|
|
}
|
|
|
|
func (w *MatchWorker) queueUsageForFolder(ctx context.Context, folderID int) (useSeriesQueue bool, useMovieQueue bool, err error) {
|
|
folderType, err := w.folderType(ctx, folderID)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
useSeriesQueue = w.enableTVSeriesRootQueue &&
|
|
w.seriesClaimer != nil &&
|
|
(isTVLibraryType(folderType) || isMixedLibraryType(folderType))
|
|
useMovieQueue = w.movieClaimer != nil && isMovieLibraryType(folderType)
|
|
return useSeriesQueue, useMovieQueue, nil
|
|
}
|
|
|
|
func (w *MatchWorker) claimBackgroundFiles(ctx context.Context) ([]*models.MediaFile, error) {
|
|
if w.enableTVSeriesRootQueue && w.movieClaimer != nil {
|
|
if claimer, ok := w.fileLister.(MixedFileClaimer); ok {
|
|
return claimer.ClaimUnmatchedMixed(ctx, w.claimBatchSize())
|
|
}
|
|
return nil, fmt.Errorf("mixed-library file claimer is not configured")
|
|
}
|
|
if w.enableTVSeriesRootQueue {
|
|
if claimer, ok := w.fileLister.(NonSeriesFileClaimer); ok {
|
|
return claimer.ClaimUnmatchedNonSeries(ctx, w.claimBatchSize())
|
|
}
|
|
return nil, fmt.Errorf("non-series file claimer is not configured")
|
|
}
|
|
return w.fileLister.ClaimUnmatched(ctx, w.claimBatchSize())
|
|
}
|
|
|
|
type scopedFallbackClaimMode int
|
|
|
|
const (
|
|
scopedFallbackGeneric scopedFallbackClaimMode = iota
|
|
scopedFallbackNonSeries
|
|
scopedFallbackMixed
|
|
)
|
|
|
|
func scopedFallbackMode(useSeriesQueue bool, useMovieQueue bool) scopedFallbackClaimMode {
|
|
switch {
|
|
case useSeriesQueue && useMovieQueue:
|
|
return scopedFallbackMixed
|
|
case useSeriesQueue:
|
|
return scopedFallbackNonSeries
|
|
default:
|
|
return scopedFallbackGeneric
|
|
}
|
|
}
|
|
|
|
func (w *MatchWorker) claimScopedFiles(ctx context.Context, folderID int, pathPrefix string, attemptBefore time.Time, mode scopedFallbackClaimMode) ([]*models.MediaFile, error) {
|
|
switch mode {
|
|
case scopedFallbackMixed:
|
|
if claimer, ok := w.fileLister.(MixedFileClaimer); ok {
|
|
return claimer.ClaimUnmatchedMixedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
|
}
|
|
return nil, fmt.Errorf("mixed-library file claimer is not configured")
|
|
case scopedFallbackNonSeries:
|
|
if claimer, ok := w.fileLister.(NonSeriesFileClaimer); ok {
|
|
return claimer.ClaimUnmatchedNonSeriesByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
|
}
|
|
return nil, fmt.Errorf("non-series file claimer is not configured")
|
|
default:
|
|
return w.fileLister.ClaimUnmatchedByFolderAndPathPrefix(ctx, folderID, pathPrefix, w.claimBatchSize(), attemptBefore)
|
|
}
|
|
}
|
|
|
|
func selectRepresentativeGroupFile(groupFiles []*models.MediaFile) *models.MediaFile {
|
|
var (
|
|
firstUnlinked *models.MediaFile
|
|
firstAny *models.MediaFile
|
|
)
|
|
for _, file := range groupFiles {
|
|
if file == nil {
|
|
continue
|
|
}
|
|
if firstAny == nil || file.ID < firstAny.ID {
|
|
firstAny = file
|
|
}
|
|
if strings.TrimSpace(file.ContentID) == "" && (firstUnlinked == nil || file.ID < firstUnlinked.ID) {
|
|
firstUnlinked = file
|
|
}
|
|
}
|
|
if firstUnlinked != nil {
|
|
return firstUnlinked
|
|
}
|
|
return firstAny
|
|
}
|
|
|
|
func hasUnlinkedGroupFile(groupFiles []*models.MediaFile) bool {
|
|
for _, file := range groupFiles {
|
|
if file != nil && strings.TrimSpace(file.ContentID) == "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func truncateSeriesQueueError(errText string) string {
|
|
errText = strings.TrimSpace(errText)
|
|
if len(errText) <= 1024 {
|
|
return errText
|
|
}
|
|
return errText[:1024]
|
|
}
|
|
|
|
func isTVLibraryType(folderType string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(folderType)) {
|
|
case "series", "tv", "show", "tvshows":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isMixedLibraryType(folderType string) bool {
|
|
return strings.ToLower(strings.TrimSpace(folderType)) == "mixed"
|
|
}
|
|
|
|
func isMovieLibraryType(folderType string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(folderType)) {
|
|
case "movie", "movies", "mixed":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// RetryUnmatchedItemsByFolderAndPathPrefix revisits linked unmatched items in
|
|
// scope once. Per-item retry failures are counted as warnings, not fatal.
|
|
func (w *MatchWorker) RetryUnmatchedItemsByFolderAndPathPrefix(ctx context.Context, folderID int, pathPrefix string) (retried int, stillUnmatched int, err error) {
|
|
if w.service == nil {
|
|
return 0, 0, fmt.Errorf("metadata match worker requires a service")
|
|
}
|
|
if w.itemLister == nil {
|
|
return 0, 0, fmt.Errorf("metadata match worker requires an item lister")
|
|
}
|
|
|
|
contentIDs, err := w.itemLister.ListUnmatchedByFolderAndPathPrefix(ctx, folderID, pathPrefix, 0)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
for _, contentID := range contentIDs {
|
|
if ctx.Err() != nil {
|
|
return retried, stillUnmatched, ctx.Err()
|
|
}
|
|
|
|
retried++
|
|
result, processErr := w.service.Process(ctx, ProcessRequest{
|
|
ContentID: contentID,
|
|
FolderID: formatFolderID(folderID),
|
|
Mode: ModeScheduledRefresh,
|
|
})
|
|
if processErr != nil {
|
|
stillUnmatched++
|
|
slog.Warn("metadata: scoped retry failed",
|
|
"content_id", contentID,
|
|
"folder_id", folderID,
|
|
"path_prefix", pathPrefix,
|
|
"error", processErr)
|
|
continue
|
|
}
|
|
if result == nil || !result.Updated {
|
|
stillUnmatched++
|
|
continue
|
|
}
|
|
w.publishCatalogItemChanged(ctx, folderID, resultContentID(result, contentID), "metadata_updated")
|
|
}
|
|
|
|
if repaired, repairErr := w.service.repairMatchedDuplicateProviderOwnersByFolderAndPathPrefix(ctx, folderID, pathPrefix); repairErr != nil {
|
|
return retried, stillUnmatched, repairErr
|
|
} else if repaired > 0 {
|
|
retried += repaired
|
|
slog.Info("metadata: repaired matched duplicate items in scope",
|
|
"folder_id", folderID,
|
|
"path_prefix", pathPrefix,
|
|
"repaired_items", repaired,
|
|
)
|
|
}
|
|
|
|
return retried, stillUnmatched, nil
|
|
}
|
|
|
|
func formatFolderID(id int) string {
|
|
return strconv.Itoa(id)
|
|
}
|
|
|
|
func (w *MatchWorker) publishCatalogItemChanged(ctx context.Context, libraryID int, contentID string, change string) {
|
|
if w == nil || w.realtimeHub == nil || libraryID <= 0 || strings.TrimSpace(contentID) == "" {
|
|
return
|
|
}
|
|
if err := w.realtimeHub.PublishCatalogItemChanged(ctx, notifications.MetadataUpdateEvent{
|
|
LibraryID: libraryID,
|
|
ContentID: contentID,
|
|
Change: change,
|
|
}); err != nil {
|
|
slog.Warn("metadata: failed to publish catalog item change",
|
|
"content_id", contentID,
|
|
"library_id", libraryID,
|
|
"change", change,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func resultContentID(result *ProcessResult, fallback string) string {
|
|
if result != nil && strings.TrimSpace(result.ContentID) != "" {
|
|
return result.ContentID
|
|
}
|
|
return fallback
|
|
}
|