feat: the library's folders are set in Admin › Library, and changing a folder moves its tracks with it, keeping their plays, likes and playlists (#6129)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m20s
release / go (push) Failing after 1m37s
release / integration (push) Failing after 4m56s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 20:37:03 -04:00
co-authored by Claude Opus 5.5
parent 453a977e48
commit edcb3a57ef
25 changed files with 1102 additions and 57 deletions
+111
View File
@@ -0,0 +1,111 @@
package api
import (
"context"
"errors"
"net/http"
"os"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// The library's folders (#6129): where the scan, the watcher and the import
// look for music, set here rather than only by MINSTREL_LIBRARY_SCAN_PATHS.
type libraryFolderView struct {
Path string `json:"path"`
// Present is whether the server can see the folder right now; a folder
// whose mount went away reads false here before anything else notices.
Present bool `json:"present"`
Tracks int64 `json:"tracks"`
}
type libraryFoldersView struct {
Folders []libraryFolderView `json:"folders"`
// Saved is false while the folders still come from
// MINSTREL_LIBRARY_SCAN_PATHS.
Saved bool `json:"saved"`
// Moved is how many tracks a save moved to a new folder.
Moved int64 `json:"moved,omitempty"`
}
func (h *handlers) libraryFolders(ctx context.Context) (libraryFoldersView, error) {
q := dbq.New(h.pool)
row, err := q.GetLibrarySettings(ctx)
if err != nil {
return libraryFoldersView{}, err
}
v := libraryFoldersView{Folders: []libraryFolderView{}, Saved: len(row.ScanPaths) > 0}
for _, root := range h.scanner.Roots() {
n, err := q.CountTracksUnder(ctx, root)
if err != nil {
return libraryFoldersView{}, err
}
info, statErr := os.Stat(root)
v.Folders = append(v.Folders, libraryFolderView{Path: root, Present: statErr == nil && info.IsDir(), Tracks: n})
}
return v, nil
}
// handleGetLibraryFolders implements GET /api/admin/library/folders.
func (h *handlers) handleGetLibraryFolders(w http.ResponseWriter, r *http.Request) {
v, err := h.libraryFolders(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin library folders: get", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, v)
}
type putLibraryFoldersBody struct {
Folders []struct {
Path string `json:"path"`
// Was is the folder this one replaces, whose files are now at Path;
// "" is a folder added.
Was string `json:"was"`
} `json:"folders"`
}
// handlePutLibraryFolders implements PUT /api/admin/library/folders.
//
// The whole list. A folder whose path changed moves its tracks with it, so
// their plays, likes and playlists survive; one left out is no longer read,
// and its tracks are marked missing by the next scan. After a save a scan
// starts, to pick up anything the old folders did not hold.
func (h *handlers) handlePutLibraryFolders(w http.ResponseWriter, r *http.Request) {
var body putLibraryFoldersBody
if !decodeBody(w, r, &body) {
return
}
if h.scanner == nil {
writeErr(w, apierror.Internal(errors.New("no scanner")))
return
}
changes := make([]library.RootChange, 0, len(body.Folders))
for _, f := range body.Folders {
changes = append(changes, library.RootChange{Path: f.Path, Was: f.Was})
}
res, err := h.scanner.SetRoots(r.Context(), changes)
if err != nil {
var re *library.RootsError
if errors.As(err, &re) {
writeErr(w, apierror.BadRequest("bad_folders", re.Error()))
return
}
writeErrWithLog(w, h.logger, "admin library folders: put", apierror.Internal(err))
return
}
if _, _, err := library.TryStartScan(context.Background(), h.pool, h.scanner, h.coverart,
h.logger.With("source", "library_folders"), h.scanCfg); err != nil {
h.logger.Warn("admin library folders: scan after save", "err", err)
}
v, err := h.libraryFolders(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "admin library folders: get", apierror.Internal(err))
return
}
v.Moved = res.Tracks
writeJSON(w, http.StatusOK, v)
}
+3 -3
View File
@@ -79,7 +79,7 @@ func (h *handlers) handleGetSearchSettings(w http.ResponseWriter, r *http.Reques
writeErrWithLog(w, h.logger, "admin search settings: get", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots))
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots()))
}
type putSearchSettingsBody struct {
@@ -117,7 +117,7 @@ func (h *handlers) handlePutSearchSettings(w http.ResponseWriter, r *http.Reques
writeErr(w, apierror.BadRequest("bad_upgrade_until", "unknown quality "+body.UpgradeUntil))
return
}
if body.ImportRoot != "" && !slices.Contains(h.importer.Roots, body.ImportRoot) {
if body.ImportRoot != "" && !slices.Contains(h.importer.Roots(), body.ImportRoot) {
writeErr(w, apierror.BadRequest("bad_import_root", "file new music into one of the library's folders"))
return
}
@@ -136,7 +136,7 @@ func (h *handlers) handlePutSearchSettings(w http.ResponseWriter, r *http.Reques
writeErrWithLog(w, h.logger, "admin search settings: put", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots))
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots()))
}
// candidateView is one release a want's last search found.
+1 -1
View File
@@ -151,7 +151,7 @@ func (h *handlers) handleGetAdminStatus(w http.ResponseWriter, r *http.Request)
out.Library = libraryStatusView{Tracks: lib.Tracks, Albums: lib.Albums, Artists: lib.Artists,
Bytes: lib.Bytes, Disks: []diskView{}}
if h.importer != nil {
for _, root := range h.importer.Roots {
for _, root := range h.importer.Roots() {
out.Library.Disks = append(out.Library.Disks, diskOf(root))
}
}
+13 -6
View File
@@ -299,6 +299,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/library/reacquisition", h.handleGetReacquisitionSettings)
admin.Put("/library/reacquisition", h.handleUpdateReacquisitionSettings)
// Where the library lives (#6129). A changed folder moves its
// tracks rather than re-adding them.
admin.Get("/library/folders", h.handleGetLibraryFolders)
admin.Put("/library/folders", h.handlePutLibraryFolders)
admin.Get("/scan/status", h.handleGetScanStatus)
admin.Post("/scan/run", h.handleTriggerScan)
// Sits under /library rather than /tracks because what it
@@ -498,11 +503,13 @@ func artSources(s *coverart.SettingsService) catalogue.ArtSources {
return s
}
// scannerRoots are the library folders, or none when the API runs without a
// scanner (tests).
func scannerRoots(s *library.Scanner) []string {
if s == nil {
return nil
// scannerRoots reads the library folders, or none when the API runs without
// a scanner (tests).
func scannerRoots(s *library.Scanner) func() []string {
return func() []string {
if s == nil {
return nil
}
return s.Roots()
}
return s.Roots()
}