- Read library ID from Target.Folder.ID everywhere - Guard scan queue enqueue against nil Folder - Simplify admin API key auth error plumbing
281 lines
7.8 KiB
Go
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))
|
|
}
|