2026-05-22 20:26:11 -04:00
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"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/scanner"
2026-05-24 19:06:51 -04:00
"github.com/Silo-Server/silo-server/internal/scantrigger"
2026-05-22 20:26:11 -04:00
"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
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 )
}
// 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"`
}
// 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"`
ChapterThumbnailsEnabled * bool `json:"chapter_thumbnails_enabled,omitempty"`
IntroDetectionEnabled * bool `json:"intro_detection_enabled,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"`
ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled"`
ChapterThumbnailsSupported bool `json:"chapter_thumbnails_supported"`
IntroDetectionEnabled bool `json:"intro_detection_enabled"`
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"`
}
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"`
}
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 {}
}
return libraryResponse {
ID : f . ID ,
Paths : paths ,
Type : f . Type ,
Name : f . Name ,
Enabled : f . Enabled ,
MetadataLanguage : f . MetadataLanguage ,
ChapterThumbnailsEnabled : f . ChapterThumbnailsEnabled ,
ChapterThumbnailsSupported : false ,
IntroDetectionEnabled : f . IntroDetectionEnabled ,
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 . Error ( "looking up user for library access" , "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 . Error ( "listing user libraries" , "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 . Error ( "listing libraries" , "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 . Error ( "reordering libraries" , "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 . Error ( "listing libraries for skipped roots" , "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 . Error ( "listing skipped roots" , "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 ,
})
if err != nil {
if errors . Is ( err , catalog . ErrDuplicatePath ) {
writeError ( w , http . StatusConflict , "conflict" , "A library with this path already exists" )
return
}
slog . Error ( "creating library" , "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 . Warn ( "seed default sections for new library" , "library_id" , folder . ID , "error" , seedErr )
}
if _ , seedErr := h . SectionRepo . CreateGeneratedHomeLibraryRecentSections ( r . Context (), folder . ID , folder . Name ); seedErr != nil {
slog . Warn ( "seed generated home sections for new library" , "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 . Warn ( "seed default chain failed" , "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 . Warn ( "queue initial library scan failed" , "library_id" , folder . ID , "error" , err )
}
} else {
initialScanID := ulid . Make (). String ()
2026-05-24 19:06:51 -04:00
h . recordAcceptedScan ( initialScanID , & scantrigger . Target {
2026-05-25 12:11:56 -04:00
Folder : folder ,
Mode : scantrigger . ModeLibrary ,
Trigger : "library_created" ,
2026-05-22 20:26:11 -04:00
})
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 . Error ( "fetching library for update" , "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 ,
ChapterThumbnailsEnabled : req . ChapterThumbnailsEnabled ,
IntroDetectionEnabled : req . IntroDetectionEnabled ,
})
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 . Error ( "updating library" , "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 . Error ( "fetching updated library" , "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 . Warn ( "sync generated home section titles" , "library_id" , id , "error" , syncErr )
}
}
// 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 . Warn ( "queue library path-change scan failed" , "library_id" , folder . ID , "error" , err )
}
} else {
updateScanID := ulid . Make (). String ()
2026-05-24 19:06:51 -04:00
h . recordAcceptedScan ( updateScanID , & scantrigger . Target {
2026-05-25 12:11:56 -04:00
Folder : folder ,
Mode : scantrigger . ModeLibrary ,
Trigger : "library_paths_changed" ,
2026-05-22 20:26:11 -04:00
})
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 . Error ( "fetching library before delete" , "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 . Error ( "disabling library before delete" , "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 . Error ( "re-enabling library after failed delete queue" ,
"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 . Error ( "queuing library delete job" , "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 . Info ( "library delete: cancelled running scans" , "library_id" , folder . ID , "cancelled" , cancelled )
}
if h . ScanQueue != nil {
queuedCancelled , err := h . ScanQueue . CancelAcceptedByLibrary ( r . Context (), folder . ID )
if err != nil {
slog . Warn ( "library delete: failed to cancel queued scans" , "library_id" , folder . ID , "error" , err )
} else if queuedCancelled > 0 {
slog . Info ( "library delete: cancelled queued scans" , "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 . Error ( "fetching library for mount check" , "error" , err )
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to fetch library" )
return
}
resp := checkLibraryMount ( folder )
if resp . Healthy && folder . ScanWarningCode != nil && * folder . ScanWarningCode == "empty_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 . Error ( "clearing empty-root warning after successful mount check" , "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
}
2026-05-24 19:06:51 -04:00
target , err := scantrigger . NewResolver ( h . folderRepo ). Resolve ( r . Context (), scantrigger . Request {
LibraryID : req . LibraryID ,
Path : req . Path ,
})
2026-05-22 20:26:11 -04:00
if err != nil {
2026-05-24 19:06:51 -04:00
var reqErr * scantrigger . RequestError
2026-05-22 20:26:11 -04:00
if errors . As ( err , & reqErr ) {
2026-05-24 19:06:51 -04:00
writeError ( w , reqErr . Status , reqErr . Code , reqErr . Message )
2026-05-22 20:26:11 -04:00
return
}
slog . Error ( "resolving scan target" , "error" , err )
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to resolve scan target" )
return
}
if h . ScanQueue != nil {
2026-05-25 12:11:56 -04:00
if _ , err := h . ScanQueue . EnqueueScan ( r . Context (), target . Folder . ID , target . Mode , target . Path , target . Trigger ); err != nil {
slog . Error ( "queueing library scan" , "library_id" , target . Folder . ID , "error" , err )
2026-05-22 20:26:11 -04:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to queue scan" )
return
}
} else if h . ingester != nil {
scanID := ulid . Make (). String ()
h . recordAcceptedScan ( scanID , target )
2026-05-24 19:06:51 -04:00
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 )
2026-05-22 20:26:11 -04:00
default :
2026-05-24 19:06:51 -04:00
h . runFolderScanAsync ( scanID , target . Folder , target . Trigger )
2026-05-22 20:26:11 -04:00
}
} else {
writeError ( w , http . StatusServiceUnavailable , "unavailable" , "Scanner not available" )
return
}
writeJSON ( w , http . StatusAccepted , scanResponse {
Status : "accepted" ,
2026-05-24 19:06:51 -04:00
Mode : target . Mode ,
2026-05-25 12:11:56 -04:00
LibraryID : target . Folder . ID ,
2026-05-22 20:26:11 -04:00
})
}
// 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 . Error ( "cancel library scans" , "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 . Info ( "scan: cancelled running scans" ,
"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 ,
)
}()
}
2026-05-24 19:06:51 -04:00
func ( h * LibraryHandler ) recordAcceptedScan ( scanID string , target * scantrigger . Target ) {
if h == nil || h . ScanRegistry == nil || target == nil || target . Folder == nil {
2026-05-22 20:26:11 -04:00
return
}
h . ScanRegistry . Upsert ( evt . ScanRun {
ID : scanID ,
2026-05-25 12:11:56 -04:00
LibraryID : target . Folder . ID ,
2026-05-24 19:06:51 -04:00
Mode : target . Mode ,
Path : target . Path ,
Trigger : target . Trigger ,
2026-05-22 20:26:11 -04:00
Status : "accepted" ,
})
}
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 . 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 checkLibraryMount ( 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
for _ , path := range folder . Paths {
root := libraryMountCheckRootResponse {
Path : path ,
Reachable : true ,
}
info , err := os . Stat ( path )
if err != nil {
code , message := classifyMountCheckError ( err , false )
root . Reachable = false
root . ErrorCode = stringPtr ( code )
root . ErrorMessage = stringPtr ( message )
} else if ! info . IsDir () {
root . Reachable = false
root . ErrorCode = stringPtr ( "not_directory" )
root . ErrorMessage = stringPtr ( "Path is not a directory" )
} else if _ , err := os . ReadDir ( path ); err != nil {
code , message := classifyMountCheckError ( err , true )
root . Reachable = false
root . ErrorCode = stringPtr ( code )
root . ErrorMessage = stringPtr ( message )
}
if ! root . Reachable {
unreachable ++
resp . Healthy = false
}
resp . Roots = append ( resp . Roots , root )
}
switch unreachable {
case 0 :
resp . Summary = "All configured roots are reachable"
case 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 classifyMountCheckError ( err error , isRead bool ) ( string , string ) {
switch {
case errors . Is ( err , os . ErrNotExist ):
return "not_found" , "Path does not exist"
case errors . Is ( err , os . ErrPermission ):
return "permission_denied" , "Permission denied"
case isRead :
return "read_failed" , "Failed to read directory"
default :
return "stat_failed" , "Failed to stat path"
}
}
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"`
}
// 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 . Error ( "library: queue library refresh failed" , "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 . Error ( "uploading library poster" , "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 . Error ( "saving library poster path" , "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 . Error ( "clearing library poster path" , "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 . Error ( "fetching library for provider chain" , "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 . Error ( "getting provider chain" , "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 })
}
// 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 . Error ( "fetching library for provider chain update" , "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 . Error ( "setting provider chain" , "error" , err )
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to set provider chain" )
return
}
if h . chainCacheInvalidator != nil {
h . chainCacheInvalidator . InvalidateChainCache ()
}
w . WriteHeader ( http . StatusNoContent )
}
// 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 . Warn ( "seed chain: failed to list metadata capabilities" , "error" , err )
return nil
}
// Determine which content levels apply.
var levels [] string
switch libraryType {
case "series" :
levels = [] string { "series" , "season" , "episode" }
case "movies" , "movie" :
levels = [] string { "movie" }
case "mixed" :
levels = [] string { "movie" , "series" , "season" , "episode" }
default :
return nil
}
var entries [] metadata . ChainEntry
for _ , level := range levels {
type candidate struct {
installationID int
capabilityID string
priority int
enabled bool
}
var candidates [] candidate
for _ , c := range caps {
defaultPriority := metadata . LookupDefaultPriority ( ctx , h . ChainRepo . Pool (), c . PluginInstallationID , level )
if defaultPriority > 0 {
candidates = append ( candidates , candidate {
installationID : c . PluginInstallationID ,
capabilityID : c . CapabilityID ,
priority : defaultPriority ,
enabled : true ,
})
} else {
// Plugin doesn't declare this level — include but disabled.
candidates = append ( candidates , candidate {
installationID : c . PluginInstallationID ,
capabilityID : c . CapabilityID ,
priority : 999 ,
enabled : false ,
})
}
}
sort . Slice ( candidates , func ( i , j int ) bool {
return candidates [ i ]. priority < candidates [ j ]. priority
})
for i , cand := range candidates {
entries = append ( entries , metadata . ChainEntry {
PluginInstallationID : cand . installationID ,
CapabilityID : cand . capabilityID ,
CapabilityType : "metadata_provider.v1" ,
ContentLevel : level ,
Priority : i ,
Enabled : cand . enabled ,
})
}
}
return entries
}
// 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 . Error ( "listing stale media IDs" , "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
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(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 . Error ( "loading items for stale IDs" , "error" , err )
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to load item data" )
return
}
defer rows . Close ()
for rows . Next () {
var cid , title , ctype , libName string
var year , libID int
if err := rows . Scan ( & cid , & title , & year , & ctype , & libID , & libName ); err != nil {
slog . Error ( "scanning item for stale IDs" , "error" , err )
continue
}
items [ cid ] = itemInfo { Title : title , Year : year , ContentType : ctype , LibraryID : libID , LibraryName : libName }
}
resp := make ([] staleMediaIDResponse , 0 , len ( staleIDs ))
for _ , s := range staleIDs {
info := items [ s . ContentID ]
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 . Error ( "clearing stale IDs from media item" , "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 . Error ( "deleting stale media ID records" , "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 . Warn ( "metadata: rematch refresh failed" , "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 . Error ( "listing scanned groups" , "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 . Warn ( "listing group overrides" , "library_id" , libraryID , "error" , err )
} else {
for _ , override := range overrides {
overrideByGroup [ groupOverrideLookupKey ( override . GroupKeyVersion , override . ContentGroupKey )] = override
}
}
}
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 ,
}
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 . Error ( "loading observed media location" , "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 multiple logical groups; override the group after splitting or selecting a specific item" )
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 . Error ( "upserting group override" , "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 . Error ( "loading observed media location" , "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 multiple logical groups; delete the override from a specific group 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 . Error ( "deleting group override" , "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
}
}
var total int
if err := h . pool . QueryRow ( r . Context (),
`SELECT COUNT(*) FROM media_items WHERE status IN ('unmatched', 'pending', 'ambiguous')` ,
). Scan ( & total ); err != nil {
slog . Error ( "counting unmatched items" , "error" , err )
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to count unmatched items" )
return
}
rows , err := h . pool . Query ( r . Context (), `
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')
ORDER BY mi.title ASC, mi.content_id ASC
LIMIT $1 OFFSET $2
` , limit , offset )
if err != nil {
slog . Error ( "listing unmatched items" , "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 . Error ( "scanning unmatched item" , "error" , err )
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to scan item" )
return
}
items = append ( items , item )
}
if err := rows . Err (); err != nil {
slog . Error ( "iterating unmatched items" , "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 )
}