Files
silo-server/internal/scantrigger/scantrigger.go
T
Quick 6535203791 ci: gate Go lint on the lines a branch changes
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.
2026-07-27 00:32:15 +00:00

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))
}