Files
silo-server/internal/api/handlers/libraries.go
99d205676f fix(metadata): prevent stale cross-provider IDs (#480)
* fix(metadata): prevent stale cross-provider IDs

* fix(metadata): address stale ID review findings

* fix(migrations): build the stale-ID primary key concurrently

ALTER TABLE ... ADD PRIMARY KEY builds the index under ACCESS EXCLUSIVE,
blocking reads and writes on stale_media_ids for the whole build. Create the
wider unique index with CREATE UNIQUE INDEX CONCURRENTLY and attach it with
ADD CONSTRAINT ... PRIMARY KEY USING INDEX instead; all three key columns are
already NOT NULL, so the attach is metadata-only. Same treatment on the
rollback path, plus the repo's INVALID-remnant cleanup so a failed concurrent
build is not silently accepted by IF NOT EXISTS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:19:33 -04:00

2807 lines
104 KiB
Go

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)
}