2026-05-22 20:26:11 -04:00
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"
2026-07-16 19:58:44 +02:00
"github.com/Silo-Server/silo-server/internal/rootcheck"
2026-05-22 20:26:11 -04:00
"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
2026-06-05 19:43:20 -07:00
MovieMatchQueueRepo libraryMovieMatchQueue
SeriesMatchQueueRepo librarySeriesMatchQueue
RawMatchBacklogRepo libraryRawMatchBacklog
TVSeriesRootQueue bool
2026-05-22 20:26:11 -04:00
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 )
}
2026-06-05 19:43:20 -07:00
type libraryMovieMatchQueue interface {
SyncForFolder ( ctx context . Context , folderID int ) error
DeleteByFolder ( ctx context . Context , folderID int ) ( int , error )
2026-07-24 18:18:49 +02:00
CountStatesByFolder ( ctx context . Context , folderID int ) ( pending int , parked int , err error )
CountStatesByFolders ( ctx context . Context , folderIDs [] int ) ( map [ int ] metadata . MatchQueueStateCounts , error )
2026-06-05 19:43:20 -07:00
ListByFolder ( ctx context . Context , folderID int , limit int , offset int ) ([] models . MovieMatchQueueEntry , int , error )
2026-07-24 18:18:49 +02:00
RetryNowByFolder ( ctx context . Context , folderID int ) ( int , error )
2026-06-05 19:43:20 -07:00
}
type librarySeriesMatchQueue interface {
SyncForFolder ( ctx context . Context , folderID int ) error
DeleteByFolder ( ctx context . Context , folderID int ) ( int , error )
2026-07-24 18:18:49 +02:00
CountStatesByFolder ( ctx context . Context , folderID int ) ( pending int , parked int , err error )
CountStatesByFolders ( ctx context . Context , folderIDs [] int ) ( map [ int ] metadata . MatchQueueStateCounts , error )
2026-06-05 19:43:20 -07:00
ListByFolder ( ctx context . Context , folderID int , limit int , offset int ) ([] models . SeriesRootMatchQueueEntry , int , error )
2026-07-24 18:18:49 +02:00
RetryNowByFolder ( ctx context . Context , folderID int ) ( int , error )
2026-06-05 19:43:20 -07:00
}
type libraryRawMatchBacklog interface {
CountUnmatchedMatchBacklogByFolder ( ctx context . Context , folderID int , mode scanner . RawMatchBacklogMode ) ( int , error )
2026-07-24 18:18:49 +02:00
CountUnmatchedMatchBacklogByFolders ( ctx context . Context , folderIDs [] int , mode scanner . RawMatchBacklogMode ) ( map [ int ] int , error )
2026-06-05 19:43:20 -07:00
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 )
}
2026-05-22 20:26:11 -04:00
// 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"`
2026-07-06 18:52:44 -04:00
// TrailerKinds is the allow-list of remote video kinds fetched during
// metadata refresh; omitted = default (all provider kinds).
TrailerKinds [] string `json:"trailer_kinds,omitempty"`
2026-05-22 20:26:11 -04:00
}
// 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"`
2026-06-10 14:58:54 -04:00
AutoTranslateMetadata * bool `json:"auto_translate_metadata,omitempty"`
2026-05-22 20:26:11 -04:00
ChapterThumbnailsEnabled * bool `json:"chapter_thumbnails_enabled,omitempty"`
IntroDetectionEnabled * bool `json:"intro_detection_enabled,omitempty"`
2026-07-06 18:52:44 -04:00
// 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"`
2026-05-22 20:26:11 -04:00
}
// 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"`
2026-06-10 14:58:54 -04:00
AutoTranslateMetadata bool `json:"auto_translate_metadata"`
2026-05-22 20:26:11 -04:00
ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled"`
ChapterThumbnailsSupported bool `json:"chapter_thumbnails_supported"`
IntroDetectionEnabled bool `json:"intro_detection_enabled"`
2026-07-06 18:52:44 -04:00
TrailerKinds [] string `json:"trailer_kinds"`
2026-05-22 20:26:11 -04:00
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"`
2026-07-16 19:58:44 +02:00
// 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"`
2026-05-22 20:26:11 -04:00
}
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"`
2026-07-06 12:23:32 -04:00
// 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"`
2026-05-22 20:26:11 -04:00
}
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 {}
}
2026-07-06 18:52:44 -04:00
trailerKinds := f . TrailerKinds
if trailerKinds == nil {
trailerKinds = [] string {}
}
2026-05-22 20:26:11 -04:00
return libraryResponse {
ID : f . ID ,
Paths : paths ,
Type : f . Type ,
Name : f . Name ,
Enabled : f . Enabled ,
MetadataLanguage : f . MetadataLanguage ,
2026-06-10 14:58:54 -04:00
AutoTranslateMetadata : f . AutoTranslateMetadata ,
2026-05-22 20:26:11 -04:00
ChapterThumbnailsEnabled : f . ChapterThumbnailsEnabled ,
ChapterThumbnailsSupported : false ,
IntroDetectionEnabled : f . IntroDetectionEnabled ,
2026-07-06 18:52:44 -04:00
TrailerKinds : trailerKinds ,
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "looking up user for library access" , "component" , "api" , "error" , userErr )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "listing user libraries" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "listing libraries" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "reordering libraries" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "listing libraries for skipped roots" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "listing skipped roots" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 ,
2026-07-06 18:52:44 -04:00
TrailerKinds : req . TrailerKinds ,
2026-05-22 20:26:11 -04:00
})
if err != nil {
if errors . Is ( err , catalog . ErrDuplicatePath ) {
writeError ( w , http . StatusConflict , "conflict" , "A library with this path already exists" )
return
}
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "creating library" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( r . Context (), "seed default sections for new library" , "component" , "api" , "library_id" , folder . ID , "error" , seedErr )
2026-05-22 20:26:11 -04:00
}
2026-06-09 03:55:35 +02:00
if sections . IsAudiobookLibraryType ( folder . Type ) {
if _ , seedErr := h . SectionRepo . EnsureHomeContinueListeningSection ( r . Context ()); seedErr != nil {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( r . Context (), "ensure home continue listening section" , "component" , "api" , "library_id" , folder . ID , "error" , seedErr )
2026-06-09 03:55:35 +02:00
}
}
2026-05-27 15:48:00 -04:00
if _ , seedErr := h . SectionRepo . CreateGeneratedHomeLibraryRecentSections ( r . Context (), folder . ID , folder . Name , folder . Type ); seedErr != nil {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( r . Context (), "seed generated home sections for new library" , "component" , "api" , "library_id" , folder . ID , "error" , seedErr )
2026-05-22 20:26:11 -04:00
}
}
// 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 {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( r . Context (), "seed default chain failed" , "component" , "api" , "folder_id" , folder . ID , "error" , seedErr )
2026-05-22 20:26:11 -04:00
}
}
}
// 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 {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( r . Context (), "queue initial library scan failed" , "component" , "api" , "library_id" , folder . ID , "error" , err )
2026-05-22 20:26:11 -04:00
}
} 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
}
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "fetching library for update" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 ,
2026-06-10 14:58:54 -04:00
AutoTranslateMetadata : req . AutoTranslateMetadata ,
2026-05-22 20:26:11 -04:00
ChapterThumbnailsEnabled : req . ChapterThumbnailsEnabled ,
IntroDetectionEnabled : req . IntroDetectionEnabled ,
2026-07-06 18:52:44 -04:00
TrailerKinds : req . TrailerKinds ,
2026-05-22 20:26:11 -04:00
})
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
}
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "updating library" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "fetching updated library" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( r . Context (), "sync generated home section titles" , "component" , "api" , "library_id" , id , "error" , syncErr )
2026-05-22 20:26:11 -04:00
}
}
2026-07-02 14:07:21 -04:00
// 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.
2026-07-24 18:18:49 +02:00
languageChanged := ! strings . EqualFold ( strings . TrimSpace ( oldFolder . MetadataLanguage ), strings . TrimSpace ( folder . MetadataLanguage ))
if languageChanged {
h . wakeMetadataMatcher ( r . Context (), folder . ID )
}
if h . JobRepo != nil && languageChanged {
2026-07-02 14:07:21 -04:00
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 ) {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( r . Context (), "queue language-change metadata refresh failed" , "component" , "api" , "library_id" , folder . ID , "error" , jobErr )
2026-07-02 14:07:21 -04:00
}
} else {
publishEventJob ( r . Context (), h . EventsHub , "job.created" , job )
}
}
2026-05-22 20:26:11 -04:00
// 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 {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( r . Context (), "queue library path-change scan failed" , "component" , "api" , "library_id" , folder . ID , "error" , err )
2026-05-22 20:26:11 -04:00
}
} 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
}
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "fetching library before delete" , "component" , "api" , "library_id" , id , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "disabling library before delete" , "component" , "api" , "library_id" , folder . ID , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "re-enabling library after failed delete queue" , "component" , "api" ,
2026-05-22 20:26:11 -04:00
"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
}
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "queuing library delete job" , "component" , "api" , "library_id" , id , "error" , err )
2026-05-22 20:26:11 -04:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to queue library delete" )
return
}
if h . ingester != nil {
cancelled := h . ingester . CancelLibrary ( folder . ID )
2026-07-09 20:53:52 +08:00
slog . InfoContext ( r . Context (), "library delete: cancelled running scans" , "component" , "api" , "library_id" , folder . ID , "cancelled" , cancelled )
2026-05-22 20:26:11 -04:00
}
if h . ScanQueue != nil {
queuedCancelled , err := h . ScanQueue . CancelAcceptedByLibrary ( r . Context (), folder . ID )
if err != nil {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( r . Context (), "library delete: failed to cancel queued scans" , "component" , "api" , "library_id" , folder . ID , "error" , err )
2026-05-22 20:26:11 -04:00
} else if queuedCancelled > 0 {
2026-07-09 20:53:52 +08:00
slog . InfoContext ( r . Context (), "library delete: cancelled queued scans" , "component" , "api" , "library_id" , folder . ID , "cancelled" , queuedCancelled )
2026-05-22 20:26:11 -04:00
}
}
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
}
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "fetching library for mount check" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to fetch library" )
return
}
2026-07-16 19:58:44 +02:00
resp := h . checkLibraryMount ( r . Context (), folder )
if resp . Healthy && folder . ScanWarningCode != nil &&
( * folder . ScanWarningCode == "empty_root" || * folder . ScanWarningCode == "dead_root" ) {
2026-05-22 20:26:11 -04:00
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
}
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "clearing empty-root warning after successful mount check" , "component" , "api" , "library_id" , folder . ID , "error" , err )
2026-05-22 20:26:11 -04:00
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
}
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "resolving scan target" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "queueing library scan" , "component" , "api" , "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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "cancel library scans" , "component" , "api" , "library_id" , req . LibraryID , "error" , err )
2026-05-22 20:26:11 -04:00
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 )
}
2026-07-09 20:53:52 +08:00
slog . InfoContext ( r . Context (), "scan: cancelled running scans" , "component" , "api" ,
2026-05-22 20:26:11 -04:00
"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" ,
})
2026-06-05 19:43:20 -07:00
if run , ok := h . ScanRegistry . Get ( scanID ); ok {
h . publishScanEvent ( context . Background (), "scan.accepted" , run )
}
2026-05-22 20:26:11 -04:00
}
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
2026-07-25 14:47:12 -04:00
resp . MissingSkippedProtected = result . ScanResult . MissingSkippedProtected
2026-05-22 20:26:11 -04:00
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 )
}
2026-07-16 19:58:44 +02:00
func ( h * LibraryHandler ) checkLibraryMount ( ctx context . Context , folder * models . MediaFolder ) libraryMountCheckResponse {
2026-05-22 20:26:11 -04:00
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
2026-07-16 19:58:44 +02:00
emptyPaths := make ([] string , 0 , len ( folder . Paths ))
probes := rootcheck . ProbeManyWithTimeout ( ctx , folder . Paths , rootcheck . DefaultProbeTimeout )
for i , path := range folder . Paths {
probe := probes [ i ]
2026-05-22 20:26:11 -04:00
root := libraryMountCheckRootResponse {
Path : path ,
2026-07-16 19:58:44 +02:00
Reachable : probe . Reachable ,
2026-05-22 20:26:11 -04:00
}
2026-07-16 19:58:44 +02:00
if ! probe . Reachable {
root . ErrorCode = stringPtr ( probe . ErrorCode )
root . ErrorMessage = stringPtr ( probe . ErrorMessage )
2026-05-22 20:26:11 -04:00
unreachable ++
resp . Healthy = false
2026-07-16 19:58:44 +02:00
} else if probe . Empty {
emptyPaths = append ( emptyPaths , path )
2026-05-22 20:26:11 -04:00
}
resp . Roots = append ( resp . Roots , root )
}
2026-07-16 19:58:44 +02:00
// 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 :
2026-05-22 20:26:11 -04:00
resp . Summary = "All configured roots are reachable"
2026-07-16 19:58:44 +02:00
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 :
2026-05-22 20:26:11 -04:00
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"`
}
2026-06-05 19:43:20 -07:00
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"`
2026-07-24 18:18:49 +02:00
PendingCount int `json:"pending_count"`
ParkedCount int `json:"parked_count"`
2026-06-05 19:43:20 -07:00
}
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
2026-07-24 18:18:49 +02:00
Limit int `json:"limit"`
Offset int `json:"offset"`
2026-06-05 19:43:20 -07:00
Movies [] libraryMovieMatchQueueEntryResponse `json:"movies"`
Series [] librarySeriesMatchQueueEntryResponse `json:"series"`
RawFiles [] libraryRawMatchBacklogEntryResponse `json:"raw_files"`
}
type libraryMovieMatchQueueEntryResponse struct {
2026-07-24 18:18:49 +02:00
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"`
2026-06-05 19:43:20 -07:00
}
type librarySeriesMatchQueueEntryResponse struct {
2026-07-24 18:18:49 +02:00
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"`
2026-06-05 19:43:20 -07:00
}
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "metadata queue: failed to list libraries" , "component" , "api" , "error" , err )
2026-06-05 19:43:20 -07:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to list metadata matcher queues" )
return
}
2026-07-24 18:18:49 +02:00
folderIDs := make ([] int , 0 , len ( folders ))
2026-06-05 19:43:20 -07:00
for _ , folder := range folders {
2026-07-24 18:18:49 +02:00
if folder != nil {
folderIDs = append ( folderIDs , folder . ID )
2026-06-05 19:43:20 -07:00
}
2026-07-24 18:18:49 +02:00
}
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 ])
2026-06-05 19:43:20 -07:00
}
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
}
}
2026-07-24 18:18:49 +02:00
offset := 0
if value := strings . TrimSpace ( r . URL . Query (). Get ( "offset" )); value != "" {
if parsed , parseErr := strconv . Atoi ( value ); parseErr == nil && parsed >= 0 {
offset = parsed
}
}
2026-06-05 19:43:20 -07:00
status , err := h . metadataMatchQueueStatus ( r . Context (), id )
if err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "metadata queue: failed to load queue status" , "component" , "api" , "library_id" , id , "error" , err )
2026-06-05 19:43:20 -07:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to load metadata matcher queue" )
return
}
resp := libraryMetadataMatchQueueDetailResponse {
libraryMetadataMatchQueueStatusResponse : status ,
2026-07-24 18:18:49 +02:00
Limit : limit ,
Offset : offset ,
2026-06-05 19:43:20 -07:00
Movies : [] libraryMovieMatchQueueEntryResponse {},
Series : [] librarySeriesMatchQueueEntryResponse {},
RawFiles : [] libraryRawMatchBacklogEntryResponse {},
}
if h . MovieMatchQueueRepo != nil {
2026-07-24 18:18:49 +02:00
movies , _ , err := h . MovieMatchQueueRepo . ListByFolder ( r . Context (), id , limit , offset )
2026-06-05 19:43:20 -07:00
if err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "metadata queue: failed to list movie queue" , "component" , "api" , "library_id" , id , "error" , err )
2026-06-05 19:43:20 -07:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to list metadata matcher queue" )
return
}
for _ , entry := range movies {
resp . Movies = append ( resp . Movies , libraryMovieMatchQueueEntryResponse {
2026-07-24 18:18:49 +02:00
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 ,
2026-06-05 19:43:20 -07:00
})
}
}
if h . SeriesMatchQueueRepo != nil {
2026-07-24 18:18:49 +02:00
series , _ , err := h . SeriesMatchQueueRepo . ListByFolder ( r . Context (), id , limit , offset )
2026-06-05 19:43:20 -07:00
if err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "metadata queue: failed to list series queue" , "component" , "api" , "library_id" , id , "error" , err )
2026-06-05 19:43:20 -07:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to list metadata matcher queue" )
return
}
for _ , entry := range series {
resp . Series = append ( resp . Series , librarySeriesMatchQueueEntryResponse {
2026-07-24 18:18:49 +02:00
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 ,
2026-06-05 19:43:20 -07:00
})
}
}
if h . RawMatchBacklogRepo != nil {
2026-07-24 18:18:49 +02:00
rawFiles , _ , err := h . RawMatchBacklogRepo . ListUnmatchedMatchBacklogByFolder ( r . Context (), id , h . rawMatchBacklogMode (), limit , offset )
2026-06-05 19:43:20 -07:00
if err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "metadata queue: failed to list raw backlog" , "component" , "api" , "library_id" , id , "error" , err )
2026-06-05 19:43:20 -07:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "metadata queue: failed to retry series queue" , "component" , "api" , "library_id" , id , "error" , err )
2026-06-05 19:43:20 -07:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to retry metadata matcher" )
return
}
2026-07-24 18:18:49 +02:00
if _ , err := h . SeriesMatchQueueRepo . RetryNowByFolder ( r . Context (), id ); err != nil {
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to retry metadata matcher" )
return
}
2026-06-05 19:43:20 -07:00
}
if h . MovieMatchQueueRepo != nil {
if err := h . MovieMatchQueueRepo . SyncForFolder ( r . Context (), id ); err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "metadata queue: failed to retry movie queue" , "component" , "api" , "library_id" , id , "error" , err )
2026-06-05 19:43:20 -07:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to retry metadata matcher" )
return
}
2026-07-24 18:18:49 +02:00
if _ , err := h . MovieMatchQueueRepo . RetryNowByFolder ( r . Context (), id ); err != nil {
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to retry metadata matcher" )
return
}
2026-06-05 19:43:20 -07:00
}
rawFileRetried := 0
if h . RawMatchBacklogRepo != nil {
rawFileRetried , err = h . RawMatchBacklogRepo . RetryUnmatchedMatchBacklogByFolder ( r . Context (), id , h . rawMatchBacklogMode ())
if err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "metadata queue: failed to retry raw backlog" , "component" , "api" , "library_id" , id , "error" , err )
2026-06-05 19:43:20 -07:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to retry metadata matcher" )
return
}
}
status , err := h . metadataMatchQueueStatus ( r . Context (), id )
if err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "metadata queue: failed to load retried queue status" , "component" , "api" , "library_id" , id , "error" , err )
2026-06-05 19:43:20 -07:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "metadata queue: failed to cancel series queue" , "component" , "api" , "library_id" , id , "error" , err )
2026-06-05 19:43:20 -07:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "metadata queue: failed to cancel movie queue" , "component" , "api" , "library_id" , id , "error" , err )
2026-06-05 19:43:20 -07:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "metadata queue: failed to suppress raw backlog" , "component" , "api" , "library_id" , id , "error" , err )
2026-06-05 19:43:20 -07:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to cancel metadata matcher" )
return
}
}
status , err := h . metadataMatchQueueStatus ( r . Context (), id )
if err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "metadata queue: failed to load cancelled queue status" , "component" , "api" , "library_id" , id , "error" , err )
2026-06-05 19:43:20 -07:00
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 ) {
2026-07-24 18:18:49 +02:00
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 }
}
2026-06-05 19:43:20 -07:00
if h . MovieMatchQueueRepo != nil {
2026-07-24 18:18:49 +02:00
counts , err := h . MovieMatchQueueRepo . CountStatesByFolders ( ctx , libraryIDs )
2026-06-05 19:43:20 -07:00
if err != nil {
2026-07-24 18:18:49 +02:00
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
2026-06-05 19:43:20 -07:00
}
}
if h . SeriesMatchQueueRepo != nil {
2026-07-24 18:18:49 +02:00
counts , err := h . SeriesMatchQueueRepo . CountStatesByFolders ( ctx , libraryIDs )
2026-06-05 19:43:20 -07:00
if err != nil {
2026-07-24 18:18:49 +02:00
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
2026-06-05 19:43:20 -07:00
}
}
if h . RawMatchBacklogRepo != nil {
2026-07-24 18:18:49 +02:00
counts , err := h . RawMatchBacklogRepo . CountUnmatchedMatchBacklogByFolders ( ctx , libraryIDs , h . rawMatchBacklogMode ())
2026-06-05 19:43:20 -07:00
if err != nil {
2026-07-24 18:18:49 +02:00
return nil , err
}
for libraryID , count := range counts {
status := statuses [ libraryID ]
status . RawFileCount = count
status . PendingCount += count
statuses [ libraryID ] = status
2026-06-05 19:43:20 -07:00
}
}
2026-07-24 18:18:49 +02:00
for libraryID , status := range statuses {
status . TotalCount = status . MovieCount + status . SeriesCount + status . RawFileCount
statuses [ libraryID ] = status
}
return statuses , nil
2026-06-05 19:43:20 -07:00
}
func ( h * LibraryHandler ) rawMatchBacklogMode () scanner . RawMatchBacklogMode {
if h . TVSeriesRootQueue && h . MovieMatchQueueRepo != nil {
return scanner . RawMatchBacklogMixed
}
if h . TVSeriesRootQueue {
return scanner . RawMatchBacklogNonSeries
}
return scanner . RawMatchBacklogGeneric
}
2026-05-22 20:26:11 -04:00
// 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
}
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "library: queue library refresh failed" , "component" , "api" , "library_id" , id , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "uploading library poster" , "component" , "api" , "library_id" , id , "error" , err )
2026-05-22 20:26:11 -04:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to upload poster" )
return
}
if err := h . folderRepo . SetPosterPath ( r . Context (), id , s3Key ); err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "saving library poster path" , "component" , "api" , "library_id" , id , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "clearing library poster path" , "component" , "api" , "library_id" , id , "error" , err )
2026-05-22 20:26:11 -04:00
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
}
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "fetching library for provider chain" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to fetch library" )
return
}
entries , err := h . ChainRepo . GetAllChainEntries ( r . Context (), id )
if err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "getting provider chain" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 })
}
2026-07-07 09:42:41 -04:00
// 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 })
}
2026-05-22 20:26:11 -04:00
// 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
}
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "fetching library for provider chain update" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "setting provider chain" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to set provider chain" )
return
}
if h . chainCacheInvalidator != nil {
h . chainCacheInvalidator . InvalidateChainCache ()
}
2026-07-24 18:18:49 +02:00
h . wakeMetadataMatcher ( r . Context (), id )
2026-05-22 20:26:11 -04:00
w . WriteHeader ( http . StatusNoContent )
}
2026-07-24 18:18:49 +02:00
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 )
}
}
}
2026-05-22 20:26:11 -04:00
// 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 {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( ctx , "seed chain: failed to list metadata capabilities" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
return nil
}
2026-06-10 08:18:35 -04:00
levels := metadataContentLevelsForLibraryType ( libraryType )
if len ( levels ) == 0 {
2026-05-22 20:26:11 -04:00
return nil
}
var entries [] metadata . ChainEntry
for _ , level := range levels {
2026-07-07 09:42:41 -04:00
candidates := make ([] seedCandidate , 0 , len ( caps ))
2026-05-22 20:26:11 -04:00
for _ , c := range caps {
2026-07-07 09:42:41 -04:00
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 ,
})
2026-05-22 20:26:11 -04:00
}
2026-07-07 09:42:41 -04:00
entries = append ( entries , buildSeededChainEntries ( level , candidates ) ... )
}
2026-05-22 20:26:11 -04:00
2026-07-07 09:42:41 -04:00
return entries
}
2026-05-22 20:26:11 -04:00
2026-07-07 09:42:41 -04:00
// 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 })
2026-05-22 20:26:11 -04:00
}
}
2026-07-07 09:42:41 -04:00
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 ,
}
}
2026-05-22 20:26:11 -04:00
return entries
}
2026-06-10 08:18:35 -04:00
func metadataContentLevelsForLibraryType ( libraryType string ) [] string {
2026-07-17 05:55:36 +08:00
return metadata . ContentLevelsForLibraryType ( libraryType )
2026-06-10 08:18:35 -04:00
}
2026-05-22 20:26:11 -04:00
// 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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "listing stale media IDs" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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
2026-07-26 17:19:33 +02:00
Status string
TmdbID string
TvdbID string
ImdbID string
2026-05-22 20:26:11 -04:00
LibraryID int
LibraryName string
}
items := make ( map [ string ] itemInfo , len ( contentIDs ))
rows , err := h . pool . Query ( r . Context (), `
2026-07-26 17:19:33 +02:00
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, ''),
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "loading items for stale IDs" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to load item data" )
return
}
defer rows . Close ()
for rows . Next () {
2026-07-26 17:19:33 +02:00
var cid , title , ctype , status , tmdbID , tvdbID , imdbID , libName string
2026-05-22 20:26:11 -04:00
var year , libID int
2026-07-26 17:19:33 +02:00
if err := rows . Scan ( & cid , & title , & year , & ctype , & status , & tmdbID , & tvdbID , & imdbID , & libID , & libName ); err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "scanning item for stale IDs" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
continue
}
2026-07-26 17:19:33 +02:00
items [ cid ] = itemInfo {
Title : title , Year : year , ContentType : ctype , Status : status ,
TmdbID : tmdbID , TvdbID : tvdbID , ImdbID : imdbID ,
LibraryID : libID , LibraryName : libName ,
}
2026-05-22 20:26:11 -04:00
}
resp := make ([] staleMediaIDResponse , 0 , len ( staleIDs ))
for _ , s := range staleIDs {
info := items [ s . ContentID ]
2026-07-26 17:19:33 +02:00
if ! metadata . IsActionableStaleProviderID ( & models . MediaItem {
ContentID : s . ContentID ,
Status : info . Status ,
TmdbID : info . TmdbID ,
TvdbID : info . TvdbID ,
ImdbID : info . ImdbID ,
}, s ) {
continue
}
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "clearing stale IDs from media item" , "component" , "api" , "content_id" , contentID , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "deleting stale media ID records" , "component" , "api" , "content_id" , contentID , "error" , err )
2026-05-22 20:26:11 -04:00
}
}
// Re-trigger metadata match.
if h . refresher != nil {
go func () {
if err := h . refresher . RefreshItem ( h . appCtx , contentID ); err != nil {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( r . Context (), "metadata: rematch refresh failed" , "component" , "api" , "content_id" , contentID , "error" , err )
2026-05-22 20:26:11 -04:00
}
}()
}
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "listing scanned groups" , "component" , "api" , "library_id" , libraryID , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( r . Context (), "listing group overrides" , "component" , "api" , "library_id" , libraryID , "error" , err )
2026-05-22 20:26:11 -04:00
} else {
for _ , override := range overrides {
overrideByGroup [ groupOverrideLookupKey ( override . GroupKeyVersion , override . ContentGroupKey )] = override
}
}
}
2026-07-06 12:23:32 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( r . Context (), "listing group claims" , "component" , "api" , "library_id" , libraryID , "error" , err )
2026-07-06 12:23:32 -04:00
} else {
defer claimRows . Close ()
for claimRows . Next () {
var version int
var groupKey , contentID string
if err := claimRows . Scan ( & version , & groupKey , & contentID ); err != nil {
2026-07-09 20:53:52 +08:00
slog . WarnContext ( r . Context (), "scanning group claim" , "component" , "api" , "library_id" , libraryID , "error" , err )
2026-07-06 12:23:32 -04:00
break
}
contentIDByGroup [ groupOverrideLookupKey ( version , groupKey )] = contentID
}
}
}
2026-05-22 20:26:11 -04:00
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 ,
2026-07-06 12:23:32 -04:00
ContentID : contentIDByGroup [ groupOverrideLookupKey ( group . GroupKeyVersion , group . ContentGroupKey )],
2026-05-22 20:26:11 -04:00
}
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "loading observed media location" , "component" , "api" , "library_id" , req . LibraryID , "root_path" , req . RootPath , "error" , err )
2026-05-22 20:26:11 -04:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to load root" )
return
}
if location == nil || location . PrimaryContentGroupKey == "" {
if location != nil && location . ContentGroupCount > 1 {
2026-07-06 12:23:32 -04:00
writeError ( w , http . StatusConflict , "ambiguous_root" , "Root contains files from multiple items; resolve it with the item split flow (POST /admin/items/{id}/split)" )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "upserting group override" , "component" , "api" , "library_id" , req . LibraryID , "root_path" , req . RootPath , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "loading observed media location" , "component" , "api" , "library_id" , req . LibraryID , "root_path" , req . RootPath , "error" , err )
2026-05-22 20:26:11 -04:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to load root" )
return
}
if location == nil || location . PrimaryContentGroupKey == "" {
if location != nil && location . ContentGroupCount > 1 {
2026-07-06 12:23:32 -04:00
writeError ( w , http . StatusConflict , "ambiguous_root" , "Root contains files from multiple items; manage its identity overrides via the item split flow instead" )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "deleting group override" , "component" , "api" , "library_id" , req . LibraryID , "root_path" , req . RootPath , "error" , err )
2026-05-22 20:26:11 -04:00
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
}
}
2026-05-28 04:16:49 +02:00
// Optional case-insensitive search across title, library name, type, and
// status. Applied server-side so it spans the whole table, not just the
2026-05-28 11:31:50 -04:00
// 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.
2026-05-28 04:16:49 +02:00
search := strings . TrimSpace ( q . Get ( "q" ))
filter := ""
filterArgs := [] any {}
if search != "" {
filterArgs = append ( filterArgs , "%" + search + "%" )
2026-05-28 11:31:50 -04:00
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
)
)`
2026-05-28 04:16:49 +02:00
}
2026-07-02 14:08:02 -04:00
// 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" )
2026-05-28 04:16:49 +02:00
countSQL := `
SELECT COUNT(*)
FROM media_items mi
2026-07-02 14:08:02 -04:00
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')` + mangaChapterGuard
2026-05-28 11:31:50 -04:00
countSQL += filter
2026-05-28 04:16:49 +02:00
2026-05-22 20:26:11 -04:00
var total int
2026-05-28 04:16:49 +02:00
if err := h . pool . QueryRow ( r . Context (), countSQL , filterArgs ... ). Scan ( & total ); err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "counting unmatched items" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to count unmatched items" )
return
}
2026-05-28 04:16:49 +02:00
listArgs := append ( append ([] any {}, filterArgs ... ), limit , offset )
listSQL := fmt . Sprintf ( `
2026-05-22 20:26:11 -04:00
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
2026-07-02 14:08:02 -04:00
WHERE mi.status IN ('unmatched', 'pending', 'ambiguous')%s%s
2026-05-22 20:26:11 -04:00
ORDER BY mi.title ASC, mi.content_id ASC
2026-05-28 04:16:49 +02:00
LIMIT $%d OFFSET $%d
2026-07-02 14:08:02 -04:00
` , mangaChapterGuard , filter , len ( filterArgs ) + 1 , len ( filterArgs ) + 2 )
2026-05-28 04:16:49 +02:00
rows , err := h . pool . Query ( r . Context (), listSQL , listArgs ... )
2026-05-22 20:26:11 -04:00
if err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "listing unmatched items" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "scanning unmatched item" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
writeError ( w , http . StatusInternalServerError , "internal_error" , "Failed to scan item" )
return
}
items = append ( items , item )
}
if err := rows . Err (); err != nil {
2026-07-09 20:53:52 +08:00
slog . ErrorContext ( r . Context (), "iterating unmatched items" , "component" , "api" , "error" , err )
2026-05-22 20:26:11 -04:00
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 )
}