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:
+16
-5
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"slices"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -135,7 +136,18 @@ func run() error {
|
|||||||
if fpErr != nil {
|
if fpErr != nil {
|
||||||
logger.Warn("fingerprint settings: using defaults", "err", fpErr)
|
logger.Warn("fingerprint settings: using defaults", "err", fpErr)
|
||||||
}
|
}
|
||||||
scanner := library.New(pool, logger, cfg.Library.ScanPaths, fpSettings)
|
// The library's folders (#6129): the ones saved in Settings, or
|
||||||
|
// MINSTREL_LIBRARY_SCAN_PATHS until any are. A load failure falls back to
|
||||||
|
// env, which is what the server read before the setting existed.
|
||||||
|
roots, rootsSaved, rootsErr := library.LoadRoots(ctx, pool, cfg.Library.ScanPaths)
|
||||||
|
switch {
|
||||||
|
case rootsErr != nil:
|
||||||
|
logger.Warn("library folders: using MINSTREL_LIBRARY_SCAN_PATHS", "err", rootsErr)
|
||||||
|
case rootsSaved && len(cfg.Library.ScanPaths) > 0 && !slices.Equal(roots, cfg.Library.ScanPaths):
|
||||||
|
logger.Warn("library folders: the ones saved in Settings are used, not MINSTREL_LIBRARY_SCAN_PATHS",
|
||||||
|
"settings", roots, "env", cfg.Library.ScanPaths)
|
||||||
|
}
|
||||||
|
scanner := library.New(pool, logger, roots, fpSettings)
|
||||||
|
|
||||||
// Loudness analysis settings (M464 #4995): shared by the loudness backfill
|
// Loudness analysis settings (M464 #4995): shared by the loudness backfill
|
||||||
// and the admin API, and falls back to the defaults like the above.
|
// and the admin API, and falls back to the defaults like the above.
|
||||||
@@ -195,7 +207,7 @@ func run() error {
|
|||||||
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
||||||
// Boot-time scan and manual-trigger scans share this path; results land
|
// Boot-time scan and manual-trigger scans share this path; results land
|
||||||
// in scan_runs for the admin overview.
|
// in scan_runs for the admin overview.
|
||||||
if cfg.Library.ScanOnStartup && len(cfg.Library.ScanPaths) > 0 {
|
if cfg.Library.ScanOnStartup && len(roots) > 0 {
|
||||||
go func() {
|
go func() {
|
||||||
if _, err := library.RunScan(ctx, pool, scanner, coverEnricher,
|
if _, err := library.RunScan(ctx, pool, scanner, coverEnricher,
|
||||||
logger.With("component", "scan_run"),
|
logger.With("component", "scan_run"),
|
||||||
@@ -340,7 +352,7 @@ func run() error {
|
|||||||
if acoustIDSettings != nil {
|
if acoustIDSettings != nil {
|
||||||
printer = &importer.AcoustIDPrinter{Settings: acoustIDSettings, Client: acoustIDClient}
|
printer = &importer.AcoustIDPrinter{Settings: acoustIDSettings, Client: acoustIDClient}
|
||||||
}
|
}
|
||||||
imp := importer.New(pool, scanner, cat, printer, cfg.Library.ScanPaths, logger.With("component", "import"))
|
imp := importer.New(pool, scanner, cat, printer, scanner.Roots, logger.With("component", "import"))
|
||||||
imp.DataDir = cfg.Storage.DataDir
|
imp.DataDir = cfg.Storage.DataDir
|
||||||
go imp.Run(ctx)
|
go imp.Run(ctx)
|
||||||
|
|
||||||
@@ -431,8 +443,7 @@ func run() error {
|
|||||||
// Filesystem watcher: near-instant pickup of new/changed files via
|
// Filesystem watcher: near-instant pickup of new/changed files via
|
||||||
// fsnotify, scanning just the affected paths + enriching their albums
|
// fsnotify, scanning just the affected paths + enriching their albums
|
||||||
// inline. Replaces the removed configurable scan scheduler.
|
// inline. Replaces the removed configurable scan scheduler.
|
||||||
watcher := library.NewWatcher(scanner, coverEnricher,
|
watcher := library.NewWatcher(scanner, coverEnricher, logger.With("component", "watcher"))
|
||||||
logger.With("component", "watcher"), cfg.Library.ScanPaths)
|
|
||||||
go func() {
|
go func() {
|
||||||
if werr := watcher.Run(ctx); werr != nil {
|
if werr := watcher.Run(ctx); werr != nil {
|
||||||
logger.Warn("library watcher exited", "err", werr)
|
logger.Warn("library watcher exited", "err", werr)
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ log:
|
|||||||
|
|
||||||
library:
|
library:
|
||||||
# Filesystem roots to scan for music. Each path is walked recursively.
|
# Filesystem roots to scan for music. Each path is walked recursively.
|
||||||
|
# These are the starting folders only: once they are changed in
|
||||||
|
# Admin › Library, the saved ones are used and this value is ignored.
|
||||||
|
# Changing a folder there moves its tracks with it, so moving the library
|
||||||
|
# to a new mount keeps every play, like and playlist.
|
||||||
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
||||||
scan_paths: []
|
scan_paths: []
|
||||||
# Kick off a scan on server startup. On by default so a fresh install
|
# Kick off a scan on server startup. On by default so a fresh install
|
||||||
|
|||||||
@@ -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))
|
writeErrWithLog(w, h.logger, "admin search settings: get", apierror.Internal(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots))
|
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots()))
|
||||||
}
|
}
|
||||||
|
|
||||||
type putSearchSettingsBody struct {
|
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))
|
writeErr(w, apierror.BadRequest("bad_upgrade_until", "unknown quality "+body.UpgradeUntil))
|
||||||
return
|
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"))
|
writeErr(w, apierror.BadRequest("bad_import_root", "file new music into one of the library's folders"))
|
||||||
return
|
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))
|
writeErrWithLog(w, h.logger, "admin search settings: put", apierror.Internal(err))
|
||||||
return
|
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.
|
// 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,
|
out.Library = libraryStatusView{Tracks: lib.Tracks, Albums: lib.Albums, Artists: lib.Artists,
|
||||||
Bytes: lib.Bytes, Disks: []diskView{}}
|
Bytes: lib.Bytes, Disks: []diskView{}}
|
||||||
if h.importer != nil {
|
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))
|
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.Get("/library/reacquisition", h.handleGetReacquisitionSettings)
|
||||||
admin.Put("/library/reacquisition", h.handleUpdateReacquisitionSettings)
|
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.Get("/scan/status", h.handleGetScanStatus)
|
||||||
admin.Post("/scan/run", h.handleTriggerScan)
|
admin.Post("/scan/run", h.handleTriggerScan)
|
||||||
// Sits under /library rather than /tracks because what it
|
// Sits under /library rather than /tracks because what it
|
||||||
@@ -498,11 +503,13 @@ func artSources(s *coverart.SettingsService) catalogue.ArtSources {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// scannerRoots are the library folders, or none when the API runs without a
|
// scannerRoots reads the library folders, or none when the API runs without
|
||||||
// scanner (tests).
|
// a scanner (tests).
|
||||||
func scannerRoots(s *library.Scanner) []string {
|
func scannerRoots(s *library.Scanner) func() []string {
|
||||||
if s == nil {
|
return func() []string {
|
||||||
return nil
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.Roots()
|
||||||
}
|
}
|
||||||
return s.Roots()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: library_settings.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const countTracksUnder = `-- name: CountTracksUnder :one
|
||||||
|
SELECT count(*) FROM tracks WHERE starts_with(file_path, $1::text || '/')
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountTracksUnder(ctx context.Context, root string) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countTracksUnder, root)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLibrarySettings = `-- name: GetLibrarySettings :one
|
||||||
|
SELECT id, scan_paths, updated_at FROM library_settings WHERE id = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetLibrarySettings(ctx context.Context) (LibrarySetting, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getLibrarySettings)
|
||||||
|
var i LibrarySetting
|
||||||
|
err := row.Scan(&i.ID, &i.ScanPaths, &i.UpdatedAt)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const rerootAlbumCoverPaths = `-- name: RerootAlbumCoverPaths :execrows
|
||||||
|
UPDATE albums
|
||||||
|
SET cover_art_path = $1::text || substr(cover_art_path, length($2::text) + 1)
|
||||||
|
WHERE starts_with(cover_art_path, $2::text || '/')
|
||||||
|
`
|
||||||
|
|
||||||
|
type RerootAlbumCoverPathsParams struct {
|
||||||
|
NewRoot string
|
||||||
|
OldRoot string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RerootAlbumCoverPaths(ctx context.Context, arg RerootAlbumCoverPathsParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, rerootAlbumCoverPaths, arg.NewRoot, arg.OldRoot)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const rerootGrabDestinations = `-- name: RerootGrabDestinations :execrows
|
||||||
|
UPDATE grabs
|
||||||
|
SET destination = $1::text || substr(destination, length($2::text) + 1)
|
||||||
|
WHERE destination = $2::text
|
||||||
|
OR starts_with(destination, $2::text || '/')
|
||||||
|
`
|
||||||
|
|
||||||
|
type RerootGrabDestinationsParams struct {
|
||||||
|
NewRoot string
|
||||||
|
OldRoot string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RerootGrabDestinations(ctx context.Context, arg RerootGrabDestinationsParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, rerootGrabDestinations, arg.NewRoot, arg.OldRoot)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const rerootImportRoot = `-- name: RerootImportRoot :execrows
|
||||||
|
UPDATE acquisition_settings
|
||||||
|
SET import_root = $1::text
|
||||||
|
WHERE import_root = $2::text
|
||||||
|
`
|
||||||
|
|
||||||
|
type RerootImportRootParams struct {
|
||||||
|
NewRoot string
|
||||||
|
OldRoot string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RerootImportRoot(ctx context.Context, arg RerootImportRootParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, rerootImportRoot, arg.NewRoot, arg.OldRoot)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const rerootTrackPaths = `-- name: RerootTrackPaths :execrows
|
||||||
|
|
||||||
|
UPDATE tracks
|
||||||
|
SET file_path = $1::text || substr(file_path, length($2::text) + 1)
|
||||||
|
WHERE starts_with(file_path, $2::text || '/')
|
||||||
|
`
|
||||||
|
|
||||||
|
type RerootTrackPathsParams struct {
|
||||||
|
NewRoot string
|
||||||
|
OldRoot string
|
||||||
|
}
|
||||||
|
|
||||||
|
// A library folder that moved (#6129): every path under old_root is rewritten
|
||||||
|
// to the same place under new_root, so the tracks keep their ids and with them
|
||||||
|
// their plays, likes and playlists. updated_at is left alone on purpose: the
|
||||||
|
// scan compares it with each file's mtime, and the files have not changed.
|
||||||
|
func (q *Queries) RerootTrackPaths(ctx context.Context, arg RerootTrackPathsParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, rerootTrackPaths, arg.NewRoot, arg.OldRoot)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleTrackPathsUnder = `-- name: SampleTrackPathsUnder :many
|
||||||
|
SELECT file_path FROM tracks
|
||||||
|
WHERE starts_with(file_path, $1::text || '/') AND missing_since IS NULL
|
||||||
|
ORDER BY random()
|
||||||
|
LIMIT $2::int
|
||||||
|
`
|
||||||
|
|
||||||
|
type SampleTrackPathsUnderParams struct {
|
||||||
|
Root string
|
||||||
|
N int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// A few present tracks under a folder, to check a move before rewriting it.
|
||||||
|
func (q *Queries) SampleTrackPathsUnder(ctx context.Context, arg SampleTrackPathsUnderParams) ([]string, error) {
|
||||||
|
rows, err := q.db.Query(ctx, sampleTrackPathsUnder, arg.Root, arg.N)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []string
|
||||||
|
for rows.Next() {
|
||||||
|
var file_path string
|
||||||
|
if err := rows.Scan(&file_path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, file_path)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateLibraryScanPaths = `-- name: UpdateLibraryScanPaths :one
|
||||||
|
UPDATE library_settings
|
||||||
|
SET scan_paths = $1::text[],
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = true
|
||||||
|
RETURNING id, scan_paths, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) UpdateLibraryScanPaths(ctx context.Context, scanPaths []string) (LibrarySetting, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateLibraryScanPaths, scanPaths)
|
||||||
|
var i LibrarySetting
|
||||||
|
err := row.Scan(&i.ID, &i.ScanPaths, &i.UpdatedAt)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -506,6 +506,12 @@ type LibraryChange struct {
|
|||||||
ChangedAt pgtype.Timestamptz
|
ChangedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LibrarySetting struct {
|
||||||
|
ID bool
|
||||||
|
ScanPaths []string
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type ListenbrainzSimilarRecording struct {
|
type ListenbrainzSimilarRecording struct {
|
||||||
SeedTrackID pgtype.UUID
|
SeedTrackID pgtype.UUID
|
||||||
RecordingMbid string
|
RecordingMbid string
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS library_settings;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- The library's folders, set in Settings (#6129). Until someone saves them
|
||||||
|
-- there, scan_paths is NULL and MINSTREL_LIBRARY_SCAN_PATHS names the folders,
|
||||||
|
-- as it always has; from the first save the row is what the server reads.
|
||||||
|
CREATE TABLE library_settings (
|
||||||
|
id boolean PRIMARY KEY DEFAULT true,
|
||||||
|
scan_paths text[],
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT library_settings_singleton CHECK (id = true)
|
||||||
|
);
|
||||||
|
INSERT INTO library_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- name: GetLibrarySettings :one
|
||||||
|
SELECT * FROM library_settings WHERE id = true;
|
||||||
|
|
||||||
|
-- name: UpdateLibraryScanPaths :one
|
||||||
|
UPDATE library_settings
|
||||||
|
SET scan_paths = sqlc.arg(scan_paths)::text[],
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = true
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- A library folder that moved (#6129): every path under old_root is rewritten
|
||||||
|
-- to the same place under new_root, so the tracks keep their ids and with them
|
||||||
|
-- their plays, likes and playlists. updated_at is left alone on purpose: the
|
||||||
|
-- scan compares it with each file's mtime, and the files have not changed.
|
||||||
|
|
||||||
|
-- name: RerootTrackPaths :execrows
|
||||||
|
UPDATE tracks
|
||||||
|
SET file_path = sqlc.arg(new_root)::text || substr(file_path, length(sqlc.arg(old_root)::text) + 1)
|
||||||
|
WHERE starts_with(file_path, sqlc.arg(old_root)::text || '/');
|
||||||
|
|
||||||
|
-- name: RerootAlbumCoverPaths :execrows
|
||||||
|
UPDATE albums
|
||||||
|
SET cover_art_path = sqlc.arg(new_root)::text || substr(cover_art_path, length(sqlc.arg(old_root)::text) + 1)
|
||||||
|
WHERE starts_with(cover_art_path, sqlc.arg(old_root)::text || '/');
|
||||||
|
|
||||||
|
-- name: RerootGrabDestinations :execrows
|
||||||
|
UPDATE grabs
|
||||||
|
SET destination = sqlc.arg(new_root)::text || substr(destination, length(sqlc.arg(old_root)::text) + 1)
|
||||||
|
WHERE destination = sqlc.arg(old_root)::text
|
||||||
|
OR starts_with(destination, sqlc.arg(old_root)::text || '/');
|
||||||
|
|
||||||
|
-- name: RerootImportRoot :execrows
|
||||||
|
UPDATE acquisition_settings
|
||||||
|
SET import_root = sqlc.arg(new_root)::text
|
||||||
|
WHERE import_root = sqlc.arg(old_root)::text;
|
||||||
|
|
||||||
|
-- name: SampleTrackPathsUnder :many
|
||||||
|
-- A few present tracks under a folder, to check a move before rewriting it.
|
||||||
|
SELECT file_path FROM tracks
|
||||||
|
WHERE starts_with(file_path, sqlc.arg(root)::text || '/') AND missing_since IS NULL
|
||||||
|
ORDER BY random()
|
||||||
|
LIMIT sqlc.arg(n)::int;
|
||||||
|
|
||||||
|
-- name: CountTracksUnder :one
|
||||||
|
SELECT count(*) FROM tracks WHERE starts_with(file_path, sqlc.arg(root)::text || '/');
|
||||||
@@ -63,9 +63,10 @@ type Importer struct {
|
|||||||
Scanner Scanner
|
Scanner Scanner
|
||||||
Catalogue Catalogue
|
Catalogue Catalogue
|
||||||
Printer Printer
|
Printer Printer
|
||||||
// Roots are the library's folders (the scan paths). New music is filed
|
// Roots are the library's folders (the scan paths), read on every use
|
||||||
// under the one the settings name, or the first.
|
// because they change in Settings (#6129). New music is filed under the
|
||||||
Roots []string
|
// one the settings name, or the first.
|
||||||
|
Roots func() []string
|
||||||
// DataDir is where cached art lives, for tidying an artist an upgrade
|
// DataDir is where cached art lives, for tidying an artist an upgrade
|
||||||
// leaves with nothing (#5524). Empty skips it.
|
// leaves with nothing (#5524). Empty skips it.
|
||||||
DataDir string
|
DataDir string
|
||||||
@@ -79,7 +80,7 @@ type Importer struct {
|
|||||||
|
|
||||||
// New returns an Importer. printer may be nil: the check then rests on
|
// New returns an Importer. printer may be nil: the check then rests on
|
||||||
// lengths and tags.
|
// lengths and tags.
|
||||||
func New(pool *pgxpool.Pool, scanner Scanner, cat Catalogue, printer Printer, roots []string, logger *slog.Logger) *Importer {
|
func New(pool *pgxpool.Pool, scanner Scanner, cat Catalogue, printer Printer, roots func() []string, logger *slog.Logger) *Importer {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
}
|
}
|
||||||
@@ -549,14 +550,15 @@ func (im *Importer) editions(ctx context.Context, group string) ([]Edition, erro
|
|||||||
|
|
||||||
// root is the library folder new music is filed into.
|
// root is the library folder new music is filed into.
|
||||||
func (im *Importer) root(ctx context.Context, q *dbq.Queries) (string, error) {
|
func (im *Importer) root(ctx context.Context, q *dbq.Queries) (string, error) {
|
||||||
if len(im.Roots) == 0 {
|
roots := im.Roots()
|
||||||
return "", errors.New("there is no library folder to file new music into (MINSTREL_LIBRARY_SCAN_PATHS)")
|
if len(roots) == 0 {
|
||||||
|
return "", errors.New("there is no library folder to file new music into; add one in Settings → Library")
|
||||||
}
|
}
|
||||||
s, err := q.GetAcquisitionSettings(ctx)
|
s, err := q.GetAcquisitionSettings(ctx)
|
||||||
if err == nil && s.ImportRoot != "" && slices.Contains(im.Roots, s.ImportRoot) {
|
if err == nil && s.ImportRoot != "" && slices.Contains(roots, s.ImportRoot) {
|
||||||
return s.ImportRoot, nil
|
return s.ImportRoot, nil
|
||||||
}
|
}
|
||||||
return im.Roots[0], nil
|
return roots[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// held reads which of the planned tracks the library already has: by
|
// held reads which of the planned tracks the library already has: by
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ func newStack(t *testing.T, printer Printer) *stack {
|
|||||||
}
|
}
|
||||||
s := &stack{pool: pool, q: q, client: &fakeClient{content: content}, scanner: &fakeScanner{},
|
s := &stack{pool: pool, q: q, client: &fakeClient{content: content}, scanner: &fakeScanner{},
|
||||||
library: t.TempDir(), want: w, grab: g}
|
library: t.TempDir(), want: w, grab: g}
|
||||||
s.im = New(pool, s.scanner, fakeCatalogue{}, printer, []string{s.library}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
s.im = New(pool, s.scanner, fakeCatalogue{}, printer, func() []string { return []string{s.library} }, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
s.im.ClientFor = func(context.Context, string) (downloader.Driver, error) { return s.client, nil }
|
s.im.ClientFor = func(context.Context, string) (downloader.Driver, error) { return s.client, nil }
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The library's folders (#6129). They are set in Settings and changed without
|
||||||
|
// a restart; MINSTREL_LIBRARY_SCAN_PATHS names them until the first save. The
|
||||||
|
// Scanner holds the live list, and the watcher and the importer read it there.
|
||||||
|
//
|
||||||
|
// Changing a folder's path is a move, not a new library: when the files that
|
||||||
|
// were under /music are now under /data/music, every row under the old path is
|
||||||
|
// rewritten to the new one, so the tracks keep their ids and their history.
|
||||||
|
// The scan never sees the change as a mass deletion followed by a mass add.
|
||||||
|
|
||||||
|
// LoadRoots is the folders the server starts with: the saved ones, or env
|
||||||
|
// when nothing has been saved. fromSettings says which.
|
||||||
|
func LoadRoots(ctx context.Context, pool *pgxpool.Pool, env []string) (roots []string, fromSettings bool, err error) {
|
||||||
|
row, err := dbq.New(pool).GetLibrarySettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return env, false, fmt.Errorf("library settings: load: %w", err)
|
||||||
|
}
|
||||||
|
if len(row.ScanPaths) == 0 {
|
||||||
|
return env, false, nil
|
||||||
|
}
|
||||||
|
return row.ScanPaths, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RootChange is one folder of a save. Was is the folder it replaces, whose
|
||||||
|
// files have moved to Path; "" is a folder added.
|
||||||
|
type RootChange struct {
|
||||||
|
Path string
|
||||||
|
Was string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RerootResult is what a save rewrote.
|
||||||
|
type RerootResult struct {
|
||||||
|
Roots []string
|
||||||
|
Tracks int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RootsError is a save the folders refuse. Its text says why, in a sentence
|
||||||
|
// for the operator.
|
||||||
|
type RootsError struct{ msg string }
|
||||||
|
|
||||||
|
func (e *RootsError) Error() string { return e.msg }
|
||||||
|
|
||||||
|
func badRoots(format string, args ...any) error {
|
||||||
|
return &RootsError{msg: fmt.Sprintf(format, args...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveSample is how many present tracks a move checks for at the new path,
|
||||||
|
// and moveSampleMinFound how many of them must be there.
|
||||||
|
const (
|
||||||
|
moveSample = 20
|
||||||
|
moveSampleMinFound = 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetRoots validates the folders, rewrites the paths of any that moved, saves
|
||||||
|
// them and starts reading them. Nothing is written when any check fails.
|
||||||
|
func (s *Scanner) SetRoots(ctx context.Context, changes []RootChange) (RerootResult, error) {
|
||||||
|
s.rootsMu.Lock()
|
||||||
|
defer s.rootsMu.Unlock()
|
||||||
|
|
||||||
|
roots, moves, err := planRoots(s.paths, changes)
|
||||||
|
if err != nil {
|
||||||
|
return RerootResult{}, err
|
||||||
|
}
|
||||||
|
q := dbq.New(s.pool)
|
||||||
|
// A scan walking the old folders would put their paths back, or mark the
|
||||||
|
// moved rows missing when it reconciles.
|
||||||
|
_, err = q.GetInFlightScanRun(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return RerootResult{}, badRoots("A library scan is running; save again once it finishes.")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return RerootResult{}, fmt.Errorf("in-flight scan check: %w", err)
|
||||||
|
}
|
||||||
|
for _, m := range moves {
|
||||||
|
if err := checkMove(ctx, q, m); err != nil {
|
||||||
|
return RerootResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var res RerootResult
|
||||||
|
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||||
|
tq := dbq.New(tx)
|
||||||
|
for _, m := range moves {
|
||||||
|
n, err := tq.RerootTrackPaths(ctx, dbq.RerootTrackPathsParams{NewRoot: m.Path, OldRoot: m.Was})
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return badRoots("Some tracks under %s are already in the library at %s, "+
|
||||||
|
"so its tracks cannot move there.", m.Was, m.Path)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("reroot tracks: %w", err)
|
||||||
|
}
|
||||||
|
res.Tracks += n
|
||||||
|
if _, err := tq.RerootAlbumCoverPaths(ctx, dbq.RerootAlbumCoverPathsParams{NewRoot: m.Path, OldRoot: m.Was}); err != nil {
|
||||||
|
return fmt.Errorf("reroot covers: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tq.RerootGrabDestinations(ctx, dbq.RerootGrabDestinationsParams{NewRoot: m.Path, OldRoot: m.Was}); err != nil {
|
||||||
|
return fmt.Errorf("reroot downloads: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tq.RerootImportRoot(ctx, dbq.RerootImportRootParams{NewRoot: m.Path, OldRoot: m.Was}); err != nil {
|
||||||
|
return fmt.Errorf("reroot import folder: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := tq.UpdateLibraryScanPaths(ctx, roots)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return RerootResult{}, err
|
||||||
|
}
|
||||||
|
for _, m := range moves {
|
||||||
|
s.logger.Info("library folder moved", "from", m.Was, "to", m.Path)
|
||||||
|
}
|
||||||
|
s.paths = roots
|
||||||
|
select {
|
||||||
|
case s.rootsChanged <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
res.Roots = slices.Clone(roots)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RootsChanged is signalled after a save changes the folders. A Scanner built
|
||||||
|
// without New never signals.
|
||||||
|
func (s *Scanner) RootsChanged() <-chan struct{} { return s.rootsChanged }
|
||||||
|
|
||||||
|
// planRoots checks a save against the current folders and returns the new
|
||||||
|
// list and the folders that moved.
|
||||||
|
func planRoots(current []string, changes []RootChange) ([]string, []RootChange, error) {
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return nil, nil, badRoots("Name at least one folder.")
|
||||||
|
}
|
||||||
|
roots := make([]string, 0, len(changes))
|
||||||
|
var moves []RootChange
|
||||||
|
replaced := map[string]bool{}
|
||||||
|
for _, c := range changes {
|
||||||
|
p := strings.TrimSpace(c.Path)
|
||||||
|
if !filepath.IsAbs(p) {
|
||||||
|
return nil, nil, badRoots("%q is not a full path; it starts with /.", c.Path)
|
||||||
|
}
|
||||||
|
p = filepath.Clean(p)
|
||||||
|
if p == "/" {
|
||||||
|
return nil, nil, badRoots("The library cannot be the whole filesystem.")
|
||||||
|
}
|
||||||
|
if err := checkDir(p); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for _, r := range roots {
|
||||||
|
if r == p {
|
||||||
|
return nil, nil, badRoots("%s is listed twice.", p)
|
||||||
|
}
|
||||||
|
if within(p, r) || within(r, p) {
|
||||||
|
return nil, nil, badRoots("%s and %s overlap; name one of them.", r, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roots = append(roots, p)
|
||||||
|
if c.Was == "" || c.Was == p {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Contains(current, c.Was) {
|
||||||
|
return nil, nil, badRoots("%s is not one of the library's folders.", c.Was)
|
||||||
|
}
|
||||||
|
if replaced[c.Was] {
|
||||||
|
return nil, nil, badRoots("%s can only move to one place.", c.Was)
|
||||||
|
}
|
||||||
|
replaced[c.Was] = true
|
||||||
|
moves = append(moves, RootChange{Path: p, Was: c.Was})
|
||||||
|
}
|
||||||
|
return roots, moves, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDir refuses a folder the server cannot read: a typo, or a mount the
|
||||||
|
// container does not have.
|
||||||
|
func checkDir(p string) error {
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return badRoots("%s does not exist on the server. Is it mounted into the container?", p)
|
||||||
|
}
|
||||||
|
return badRoots("%s cannot be read: %v.", p, err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return badRoots("%s is a file, not a folder.", p)
|
||||||
|
}
|
||||||
|
if _, err := os.ReadDir(p); err != nil {
|
||||||
|
return badRoots("%s cannot be read: %v.", p, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMove looks for a sample of the folder's tracks at the new path, so a
|
||||||
|
// move to the wrong folder is refused before any row is rewritten.
|
||||||
|
func checkMove(ctx context.Context, q *dbq.Queries, m RootChange) error {
|
||||||
|
sample, err := q.SampleTrackPathsUnder(ctx, dbq.SampleTrackPathsUnderParams{Root: m.Was, N: moveSample})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sample tracks: %w", err)
|
||||||
|
}
|
||||||
|
if len(sample) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
found := 0
|
||||||
|
for _, p := range sample {
|
||||||
|
if _, err := os.Stat(m.Path + strings.TrimPrefix(p, m.Was)); err == nil {
|
||||||
|
found++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if float64(found) < moveSampleMinFound*float64(len(sample)) {
|
||||||
|
return badRoots("Only %d of %d tracks checked from %s are at the same place under %s. "+
|
||||||
|
"Is that the folder they moved to?", found, len(sample), m.Was, m.Path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// within reports whether p is inside dir.
|
||||||
|
func within(p, dir string) bool {
|
||||||
|
return strings.HasPrefix(p, dir+"/")
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mkdir(t *testing.T, parts ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(parts...)
|
||||||
|
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanRoots(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
music := mkdir(t, base, "music")
|
||||||
|
data := mkdir(t, base, "data", "music")
|
||||||
|
more := mkdir(t, base, "more")
|
||||||
|
file := filepath.Join(base, "a.mp3")
|
||||||
|
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
current := []string{music, more}
|
||||||
|
|
||||||
|
roots, moves, err := planRoots(current, []RootChange{{Path: data + "/", Was: music}, {Path: more, Was: more}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(roots, []string{data, more}) {
|
||||||
|
t.Errorf("roots %v", roots)
|
||||||
|
}
|
||||||
|
if len(moves) != 1 || moves[0] != (RootChange{Path: data, Was: music}) {
|
||||||
|
t.Errorf("moves %v; want only %s → %s", moves, music, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
changes []RootChange
|
||||||
|
mentions string
|
||||||
|
}{
|
||||||
|
"none": {nil, "At least one"},
|
||||||
|
"relative": {[]RootChange{{Path: "music"}}, "full path"},
|
||||||
|
"root": {[]RootChange{{Path: "/"}}, "whole filesystem"},
|
||||||
|
"absent": {[]RootChange{{Path: filepath.Join(base, "nope")}}, "does not exist"},
|
||||||
|
"a file": {[]RootChange{{Path: file}}, "not a folder"},
|
||||||
|
"twice": {[]RootChange{{Path: music}, {Path: music}}, "twice"},
|
||||||
|
"nested": {[]RootChange{{Path: base}, {Path: music}}, "overlap"},
|
||||||
|
"unknown was": {[]RootChange{{Path: data, Was: "/elsewhere"}}, "not one of"},
|
||||||
|
"split a move": {[]RootChange{{Path: data, Was: music}, {Path: more, Was: music}}, "one place"},
|
||||||
|
} {
|
||||||
|
_, _, err := planRoots(current, tc.changes)
|
||||||
|
var re *RootsError
|
||||||
|
if !errors.As(err, &re) || !strings.Contains(err.Error(), tc.mentions) {
|
||||||
|
t.Errorf("%s: err %v; want a RootsError mentioning %q", name, err, tc.mentions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearSavedRoots puts the folders back to env afterwards. It also finishes any
|
||||||
|
// scan an earlier test left running: dbtest.ResetDB keeps scan_runs, and a
|
||||||
|
// save refuses while a scan is in flight.
|
||||||
|
func clearSavedRoots(t *testing.T, pool *pgxpool.Pool) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`UPDATE scan_runs SET finished_at = now() WHERE finished_at IS NULL`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(context.Background(), `UPDATE library_settings SET scan_paths = NULL`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetRoots_TracksMoveWithTheirFolder_Integration(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
clearSavedRoots(t, pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
base := t.TempDir()
|
||||||
|
old := filepath.Join(base, "music") // gone: the mount moved
|
||||||
|
moved := mkdir(t, base, "data", "music", "Artist")
|
||||||
|
if err := os.WriteFile(filepath.Join(moved, "a.mp3"), []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
newRoot := filepath.Dir(moved)
|
||||||
|
track, _, _ := seedTrack(t, pool, filepath.Join(old, "Artist", "a.mp3"))
|
||||||
|
|
||||||
|
s := New(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), []string{old}, nil)
|
||||||
|
res, err := s.SetRoots(ctx, []RootChange{{Path: newRoot, Was: old}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Tracks != 1 {
|
||||||
|
t.Errorf("moved %d tracks; want 1", res.Tracks)
|
||||||
|
}
|
||||||
|
got, err := dbq.New(pool).GetTrackByPath(ctx, filepath.Join(newRoot, "Artist", "a.mp3"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("the track is not at its new path: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != track.ID {
|
||||||
|
t.Error("the move made a new track instead of keeping the old one")
|
||||||
|
}
|
||||||
|
if !slices.Equal(s.Roots(), []string{newRoot}) {
|
||||||
|
t.Errorf("roots %v", s.Roots())
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-s.RootsChanged():
|
||||||
|
default:
|
||||||
|
t.Error("the watcher was not told the folders changed")
|
||||||
|
}
|
||||||
|
roots, saved, err := LoadRoots(ctx, pool, []string{old})
|
||||||
|
if err != nil || !saved || !slices.Equal(roots, []string{newRoot}) {
|
||||||
|
t.Errorf("after a restart: %v saved=%v err=%v; want the saved folder over env", roots, saved, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetRoots_RefusesAMoveToAFolderWithoutTheFiles_Integration(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
clearSavedRoots(t, pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
base := t.TempDir()
|
||||||
|
old := filepath.Join(base, "music")
|
||||||
|
wrong := mkdir(t, base, "elsewhere")
|
||||||
|
seedTrack(t, pool, filepath.Join(old, "a.mp3"))
|
||||||
|
|
||||||
|
s := New(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), []string{old}, nil)
|
||||||
|
var re *RootsError
|
||||||
|
if _, err := s.SetRoots(ctx, []RootChange{{Path: wrong, Was: old}}); !errors.As(err, &re) {
|
||||||
|
t.Fatalf("err %v; want a RootsError", err)
|
||||||
|
}
|
||||||
|
if _, err := dbq.New(pool).GetTrackByPath(ctx, filepath.Join(old, "a.mp3")); err != nil {
|
||||||
|
t.Errorf("a refused move rewrote the track: %v", err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(s.Roots(), []string{old}) {
|
||||||
|
t.Errorf("a refused move changed the folders to %v", s.Roots())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,10 +176,11 @@ func logTrackChanges(ctx context.Context, q trackReconciler, ids []pgtype.UUID)
|
|||||||
// obvious case, and it is common enough — a container restart racing its volume
|
// obvious case, and it is common enough — a container restart racing its volume
|
||||||
// mount does exactly this.
|
// mount does exactly this.
|
||||||
func (s *Scanner) verifyRootsPresent() error {
|
func (s *Scanner) verifyRootsPresent() error {
|
||||||
if len(s.paths) == 0 {
|
roots := s.Roots()
|
||||||
|
if len(roots) == 0 {
|
||||||
return errors.New("no scan roots configured")
|
return errors.New("no scan roots configured")
|
||||||
}
|
}
|
||||||
for _, root := range s.paths {
|
for _, root := range roots {
|
||||||
info, err := os.Stat(root)
|
info, err := os.Stat(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("scan root %q unavailable: %w", root, err)
|
return fmt.Errorf("scan root %q unavailable: %w", root, err)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dhowden/tag"
|
"github.com/dhowden/tag"
|
||||||
@@ -87,7 +88,11 @@ type Stats struct {
|
|||||||
type Scanner struct {
|
type Scanner struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
paths []string
|
// paths are the library's folders, changed by SetRoots (#6129). Read them
|
||||||
|
// through Roots.
|
||||||
|
paths []string
|
||||||
|
rootsMu sync.RWMutex
|
||||||
|
rootsChanged chan struct{}
|
||||||
// fingerprint derives a file's acoustic identity (M400). A field so an
|
// fingerprint derives a file's acoustic identity (M400). A field so an
|
||||||
// integration test can substitute a deterministic one: CI has no real audio
|
// integration test can substitute a deterministic one: CI has no real audio
|
||||||
// to fingerprint, and what the test pins is WHEN the scan fingerprints, not
|
// to fingerprint, and what the test pins is WHEN the scan fingerprints, not
|
||||||
@@ -99,7 +104,8 @@ type Scanner struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(pool *pgxpool.Pool, logger *slog.Logger, paths []string, settings *FingerprintSettingsService) *Scanner {
|
func New(pool *pgxpool.Pool, logger *slog.Logger, paths []string, settings *FingerprintSettingsService) *Scanner {
|
||||||
return &Scanner{pool: pool, logger: logger, paths: paths, fingerprint: computeFingerprint, settings: settings}
|
return &Scanner{pool: pool, logger: logger, paths: paths, rootsChanged: make(chan struct{}, 1),
|
||||||
|
fingerprint: computeFingerprint, settings: settings}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan walks every configured root and upserts any audio file whose mtime is
|
// Scan walks every configured root and upserts any audio file whose mtime is
|
||||||
@@ -206,7 +212,7 @@ func (s *Scanner) enumerate(
|
|||||||
) ([]string, int) {
|
) ([]string, int) {
|
||||||
paths := make([]string, 0, 8192)
|
paths := make([]string, 0, 8192)
|
||||||
errs := 0
|
errs := 0
|
||||||
for _, root := range s.paths {
|
for _, root := range s.Roots() {
|
||||||
// WalkDir's own error return is folded into the per-entry handler below,
|
// WalkDir's own error return is folded into the per-entry handler below,
|
||||||
// so a bad root is counted rather than aborting the whole scan — one
|
// so a bad root is counted rather than aborting the whole scan — one
|
||||||
// unreadable root shouldn't discard the others' results.
|
// unreadable root shouldn't discard the others' results.
|
||||||
@@ -691,4 +697,11 @@ func IsAudioFile(path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Roots are the folders the scanner reads: the library.
|
// Roots are the folders the scanner reads: the library.
|
||||||
func (s *Scanner) Roots() []string { return append([]string(nil), s.paths...) }
|
func (s *Scanner) Roots() []string {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.rootsMu.RLock()
|
||||||
|
defer s.rootsMu.RUnlock()
|
||||||
|
return append([]string(nil), s.paths...)
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,18 +67,16 @@ type Watcher struct {
|
|||||||
scanner *Scanner
|
scanner *Scanner
|
||||||
enricher *coverart.Enricher
|
enricher *coverart.Enricher
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
roots []string
|
|
||||||
debounce time.Duration
|
debounce time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWatcher(
|
// NewWatcher watches the scanner's folders, and moves to the new ones when
|
||||||
scanner *Scanner, enricher *coverart.Enricher, logger *slog.Logger, roots []string,
|
// they change in Settings (#6129).
|
||||||
) *Watcher {
|
func NewWatcher(scanner *Scanner, enricher *coverart.Enricher, logger *slog.Logger) *Watcher {
|
||||||
return &Watcher{
|
return &Watcher{
|
||||||
scanner: scanner,
|
scanner: scanner,
|
||||||
enricher: enricher,
|
enricher: enricher,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
roots: roots,
|
|
||||||
debounce: watcherDebounce,
|
debounce: watcherDebounce,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,8 +85,32 @@ func NewWatcher(
|
|||||||
// fsnotify watcher cannot be created at all; per-path watch failures and
|
// fsnotify watcher cannot be created at all; per-path watch failures and
|
||||||
// fsnotify errors are logged and tolerated.
|
// fsnotify errors are logged and tolerated.
|
||||||
func (w *Watcher) Run(ctx context.Context) error {
|
func (w *Watcher) Run(ctx context.Context) error {
|
||||||
if len(w.roots) == 0 {
|
for {
|
||||||
w.logger.Info("library watcher: no scan paths configured, watcher disabled")
|
if err := w.watch(ctx, w.scanner.Roots()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watch watches roots until ctx is cancelled or the folders change.
|
||||||
|
func (w *Watcher) watch(ctx context.Context, roots []string) error {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-w.scanner.RootsChanged():
|
||||||
|
w.logger.Info("library watcher: folders changed, restarting")
|
||||||
|
cancel()
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if len(roots) == 0 {
|
||||||
|
w.logger.Info("library watcher: no scan paths configured, watcher idle")
|
||||||
|
<-ctx.Done()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fsw, err := fsnotify.NewWatcher()
|
fsw, err := fsnotify.NewWatcher()
|
||||||
@@ -98,10 +120,10 @@ func (w *Watcher) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
defer func() { _ = fsw.Close() }()
|
defer func() { _ = fsw.Close() }()
|
||||||
|
|
||||||
for _, root := range w.roots {
|
for _, root := range roots {
|
||||||
w.addRecursive(fsw, root)
|
w.addRecursive(fsw, root)
|
||||||
}
|
}
|
||||||
w.logger.Info("library watcher: started", "roots", len(w.roots))
|
w.logger.Info("library watcher: started", "roots", len(roots))
|
||||||
|
|
||||||
batches := make(chan []string, batchQueueDepth)
|
batches := make(chan []string, batchQueueDepth)
|
||||||
go w.processLoop(ctx, batches)
|
go w.processLoop(ctx, batches)
|
||||||
|
|||||||
@@ -365,6 +365,35 @@ export async function updateLoudnessSettings(s: LoudnessSettings): Promise<Loudn
|
|||||||
return api.put<LoudnessSettings>('/api/admin/library/loudness-settings', s);
|
return api.put<LoudnessSettings>('/api/admin/library/loudness-settings', s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The library's folders (#6129) -----------------------------------------------
|
||||||
|
|
||||||
|
export type LibraryFolder = {
|
||||||
|
path: string;
|
||||||
|
/** Whether the server can see the folder right now. */
|
||||||
|
present: boolean;
|
||||||
|
tracks: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LibraryFolders = {
|
||||||
|
folders: LibraryFolder[];
|
||||||
|
/** False while the folders still come from MINSTREL_LIBRARY_SCAN_PATHS. */
|
||||||
|
saved: boolean;
|
||||||
|
/** How many tracks a save moved to a new folder. */
|
||||||
|
moved?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One folder of a save. `was` is the folder it replaces, whose files are now
|
||||||
|
* at `path`; '' is a folder added. */
|
||||||
|
export type LibraryFolderChange = { path: string; was: string };
|
||||||
|
|
||||||
|
export async function getLibraryFolders(): Promise<LibraryFolders> {
|
||||||
|
return api.get<LibraryFolders>('/api/admin/library/folders');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLibraryFolders(folders: LibraryFolderChange[]): Promise<LibraryFolders> {
|
||||||
|
return api.put<LibraryFolders>('/api/admin/library/folders', { folders });
|
||||||
|
}
|
||||||
|
|
||||||
// AcoustID recording-id lookup (M401) ---------------------------------------
|
// AcoustID recording-id lookup (M401) ---------------------------------------
|
||||||
|
|
||||||
export type AcoustIDSettings = {
|
export type AcoustIDSettings = {
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ const DETAIL_CODES: ReadonlySet<string> = new Set([
|
|||||||
// The server names the field and its range (#3913).
|
// The server names the field and its range (#3913).
|
||||||
'invalid_setting',
|
'invalid_setting',
|
||||||
// The server says what shape of address it wants (#4981).
|
// The server says what shape of address it wants (#4981).
|
||||||
'invalid_public_url'
|
'invalid_public_url',
|
||||||
|
// The server names the folder and what is wrong with it (#6129).
|
||||||
|
'bad_folders'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
getLibraryFolders,
|
||||||
|
updateLibraryFolders,
|
||||||
|
type LibraryFolders
|
||||||
|
} from '#lib/api/admin.js';
|
||||||
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
import { plural } from '#lib/utils/plural.js';
|
||||||
|
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||||
|
|
||||||
|
// The library's folders (#6129), set here rather than only by
|
||||||
|
// MINSTREL_LIBRARY_SCAN_PATHS. Editing a folder's path says its files moved
|
||||||
|
// there: the server rewrites its tracks' paths, so plays, likes and
|
||||||
|
// playlists stay with them. A folder removed is no longer read.
|
||||||
|
|
||||||
|
let { onSaved }: { onSaved?: () => void } = $props();
|
||||||
|
|
||||||
|
let current = $state<LibraryFolders | null>(null);
|
||||||
|
let loadError = $state(false);
|
||||||
|
let editing = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
// was: the folder a row started as ('' for one added), so the server can
|
||||||
|
// tell a move from an addition.
|
||||||
|
let rows = $state<{ path: string; was: string }[]>([]);
|
||||||
|
|
||||||
|
const removed = $derived(
|
||||||
|
editing && current ? current.folders.filter((f) => !rows.some((r) => r.was === f.path)) : []
|
||||||
|
);
|
||||||
|
const moved = $derived(rows.filter((r) => r.was && r.path.trim() && r.path.trim() !== r.was));
|
||||||
|
const blank = $derived(rows.some((r) => !r.path.trim()));
|
||||||
|
const valid = $derived(rows.length > 0 && !blank);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
current = await getLibraryFolders();
|
||||||
|
loadError = false;
|
||||||
|
} catch {
|
||||||
|
loadError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
|
||||||
|
function edit() {
|
||||||
|
if (!current) return;
|
||||||
|
rows = current.folders.map((f) => ({ path: f.path, was: f.path }));
|
||||||
|
if (rows.length === 0) rows = [{ path: '', was: '' }];
|
||||||
|
editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!valid) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
current = await updateLibraryFolders(rows.map((r) => ({ path: r.path.trim(), was: r.was })));
|
||||||
|
editing = false;
|
||||||
|
const n = current.moved ?? 0;
|
||||||
|
pushToast(
|
||||||
|
n > 0
|
||||||
|
? `Library folders saved. ${plural(n, 'track', 'tracks')} moved with their folder; a scan has started.`
|
||||||
|
: 'Library folders saved; a scan has started.'
|
||||||
|
);
|
||||||
|
onSaved?.();
|
||||||
|
} catch (e) {
|
||||||
|
pushToast(errMessage(e), 'error');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const button =
|
||||||
|
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
||||||
|
const field = 'w-64 max-w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<p class="px-4 py-3 text-sm text-error-fg">
|
||||||
|
Couldn't load the library folders.
|
||||||
|
<button type="button" class="underline hover:no-underline" onclick={load}>Try again</button>
|
||||||
|
</p>
|
||||||
|
{:else if current === null}
|
||||||
|
<p class="px-4 py-3 text-sm text-text-secondary">Loading…</p>
|
||||||
|
{:else if !editing}
|
||||||
|
<SettingRow
|
||||||
|
label="Folders"
|
||||||
|
hint={current.saved ? 'Where Minstrel looks for music.' : 'Set by MINSTREL_LIBRARY_SCAN_PATHS until changed here.'}
|
||||||
|
>
|
||||||
|
<span class="flex flex-col items-end font-mono text-xs text-text-secondary" data-testid="library-folders">
|
||||||
|
{#each current.folders as f (f.path)}<span>{f.path}</span>{:else}<span>—</span>{/each}
|
||||||
|
</span>
|
||||||
|
<button type="button" class={button} onclick={edit}>Change</button>
|
||||||
|
</SettingRow>
|
||||||
|
{#each current.folders.filter((f) => !f.present) as f (f.path)}
|
||||||
|
<p class="px-4 pb-3 text-xs text-error-fg" data-testid="folder-missing">
|
||||||
|
The server can't see {f.path}{f.tracks > 0 ? `, which holds ${plural(f.tracks, 'track', 'tracks')}` : ''}.
|
||||||
|
If its files are now somewhere else, change the folder to where they are.
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-2 px-4 py-3" data-testid="library-folders-form">
|
||||||
|
<span class="text-sm text-text-primary">Folders</span>
|
||||||
|
<p class="text-xs text-text-secondary">
|
||||||
|
Change a folder's path when its files have moved; the tracks move with it and keep their plays, likes and
|
||||||
|
playlists. Each path is as the server sees it, inside its container.
|
||||||
|
</p>
|
||||||
|
{#each rows as row, i (i)}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label="Folder {i + 1}"
|
||||||
|
placeholder="/data/music"
|
||||||
|
bind:value={row.path}
|
||||||
|
class={field}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={button}
|
||||||
|
aria-label="Remove folder {i + 1}"
|
||||||
|
onclick={() => (rows = rows.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div>
|
||||||
|
<button type="button" class={button} onclick={() => (rows = [...rows, { path: '', was: '' }])}>Add folder</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-text-secondary" data-testid="folder-changes">
|
||||||
|
{#each moved as m (m.was)}
|
||||||
|
<p>{m.was} → {m.path.trim()}: its tracks move with it.</p>
|
||||||
|
{/each}
|
||||||
|
{#each removed as f (f.path)}
|
||||||
|
<p class="text-error-fg">
|
||||||
|
{f.path} will no longer be read{f.tracks > 0
|
||||||
|
? `; its ${plural(f.tracks, 'track is', 'tracks are')} marked missing at the next scan`
|
||||||
|
: ''}.
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
{#if blank}
|
||||||
|
<p class="mr-auto text-xs text-error-fg">Fill in or remove the empty folder.</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="mr-auto text-xs text-error-fg">The library needs at least one folder.</p>
|
||||||
|
{/if}
|
||||||
|
<button type="button" class={button} disabled={saving} onclick={() => (editing = false)}>Cancel</button>
|
||||||
|
<button type="button" class={button} disabled={!valid || saving} onclick={save}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
|
import type { LibraryFolders } from '#lib/api/admin.js';
|
||||||
|
|
||||||
|
vi.mock('#lib/api/admin.js', () => ({
|
||||||
|
getLibraryFolders: vi.fn(),
|
||||||
|
updateLibraryFolders: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||||
|
|
||||||
|
import LibraryFoldersSetting from './LibraryFoldersSetting.svelte';
|
||||||
|
import { getLibraryFolders, updateLibraryFolders } from '#lib/api/admin.js';
|
||||||
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
|
// The mount moved: the server can no longer see /music.
|
||||||
|
const before: LibraryFolders = {
|
||||||
|
saved: false,
|
||||||
|
folders: [{ path: '/music', present: false, tracks: 12000 }]
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
async function renderSetting(f: LibraryFolders = before) {
|
||||||
|
vi.mocked(getLibraryFolders).mockResolvedValue(f);
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
render(LibraryFoldersSetting, { onSaved });
|
||||||
|
await screen.findByTestId('library-folders');
|
||||||
|
return onSaved;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LibraryFoldersSetting', () => {
|
||||||
|
test('lists the folders, says where they come from, and flags one the server cannot see', async () => {
|
||||||
|
await renderSetting();
|
||||||
|
expect(screen.getByTestId('library-folders').textContent).toBe('/music');
|
||||||
|
expect(screen.getByText(/MINSTREL_LIBRARY_SCAN_PATHS/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('folder-missing').textContent).toMatch(/can't see \/music, which holds 12,000 tracks/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing a folder moves its tracks there', async () => {
|
||||||
|
const onSaved = await renderSetting();
|
||||||
|
vi.mocked(updateLibraryFolders).mockResolvedValue({
|
||||||
|
saved: true,
|
||||||
|
moved: 12000,
|
||||||
|
folders: [{ path: '/data/music', present: true, tracks: 12000 }]
|
||||||
|
});
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Change' }));
|
||||||
|
await fireEvent.input(screen.getByRole('textbox', { name: 'Folder 1' }), { target: { value: '/data/music' } });
|
||||||
|
expect(screen.getByTestId('folder-changes').textContent).toMatch(/\/music → \/data\/music: its tracks move with it/);
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(updateLibraryFolders).toHaveBeenCalledWith([{ path: '/data/music', was: '/music' }])
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
||||||
|
expect(pushToast).toHaveBeenCalledWith(expect.stringMatching(/12,000 tracks moved/));
|
||||||
|
expect((await screen.findByTestId('library-folders')).textContent).toBe('/data/music');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a folder removed warns that its tracks will be marked missing', async () => {
|
||||||
|
await renderSetting({
|
||||||
|
saved: true,
|
||||||
|
folders: [
|
||||||
|
{ path: '/music', present: true, tracks: 5 },
|
||||||
|
{ path: '/more', present: true, tracks: 1 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Change' }));
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Remove folder 2' }));
|
||||||
|
expect(screen.getByTestId('folder-changes').textContent).toMatch(
|
||||||
|
/\/more will no longer be read; its 1 track is marked missing/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a blank folder cannot be saved', async () => {
|
||||||
|
await renderSetting();
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Change' }));
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Add folder' }));
|
||||||
|
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||||
|
expect(screen.getByText(/Fill in or remove the empty folder/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"invalid_paging": "Invalid page size or offset.",
|
"invalid_paging": "Invalid page size or offset.",
|
||||||
"not_decidable": "That download isn't waiting on a decision any more. Refresh the list.",
|
"not_decidable": "That download isn't waiting on a decision any more. Refresh the list.",
|
||||||
"bad_import_root": "New music has to be filed into one of the library's folders.",
|
"bad_import_root": "New music has to be filed into one of the library's folders.",
|
||||||
|
"bad_folders": "The library folders weren't saved.",
|
||||||
"bad_reason": "Invalid quarantine reason.",
|
"bad_reason": "Invalid quarantine reason.",
|
||||||
"mbid_required": "An MBID is required for this lookup.",
|
"mbid_required": "An MBID is required for this lookup.",
|
||||||
"system_playlist_readonly": "System playlists can't be edited directly.",
|
"system_playlist_readonly": "System playlists can't be edited directly.",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import StageBadge from '#lib/components/StageBadge.svelte';
|
import StageBadge from '#lib/components/StageBadge.svelte';
|
||||||
import LoudnessSettingsCard from '#lib/components/LoudnessSettingsCard.svelte';
|
import LoudnessSettingsCard from '#lib/components/LoudnessSettingsCard.svelte';
|
||||||
import FingerprintSettingsCard from '#lib/components/FingerprintSettingsCard.svelte';
|
import FingerprintSettingsCard from '#lib/components/FingerprintSettingsCard.svelte';
|
||||||
|
import LibraryFoldersSetting from '#lib/components/LibraryFoldersSetting.svelte';
|
||||||
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
|
import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte';
|
||||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||||
@@ -15,7 +16,6 @@
|
|||||||
triggerScan,
|
triggerScan,
|
||||||
researchMissingArt
|
researchMissingArt
|
||||||
} from '#lib/api/admin.js';
|
} from '#lib/api/admin.js';
|
||||||
import { createSearchSettingsQuery } from '#lib/api/wanted.js';
|
|
||||||
import { qk } from '#lib/api/queries.js';
|
import { qk } from '#lib/api/queries.js';
|
||||||
import { errMessage } from '#lib/api/errors.js';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
@@ -31,9 +31,6 @@
|
|||||||
const button =
|
const button =
|
||||||
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
|
||||||
|
|
||||||
const searchStore = createSearchSettingsQuery();
|
|
||||||
const folders = $derived($searchStore.data?.scan_paths ?? []);
|
|
||||||
|
|
||||||
const scanStore = createScanStatusQuery();
|
const scanStore = createScanStatusQuery();
|
||||||
const scan = $derived($scanStore.data);
|
const scan = $derived($scanStore.data);
|
||||||
const scanInFlight = $derived(scan?.in_flight === true);
|
const scanInFlight = $derived(scan?.in_flight === true);
|
||||||
@@ -150,11 +147,13 @@
|
|||||||
<SettingsColumns>
|
<SettingsColumns>
|
||||||
{#snippet main()}
|
{#snippet main()}
|
||||||
<SettingsGroup title="Library" id="scan" hint="The folders Minstrel reads, and the scan that reads them.">
|
<SettingsGroup title="Library" id="scan" hint="The folders Minstrel reads, and the scan that reads them.">
|
||||||
<SettingRow label="Folders" hint="Set where Minstrel is installed.">
|
<!-- The import's folder picker reads the folders through search settings. -->
|
||||||
<span class="flex flex-col items-end font-mono text-xs text-text-secondary" data-testid="library-folders">
|
<LibraryFoldersSetting
|
||||||
{#each folders as f (f)}<span>{f}</span>{:else}<span>—</span>{/each}
|
onSaved={() => {
|
||||||
</span>
|
client.invalidateQueries({ queryKey: qk.scanStatus() });
|
||||||
</SettingRow>
|
client.invalidateQueries({ queryKey: qk.searchSettings() });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<SettingRow label="Scan" hint={scanLine}>
|
<SettingRow label="Scan" hint={scanLine}>
|
||||||
<button type="button" onclick={onRunScan} disabled={triggering || scanInFlight} class={button}>
|
<button type="button" onclick={onRunScan} disabled={triggering || scanInFlight} class={button}>
|
||||||
{scanInFlight ? 'Scanning…' : triggering ? 'Starting…' : 'Run scan'}
|
{scanInFlight ? 'Scanning…' : triggering ? 'Starting…' : 'Run scan'}
|
||||||
|
|||||||
@@ -21,14 +21,17 @@ vi.mock('#lib/api/admin.js', () => ({
|
|||||||
updateLoudnessSettings: vi.fn(),
|
updateLoudnessSettings: vi.fn(),
|
||||||
getLoudnessCoverage: vi.fn(() => new Promise(() => {})),
|
getLoudnessCoverage: vi.fn(() => new Promise(() => {})),
|
||||||
getFingerprintSettings: vi.fn(() => new Promise(() => {})),
|
getFingerprintSettings: vi.fn(() => new Promise(() => {})),
|
||||||
updateFingerprintSettings: vi.fn()
|
updateFingerprintSettings: vi.fn(),
|
||||||
|
getLibraryFolders: vi.fn().mockResolvedValue({
|
||||||
|
saved: false,
|
||||||
|
folders: [
|
||||||
|
{ path: '/music', present: true, tracks: 1200 },
|
||||||
|
{ path: '/more-music', present: true, tracks: 3 }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
updateLibraryFolders: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('#lib/api/wanted.js', async () => {
|
|
||||||
const { mockQuery } = await import('../../../test-utils/query');
|
|
||||||
return { createSearchSettingsQuery: vi.fn(() => mockQuery({ data: { scan_paths: ['/music', '/more-music'] } })) };
|
|
||||||
});
|
|
||||||
|
|
||||||
import LibraryPage from './+page.svelte';
|
import LibraryPage from './+page.svelte';
|
||||||
import {
|
import {
|
||||||
createScanStatusQuery,
|
createScanStatusQuery,
|
||||||
@@ -57,9 +60,9 @@ function setup(scan: Partial<ScanStatus> = { id: '', started_at: '', finished_at
|
|||||||
const squash = (el: Element) => el.textContent?.replace(/\s+/g, ' ').trim();
|
const squash = (el: Element) => el.textContent?.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
describe('Admin › Library', () => {
|
describe('Admin › Library', () => {
|
||||||
test('lists the folders Minstrel reads', () => {
|
test('lists the folders Minstrel reads', async () => {
|
||||||
setup();
|
setup();
|
||||||
const folders = [...screen.getByTestId('library-folders').children].map((el) => el.textContent);
|
const folders = [...(await screen.findByTestId('library-folders')).children].map((el) => el.textContent);
|
||||||
expect(folders).toEqual(['/music', '/more-music']);
|
expect(folders).toEqual(['/music', '/more-music']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user