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