From 0f1d0abbb0f1045586613b3324d2a27f619dd714 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 27 Oct 2026 13:22:41 -0400 Subject: [PATCH] feat(libraries): scope a library scan to one registered folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/libraries/:id/scan accepts an optional {"folder_path": "..."}: the scan then covers exactly that one registered folder's subtree instead of the whole library. The path is validated against the library's registered folders — anything else is a 400, never trusted. An omitted body keeps today's full-library behavior, and coalescing (409 with the active job) is unchanged. The LibraryPanel folder rows get a per-folder Scan button: pure htmx attributes posting to the same scan endpoint, with hxValsFolderScan emitting plain eval-free JSON for hx-vals alongside the existing hxValsFolderDelete precedent. Progress surfaces through the existing active-scan polling; no new client code. Safety: the scanner's archive-reconciliation guard (previous commit) skips partially-covered libraries, so a one-folder scan cannot mark the library's other folders' items missing. --- bruno/libraries/scan_library.yml | 2 +- docs/developer/api/libraries/scan_library.md | 11 +- internal/handlers/scanner.go | 32 +- templates/admin_library.templ | 12 + templates/admin_library_templ.go | 414 ++++++++++--------- templates/utils.go | 12 + 6 files changed, 286 insertions(+), 197 deletions(-) diff --git a/bruno/libraries/scan_library.yml b/bruno/libraries/scan_library.yml index c61a6e74..3d145d8f 100644 --- a/bruno/libraries/scan_library.yml +++ b/bruno/libraries/scan_library.yml @@ -1,7 +1,7 @@ info: name: Scan Library type: http - seq: 18 + seq: 19 http: method: POST diff --git a/docs/developer/api/libraries/scan_library.md b/docs/developer/api/libraries/scan_library.md index 3cb74597..ac45693b 100644 --- a/docs/developer/api/libraries/scan_library.md +++ b/docs/developer/api/libraries/scan_library.md @@ -8,10 +8,17 @@ area: libraries # Scan Library -Enqueues a full scan of one library's configured folders. The library id comes +Enqueues a scan of one library's configured folders. The library id comes from the path — no request body is needed (the generic `POST /api/scanner/scan` variant is the one that takes `folder_paths`). +**Per-folder scope**: an optional body of `{"folder_path": "…"}` restricts the +scan to ONE of the library's registered folders (the per-folder Scan buttons +in the library panel). The path must be a registered folder — anything else is +a **400**. A scoped scan never runs archive reconciliation for the library's +other folders, so items outside the scanned folder cannot be marked missing +by it. + Scans are **coalesced**: if a scan for this library is already pending or running you get **409** with the active job id instead of a second scan. @@ -29,7 +36,7 @@ running you get **409** with the active job id instead of a second scan. | Status | Body | When | | ------ | ---- | ---- | -| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` | malformed id / empty library | +| 400 | `{"error": "invalid library id"}` / `{"error": "library has no folders configured"}` / `{"error": "folder_path is not one of this library's registered folders"}` | malformed id / empty library / unregistered folder scope | | 404 | `{"error": "library not found or has no folders"}` | unknown library | | 409 | `{"error": "a scan for this library is already pending or running", "active_job_id": "..."}` | scan coalescing | | 500 | `{"error": "failed to enqueue scan job: ..."}` | queue failure | diff --git a/internal/handlers/scanner.go b/internal/handlers/scanner.go index 6ca4ac15..126c21c9 100644 --- a/internal/handlers/scanner.go +++ b/internal/handlers/scanner.go @@ -27,7 +27,12 @@ func (h *Handler) StartBackgroundTasks() { type ScanLibraryRequest struct { FolderPaths []string `json:"folder_paths,omitempty"` LibraryID string `json:"library_id,omitempty"` - Force bool `json:"force,omitempty"` + // FolderPath (singular) scopes a library scan to ONE of the library's + // registered folders (the LibraryPanel per-folder Scan buttons). It + // must be a registered folder — validated below, never trusted. The + // raw /api/scanner/scan route ignores it (no library attribution). + FolderPath string `json:"folder_path,omitempty"` + Force bool `json:"force,omitempty"` } // ScanLibrary handles POST /api/scanner/scan (now runs in background) @@ -68,9 +73,28 @@ func (h *Handler) ScanLibrary(c *echo.Context) error { return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"}) } - // Extract folder paths - for _, folder := range libraryFolders { - folderPaths = append(folderPaths, folder.FolderPath) + if req.FolderPath != "" { + // Per-folder scope (LibraryPanel per-folder Scan buttons): + // scan exactly one registered folder's subtree. The archive + // reconciliation pass skips partially-covered libraries + // (processScanJob → ScanFolders), so the other folders' + // items cannot be marked missing by this run. + registered := false + for _, folder := range libraryFolders { + if folder.FolderPath == req.FolderPath { + registered = true + break + } + } + if !registered { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "folder_path is not one of this library's registered folders"}) + } + folderPaths = []string{req.FolderPath} + } else { + // Extract folder paths + for _, folder := range libraryFolders { + folderPaths = append(folderPaths, folder.FolderPath) + } } if len(folderPaths) == 0 { diff --git a/templates/admin_library.templ b/templates/admin_library.templ index b708d33f..d8b7adab 100644 --- a/templates/admin_library.templ +++ b/templates/admin_library.templ @@ -267,6 +267,18 @@ templ LibraryPanel(user User, libraryID string, library LibraryData, folders []F for _, folder := range folders {
{ folder.FolderPath } +
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "Add") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(users) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -657,115 +691,115 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "

User Access

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "

User Access

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, u := range users { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" hx-swap=\"innerHTML\"> ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 343, Col: 76} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var32 string + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 344, Col: 75} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if issueCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" class=\"flex items-center gap-2 text-sm\" style=\"color: var(--status-warning);\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -774,25 +808,25 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo return templ_7745c5c3_Err } if issueCount == 1 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "1 processing issue") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "1 processing issue") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount) + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 346, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 358, Col: 24} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " processing issues") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " processing issues") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -801,51 +835,51 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "Delete Library") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -911,12 +945,12 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var38 := templ.GetChildren(ctx) - if templ_7745c5c3_Var38 == nil { - templ_7745c5c3_Var38 = templ.NopComponent + templ_7745c5c3_Var40 := templ.GetChildren(ctx) + if templ_7745c5c3_Var40 == nil { + templ_7745c5c3_Var40 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -924,38 +958,38 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var39 string - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath) + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 381, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 393, Col: 89} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if parentPath != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "..") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, entry := range entries { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "Select This Folder
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/utils.go b/templates/utils.go index c60320c7..e3512654 100644 --- a/templates/utils.go +++ b/templates/utils.go @@ -383,6 +383,18 @@ func hxValsFolderDelete(folderPath string) string { return "js:" + string(b) } +// hxValsFolderScan builds the hx-vals for the LibraryPanel per-folder +// Scan button: it scopes the library scan endpoint to ONE registered +// folder (ScanLibraryRequest.FolderPath). Plain JSON, no js: prefix — +// the eval-free form under the hash CSP. +func hxValsFolderScan(folderPath string) string { + b, err := json.Marshal(map[string]string{"folder_path": folderPath}) + if err != nil { + return "{}" + } + return string(b) +} + // fmtSecondsHMS renders seconds as H:MM:SS (or M:SS under an hour) — // the audio progress display shape shared with the web player. func fmtSecondsHMS(seconds float64) string {