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
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:
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user