AGENTS.md told contributors CI ran the same checks as `make lint`, and the Go job ran only gofmt and vet. A change failing the documented Go lint gate passed all three jobs. Running the linter as-is is not an option: the tree has ~296 findings today, which is why this half of `make lint` was never enforced. Blocking every PR on a cleanup nobody has scheduled gets the gate deleted again, so CI runs with --new-from-merge-base and only the lines a branch touches have to be clean. The count can then only fall. golangci-lint is built from source at a pinned version rather than downloaded. A released binary refuses to run against a Go newer than the one it was built with, and go.mod here tracks Go closely enough that the current release already fails that way on 1.26.4. .golangci.yml declared version 2 while still using v1's issues.exclude-rules key. Current golangci-lint ignores it, so the "allow repeated strings and unchecked cleanup errors in tests" exclusions silently did not apply — 16 findings in test files that the config says to skip. Moved to linters.exclusions, which `golangci-lint config verify` accepts. The four lines this surfaced in scantrigger are fixed rather than excluded: its repeated status codes and messages are now named constants, so one condition cannot end up worded two ways. Also drops the workflow token to contents:read and stops persisting credentials in the three checkouts, neither of which any job needs. Reported by CodeRabbit and Codex review on #479.
445 lines
16 KiB
Go
445 lines
16 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/librarykind"
|
|
"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
|
|
}
|
|
|
|
// Reason identifies why a request was rejected, independently of the prose in
|
|
// Message. Callers that need to branch on a specific rejection match on this:
|
|
// Message is written for the client reading the response, and rewording it
|
|
// silently broke every caller comparing the string. Status and Code are too
|
|
// coarse to tell two bad requests apart.
|
|
type Reason string
|
|
|
|
const (
|
|
ReasonScannerUnavailable Reason = "scanner_unavailable"
|
|
ReasonPathRequired Reason = "path_required"
|
|
ReasonLibraryOrPathRequired Reason = "library_or_path_required"
|
|
ReasonSubtreeOutsideLibrary Reason = "subtree_outside_library"
|
|
ReasonLibraryDisabled Reason = "library_disabled"
|
|
ReasonLibraryNotFound Reason = "library_not_found"
|
|
ReasonLibraryRootOffline Reason = "library_root_offline"
|
|
ReasonPathMissing Reason = "path_missing"
|
|
ReasonPathStillExists Reason = "path_still_exists"
|
|
ReasonPathNotInspectable Reason = "path_not_inspectable"
|
|
ReasonPathNotFileOrDir Reason = "path_not_file_or_dir"
|
|
ReasonPathPermissionDenied Reason = "path_permission_denied"
|
|
ReasonPathOutsideLibrary Reason = "path_outside_library"
|
|
ReasonPathAmbiguous Reason = "path_ambiguous"
|
|
ReasonNoLibraryMatch Reason = "no_library_match"
|
|
ReasonUnsupportedExtension Reason = "unsupported_extension"
|
|
)
|
|
|
|
// Error codes and the messages repeated across more than one rejection site.
|
|
// Keeping them named means the same condition cannot end up worded two ways
|
|
// depending on which branch produced it.
|
|
const (
|
|
codeBadRequest = "bad_request"
|
|
codeConflict = "conflict"
|
|
codeNotFound = "not_found"
|
|
codeUnavailable = "unavailable"
|
|
|
|
msgScannerUnavailable = "Scanner not available"
|
|
msgLibraryDisabled = "Library is disabled"
|
|
msgPathRequired = "Path is required"
|
|
msgUnsupportedExt = "Unsupported media file extension for library type"
|
|
msgPathNotInspectable = "Path could not be inspected"
|
|
)
|
|
|
|
type RequestError struct {
|
|
Status int
|
|
Code string
|
|
Message string
|
|
Reason Reason
|
|
}
|
|
|
|
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: codeUnavailable, Message: msgScannerUnavailable, Reason: ReasonScannerUnavailable}
|
|
}
|
|
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)
|
|
}
|
|
|
|
// ResolveMissingSubtree resolves a subtree path that may no longer exist on
|
|
// disk. This is intentionally narrower than Resolve: it never stats the path
|
|
// and only returns ModeSubtree for paths below a configured library root.
|
|
func (r *Resolver) ResolveMissingSubtree(ctx context.Context, subtreePath, trigger string) (*Target, error) {
|
|
if r == nil || r.folders == nil {
|
|
return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: codeUnavailable, Message: msgScannerUnavailable, Reason: ReasonScannerUnavailable}
|
|
}
|
|
cleanPath := filepath.Clean(subtreePath)
|
|
if strings.TrimSpace(subtreePath) == "" || cleanPath == "." {
|
|
return nil, &RequestError{Status: http.StatusBadRequest, Code: codeBadRequest, Message: msgPathRequired, Reason: ReasonPathRequired}
|
|
}
|
|
folder, matchedRoot, err := r.matchEnabledFolder(ctx, cleanPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if filepath.Clean(cleanPath) == filepath.Clean(matchedRoot) {
|
|
return nil, &RequestError{Status: http.StatusBadRequest, Code: codeBadRequest, Message: "Subtree path must be below a library root", Reason: ReasonSubtreeOutsideLibrary}
|
|
}
|
|
return &Target{Folder: folder, Mode: ModeSubtree, Path: cleanPath, Trigger: normalizeTrigger(trigger)}, nil
|
|
}
|
|
|
|
// matchEnabledFolder lists the configured libraries and returns the enabled
|
|
// folder (and its matched root) that owns the given path. Shared by the
|
|
// resolvers that accept paths which may no longer exist on disk.
|
|
func (r *Resolver) matchEnabledFolder(ctx context.Context, cleanPath string) (*models.MediaFolder, string, error) {
|
|
folders, err := r.folders.List(ctx)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("listing libraries for scan: %w", err)
|
|
}
|
|
folder, matchedRoot, err := MatchFolderForPath(cleanPath, folders)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if folder != nil && !folder.Enabled {
|
|
return nil, "", &RequestError{Status: http.StatusConflict, Code: codeConflict, Message: msgLibraryDisabled, Reason: ReasonLibraryDisabled}
|
|
}
|
|
return folder, matchedRoot, nil
|
|
}
|
|
|
|
func normalizeTrigger(trigger string) string {
|
|
if trigger = strings.TrimSpace(trigger); trigger == "" {
|
|
return "path"
|
|
}
|
|
return trigger
|
|
}
|
|
|
|
// ResolveVanishedPath resolves a change for a path that no longer exists on
|
|
// disk (a file deleted by an upgrade/replacement, or a removed directory) to a
|
|
// reconciling scan target. Paths with a supported extension for their library
|
|
// map to an exact file scan; paths with a media extension the library type
|
|
// does not support are rejected; remaining paths map to a subtree scan of the
|
|
// path itself. The scoped scan marks vanished files missing so stale versions
|
|
// stop being offered for playback.
|
|
//
|
|
// Two guards keep this from turning transient storage loss into cleanup:
|
|
// the path must actually be gone (a still-existing path is rejected — use
|
|
// Resolve), and the matched library root must still exist on disk so an
|
|
// unmounted share never resolves to a reconciling scan.
|
|
func (r *Resolver) ResolveVanishedPath(ctx context.Context, path, trigger string) (*Target, error) {
|
|
if r == nil || r.folders == nil {
|
|
return nil, &RequestError{Status: http.StatusServiceUnavailable, Code: codeUnavailable, Message: msgScannerUnavailable, Reason: ReasonScannerUnavailable}
|
|
}
|
|
cleanPath := filepath.Clean(path)
|
|
if strings.TrimSpace(path) == "" || cleanPath == "." {
|
|
return nil, &RequestError{Status: http.StatusBadRequest, Code: codeBadRequest, Message: msgPathRequired, Reason: ReasonPathRequired}
|
|
}
|
|
if _, err := os.Lstat(cleanPath); err == nil {
|
|
return nil, &RequestError{Status: http.StatusBadRequest, Code: codeBadRequest, Message: "Path still exists", Reason: ReasonPathStillExists}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
// Only a confirmed ENOENT counts as vanished. Permission or other
|
|
// stat failures must not reconcile still-existing files as missing.
|
|
return nil, &RequestError{Status: http.StatusBadRequest, Code: codeBadRequest, Message: msgPathNotInspectable, Reason: ReasonPathNotInspectable}
|
|
}
|
|
folder, matchedRoot, err := r.matchEnabledFolder(ctx, cleanPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info, statErr := os.Stat(matchedRoot); statErr != nil || !info.IsDir() {
|
|
return nil, &RequestError{Status: http.StatusConflict, Code: codeConflict, Message: "Library root is not available", Reason: ReasonLibraryRootOffline}
|
|
}
|
|
trigger = normalizeTrigger(trigger)
|
|
|
|
if supportsLibraryMediaFile(cleanPath, folder.Type) {
|
|
return &Target{Folder: folder, Mode: ModeFile, Path: cleanPath, Trigger: trigger}, nil
|
|
}
|
|
if supportsMediaFile(cleanPath) {
|
|
return nil, &RequestError{Status: http.StatusBadRequest, Code: codeBadRequest, Message: msgUnsupportedExt, Reason: ReasonUnsupportedExtension}
|
|
}
|
|
scope := cleanPath
|
|
if filepath.Clean(scope) == filepath.Clean(matchedRoot) {
|
|
// A vanished entry directly under the root reconciles via a full
|
|
// library scan, which keeps the empty-root guard in play.
|
|
return &Target{Folder: folder, Mode: ModeLibrary, Trigger: trigger}, nil
|
|
}
|
|
return &Target{Folder: folder, Mode: ModeSubtree, Path: scope, Trigger: trigger}, nil
|
|
}
|
|
|
|
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: codeUnavailable, Message: msgScannerUnavailable, Reason: ReasonScannerUnavailable}
|
|
}
|
|
if req.LibraryID == nil && strings.TrimSpace(req.Path) == "" {
|
|
return nil, &RequestError{Status: http.StatusBadRequest, Code: codeBadRequest, Message: "Either library_id or path is required", Reason: ReasonLibraryOrPathRequired}
|
|
}
|
|
|
|
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: codeNotFound, Message: "Library not found", Reason: ReasonLibraryNotFound}
|
|
}
|
|
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: codeConflict, Message: msgLibraryDisabled, Reason: ReasonLibraryDisabled}
|
|
}
|
|
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: codeBadRequest, Message: "Path does not belong to the specified library", Reason: ReasonPathOutsideLibrary}
|
|
}
|
|
} 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: codeConflict, Message: msgLibraryDisabled, Reason: ReasonLibraryDisabled}
|
|
}
|
|
|
|
mode, err := ClassifyLibraryPath(cleanPath, matchedRoot, folder.Type)
|
|
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: codeUnavailable, Message: msgScannerUnavailable, Reason: ReasonScannerUnavailable}
|
|
}
|
|
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: codeBadRequest, Message: "Path matches multiple libraries", Reason: ReasonPathAmbiguous}
|
|
}
|
|
if bestFolder == nil {
|
|
return nil, "", &RequestError{Status: http.StatusBadRequest, Code: codeBadRequest, Message: "No library matches the given path", Reason: ReasonNoLibraryMatch}
|
|
}
|
|
return bestFolder, bestRoot, nil
|
|
}
|
|
|
|
func ClassifyPath(targetPath, matchedRoot string) (string, error) {
|
|
return ClassifyLibraryPath(targetPath, matchedRoot, "")
|
|
}
|
|
|
|
func ClassifyLibraryPath(targetPath, matchedRoot, folderType 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: codeBadRequest, Message: "Path does not exist", Reason: ReasonPathMissing}
|
|
case errors.Is(err, os.ErrPermission):
|
|
return "", &RequestError{Status: http.StatusBadRequest, Code: codeBadRequest, Message: "Permission denied for path", Reason: ReasonPathPermissionDenied}
|
|
default:
|
|
return "", &RequestError{Status: http.StatusBadRequest, Code: codeBadRequest, Message: msgPathNotInspectable, Reason: ReasonPathNotInspectable}
|
|
}
|
|
}
|
|
if info.IsDir() {
|
|
return ModeSubtree, nil
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return "", &RequestError{Status: http.StatusBadRequest, Code: codeBadRequest, Message: "Path must be a file or directory", Reason: ReasonPathNotFileOrDir}
|
|
}
|
|
if !supportsLibraryMediaFile(targetPath, folderType) {
|
|
return "", &RequestError{Status: http.StatusBadRequest, Code: codeBadRequest, Message: msgUnsupportedExt, Reason: ReasonUnsupportedExtension}
|
|
}
|
|
return ModeFile, nil
|
|
}
|
|
|
|
func supportsLibraryMediaFile(path, folderType string) bool {
|
|
switch {
|
|
case librarykind.IsAudiobook(folderType):
|
|
return scanner.SupportsAudioFile(path)
|
|
case librarykind.IsEbook(folderType), librarykind.IsManga(folderType):
|
|
return scanner.SupportsEbookFile(path)
|
|
case librarykind.IsPodcast(folderType):
|
|
return false
|
|
default:
|
|
return scanner.SupportsVideoFile(path)
|
|
}
|
|
}
|
|
|
|
func supportsMediaFile(path string) bool {
|
|
return scanner.SupportsVideoFile(path) ||
|
|
scanner.SupportsAudioFile(path) ||
|
|
scanner.SupportsEbookFile(path)
|
|
}
|
|
|
|
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))
|
|
}
|