Files
silo-server/internal/scantrigger/scantrigger.go
T
Silo Server Migration b2fae0aa70 refactor(scantrigger): drop redundant Target.LibraryID field
- Read library ID from Target.Folder.ID everywhere
- Guard scan queue enqueue against nil Folder
- Simplify admin API key auth error plumbing
2026-05-25 12:11:56 -04:00

281 lines
7.8 KiB
Go

package scantrigger
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
)
const (
ModeLibrary = "library"
ModeSubtree = "subtree"
ModeFile = "file"
)
type FolderRepository interface {
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
List(ctx context.Context) ([]*models.MediaFolder, error)
}
type Queuer interface {
EnqueueScan(ctx context.Context, folderID int, mode, path, trigger string) (bool, error)
EnqueueScans(ctx context.Context, targets []Target) error
}
type Request struct {
LibraryID *int
Path string
Trigger string
}
// Target is a fully-resolved scan request. Folder is always non-nil for
// targets returned by Resolver; callers should read the library ID via
// target.Folder.ID rather than tracking it separately.
type Target struct {
Folder *models.MediaFolder
Mode string
Path string
Trigger string
}
type RequestError struct {
Status int
Code string
Message string
}
func (e *RequestError) Error() string {
return e.Message
}
type Resolver struct {
folders FolderRepository
}
func NewResolver(folders FolderRepository) *Resolver {
return &Resolver{folders: folders}
}
func (r *Resolver) ResolveAll(ctx context.Context, requests []Request) ([]Target, error) {
targets := make([]Target, 0, len(requests))
var pathFolders []*models.MediaFolder
pathFoldersLoaded := false
for _, req := range requests {
usePathFolders := req.LibraryID == nil && strings.TrimSpace(req.Path) != ""
if usePathFolders && !pathFoldersLoaded {
if r == nil || r.folders == nil {
return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"}
}
folders, listErr := r.folders.List(ctx)
if listErr != nil {
return nil, fmt.Errorf("listing libraries for scan: %w", listErr)
}
pathFolders = folders
pathFoldersLoaded = true
}
target, err := r.resolve(ctx, req, pathFolders, usePathFolders)
if err != nil {
return nil, err
}
targets = append(targets, *target)
}
return targets, nil
}
func (r *Resolver) Resolve(ctx context.Context, req Request) (*Target, error) {
return r.resolve(ctx, req, nil, false)
}
func (r *Resolver) resolve(ctx context.Context, req Request, pathFolders []*models.MediaFolder, usePathFolders bool) (*Target, error) {
if r == nil || r.folders == nil {
return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"}
}
if req.LibraryID == nil && strings.TrimSpace(req.Path) == "" {
return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Either library_id or path is required"}
}
var folder *models.MediaFolder
var err error
if req.LibraryID != nil {
folder, err = r.folders.GetByID(ctx, *req.LibraryID)
if err != nil {
if errors.Is(err, catalog.ErrFolderNotFound) {
return nil, &RequestError{Status: http.StatusNotFound, Code: "not_found", Message: "Library not found"}
}
return nil, fmt.Errorf("fetching library for scan: %w", err)
}
}
trigger := strings.TrimSpace(req.Trigger)
if trigger == "" {
trigger = "manual"
}
if strings.TrimSpace(req.Path) == "" {
if folder != nil && !folder.Enabled {
return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"}
}
return &Target{Folder: folder, Mode: ModeLibrary, Trigger: trigger}, nil
}
cleanPath := filepath.Clean(req.Path)
var matchedRoot string
if folder != nil {
matchedRoot, err = LongestMatchingRoot(cleanPath, folder.Paths)
if err != nil {
return nil, err
}
if matchedRoot == "" {
return nil, &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path does not belong to the specified library"}
}
} else {
folders := pathFolders
if !usePathFolders {
var listErr error
folders, listErr = r.folders.List(ctx)
if listErr != nil {
return nil, fmt.Errorf("listing libraries for scan: %w", listErr)
}
}
folder, matchedRoot, err = MatchFolderForPath(cleanPath, folders)
if err != nil {
return nil, err
}
}
if folder != nil && !folder.Enabled {
return nil, &RequestError{Status: http.StatusConflict, Code: "conflict", Message: "Library is disabled"}
}
mode, err := ClassifyPath(cleanPath, matchedRoot)
if err != nil {
return nil, err
}
if trigger == "manual" {
trigger = "path"
if req.LibraryID != nil {
trigger = "library_id_path"
}
}
targetPath := cleanPath
if mode == ModeLibrary {
targetPath = ""
}
return &Target{Folder: folder, Mode: mode, Path: targetPath, Trigger: trigger}, nil
}
func EnqueueAll(ctx context.Context, queue Queuer, targets []Target) error {
if queue == nil {
return &RequestError{Status: http.StatusServiceUnavailable, Code: "unavailable", Message: "Scanner not available"}
}
if err := queue.EnqueueScans(ctx, targets); err != nil {
return fmt.Errorf("queueing library scans: %w", err)
}
return nil
}
func LongestMatchingRoot(targetPath string, roots []string) (string, error) {
bestRoot := ""
bestLen := -1
for _, root := range roots {
if !PathWithinRoot(targetPath, root) {
continue
}
cleanRoot := filepath.Clean(root)
rootLen := len(cleanRoot)
if rootLen > bestLen {
bestRoot = cleanRoot
bestLen = rootLen
}
}
return bestRoot, nil
}
func MatchFolderForPath(targetPath string, folders []*models.MediaFolder) (*models.MediaFolder, string, error) {
var bestFolder *models.MediaFolder
bestRoot := ""
bestLen := -1
ambiguous := false
for _, folder := range folders {
if folder == nil {
continue
}
root, err := LongestMatchingRoot(targetPath, folder.Paths)
if err != nil {
return nil, "", err
}
if root == "" {
continue
}
rootLen := len(root)
if rootLen > bestLen {
bestFolder = folder
bestRoot = root
bestLen = rootLen
ambiguous = false
continue
}
if rootLen == bestLen && bestFolder != nil && folder.ID != bestFolder.ID {
ambiguous = true
}
}
if ambiguous {
return nil, "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path matches multiple libraries"}
}
if bestFolder == nil {
return nil, "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "No library matches the given path"}
}
return bestFolder, bestRoot, nil
}
func ClassifyPath(targetPath, matchedRoot string) (string, error) {
if filepath.Clean(targetPath) == filepath.Clean(matchedRoot) {
return ModeLibrary, nil
}
info, err := os.Stat(targetPath)
if err != nil {
switch {
case errors.Is(err, os.ErrNotExist):
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path does not exist"}
case errors.Is(err, os.ErrPermission):
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Permission denied for path"}
default:
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path could not be inspected"}
}
}
if info.IsDir() {
return ModeSubtree, nil
}
if !info.Mode().IsRegular() {
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Path must be a file or directory"}
}
if !scanner.SupportsVideoFile(targetPath) {
return "", &RequestError{Status: http.StatusBadRequest, Code: "bad_request", Message: "Unsupported media file extension"}
}
return ModeFile, nil
}
func PathWithinRoot(targetPath, rootPath string) bool {
cleanTarget := filepath.Clean(targetPath)
cleanRoot := filepath.Clean(rootPath)
rel, err := filepath.Rel(cleanRoot, cleanTarget)
if err != nil {
return false
}
if rel == "." || rel == "" {
return true
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}