package handlers import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "path/filepath" "slices" "sort" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/oklog/ulid/v2" "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/adminjob" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/cache" "github.com/Silo-Server/silo-server/internal/catalog" evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/libraryingest" "github.com/Silo-Server/silo-server/internal/metadata" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/plugins" "github.com/Silo-Server/silo-server/internal/rootcheck" "github.com/Silo-Server/silo-server/internal/scanner" "github.com/Silo-Server/silo-server/internal/scantrigger" "github.com/Silo-Server/silo-server/internal/sections" "github.com/Silo-Server/silo-server/internal/userstore" ) // LibraryHandler handles HTTP endpoints for library (media folder) management // and scan triggering. type LibraryHandler struct { folderRepo *catalog.FolderRepository ingester libraryIngester userRepo *auth.UserRepository pool *pgxpool.Pool refresher AdminMetadataRefresher chainCacheInvalidator interface{ InvalidateChainCache() } JobRepo AdminJobCreator ChainRepo *metadata.ChainRepository PluginInstallations pluginInstallationLister SkippedRootRepo *metadata.SkippedRootRepository StaleIDRepo *metadata.StaleMediaIDRepository MovieMatchQueueRepo libraryMovieMatchQueue SeriesMatchQueueRepo librarySeriesMatchQueue RawMatchBacklogRepo libraryRawMatchBacklog TVSeriesRootQueue bool ScannedGroupRepo *scanner.ScannedGroupRepository GroupOverrideRepo *scanner.MediaGroupOverrideRepository ObservedLocationRepo *scanner.ObservedLocationRepository SectionRepo *sections.Repository StoreProvider userstore.UserStoreProvider S3Meta LibraryImageStore PresignTTL time.Duration appCtx context.Context EventBus cache.EventBus EventsHub *evt.Hub ScanRegistry *evt.ScanRegistry ScanQueue libraryScanQueuer } // LibraryImageStore provides S3 operations for library poster images. type LibraryImageStore interface { PutObject(ctx context.Context, bucket, key string, data []byte) error DeleteObject(ctx context.Context, bucket, key string) error PresignGetURL(ctx context.Context, bucket, key string, expiry time.Duration) (string, error) Bucket() string } // pluginInstallationLister provides access to plugin installations and capabilities // for seeding default provider chains from manifest metadata. type pluginInstallationLister interface { ListEnabled(ctx context.Context) ([]*plugins.Installation, error) ListCapabilities(ctx context.Context, installationID int) ([]*plugins.Capability, error) } type libraryIngester interface { IngestFolder(ctx context.Context, folder *models.MediaFolder) (*libraryingest.Result, error) IngestSubtree(ctx context.Context, folder *models.MediaFolder, subtreePath string) (*libraryingest.Result, error) IngestFile(ctx context.Context, folder *models.MediaFolder, filePath string) (*libraryingest.Result, error) CancelLibrary(folderID int) int } type libraryScanQueuer interface { EnqueueLibraryScan(ctx context.Context, folderID int, trigger string) (bool, error) EnqueueScan(ctx context.Context, folderID int, mode, path, trigger string) (bool, error) CancelAcceptedByLibrary(ctx context.Context, libraryID int) (int, error) CancelByLibrary(ctx context.Context, libraryID int) (int, error) } type libraryMovieMatchQueue interface { SyncForFolder(ctx context.Context, folderID int) error DeleteByFolder(ctx context.Context, folderID int) (int, error) CountStatesByFolder(ctx context.Context, folderID int) (pending int, parked int, err error) CountStatesByFolders(ctx context.Context, folderIDs []int) (map[int]metadata.MatchQueueStateCounts, error) ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.MovieMatchQueueEntry, int, error) RetryNowByFolder(ctx context.Context, folderID int) (int, error) } type librarySeriesMatchQueue interface { SyncForFolder(ctx context.Context, folderID int) error DeleteByFolder(ctx context.Context, folderID int) (int, error) CountStatesByFolder(ctx context.Context, folderID int) (pending int, parked int, err error) CountStatesByFolders(ctx context.Context, folderIDs []int) (map[int]metadata.MatchQueueStateCounts, error) ListByFolder(ctx context.Context, folderID int, limit int, offset int) ([]models.SeriesRootMatchQueueEntry, int, error) RetryNowByFolder(ctx context.Context, folderID int) (int, error) } type libraryRawMatchBacklog interface { CountUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error) CountUnmatchedMatchBacklogByFolders(ctx context.Context, folderIDs []int, mode scanner.RawMatchBacklogMode) (map[int]int, error) ListUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode, limit int, offset int) ([]*models.MediaFile, int, error) SuppressUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error) RetryUnmatchedMatchBacklogByFolder(ctx context.Context, folderID int, mode scanner.RawMatchBacklogMode) (int, error) } // NewLibraryHandler creates a new LibraryHandler backed by the given folder // repository and ingest executor. The ingester may be nil if scan endpoints are not needed. func NewLibraryHandler( folderRepo *catalog.FolderRepository, ingester libraryIngester, userRepo *auth.UserRepository, pool *pgxpool.Pool, refresher AdminMetadataRefresher, appCtx ...context.Context, ) *LibraryHandler { ctx := context.Background() if len(appCtx) > 0 && appCtx[0] != nil { ctx = appCtx[0] } var scannedGroupRepo *scanner.ScannedGroupRepository var groupOverrideRepo *scanner.MediaGroupOverrideRepository var observedLocationRepo *scanner.ObservedLocationRepository if pool != nil { scannedGroupRepo = scanner.NewScannedGroupRepository(pool) groupOverrideRepo = scanner.NewMediaGroupOverrideRepository(pool) observedLocationRepo = scanner.NewObservedLocationRepository(pool) } return &LibraryHandler{ folderRepo: folderRepo, ingester: ingester, userRepo: userRepo, pool: pool, refresher: refresher, ScannedGroupRepo: scannedGroupRepo, GroupOverrideRepo: groupOverrideRepo, ObservedLocationRepo: observedLocationRepo, appCtx: ctx, } } func (h *LibraryHandler) SetChainCacheInvalidator(invalidator interface{ InvalidateChainCache() }) { if h == nil { return } h.chainCacheInvalidator = invalidator } // validMetadataLanguages is the set of ISO 639-1 codes accepted for // per-library metadata language. Kept in sync with the frontend LANGUAGES list. var validMetadataLanguages = map[string]bool{ "en": true, "es": true, "fr": true, "de": true, "it": true, "pt": true, "nl": true, "pl": true, "ru": true, "zh": true, "ja": true, "ko": true, "ar": true, "tr": true, "sv": true, "da": true, "no": true, "fi": true, "hu": true, "cs": true, "ro": true, "he": true, "th": true, "vi": true, "el": true, "bg": true, "hr": true, "sk": true, "sl": true, "uk": true, "id": true, "ms": true, "hi": true, "ta": true, "te": true, "bn": true, "fa": true, } // --- Request/Response types --- // createLibraryRequest represents the JSON body for POST /libraries. type createLibraryRequest struct { Paths []string `json:"paths"` Type string `json:"type"` Name string `json:"name"` MetadataLanguage string `json:"metadata_language,omitempty"` ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled,omitempty"` IntroDetectionEnabled bool `json:"intro_detection_enabled,omitempty"` // TrailerKinds is the allow-list of remote video kinds fetched during // metadata refresh; omitted = default (all provider kinds). TrailerKinds []string `json:"trailer_kinds,omitempty"` } // updateLibraryRequest represents the JSON body for PUT /libraries/{id}. type updateLibraryRequest struct { Paths *[]string `json:"paths,omitempty"` Type *string `json:"type,omitempty"` Name *string `json:"name,omitempty"` Enabled *bool `json:"enabled,omitempty"` MetadataLanguage *string `json:"metadata_language,omitempty"` AutoTranslateMetadata *bool `json:"auto_translate_metadata,omitempty"` ChapterThumbnailsEnabled *bool `json:"chapter_thumbnails_enabled,omitempty"` IntroDetectionEnabled *bool `json:"intro_detection_enabled,omitempty"` // TrailerKinds is the allow-list of remote video kinds fetched during // metadata refresh (ExtraKind values); empty array disables remote videos. TrailerKinds *[]string `json:"trailer_kinds,omitempty"` } // scanRequest represents the JSON body for POST /scan. type scanRequest struct { LibraryID *int `json:"library_id,omitempty"` Path string `json:"path,omitempty"` } type scanResponse struct { Status string `json:"status"` Mode string `json:"mode"` LibraryID int `json:"library_id"` } // scanCancelRequest represents the JSON body for POST /scan/cancel. type scanCancelRequest struct { LibraryID int `json:"library_id"` } type scanCancelResponse struct { Cancelled int `json:"cancelled"` LibraryID int `json:"library_id"` } // libraryResponse represents a library (media folder) in JSON responses. type libraryResponse struct { ID int `json:"id"` Paths []string `json:"paths"` Type string `json:"type"` Name string `json:"name"` Enabled bool `json:"enabled"` MetadataLanguage string `json:"metadata_language"` AutoTranslateMetadata bool `json:"auto_translate_metadata"` ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled"` ChapterThumbnailsSupported bool `json:"chapter_thumbnails_supported"` IntroDetectionEnabled bool `json:"intro_detection_enabled"` TrailerKinds []string `json:"trailer_kinds"` SortOrder int `json:"sort_order"` PosterURL string `json:"poster_url,omitempty"` LastScannedAt *time.Time `json:"last_scanned_at,omitempty"` ScanWarningCode *string `json:"scan_warning_code,omitempty"` ScanWarningMessage *string `json:"scan_warning_message,omitempty"` ScanWarningAt *time.Time `json:"scan_warning_at,omitempty"` } type libraryMountCheckRootResponse struct { Path string `json:"path"` Reachable bool `json:"reachable"` ErrorCode *string `json:"error_code"` ErrorMessage *string `json:"error_message"` // SuspectEmpty is set when the root is reachable but the library holds // only missing-marked files under it — the signature of a lost mount // exposing an empty mountpoint directory, which a reachability probe // alone cannot detect. Additive field; absent/false for healthy roots. SuspectEmpty bool `json:"suspect_empty"` } type libraryMountCheckResponse struct { Status string `json:"status"` LibraryID int `json:"library_id"` LibraryName string `json:"library_name"` Healthy bool `json:"healthy"` CheckedAt time.Time `json:"checked_at"` Summary string `json:"summary"` Roots []libraryMountCheckRootResponse `json:"roots"` } type librarySkippedRootResponse struct { LibraryID int `json:"library_id"` LibraryName string `json:"library_name"` RootPath string `json:"root_path"` Reason string `json:"reason"` SampleFilePath string `json:"sample_file_path"` FileCount int `json:"file_count"` FirstSeenAt time.Time `json:"first_seen_at"` LastSeenAt time.Time `json:"last_seen_at"` } type staleMediaIDResponse struct { ContentID string `json:"content_id"` LibraryID int `json:"library_id"` LibraryName string `json:"library_name"` Title string `json:"title"` Year int `json:"year"` ContentType string `json:"content_type"` Provider string `json:"provider"` ProviderID string `json:"provider_id"` FirstSeenAt string `json:"first_seen_at"` LastSeenAt string `json:"last_seen_at"` } type libraryRootResponse struct { LibraryID int `json:"library_id"` LibraryName string `json:"library_name"` RootPath string `json:"root_path"` State string `json:"state"` InferredType string `json:"inferred_type"` TypeConfidence string `json:"type_confidence"` Title string `json:"title"` Year int `json:"year"` TmdbID string `json:"tmdb_id,omitempty"` ImdbID string `json:"imdb_id,omitempty"` TvdbID string `json:"tvdb_id,omitempty"` ObservedFiles int `json:"observed_file_count"` SampleFilePath string `json:"sample_file_path,omitempty"` Evidence json.RawMessage `json:"evidence_json,omitempty"` OverrideSource string `json:"override_source,omitempty"` FirstSeenAt time.Time `json:"first_seen_at"` LastSeenAt time.Time `json:"last_seen_at"` ActiveOverride *rootOverride `json:"active_override,omitempty"` // ContentID is the catalog item this group matched to, when known — it // lets the admin UI jump from an ambiguous root to the item's split flow. ContentID string `json:"content_id,omitempty"` } type rootOverride struct { ForcedType string `json:"forced_type,omitempty"` ForcedTitle string `json:"forced_title,omitempty"` ForcedYear int `json:"forced_year,omitempty"` ForcedTmdbID string `json:"forced_tmdb_id,omitempty"` ForcedImdbID string `json:"forced_imdb_id,omitempty"` ForcedTvdbID string `json:"forced_tvdb_id,omitempty"` Note string `json:"note,omitempty"` } type libraryRootsListResponse struct { Items []libraryRootResponse `json:"items"` Total int `json:"total"` } type rootOverrideUpsertRequest struct { LibraryID int `json:"library_id"` RootPath string `json:"root_path"` ForcedType string `json:"forced_type,omitempty"` ForcedTitle string `json:"forced_title,omitempty"` ForcedYear int `json:"forced_year,omitempty"` ForcedTmdbID string `json:"forced_tmdb_id,omitempty"` ForcedImdbID string `json:"forced_imdb_id,omitempty"` ForcedTvdbID string `json:"forced_tvdb_id,omitempty"` Note string `json:"note,omitempty"` } type rootOverrideDeleteRequest struct { LibraryID int `json:"library_id"` RootPath string `json:"root_path"` } func groupOverrideLookupKey(groupKeyVersion int, contentGroupKey string) string { return strconv.Itoa(groupKeyVersion) + "|" + contentGroupKey } // toLibraryResponse converts a MediaFolder model to a libraryResponse. func toLibraryResponse(f *models.MediaFolder) libraryResponse { paths := f.Paths if paths == nil { paths = []string{} } trailerKinds := f.TrailerKinds if trailerKinds == nil { trailerKinds = []string{} } return libraryResponse{ ID: f.ID, Paths: paths, Type: f.Type, Name: f.Name, Enabled: f.Enabled, MetadataLanguage: f.MetadataLanguage, AutoTranslateMetadata: f.AutoTranslateMetadata, ChapterThumbnailsEnabled: f.ChapterThumbnailsEnabled, ChapterThumbnailsSupported: false, IntroDetectionEnabled: f.IntroDetectionEnabled, TrailerKinds: trailerKinds, SortOrder: f.SortOrder, LastScannedAt: f.LastScannedAt, ScanWarningCode: f.ScanWarningCode, ScanWarningMessage: f.ScanWarningMessage, ScanWarningAt: f.ScanWarningAt, } } // toLibraryResponseWithPoster converts a MediaFolder model to a libraryResponse // and presigns the poster URL if a poster path is set. func (h *LibraryHandler) toLibraryResponseWithPoster(ctx context.Context, f *models.MediaFolder) libraryResponse { resp := toLibraryResponse(f) resp.ChapterThumbnailsSupported = h.S3Meta != nil if f.PosterPath != "" && h.S3Meta != nil { ttl := h.PresignTTL if ttl <= 0 { ttl = 4 * time.Hour } url, err := h.S3Meta.PresignGetURL(ctx, h.S3Meta.Bucket(), f.PosterPath, ttl) if err == nil { resp.PosterURL = url } } return resp } // userLibraryResponse is a simplified library view for non-admin users. type userLibraryResponse struct { ID int `json:"id"` Name string `json:"name"` Type string `json:"type"` SortOrder int `json:"sort_order"` PosterURL string `json:"poster_url,omitempty"` } // --- Handler methods --- // HandleListUserLibraries handles GET /user/libraries. // It returns only enabled libraries the current user has access to, with // simplified fields (no paths, last scan metadata, etc.). func (h *LibraryHandler) HandleListUserLibraries(w http.ResponseWriter, r *http.Request) { var folders []*models.MediaFolder var err error if scope, ok := access.GetScope(r.Context()); ok { if scope.LibrariesRestricted { folders, err = h.folderRepo.ListByIDs(r.Context(), scope.AllowedLibraryIDs) } else { folders, err = h.folderRepo.GetEnabled(r.Context()) } } else { userID := apimw.GetUserID(r.Context()) if h.userRepo != nil { user, userErr := h.userRepo.GetByID(r.Context(), userID) if userErr != nil { slog.ErrorContext(r.Context(), "looking up user for library access", "component", "api", "error", userErr) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to look up user") return } if user.LibraryIDs != nil { folders, err = h.folderRepo.ListByIDs(r.Context(), user.LibraryIDs) } else { folders, err = h.folderRepo.GetEnabled(r.Context()) } } else { folders, err = h.folderRepo.GetEnabled(r.Context()) } } if err != nil { slog.ErrorContext(r.Context(), "listing user libraries", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries") return } resp := make([]userLibraryResponse, 0, len(folders)) for _, f := range folders { entry := userLibraryResponse{ ID: f.ID, Name: f.Name, Type: f.Type, SortOrder: f.SortOrder, } if f.PosterPath != "" && h.S3Meta != nil { ttl := h.PresignTTL if ttl <= 0 { ttl = 4 * time.Hour } if url, err := h.S3Meta.PresignGetURL(r.Context(), h.S3Meta.Bucket(), f.PosterPath, ttl); err == nil { entry.PosterURL = url } } resp = append(resp, entry) } writeJSON(w, http.StatusOK, resp) } // HandleListLibraries handles GET /libraries. func (h *LibraryHandler) HandleListLibraries(w http.ResponseWriter, r *http.Request) { folders, err := h.folderRepo.List(r.Context()) if err != nil { slog.ErrorContext(r.Context(), "listing libraries", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries") return } resp := make([]libraryResponse, 0, len(folders)) for _, f := range folders { resp = append(resp, h.toLibraryResponseWithPoster(r.Context(), f)) } writeJSON(w, http.StatusOK, resp) } // reorderLibrariesRequest is the JSON body for PUT /libraries/reorder. type reorderLibrariesRequest struct { Entries []catalog.FolderReorderEntry `json:"entries"` } // HandleReorderLibraries handles PUT /libraries/reorder. func (h *LibraryHandler) HandleReorderLibraries(w http.ResponseWriter, r *http.Request) { var req reorderLibrariesRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } if err := h.folderRepo.Reorder(r.Context(), req.Entries); err != nil { slog.ErrorContext(r.Context(), "reordering libraries", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reorder libraries") return } w.WriteHeader(http.StatusNoContent) } // HandleListSkippedRoots handles GET /libraries/skipped-roots. func (h *LibraryHandler) HandleListSkippedRoots(w http.ResponseWriter, r *http.Request) { if h.SkippedRootRepo == nil { writeJSON(w, http.StatusOK, []librarySkippedRootResponse{}) return } folders, err := h.folderRepo.List(r.Context()) if err != nil { slog.ErrorContext(r.Context(), "listing libraries for skipped roots", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list libraries") return } folderNames := make(map[int]string, len(folders)) for _, folder := range folders { folderNames[folder.ID] = folder.Name } roots, err := h.SkippedRootRepo.ListAll(r.Context()) if err != nil { slog.ErrorContext(r.Context(), "listing skipped roots", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list skipped roots") return } resp := make([]librarySkippedRootResponse, 0, len(roots)) for _, root := range roots { resp = append(resp, librarySkippedRootResponse{ LibraryID: root.MediaFolderID, LibraryName: folderNames[root.MediaFolderID], RootPath: root.RootPath, Reason: root.Reason, SampleFilePath: root.SampleFilePath, FileCount: root.FileCount, FirstSeenAt: root.FirstSeenAt, LastSeenAt: root.LastSeenAt, }) } writeJSON(w, http.StatusOK, resp) } // HandleCreateLibrary handles POST /libraries. func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Request) { var req createLibraryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } if len(req.Paths) == 0 || req.Type == "" || req.Name == "" { writeError(w, http.StatusBadRequest, "bad_request", "Paths, type, and name are required") return } if req.MetadataLanguage != "" && !validMetadataLanguages[req.MetadataLanguage] { writeError(w, http.StatusBadRequest, "bad_request", "Invalid metadata_language; must be a valid ISO 639-1 code") return } if req.ChapterThumbnailsEnabled && h.S3Meta == nil { writeError(w, http.StatusBadRequest, "bad_request", "Chapter thumbnails require configured public asset S3 storage") return } folder, err := h.folderRepo.Create(r.Context(), catalog.CreateFolderInput{ Paths: req.Paths, Type: req.Type, Name: req.Name, MetadataLanguage: req.MetadataLanguage, ChapterThumbnailsEnabled: req.ChapterThumbnailsEnabled, IntroDetectionEnabled: req.IntroDetectionEnabled, TrailerKinds: req.TrailerKinds, }) if err != nil { if errors.Is(err, catalog.ErrDuplicatePath) { writeError(w, http.StatusConflict, "conflict", "A library with this path already exists") return } slog.ErrorContext(r.Context(), "creating library", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create library") return } // Seed default sections for the new library. if h.SectionRepo != nil { if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil { slog.WarnContext(r.Context(), "seed default sections for new library", "component", "api", "library_id", folder.ID, "error", seedErr) } if sections.IsAudiobookLibraryType(folder.Type) { if _, seedErr := h.SectionRepo.EnsureHomeContinueListeningSection(r.Context()); seedErr != nil { slog.WarnContext(r.Context(), "ensure home continue listening section", "component", "api", "library_id", folder.ID, "error", seedErr) } } if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil { slog.WarnContext(r.Context(), "seed generated home sections for new library", "component", "api", "library_id", folder.ID, "error", seedErr) } } // Seed default provider chain from plugin manifest defaults. if h.ChainRepo != nil { entries := h.seedDefaultChain(r.Context(), req.Type) if len(entries) > 0 { if seedErr := h.ChainRepo.SetChain(r.Context(), folder.ID, entries); seedErr != nil { slog.WarnContext(r.Context(), "seed default chain failed", "component", "api", "folder_id", folder.ID, "error", seedErr) } } } // Kick off an initial scan so content appears immediately. if h.ScanQueue != nil { if _, err := h.ScanQueue.EnqueueLibraryScan(r.Context(), folder.ID, "library_created"); err != nil { slog.WarnContext(r.Context(), "queue initial library scan failed", "component", "api", "library_id", folder.ID, "error", err) } } else { initialScanID := ulid.Make().String() h.recordAcceptedScan(initialScanID, &scantrigger.Target{ Folder: folder, Mode: scantrigger.ModeLibrary, Trigger: "library_created", }) h.runFolderScanAsync(initialScanID, folder, "library_created") } writeJSON(w, http.StatusCreated, h.toLibraryResponseWithPoster(r.Context(), folder)) } // HandleUpdateLibrary handles PUT /libraries/{id}. func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Request) { id, err := parseIDParam(r) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") return } var req updateLibraryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } if req.MetadataLanguage != nil && *req.MetadataLanguage != "" && !validMetadataLanguages[*req.MetadataLanguage] { writeError(w, http.StatusBadRequest, "bad_request", "Invalid metadata_language; must be a valid ISO 639-1 code") return } if req.ChapterThumbnailsEnabled != nil && *req.ChapterThumbnailsEnabled && h.S3Meta == nil { writeError(w, http.StatusBadRequest, "bad_request", "Chapter thumbnails require configured public asset S3 storage") return } // Fetch the folder before updating so we can detect path changes. oldFolder, err := h.folderRepo.GetByID(r.Context(), id) if err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } slog.ErrorContext(r.Context(), "fetching library for update", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } err = h.folderRepo.Update(r.Context(), id, catalog.UpdateFolderInput{ Paths: req.Paths, Type: req.Type, Name: req.Name, Enabled: req.Enabled, MetadataLanguage: req.MetadataLanguage, AutoTranslateMetadata: req.AutoTranslateMetadata, ChapterThumbnailsEnabled: req.ChapterThumbnailsEnabled, IntroDetectionEnabled: req.IntroDetectionEnabled, TrailerKinds: req.TrailerKinds, }) if err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } if errors.Is(err, catalog.ErrDuplicatePath) { writeError(w, http.StatusConflict, "conflict", "A library with this path already exists") return } slog.ErrorContext(r.Context(), "updating library", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update library") return } // Fetch the updated folder to return it. folder, err := h.folderRepo.GetByID(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "fetching updated library", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch updated library") return } if h.SectionRepo != nil && oldFolder.Name != folder.Name { if syncErr := h.SectionRepo.SyncGeneratedHomeLibraryRecentTitles(r.Context(), id, oldFolder.Name, folder.Name); syncErr != nil { slog.WarnContext(r.Context(), "sync generated home section titles", "component", "api", "library_id", id, "error", syncErr) } } // Re-fetch metadata when the library's metadata language changed, so // existing items adopt the new language instead of keeping the one // stamped at first match. Quick mode suffices: the refresh item lister // includes complete-but-language-mismatched items. languageChanged := !strings.EqualFold(strings.TrimSpace(oldFolder.MetadataLanguage), strings.TrimSpace(folder.MetadataLanguage)) if languageChanged { h.wakeMetadataMatcher(r.Context(), folder.ID) } if h.JobRepo != nil && languageChanged { job, jobErr := h.JobRepo.CreateLibraryRefresh(r.Context(), currentAdminUserID(r), adminjob.LibraryRefreshRequest{ LibraryID: folder.ID, LibraryName: folder.Name, Mode: adminjob.LibraryRefreshModeQuick, }, "Queued metadata refresh after library language change") if jobErr != nil { var conflict *adminjob.ActiveJobConflictError if !errors.As(jobErr, &conflict) { slog.WarnContext(r.Context(), "queue language-change metadata refresh failed", "component", "api", "library_id", folder.ID, "error", jobErr) } } else { publishEventJob(r.Context(), h.EventsHub, "job.created", job) } } // Rescan when paths have changed (folders added or removed). if req.Paths != nil && !slices.Equal(oldFolder.Paths, *req.Paths) { if h.ScanQueue != nil { if _, err := h.ScanQueue.EnqueueLibraryScan(r.Context(), folder.ID, "library_paths_changed"); err != nil { slog.WarnContext(r.Context(), "queue library path-change scan failed", "component", "api", "library_id", folder.ID, "error", err) } } else { updateScanID := ulid.Make().String() h.recordAcceptedScan(updateScanID, &scantrigger.Target{ Folder: folder, Mode: scantrigger.ModeLibrary, Trigger: "library_paths_changed", }) h.runFolderScanAsync(updateScanID, folder, "library_paths_changed") } } writeJSON(w, http.StatusOK, h.toLibraryResponseWithPoster(r.Context(), folder)) } // HandleDeleteLibrary handles DELETE /libraries/{id}. func (h *LibraryHandler) HandleDeleteLibrary(w http.ResponseWriter, r *http.Request) { id, err := parseIDParam(r) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") return } if h.JobRepo == nil { writeError(w, http.StatusServiceUnavailable, "unavailable", "Library delete jobs are not configured") return } folder, err := h.folderRepo.GetByID(r.Context(), id) if err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } slog.ErrorContext(r.Context(), "fetching library before delete", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load library") return } wasEnabled := folder.Enabled if wasEnabled { disabled := false if err := h.folderRepo.Update(r.Context(), folder.ID, catalog.UpdateFolderInput{Enabled: &disabled}); err != nil { slog.ErrorContext(r.Context(), "disabling library before delete", "component", "api", "library_id", folder.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare library deletion") return } folder.Enabled = false } job, err := h.JobRepo.Create(r.Context(), adminjob.CreateJobInput{ JobType: adminjob.JobTypeDeleteLibrary, CreatedByUserID: currentAdminUserID(r), RequestPayload: adminjob.DeleteLibraryRequest{ LibraryID: folder.ID, LibraryName: folder.Name, }, Message: "Queued library deletion", }) if err != nil { if wasEnabled { enabled := true if revertErr := h.folderRepo.Update(r.Context(), folder.ID, catalog.UpdateFolderInput{Enabled: &enabled}); revertErr != nil { slog.ErrorContext(r.Context(), "re-enabling library after failed delete queue", "component", "api", "library_id", folder.ID, "queue_error", err, "revert_error", revertErr, ) } } var conflict *adminjob.ActiveJobConflictError if errors.As(err, &conflict) { jobsHandler := NewAdminJobsHandler(nil, nil) writeAdminJobConflict(w, "A library deletion is already queued or running", conflict.Job, jobsHandler, r) return } slog.ErrorContext(r.Context(), "queuing library delete job", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue library delete") return } if h.ingester != nil { cancelled := h.ingester.CancelLibrary(folder.ID) slog.InfoContext(r.Context(), "library delete: cancelled running scans", "component", "api", "library_id", folder.ID, "cancelled", cancelled) } if h.ScanQueue != nil { queuedCancelled, err := h.ScanQueue.CancelAcceptedByLibrary(r.Context(), folder.ID) if err != nil { slog.WarnContext(r.Context(), "library delete: failed to cancel queued scans", "component", "api", "library_id", folder.ID, "error", err) } else if queuedCancelled > 0 { slog.InfoContext(r.Context(), "library delete: cancelled queued scans", "component", "api", "library_id", folder.ID, "cancelled", queuedCancelled) } } publishEventJob(r.Context(), h.EventsHub, "job.created", job) writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, nil)) } // HandleCheckLibraryMount handles POST /libraries/{id}/check-mount. // It verifies that each configured library root exists and can be listed. func (h *LibraryHandler) HandleCheckLibraryMount(w http.ResponseWriter, r *http.Request) { id, err := parseIDParam(r) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") return } folder, err := h.folderRepo.GetByID(r.Context(), id) if err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } slog.ErrorContext(r.Context(), "fetching library for mount check", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } resp := h.checkLibraryMount(r.Context(), folder) if resp.Healthy && folder.ScanWarningCode != nil && (*folder.ScanWarningCode == "empty_root" || *folder.ScanWarningCode == "dead_root") { if err := h.folderRepo.ClearScanWarning(r.Context(), folder.ID); err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } slog.ErrorContext(r.Context(), "clearing empty-root warning after successful mount check", "component", "api", "library_id", folder.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear library warning") return } } writeJSON(w, http.StatusOK, resp) } // HandleScan handles POST /scan. It accepts either a library_id, a path, or both // and dispatches to full-library, subtree, or single-file scanning. func (h *LibraryHandler) HandleScan(w http.ResponseWriter, r *http.Request) { var req scanRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } target, err := scantrigger.NewResolver(h.folderRepo).Resolve(r.Context(), scantrigger.Request{ LibraryID: req.LibraryID, Path: req.Path, }) if err != nil { var reqErr *scantrigger.RequestError if errors.As(err, &reqErr) { writeError(w, reqErr.Status, reqErr.Code, reqErr.Message) return } slog.ErrorContext(r.Context(), "resolving scan target", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve scan target") return } if h.ScanQueue != nil { if _, err := h.ScanQueue.EnqueueScan(r.Context(), target.Folder.ID, target.Mode, target.Path, target.Trigger); err != nil { slog.ErrorContext(r.Context(), "queueing library scan", "component", "api", "library_id", target.Folder.ID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue scan") return } } else if h.ingester != nil { scanID := ulid.Make().String() h.recordAcceptedScan(scanID, target) switch target.Mode { case scantrigger.ModeFile: h.runFileScanAsync(scanID, target.Folder, target.Path, target.Trigger) case scantrigger.ModeSubtree: h.runSubtreeScanAsync(scanID, target.Folder, target.Path, target.Trigger) default: h.runFolderScanAsync(scanID, target.Folder, target.Trigger) } } else { writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available") return } writeJSON(w, http.StatusAccepted, scanResponse{ Status: "accepted", Mode: target.Mode, LibraryID: target.Folder.ID, }) } // HandleScanCancel handles POST /scan/cancel. It cancels all running scans // for a given library. func (h *LibraryHandler) HandleScanCancel(w http.ResponseWriter, r *http.Request) { var req scanCancelRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } if req.LibraryID <= 0 { writeError(w, http.StatusBadRequest, "bad_request", "library_id is required") return } if h.ingester == nil && h.ScanQueue == nil { writeError(w, http.StatusServiceUnavailable, "unavailable", "Scanner not available") return } cancelled := 0 if h.ScanQueue != nil { queuedCancelled, err := h.ScanQueue.CancelByLibrary(r.Context(), req.LibraryID) if err != nil { slog.ErrorContext(r.Context(), "cancel library scans", "component", "api", "library_id", req.LibraryID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel scans") return } cancelled += queuedCancelled } if h.ingester != nil { cancelled += h.ingester.CancelLibrary(req.LibraryID) } for _, run := range h.cancelActiveScans(req.LibraryID) { h.publishScanEvent(r.Context(), "scan.cancelled", run) } slog.InfoContext(r.Context(), "scan: cancelled running scans", "component", "api", "library_id", req.LibraryID, "cancelled", cancelled, ) writeJSON(w, http.StatusOK, scanCancelResponse{ Cancelled: cancelled, LibraryID: req.LibraryID, }) } func (h *LibraryHandler) runFolderScanAsync(scanID string, folder *models.MediaFolder, trigger string) { go func() { h.markScanRunning(scanID) slog.Info("scan: starting library scan", "trigger", trigger, "library_id", folder.ID, "name", folder.Name, "paths", folder.Paths, ) start := time.Now() result, ingestErr := h.ingester.IngestFolder(h.appCtx, folder) if ingestErr != nil { if errors.Is(ingestErr, context.Canceled) { h.markScanCancelled(scanID) slog.Info("scan: library scan canceled", "trigger", trigger, "library_id", folder.ID, "elapsed", time.Since(start).Round(time.Millisecond), ) return } h.markScanFailed(scanID, ingestErr) slog.Error("scan: library ingest failed", "trigger", trigger, "library_id", folder.ID, "paths", folder.Paths, "error", ingestErr, "elapsed", time.Since(start).Round(time.Millisecond), ) return } h.markScanCompleted(scanID, result) slog.Info("scan: library ingest complete", "trigger", trigger, "library_id", folder.ID, "name", folder.Name, "new", scanMetric(result, func(r *scanner.ScanResult) int { return r.New }), "updated", scanMetric(result, func(r *scanner.ScanResult) int { return r.Updated }), "unchanged", scanMetric(result, func(r *scanner.ScanResult) int { return r.Unchanged }), "missing", scanMetric(result, func(r *scanner.ScanResult) int { return r.Missing }), "files_deleted", scanMetric(result, func(r *scanner.ScanResult) int { return r.FilesDeleted }), "memberships_removed", scanMetric(result, func(r *scanner.ScanResult) int { return r.MembershipsRemoved }), "items_deleted", scanMetric(result, func(r *scanner.ScanResult) int { return r.ItemsDeleted }), "empty_root_guarded", scanBoolMetric(result, func(r *scanner.ScanResult) bool { return r.EmptyRootGuarded }), "errors", scanMetric(result, func(r *scanner.ScanResult) int { return r.Errors }), "matched_files", result.MatchedFiles, "retried_items", result.RetriedItems, "still_unmatched_warnings", result.StillUnmatchedWarnings, "skipped", result.Skipped, "elapsed", time.Since(start).Round(time.Millisecond), ) }() } func (h *LibraryHandler) runSubtreeScanAsync(scanID string, folder *models.MediaFolder, subtreePath, trigger string) { go func() { h.markScanRunning(scanID) slog.Info("scan: starting subtree scan", "trigger", trigger, "library_id", folder.ID, "name", folder.Name, "path", subtreePath, ) start := time.Now() result, ingestErr := h.ingester.IngestSubtree(h.appCtx, folder, subtreePath) if ingestErr != nil { if errors.Is(ingestErr, context.Canceled) { h.markScanCancelled(scanID) slog.Info("scan: subtree scan canceled", "trigger", trigger, "library_id", folder.ID, "path", subtreePath, "elapsed", time.Since(start).Round(time.Millisecond), ) return } h.markScanFailed(scanID, ingestErr) slog.Error("scan: subtree ingest failed", "trigger", trigger, "library_id", folder.ID, "path", subtreePath, "error", ingestErr, "elapsed", time.Since(start).Round(time.Millisecond), ) return } h.markScanCompleted(scanID, result) slog.Info("scan: subtree ingest complete", "trigger", trigger, "library_id", folder.ID, "name", folder.Name, "path", subtreePath, "new", scanMetric(result, func(r *scanner.ScanResult) int { return r.New }), "updated", scanMetric(result, func(r *scanner.ScanResult) int { return r.Updated }), "unchanged", scanMetric(result, func(r *scanner.ScanResult) int { return r.Unchanged }), "missing", scanMetric(result, func(r *scanner.ScanResult) int { return r.Missing }), "files_deleted", scanMetric(result, func(r *scanner.ScanResult) int { return r.FilesDeleted }), "memberships_removed", scanMetric(result, func(r *scanner.ScanResult) int { return r.MembershipsRemoved }), "items_deleted", scanMetric(result, func(r *scanner.ScanResult) int { return r.ItemsDeleted }), "errors", scanMetric(result, func(r *scanner.ScanResult) int { return r.Errors }), "matched_files", result.MatchedFiles, "retried_items", result.RetriedItems, "still_unmatched_warnings", result.StillUnmatchedWarnings, "skipped", result.Skipped, "elapsed", time.Since(start).Round(time.Millisecond), ) }() } func (h *LibraryHandler) runFileScanAsync(scanID string, folder *models.MediaFolder, filePath, trigger string) { go func() { h.markScanRunning(scanID) slog.Info("scan: starting file scan", "trigger", trigger, "library_id", folder.ID, "name", folder.Name, "path", filePath, ) result, ingestErr := h.ingester.IngestFile(h.appCtx, folder, filePath) if ingestErr != nil { if errors.Is(ingestErr, context.Canceled) { h.markScanCancelled(scanID) slog.Info("scan: file scan canceled", "trigger", trigger, "library_id", folder.ID, "path", filePath, ) return } h.markScanFailed(scanID, ingestErr) slog.Error("scan: file ingest failed", "trigger", trigger, "library_id", folder.ID, "path", filePath, "error", ingestErr, ) return } h.markScanCompleted(scanID, result) slog.Info("scan: file ingest complete", "trigger", trigger, "library_id", folder.ID, "name", folder.Name, "path", filePath, "matched_files", result.MatchedFiles, "retried_items", result.RetriedItems, "still_unmatched_warnings", result.StillUnmatchedWarnings, "skipped", result.Skipped, ) }() } func (h *LibraryHandler) recordAcceptedScan(scanID string, target *scantrigger.Target) { if h == nil || h.ScanRegistry == nil || target == nil || target.Folder == nil { return } h.ScanRegistry.Upsert(evt.ScanRun{ ID: scanID, LibraryID: target.Folder.ID, Mode: target.Mode, Path: target.Path, Trigger: target.Trigger, Status: "accepted", }) if run, ok := h.ScanRegistry.Get(scanID); ok { h.publishScanEvent(context.Background(), "scan.accepted", run) } } func (h *LibraryHandler) markScanRunning(scanID string) { if h == nil || h.ScanRegistry == nil { return } run, ok := h.ScanRegistry.Get(scanID) if !ok { return } now := time.Now().UTC() run.Status = "running" run.StartedAt = &now h.ScanRegistry.Upsert(run) h.publishScanEvent(context.Background(), "scan.started", run) } func (h *LibraryHandler) markScanCompleted(scanID string, result *libraryingest.Result) { if h == nil || h.ScanRegistry == nil { return } run, ok := h.ScanRegistry.Get(scanID) if !ok { return } now := time.Now().UTC() run.Status = "completed" run.CompletedAt = &now run.Result = scanRunResultFromIngest(result) h.ScanRegistry.MarkTerminal(run) h.publishScanEvent(context.Background(), "scan.completed", run) } func (h *LibraryHandler) markScanFailed(scanID string, err error) { if h == nil || h.ScanRegistry == nil { return } run, ok := h.ScanRegistry.Get(scanID) if !ok { return } now := time.Now().UTC() run.Status = "failed" run.CompletedAt = &now if err != nil { run.ErrorMessage = err.Error() } h.ScanRegistry.MarkTerminal(run) h.publishScanEvent(context.Background(), "scan.failed", run) } func (h *LibraryHandler) markScanCancelled(scanID string) { if h == nil || h.ScanRegistry == nil { return } run, ok := h.ScanRegistry.Get(scanID) if !ok { return } now := time.Now().UTC() run.Status = "cancelled" run.CompletedAt = &now h.ScanRegistry.MarkTerminal(run) h.publishScanEvent(context.Background(), "scan.cancelled", run) } func (h *LibraryHandler) cancelActiveScans(libraryID int) []evt.ScanRun { if h == nil || h.ScanRegistry == nil { return nil } return h.ScanRegistry.CancelLibrary(libraryID, time.Now().UTC()) } func (h *LibraryHandler) publishScanEvent(ctx context.Context, eventName string, run evt.ScanRun) { if h == nil || h.EventsHub == nil { return } _ = h.EventsHub.PublishJSON(ctx, evt.ChannelScans, eventName, run, evt.PublishOptions{ AdminOnly: true, }) } func scanRunResultFromIngest(result *libraryingest.Result) *evt.ScanRunResult { if result == nil { return nil } resp := &evt.ScanRunResult{ MatchedFiles: result.MatchedFiles, RetriedItems: result.RetriedItems, StillUnmatchedWarnings: result.StillUnmatchedWarnings, } if result.Skipped { resp.Skipped = 1 } if result.ScanResult != nil { resp.New = result.ScanResult.New resp.Updated = result.ScanResult.Updated resp.Unchanged = result.ScanResult.Unchanged resp.Missing = result.ScanResult.Missing resp.MissingSkippedProtected = result.ScanResult.MissingSkippedProtected resp.FilesDeleted = result.ScanResult.FilesDeleted resp.MembershipsRemoved = result.ScanResult.MembershipsRemoved resp.ItemsDeleted = result.ScanResult.ItemsDeleted resp.Errors = result.ScanResult.Errors } return resp } func scanMetric(result *libraryingest.Result, pick func(*scanner.ScanResult) int) int { if result == nil || result.ScanResult == nil { return 0 } return pick(result.ScanResult) } func scanBoolMetric(result *libraryingest.Result, pick func(*scanner.ScanResult) bool) bool { if result == nil || result.ScanResult == nil { return false } return pick(result.ScanResult) } func (h *LibraryHandler) checkLibraryMount(ctx context.Context, folder *models.MediaFolder) libraryMountCheckResponse { resp := libraryMountCheckResponse{ Status: "ok", LibraryID: folder.ID, LibraryName: folder.Name, Healthy: true, CheckedAt: time.Now().UTC(), Roots: make([]libraryMountCheckRootResponse, 0, len(folder.Paths)), } if len(folder.Paths) == 0 { resp.Healthy = false resp.Summary = "Library has no configured roots" return resp } unreachable := 0 emptyPaths := make([]string, 0, len(folder.Paths)) probes := rootcheck.ProbeManyWithTimeout(ctx, folder.Paths, rootcheck.DefaultProbeTimeout) for i, path := range folder.Paths { probe := probes[i] root := libraryMountCheckRootResponse{ Path: path, Reachable: probe.Reachable, } if !probe.Reachable { root.ErrorCode = stringPtr(probe.ErrorCode) root.ErrorMessage = stringPtr(probe.ErrorMessage) unreachable++ resp.Healthy = false } else if probe.Empty { emptyPaths = append(emptyPaths, path) } resp.Roots = append(resp.Roots, root) } // A reachable but literally empty root can be a lost mount that left its // bare mountpoint behind. Cross-check against the catalog: an empty root // holding only missing-marked files is suspect, and reporting it healthy // would both mislead the operator and wrongly clear the dead_root // warning. Best-effort — probe results stand on a query error. suspect := 0 if h.pool != nil && len(emptyPaths) > 0 { suspectRoots, err := scanner.NewFileRepository(h.pool).ListRootsWithOnlyMissingFiles(ctx, folder.ID, emptyPaths) if err != nil { slog.WarnContext(ctx, "mount check: suspect-empty root query failed", "component", "api", "library_id", folder.ID, "error", err) } else if len(suspectRoots) > 0 { suspectSet := make(map[string]bool, len(suspectRoots)) for _, root := range suspectRoots { suspectSet[root] = true } for i := range resp.Roots { if resp.Roots[i].Reachable && suspectSet[resp.Roots[i].Path] { resp.Roots[i].SuspectEmpty = true suspect++ resp.Healthy = false } } } } switch { case unreachable == 0 && suspect == 0: resp.Summary = "All configured roots are reachable" case unreachable == 0: resp.Summary = fmt.Sprintf("%d of %d roots reachable but empty while the library still has cataloged files (lost mount?)", suspect, len(folder.Paths)) case suspect > 0: resp.Summary = fmt.Sprintf("%d of %d roots unreachable; %d more reachable but empty while the library still has cataloged files", unreachable, len(folder.Paths), suspect) case unreachable == 1: resp.Summary = fmt.Sprintf("1 of %d roots unreachable", len(folder.Paths)) default: resp.Summary = fmt.Sprintf("%d of %d roots unreachable", unreachable, len(folder.Paths)) } return resp } func stringPtr(v string) *string { return &v } func boolPtr(v bool) *bool { return &v } func (h *LibraryHandler) publishCatalogStatsInvalidation(eventType, payload string) { if h.EventBus == nil { return } if err := h.EventBus.Publish(h.appCtx, cache.ChannelCatalog, cache.Event{Type: eventType, Payload: payload}); err != nil { slog.Warn("scan: failed to publish catalog invalidation event", "type", eventType, "payload", payload, "error", err, ) } } type refreshLibraryMetadataRequest struct { Mode string `json:"mode"` } type libraryMetadataMatchQueueStatusResponse struct { LibraryID int `json:"library_id"` MovieCount int `json:"movie_count"` SeriesCount int `json:"series_count"` RawFileCount int `json:"raw_file_count"` TotalCount int `json:"total_count"` PendingCount int `json:"pending_count"` ParkedCount int `json:"parked_count"` } type libraryMetadataMatchQueueActionResponse struct { Status string `json:"status"` LibraryID int `json:"library_id"` MovieCancelled int `json:"movie_cancelled,omitempty"` SeriesCancelled int `json:"series_cancelled,omitempty"` RawFileCancelled int `json:"raw_file_cancelled,omitempty"` RawFileRetried int `json:"raw_file_retried,omitempty"` TotalCancelled int `json:"total_cancelled,omitempty"` Queue libraryMetadataMatchQueueStatusResponse `json:"queue"` } type libraryMetadataMatchQueueDetailResponse struct { libraryMetadataMatchQueueStatusResponse Limit int `json:"limit"` Offset int `json:"offset"` Movies []libraryMovieMatchQueueEntryResponse `json:"movies"` Series []librarySeriesMatchQueueEntryResponse `json:"series"` RawFiles []libraryRawMatchBacklogEntryResponse `json:"raw_files"` } type libraryMovieMatchQueueEntryResponse struct { MediaFileID int `json:"media_file_id"` MediaFolderID int `json:"media_folder_id"` FilePath string `json:"file_path"` FirstQueuedAt time.Time `json:"first_queued_at"` AvailableAt time.Time `json:"available_at"` LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"` AttemptCount int `json:"attempt_count"` LastError string `json:"last_error,omitempty"` State string `json:"state"` FailureKind string `json:"failure_kind,omitempty"` FailureDetail json.RawMessage `json:"failure_detail,omitempty"` DeterministicAttemptCount int `json:"deterministic_attempt_count"` MatcherRevision int `json:"matcher_revision"` ParkedAt *time.Time `json:"parked_at,omitempty"` UpdatedAt time.Time `json:"updated_at"` } type librarySeriesMatchQueueEntryResponse struct { MediaFolderID int `json:"media_folder_id"` ObservedRootPath string `json:"observed_root_path"` FirstQueuedAt time.Time `json:"first_queued_at"` AvailableAt time.Time `json:"available_at"` LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"` AttemptCount int `json:"attempt_count"` LastError string `json:"last_error,omitempty"` State string `json:"state"` FailureKind string `json:"failure_kind,omitempty"` FailureDetail json.RawMessage `json:"failure_detail,omitempty"` DeterministicAttemptCount int `json:"deterministic_attempt_count"` MatcherRevision int `json:"matcher_revision"` ParkedAt *time.Time `json:"parked_at,omitempty"` UpdatedAt time.Time `json:"updated_at"` } type libraryRawMatchBacklogEntryResponse struct { MediaFileID int `json:"media_file_id"` MediaFolderID int `json:"media_folder_id"` FilePath string `json:"file_path"` BaseTitle string `json:"base_title,omitempty"` BaseYear int `json:"base_year,omitempty"` BaseType string `json:"base_type,omitempty"` LastAttemptedAt *time.Time `json:"last_attempted_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } func (h *LibraryHandler) HandleListMetadataMatchQueues(w http.ResponseWriter, r *http.Request) { if h.folderRepo == nil { writeError(w, http.StatusServiceUnavailable, "unavailable", "Library repository is not configured") return } folders, err := h.folderRepo.List(r.Context()) if err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to list libraries", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queues") return } folderIDs := make([]int, 0, len(folders)) for _, folder := range folders { if folder != nil { folderIDs = append(folderIDs, folder.ID) } } statuses, err := h.metadataMatchQueueStatuses(r.Context(), folderIDs) if err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to load queue statuses", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queues") return } resp := make([]libraryMetadataMatchQueueStatusResponse, 0, len(folderIDs)) for _, folderID := range folderIDs { resp = append(resp, statuses[folderID]) } writeJSON(w, http.StatusOK, resp) } func (h *LibraryHandler) HandleGetMetadataMatchQueue(w http.ResponseWriter, r *http.Request) { if !h.metadataMatchBacklogConfigured() { writeError(w, http.StatusServiceUnavailable, "unavailable", "Metadata matcher backlog is not configured") return } id, err := parseIDParam(r) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") return } if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } limit := 10 if value := strings.TrimSpace(r.URL.Query().Get("limit")); value != "" { if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 && parsed <= 50 { limit = parsed } } offset := 0 if value := strings.TrimSpace(r.URL.Query().Get("offset")); value != "" { if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed >= 0 { offset = parsed } } status, err := h.metadataMatchQueueStatus(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to load queue status", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue") return } resp := libraryMetadataMatchQueueDetailResponse{ libraryMetadataMatchQueueStatusResponse: status, Limit: limit, Offset: offset, Movies: []libraryMovieMatchQueueEntryResponse{}, Series: []librarySeriesMatchQueueEntryResponse{}, RawFiles: []libraryRawMatchBacklogEntryResponse{}, } if h.MovieMatchQueueRepo != nil { movies, _, err := h.MovieMatchQueueRepo.ListByFolder(r.Context(), id, limit, offset) if err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to list movie queue", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue") return } for _, entry := range movies { resp.Movies = append(resp.Movies, libraryMovieMatchQueueEntryResponse{ MediaFileID: entry.MediaFileID, MediaFolderID: entry.MediaFolderID, FilePath: entry.FilePath, FirstQueuedAt: entry.FirstQueuedAt, AvailableAt: entry.AvailableAt, LastAttemptedAt: entry.LastAttemptedAt, AttemptCount: entry.AttemptCount, LastError: entry.LastError, State: entry.State, FailureKind: entry.FailureKind, FailureDetail: entry.FailureDetail, DeterministicAttemptCount: entry.DeterministicAttemptCount, MatcherRevision: entry.MatcherRevision, ParkedAt: entry.ParkedAt, UpdatedAt: entry.UpdatedAt, }) } } if h.SeriesMatchQueueRepo != nil { series, _, err := h.SeriesMatchQueueRepo.ListByFolder(r.Context(), id, limit, offset) if err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to list series queue", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher queue") return } for _, entry := range series { resp.Series = append(resp.Series, librarySeriesMatchQueueEntryResponse{ MediaFolderID: entry.MediaFolderID, ObservedRootPath: entry.ObservedRootPath, FirstQueuedAt: entry.FirstQueuedAt, AvailableAt: entry.AvailableAt, LastAttemptedAt: entry.LastAttemptedAt, AttemptCount: entry.AttemptCount, LastError: entry.LastError, State: entry.State, FailureKind: entry.FailureKind, FailureDetail: entry.FailureDetail, DeterministicAttemptCount: entry.DeterministicAttemptCount, MatcherRevision: entry.MatcherRevision, ParkedAt: entry.ParkedAt, UpdatedAt: entry.UpdatedAt, }) } } if h.RawMatchBacklogRepo != nil { rawFiles, _, err := h.RawMatchBacklogRepo.ListUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode(), limit, offset) if err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to list raw backlog", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list metadata matcher backlog") return } for _, file := range rawFiles { if file == nil { continue } resp.RawFiles = append(resp.RawFiles, libraryRawMatchBacklogEntryResponse{ MediaFileID: file.ID, MediaFolderID: file.MediaFolderID, FilePath: file.FilePath, BaseTitle: file.BaseTitle, BaseYear: file.BaseYear, BaseType: file.BaseType, LastAttemptedAt: file.MatchAttemptedAt, CreatedAt: file.CreatedAt, UpdatedAt: file.UpdatedAt, }) } } writeJSON(w, http.StatusOK, resp) } func (h *LibraryHandler) HandleRetryMetadataMatchQueue(w http.ResponseWriter, r *http.Request) { if !h.metadataMatchBacklogConfigured() { writeError(w, http.StatusServiceUnavailable, "unavailable", "Metadata matcher backlog is not configured") return } id, err := parseIDParam(r) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") return } if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } if h.SeriesMatchQueueRepo != nil { if err := h.SeriesMatchQueueRepo.SyncForFolder(r.Context(), id); err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to retry series queue", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher") return } if _, err := h.SeriesMatchQueueRepo.RetryNowByFolder(r.Context(), id); err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher") return } } if h.MovieMatchQueueRepo != nil { if err := h.MovieMatchQueueRepo.SyncForFolder(r.Context(), id); err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to retry movie queue", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher") return } if _, err := h.MovieMatchQueueRepo.RetryNowByFolder(r.Context(), id); err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher") return } } rawFileRetried := 0 if h.RawMatchBacklogRepo != nil { rawFileRetried, err = h.RawMatchBacklogRepo.RetryUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode()) if err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to retry raw backlog", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to retry metadata matcher") return } } status, err := h.metadataMatchQueueStatus(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to load retried queue status", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue") return } writeJSON(w, http.StatusOK, libraryMetadataMatchQueueActionResponse{ Status: "queued", LibraryID: id, RawFileRetried: rawFileRetried, Queue: status, }) } func (h *LibraryHandler) HandleCancelMetadataMatchQueue(w http.ResponseWriter, r *http.Request) { if !h.metadataMatchBacklogConfigured() { writeError(w, http.StatusServiceUnavailable, "unavailable", "Metadata matcher backlog is not configured") return } id, err := parseIDParam(r) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") return } if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } seriesCancelled := 0 if h.SeriesMatchQueueRepo != nil { seriesCancelled, err = h.SeriesMatchQueueRepo.DeleteByFolder(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to cancel series queue", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher") return } } movieCancelled := 0 if h.MovieMatchQueueRepo != nil { movieCancelled, err = h.MovieMatchQueueRepo.DeleteByFolder(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to cancel movie queue", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher") return } } rawFileCancelled := 0 if h.RawMatchBacklogRepo != nil { rawFileCancelled, err = h.RawMatchBacklogRepo.SuppressUnmatchedMatchBacklogByFolder(r.Context(), id, h.rawMatchBacklogMode()) if err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to suppress raw backlog", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel metadata matcher") return } } status, err := h.metadataMatchQueueStatus(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "metadata queue: failed to load cancelled queue status", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load metadata matcher queue") return } writeJSON(w, http.StatusOK, libraryMetadataMatchQueueActionResponse{ Status: "cancelled", LibraryID: id, MovieCancelled: movieCancelled, SeriesCancelled: seriesCancelled, RawFileCancelled: rawFileCancelled, TotalCancelled: movieCancelled + seriesCancelled + rawFileCancelled, Queue: status, }) } func (h *LibraryHandler) metadataMatchBacklogConfigured() bool { return h.folderRepo != nil && (h.MovieMatchQueueRepo != nil || h.SeriesMatchQueueRepo != nil || h.RawMatchBacklogRepo != nil) } func (h *LibraryHandler) metadataMatchQueueStatus(ctx context.Context, libraryID int) (libraryMetadataMatchQueueStatusResponse, error) { statuses, err := h.metadataMatchQueueStatuses(ctx, []int{libraryID}) if err != nil { return libraryMetadataMatchQueueStatusResponse{LibraryID: libraryID}, err } return statuses[libraryID], nil } func (h *LibraryHandler) metadataMatchQueueStatuses(ctx context.Context, libraryIDs []int) (map[int]libraryMetadataMatchQueueStatusResponse, error) { statuses := make(map[int]libraryMetadataMatchQueueStatusResponse, len(libraryIDs)) for _, libraryID := range libraryIDs { statuses[libraryID] = libraryMetadataMatchQueueStatusResponse{LibraryID: libraryID} } if h.MovieMatchQueueRepo != nil { counts, err := h.MovieMatchQueueRepo.CountStatesByFolders(ctx, libraryIDs) if err != nil { return nil, err } for libraryID, count := range counts { status := statuses[libraryID] status.MovieCount = count.Pending + count.Parked status.PendingCount += count.Pending status.ParkedCount += count.Parked statuses[libraryID] = status } } if h.SeriesMatchQueueRepo != nil { counts, err := h.SeriesMatchQueueRepo.CountStatesByFolders(ctx, libraryIDs) if err != nil { return nil, err } for libraryID, count := range counts { status := statuses[libraryID] status.SeriesCount = count.Pending + count.Parked status.PendingCount += count.Pending status.ParkedCount += count.Parked statuses[libraryID] = status } } if h.RawMatchBacklogRepo != nil { counts, err := h.RawMatchBacklogRepo.CountUnmatchedMatchBacklogByFolders(ctx, libraryIDs, h.rawMatchBacklogMode()) if err != nil { return nil, err } for libraryID, count := range counts { status := statuses[libraryID] status.RawFileCount = count status.PendingCount += count statuses[libraryID] = status } } for libraryID, status := range statuses { status.TotalCount = status.MovieCount + status.SeriesCount + status.RawFileCount statuses[libraryID] = status } return statuses, nil } func (h *LibraryHandler) rawMatchBacklogMode() scanner.RawMatchBacklogMode { if h.TVSeriesRootQueue && h.MovieMatchQueueRepo != nil { return scanner.RawMatchBacklogMixed } if h.TVSeriesRootQueue { return scanner.RawMatchBacklogNonSeries } return scanner.RawMatchBacklogGeneric } // HandleRefreshLibraryMetadata handles POST /libraries/{id}/refresh-metadata. // It queues a background admin job to refresh metadata for items in the // specified library. Quick mode is the default. func (h *LibraryHandler) HandleRefreshLibraryMetadata(w http.ResponseWriter, r *http.Request) { if h.JobRepo == nil { writeError(w, http.StatusServiceUnavailable, "unavailable", "Library refresh jobs are not configured") return } id, err := parseIDParam(r) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") return } mode := adminjob.LibraryRefreshModeQuick if r.Body != nil { var req refreshLibraryMetadataRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } if req.Mode != "" { switch adminjob.LibraryRefreshMode(req.Mode) { case adminjob.LibraryRefreshModeQuick, adminjob.LibraryRefreshModeFull: mode = adminjob.LibraryRefreshMode(req.Mode) default: writeError(w, http.StatusBadRequest, "bad_request", "Invalid refresh mode") return } } } folder, err := h.folderRepo.GetByID(r.Context(), id) if err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } job, err := h.JobRepo.CreateLibraryRefresh(r.Context(), currentAdminUserID(r), adminjob.LibraryRefreshRequest{ LibraryID: folder.ID, LibraryName: folder.Name, Mode: mode, }, fmt.Sprintf("Queued %s library metadata refresh", mode)) if err != nil { var conflict *adminjob.ActiveJobConflictError if errors.As(err, &conflict) { jobsHandler := NewAdminJobsHandler(nil, nil) writeAdminJobConflict(w, "A metadata refresh is already queued or running for this library", conflict.Job, jobsHandler, r) return } slog.ErrorContext(r.Context(), "library: queue library refresh failed", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to queue library metadata refresh") return } publishEventJob(r.Context(), h.EventsHub, "job.created", job) writeJSON(w, http.StatusAccepted, adminJobToResponse(r, job, nil)) } // HandleConfirmEmptyRootCleanup handles POST /libraries/{id}/confirm-empty-root-cleanup. // It arms the next empty-root scan for destructive cleanup. func (h *LibraryHandler) HandleConfirmEmptyRootCleanup(w http.ResponseWriter, r *http.Request) { id, err := parseIDParam(r) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") return } if err := h.folderRepo.AllowEmptyCleanupOnce(r.Context(), id); err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } writeError(w, http.StatusInternalServerError, "internal_error", "Failed to confirm cleanup") return } writeJSON(w, http.StatusOK, map[string]string{ "status": "ok", "message": "Empty-root cleanup confirmed for next scan", }) } // --- Library poster handlers --- // HandleUploadPoster handles PUT /libraries/{id}/poster. // Accepts a multipart form upload with a single "poster" file field. func (h *LibraryHandler) HandleUploadPoster(w http.ResponseWriter, r *http.Request) { if h.S3Meta == nil { writeError(w, http.StatusServiceUnavailable, "unavailable", "Image storage is not configured") return } id, err := parseIDParam(r) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") return } folder, err := h.folderRepo.GetByID(r.Context(), id) if err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } // Parse multipart form (max 10 MB). if err := r.ParseMultipartForm(10 << 20); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid multipart form") return } file, header, err := r.FormFile("poster") if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Missing poster file") return } defer file.Close() // Validate content type. ct := header.Header.Get("Content-Type") ext := posterExtension(ct) if ext == "" { writeError(w, http.StatusBadRequest, "bad_request", "Unsupported image type; use JPEG, PNG, or WebP") return } data, err := io.ReadAll(io.LimitReader(file, 10<<20+1)) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read upload") return } if len(data) > 10<<20 { writeError(w, http.StatusRequestEntityTooLarge, "too_large", "Poster must be under 10 MB") return } // Delete old poster if it exists and has a different key. s3Key := fmt.Sprintf("library-posters/%d%s", id, ext) if folder.PosterPath != "" && folder.PosterPath != s3Key { _ = h.S3Meta.DeleteObject(r.Context(), h.S3Meta.Bucket(), folder.PosterPath) } if err := h.S3Meta.PutObject(r.Context(), h.S3Meta.Bucket(), s3Key, data); err != nil { slog.ErrorContext(r.Context(), "uploading library poster", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to upload poster") return } if err := h.folderRepo.SetPosterPath(r.Context(), id, s3Key); err != nil { slog.ErrorContext(r.Context(), "saving library poster path", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save poster") return } folder.PosterPath = s3Key writeJSON(w, http.StatusOK, h.toLibraryResponseWithPoster(r.Context(), folder)) } // HandleDeletePoster handles DELETE /libraries/{id}/poster. func (h *LibraryHandler) HandleDeletePoster(w http.ResponseWriter, r *http.Request) { if h.S3Meta == nil { writeError(w, http.StatusServiceUnavailable, "unavailable", "Image storage is not configured") return } id, err := parseIDParam(r) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") return } folder, err := h.folderRepo.GetByID(r.Context(), id) if err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } if folder.PosterPath != "" { _ = h.S3Meta.DeleteObject(r.Context(), h.S3Meta.Bucket(), folder.PosterPath) if err := h.folderRepo.ClearPosterPath(r.Context(), id); err != nil { slog.ErrorContext(r.Context(), "clearing library poster path", "component", "api", "library_id", id, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear poster") return } } w.WriteHeader(http.StatusNoContent) } // posterExtension returns the file extension for a valid poster content type. func posterExtension(contentType string) string { switch contentType { case "image/jpeg": return ".jpg" case "image/png": return ".png" case "image/webp": return ".webp" default: return "" } } // --- Provider chain types --- // chainLevelEntry represents a single entry in a per-level provider chain response. type chainLevelEntry struct { PluginInstallationID int `json:"plugin_installation_id"` CapabilityID string `json:"capability_id"` ProviderSlug string `json:"provider_slug"` Priority int `json:"priority"` Enabled bool `json:"enabled"` } // setChainLevelRequest is the JSON body for PUT /libraries/{id}/providers. type setChainLevelRequest struct { Levels map[string][]chainEntryInput `json:"levels"` } // chainEntryInput is a single entry in a set-chain request. type chainEntryInput struct { PluginInstallationID int `json:"plugin_installation_id"` CapabilityID string `json:"capability_id"` Priority int `json:"priority"` Enabled bool `json:"enabled"` } // HandleGetLibraryProviders handles GET /libraries/{id}/providers. // It returns the provider chain for the given library grouped by content level. func (h *LibraryHandler) HandleGetLibraryProviders(w http.ResponseWriter, r *http.Request) { if h.ChainRepo == nil { writeError(w, http.StatusServiceUnavailable, "unavailable", "Provider chain management is not configured") return } id, err := parseIDParam(r) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") return } // Verify the library exists. if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } slog.ErrorContext(r.Context(), "fetching library for provider chain", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } entries, err := h.ChainRepo.GetAllChainEntries(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "getting provider chain", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get provider chain") return } // Group by content level — capability_id is the provider slug. levels := make(map[string][]chainLevelEntry) for _, e := range entries { levels[e.ContentLevel] = append(levels[e.ContentLevel], chainLevelEntry{ PluginInstallationID: e.PluginInstallationID, CapabilityID: e.CapabilityID, ProviderSlug: e.CapabilityID, Priority: e.Priority, Enabled: e.Enabled, }) } writeJSON(w, http.StatusOK, map[string]any{"levels": levels}) } // HandleGetLibraryProviderDefaults handles GET /libraries/provider-defaults. // It returns the provider chain that would be seeded for a new library of the // given type, grouped by content level and in seeded order. The admin UI // renders this while creating a library instead of re-deriving the chain from // plugin manifests client-side, so the displayed defaults and the chain the // server seeds on create can never disagree. func (h *LibraryHandler) HandleGetLibraryProviderDefaults(w http.ResponseWriter, r *http.Request) { libraryType := r.URL.Query().Get("library_type") levels := metadataContentLevelsForLibraryType(libraryType) if len(levels) == 0 { // A type the server doesn't seed chains for (unknown, or one like // podcasts with no metadata content levels) simply has no defaults. writeJSON(w, http.StatusOK, map[string]any{"levels": map[string][]chainLevelEntry{}}) return } if h.ChainRepo == nil { writeError(w, http.StatusServiceUnavailable, "unavailable", "Provider chain management is not configured") return } out := make(map[string][]chainLevelEntry, len(levels)) for _, level := range levels { out[level] = []chainLevelEntry{} } for _, e := range h.seedDefaultChain(r.Context(), libraryType) { out[e.ContentLevel] = append(out[e.ContentLevel], chainLevelEntry{ PluginInstallationID: e.PluginInstallationID, CapabilityID: e.CapabilityID, ProviderSlug: e.CapabilityID, Priority: e.Priority, Enabled: e.Enabled, }) } writeJSON(w, http.StatusOK, map[string]any{"levels": out}) } // HandleSetLibraryProviders handles PUT /libraries/{id}/providers. // It replaces the entire provider chain for the given library. func (h *LibraryHandler) HandleSetLibraryProviders(w http.ResponseWriter, r *http.Request) { if h.ChainRepo == nil { writeError(w, http.StatusServiceUnavailable, "unavailable", "Provider chain management is not configured") return } id, err := parseIDParam(r) if err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid library ID") return } // Verify the library exists. if _, err := h.folderRepo.GetByID(r.Context(), id); err != nil { if errors.Is(err, catalog.ErrFolderNotFound) { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } slog.ErrorContext(r.Context(), "fetching library for provider chain update", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch library") return } var req setChainLevelRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } var entries []metadata.ChainEntry for level, inputs := range req.Levels { for _, input := range inputs { entries = append(entries, metadata.ChainEntry{ PluginInstallationID: input.PluginInstallationID, CapabilityID: input.CapabilityID, CapabilityType: "metadata_provider.v1", ContentLevel: level, Priority: input.Priority, Enabled: input.Enabled, }) } } if err := h.ChainRepo.SetChain(r.Context(), id, entries); err != nil { slog.ErrorContext(r.Context(), "setting provider chain", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to set provider chain") return } if h.chainCacheInvalidator != nil { h.chainCacheInvalidator.InvalidateChainCache() } h.wakeMetadataMatcher(r.Context(), id) w.WriteHeader(http.StatusNoContent) } func (h *LibraryHandler) wakeMetadataMatcher(ctx context.Context, libraryID int) { if h.MovieMatchQueueRepo != nil { if _, err := h.MovieMatchQueueRepo.RetryNowByFolder(ctx, libraryID); err != nil { slog.WarnContext(ctx, "wake movie metadata matcher", "component", "api", "library_id", libraryID, "error", err) } } if h.SeriesMatchQueueRepo != nil { if _, err := h.SeriesMatchQueueRepo.RetryNowByFolder(ctx, libraryID); err != nil { slog.WarnContext(ctx, "wake series metadata matcher", "component", "api", "library_id", libraryID, "error", err) } } } // seedDefaultChain builds a default provider chain from plugin manifest defaults // for the given library type. Returns entries for all applicable content levels. func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType string) []metadata.ChainEntry { if h.ChainRepo == nil { return nil } caps, err := metadata.ListEnabledMetadataCapabilities(ctx, h.ChainRepo.Pool()) if err != nil { slog.WarnContext(ctx, "seed chain: failed to list metadata capabilities", "component", "api", "error", err) return nil } levels := metadataContentLevelsForLibraryType(libraryType) if len(levels) == 0 { return nil } var entries []metadata.ChainEntry for _, level := range levels { candidates := make([]seedCandidate, 0, len(caps)) for _, c := range caps { p := metadata.LookupSeedPlacement(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, c.CapabilityID, level) candidates = append(candidates, seedCandidate{ installationID: c.PluginInstallationID, capabilityID: c.CapabilityID, supportsLevel: p.SupportsLevel, declaredPriority: p.DefaultPriority, defaultEnabled: p.DefaultEnabled, }) } entries = append(entries, buildSeededChainEntries(level, candidates)...) } return entries } // seedCandidate is a metadata provider under consideration for a freshly seeded // chain at one content level, carrying the manifest-declared values that decide // its placement. type seedCandidate struct { installationID int capabilityID string supportsLevel bool // provider handles this content level (declared it, or is a legacy catch-all) declaredPriority int // manifest default_priority for this level; 0 = level not declared defaultEnabled bool // manifest default_enabled; false = specialist opts out of auto-enable } // buildSeededChainEntries orders the providers for one content level and assigns // positional priorities. Providers that do not handle this level are dropped // outright — a single-purpose provider (e.g. audiobook/ebook/manga metadata) // never clutters a library type it cannot serve. Of the remaining providers, one // that declares this level (declaredPriority>0) is placed by that priority and // seeded enabled unless it opted out via default_enabled; a legacy provider that // declares no levels at all is parked last and disabled. Keeping an opted-out // provider at its declared priority (rather than forcing it last) means that when // a user does enable it, it slots in where the manifest intends instead of // jumping to the top of the chain. func buildSeededChainEntries(level string, candidates []seedCandidate) []metadata.ChainEntry { ranked := make([]seedCandidate, 0, len(candidates)) for _, c := range candidates { if !c.supportsLevel { continue } if c.declaredPriority > 0 { ranked = append(ranked, c) } else { // Legacy provider that declares no levels — park last, disabled. ranked = append(ranked, seedCandidate{installationID: c.installationID, capabilityID: c.capabilityID, supportsLevel: true, declaredPriority: 999, defaultEnabled: false}) } } sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].declaredPriority < ranked[j].declaredPriority }) entries := make([]metadata.ChainEntry, len(ranked)) for i, c := range ranked { entries[i] = metadata.ChainEntry{ PluginInstallationID: c.installationID, CapabilityID: c.capabilityID, CapabilityType: "metadata_provider.v1", ContentLevel: level, Priority: i, Enabled: c.declaredPriority > 0 && c.defaultEnabled, } } return entries } func metadataContentLevelsForLibraryType(libraryType string) []string { return metadata.ContentLevelsForLibraryType(libraryType) } // HandleListStaleIDs handles GET /libraries/stale-ids. func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Request) { if h.StaleIDRepo == nil { writeJSON(w, http.StatusOK, []staleMediaIDResponse{}) return } staleIDs, err := h.StaleIDRepo.ListAll(r.Context()) if err != nil { slog.ErrorContext(r.Context(), "listing stale media IDs", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list stale IDs") return } if len(staleIDs) == 0 { writeJSON(w, http.StatusOK, []staleMediaIDResponse{}) return } // Collect unique content IDs for batch lookup. contentIDs := make([]string, 0, len(staleIDs)) seen := make(map[string]bool, len(staleIDs)) for _, s := range staleIDs { if !seen[s.ContentID] { contentIDs = append(contentIDs, s.ContentID) seen[s.ContentID] = true } } // Batch-load item metadata and library associations. type itemInfo struct { Title string Year int ContentType string Status string TmdbID string TvdbID string ImdbID string LibraryID int LibraryName string } items := make(map[string]itemInfo, len(contentIDs)) rows, err := h.pool.Query(r.Context(), ` SELECT mi.content_id, mi.title, mi.year, mi.type, COALESCE(mi.status, ''), COALESCE(mi.tmdb_id, ''), COALESCE(mi.tvdb_id, ''), COALESCE(mi.imdb_id, ''), COALESCE(mf_lib.folder_id, 0), COALESCE(mf_lib.folder_name, '') FROM media_items mi LEFT JOIN LATERAL ( SELECT mf2.media_folder_id AS folder_id, f.name AS folder_name FROM media_files mf2 JOIN media_folders f ON f.id = mf2.media_folder_id WHERE mf2.content_id = mi.content_id LIMIT 1 ) mf_lib ON true WHERE mi.content_id = ANY($1) `, contentIDs) if err != nil { slog.ErrorContext(r.Context(), "loading items for stale IDs", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item data") return } defer rows.Close() for rows.Next() { var cid, title, ctype, status, tmdbID, tvdbID, imdbID, libName string var year, libID int if err := rows.Scan(&cid, &title, &year, &ctype, &status, &tmdbID, &tvdbID, &imdbID, &libID, &libName); err != nil { slog.ErrorContext(r.Context(), "scanning item for stale IDs", "component", "api", "error", err) continue } items[cid] = itemInfo{ Title: title, Year: year, ContentType: ctype, Status: status, TmdbID: tmdbID, TvdbID: tvdbID, ImdbID: imdbID, LibraryID: libID, LibraryName: libName, } } resp := make([]staleMediaIDResponse, 0, len(staleIDs)) for _, s := range staleIDs { info := items[s.ContentID] if !metadata.IsActionableStaleProviderID(&models.MediaItem{ ContentID: s.ContentID, Status: info.Status, TmdbID: info.TmdbID, TvdbID: info.TvdbID, ImdbID: info.ImdbID, }, s) { continue } resp = append(resp, staleMediaIDResponse{ ContentID: s.ContentID, LibraryID: info.LibraryID, LibraryName: info.LibraryName, Title: info.Title, Year: info.Year, ContentType: info.ContentType, Provider: s.Provider, ProviderID: s.ProviderID, FirstSeenAt: s.FirstSeenAt.Format("2006-01-02T15:04:05Z"), LastSeenAt: s.LastSeenAt.Format("2006-01-02T15:04:05Z"), }) } writeJSON(w, http.StatusOK, resp) } // HandleRematchStaleID handles POST /libraries/stale-ids/{contentID}/rematch. // Deprecated: prefer the explicit admin match search/apply flow via // POST /admin/items/{id}/match/search and POST /admin/items/{id}/match/apply. func (h *LibraryHandler) HandleRematchStaleID(w http.ResponseWriter, r *http.Request) { contentID := chi.URLParam(r, "contentID") if contentID == "" { writeError(w, http.StatusBadRequest, "bad_request", "Missing content ID") return } // Clear the stale external IDs from the media_items row. // We clear all provider IDs so the re-match starts fresh from title/year. _, err := h.pool.Exec(r.Context(), ` UPDATE media_items SET tmdb_id = '', tvdb_id = '', imdb_id = '' WHERE content_id = $1 `, contentID) if err != nil { slog.ErrorContext(r.Context(), "clearing stale IDs from media item", "component", "api", "content_id", contentID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear IDs") return } // Remove stale_media_ids records. if h.StaleIDRepo != nil { if err := h.StaleIDRepo.DeleteByContentID(r.Context(), contentID); err != nil { slog.ErrorContext(r.Context(), "deleting stale media ID records", "component", "api", "content_id", contentID, "error", err) } } // Re-trigger metadata match. if h.refresher != nil { go func() { if err := h.refresher.RefreshItem(h.appCtx, contentID); err != nil { slog.WarnContext(r.Context(), "metadata: rematch refresh failed", "component", "api", "content_id", contentID, "error", err) } }() } w.WriteHeader(http.StatusNoContent) } func (h *LibraryHandler) HandleListRoots(w http.ResponseWriter, r *http.Request) { if h.ScannedGroupRepo == nil || h.folderRepo == nil { writeError(w, http.StatusInternalServerError, "internal_error", "Group snapshots not configured") return } q := r.URL.Query() libraryID, err := strconv.Atoi(q.Get("library_id")) if err != nil || libraryID <= 0 { writeError(w, http.StatusBadRequest, "bad_request", "library_id is required") return } limit := 100 if value := strings.TrimSpace(q.Get("limit")); value != "" { if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 && parsed <= 500 { limit = parsed } } offset := 0 if value := strings.TrimSpace(q.Get("offset")); value != "" { if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed >= 0 { offset = parsed } } state := strings.TrimSpace(q.Get("state")) folder, err := h.folderRepo.GetByID(r.Context(), libraryID) if err != nil { writeError(w, http.StatusNotFound, "not_found", "Library not found") return } groups, total, err := h.ScannedGroupRepo.ListByFolder(r.Context(), libraryID, state, limit, offset) if err != nil { slog.ErrorContext(r.Context(), "listing scanned groups", "component", "api", "library_id", libraryID, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list roots") return } overrideByGroup := map[string]models.MediaGroupOverride{} if h.GroupOverrideRepo != nil { overrides, err := h.GroupOverrideRepo.ListByFolder(r.Context(), libraryID) if err != nil { slog.WarnContext(r.Context(), "listing group overrides", "component", "api", "library_id", libraryID, "error", err) } else { for _, override := range overrides { overrideByGroup[groupOverrideLookupKey(override.GroupKeyVersion, override.ContentGroupKey)] = override } } } contentIDByGroup := map[string]string{} if h.pool != nil { claimRows, err := h.pool.Query(r.Context(), ` SELECT group_key_version, content_group_key, content_id FROM media_item_groups WHERE media_folder_id = $1 `, libraryID) if err != nil { slog.WarnContext(r.Context(), "listing group claims", "component", "api", "library_id", libraryID, "error", err) } else { defer claimRows.Close() for claimRows.Next() { var version int var groupKey, contentID string if err := claimRows.Scan(&version, &groupKey, &contentID); err != nil { slog.WarnContext(r.Context(), "scanning group claim", "component", "api", "library_id", libraryID, "error", err) break } contentIDByGroup[groupOverrideLookupKey(version, groupKey)] = contentID } } } items := make([]libraryRootResponse, 0, len(groups)) for _, group := range groups { rootPath := strings.TrimSpace(group.SampleObservedRootPath) if rootPath == "" { rootPath = filepath.Dir(group.SampleFilePath) } resp := libraryRootResponse{ LibraryID: libraryID, LibraryName: folder.Name, RootPath: rootPath, State: group.State, InferredType: group.InferredType, TypeConfidence: group.TypeConfidence, Title: group.BaseTitle, Year: group.BaseYear, TmdbID: group.TmdbID, ImdbID: group.ImdbID, TvdbID: group.TvdbID, ObservedFiles: group.ObservedFileCount, SampleFilePath: group.SampleFilePath, Evidence: append(json.RawMessage(nil), group.EvidenceJSON...), OverrideSource: group.OverrideSource, FirstSeenAt: group.FirstSeenAt, LastSeenAt: group.LastSeenAt, ContentID: contentIDByGroup[groupOverrideLookupKey(group.GroupKeyVersion, group.ContentGroupKey)], } if override, ok := overrideByGroup[groupOverrideLookupKey(group.GroupKeyVersion, group.ContentGroupKey)]; ok { resp.ActiveOverride = &rootOverride{ ForcedType: override.ForcedType, ForcedTitle: override.ForcedTitle, ForcedYear: override.ForcedYear, ForcedTmdbID: override.ForcedTmdbID, ForcedImdbID: override.ForcedImdbID, ForcedTvdbID: override.ForcedTvdbID, Note: override.Note, } } items = append(items, resp) } writeJSON(w, http.StatusOK, libraryRootsListResponse{Items: items, Total: total}) } func (h *LibraryHandler) HandleUpsertRootOverride(w http.ResponseWriter, r *http.Request) { if h.GroupOverrideRepo == nil || h.ObservedLocationRepo == nil { writeError(w, http.StatusInternalServerError, "internal_error", "Root overrides not configured") return } var req rootOverrideUpsertRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } req.RootPath = filepath.Clean(strings.TrimSpace(req.RootPath)) if req.LibraryID <= 0 || req.RootPath == "" { writeError(w, http.StatusBadRequest, "bad_request", "library_id and root_path are required") return } location, err := h.ObservedLocationRepo.Get(r.Context(), req.LibraryID, req.RootPath) if err != nil { slog.ErrorContext(r.Context(), "loading observed media location", "component", "api", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load root") return } if location == nil || location.PrimaryContentGroupKey == "" { if location != nil && location.ContentGroupCount > 1 { writeError(w, http.StatusConflict, "ambiguous_root", "Root contains files from multiple items; resolve it with the item split flow (POST /admin/items/{id}/split)") return } writeError(w, http.StatusNotFound, "not_found", "Root not found") return } userID := apimw.GetUserID(r.Context()) override := models.MediaGroupOverride{ MediaFolderID: req.LibraryID, GroupKeyVersion: location.PrimaryGroupKeyVersion, ContentGroupKey: location.PrimaryContentGroupKey, ForcedType: strings.TrimSpace(req.ForcedType), ForcedTitle: strings.TrimSpace(req.ForcedTitle), ForcedYear: req.ForcedYear, ForcedTmdbID: strings.TrimSpace(req.ForcedTmdbID), ForcedImdbID: strings.TrimSpace(req.ForcedImdbID), ForcedTvdbID: strings.TrimSpace(req.ForcedTvdbID), Note: strings.TrimSpace(req.Note), CreatedByUserID: nil, UpdatedByUserID: nil, } if userID > 0 { override.CreatedByUserID = &userID override.UpdatedByUserID = &userID } if err := h.GroupOverrideRepo.Upsert(r.Context(), override); err != nil { slog.ErrorContext(r.Context(), "upserting group override", "component", "api", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save override") return } w.WriteHeader(http.StatusNoContent) } func (h *LibraryHandler) HandleDeleteRootOverride(w http.ResponseWriter, r *http.Request) { if h.GroupOverrideRepo == nil || h.ObservedLocationRepo == nil { writeError(w, http.StatusInternalServerError, "internal_error", "Root overrides not configured") return } var req rootOverrideDeleteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } req.RootPath = filepath.Clean(strings.TrimSpace(req.RootPath)) if req.LibraryID <= 0 || req.RootPath == "" { writeError(w, http.StatusBadRequest, "bad_request", "library_id and root_path are required") return } location, err := h.ObservedLocationRepo.Get(r.Context(), req.LibraryID, req.RootPath) if err != nil { slog.ErrorContext(r.Context(), "loading observed media location", "component", "api", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load root") return } if location == nil || location.PrimaryContentGroupKey == "" { if location != nil && location.ContentGroupCount > 1 { writeError(w, http.StatusConflict, "ambiguous_root", "Root contains files from multiple items; manage its identity overrides via the item split flow instead") return } writeError(w, http.StatusNotFound, "not_found", "Root not found") return } if err := h.GroupOverrideRepo.Delete(r.Context(), req.LibraryID, location.PrimaryGroupKeyVersion, location.PrimaryContentGroupKey); err != nil { slog.ErrorContext(r.Context(), "deleting group override", "component", "api", "library_id", req.LibraryID, "root_path", req.RootPath, "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete override") return } w.WriteHeader(http.StatusNoContent) } // unmatchedItemResponse represents an item in the unmatched-items list. type unmatchedItemResponse struct { ContentID string `json:"content_id"` Title string `json:"title"` Year int `json:"year"` ContentType string `json:"content_type"` LibraryID int `json:"library_id"` LibraryName string `json:"library_name"` Status string `json:"status"` } type unmatchedItemsListResponse struct { Items []unmatchedItemResponse `json:"items"` Total int `json:"total"` } // HandleListUnmatchedItems handles GET /libraries/unmatched-items. // Returns items that are in unmatched, pending, or ambiguous status, enriched with // library context so the admin maintenance page can link to them. func (h *LibraryHandler) HandleListUnmatchedItems(w http.ResponseWriter, r *http.Request) { if h.pool == nil { writeError(w, http.StatusInternalServerError, "internal_error", "Database not configured") return } q := r.URL.Query() limit := 100 if v := q.Get("limit"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 { limit = n } } offset := 0 if v := q.Get("offset"); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { offset = n } } // Optional case-insensitive search across title, library name, type, and // status. Applied server-side so it spans the whole table, not just the // current page. The displayed folder still comes from the lateral join below, // but the search predicate checks every membership so multi-library items are // found when any linked library name matches. search := strings.TrimSpace(q.Get("q")) filter := "" filterArgs := []any{} if search != "" { filterArgs = append(filterArgs, "%"+search+"%") filter = ` AND ( mi.title ILIKE $1 OR mi.type ILIKE $1 OR mi.status ILIKE $1 OR EXISTS ( SELECT 1 FROM media_item_libraries search_mil JOIN media_folders search_f ON search_f.id = search_mil.media_folder_id WHERE search_mil.content_id = mi.content_id AND search_f.name ILIKE $1 ) )` } // Manga chapters carry their series' match state; the chapter rows // themselves stay 'pending' and are resolved through the manga series, // so they must not surface as actionable unmatched items here. mangaChapterGuard := ` AND ` + catalog.MangaChapterExclusionWhere("mi") countSQL := ` SELECT COUNT(*) FROM media_items mi WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')` + mangaChapterGuard countSQL += filter var total int if err := h.pool.QueryRow(r.Context(), countSQL, filterArgs...).Scan(&total); err != nil { slog.ErrorContext(r.Context(), "counting unmatched items", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count unmatched items") return } listArgs := append(append([]any{}, filterArgs...), limit, offset) listSQL := fmt.Sprintf(` SELECT mi.content_id, mi.title, mi.year, mi.type, mi.status, COALESCE(lib.folder_id, 0), COALESCE(lib.folder_name, '') FROM media_items mi LEFT JOIN LATERAL ( SELECT mil.media_folder_id AS folder_id, f.name AS folder_name FROM media_item_libraries mil JOIN media_folders f ON f.id = mil.media_folder_id WHERE mil.content_id = mi.content_id LIMIT 1 ) lib ON true WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')%s%s ORDER BY mi.title ASC, mi.content_id ASC LIMIT $%d OFFSET $%d `, mangaChapterGuard, filter, len(filterArgs)+1, len(filterArgs)+2) rows, err := h.pool.Query(r.Context(), listSQL, listArgs...) if err != nil { slog.ErrorContext(r.Context(), "listing unmatched items", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list unmatched items") return } defer rows.Close() items := make([]unmatchedItemResponse, 0) for rows.Next() { var item unmatchedItemResponse if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &item.ContentType, &item.Status, &item.LibraryID, &item.LibraryName); err != nil { slog.ErrorContext(r.Context(), "scanning unmatched item", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to scan item") return } items = append(items, item) } if err := rows.Err(); err != nil { slog.ErrorContext(r.Context(), "iterating unmatched items", "component", "api", "error", err) writeError(w, http.StatusInternalServerError, "internal_error", "Failed to iterate items") return } writeJSON(w, http.StatusOK, unmatchedItemsListResponse{ Items: items, Total: total, }) } // parseIDParam extracts and parses the "id" URL parameter as an integer. func parseIDParam(r *http.Request) (int, error) { idStr := chi.URLParam(r, "id") return strconv.Atoi(idStr) }