diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index e7bae99c..dc4fadf4 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "os/signal" + "slices" "syscall" "time" @@ -135,7 +136,18 @@ func run() error { if fpErr != nil { 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 // 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. // Boot-time scan and manual-trigger scans share this path; results land // 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() { if _, err := library.RunScan(ctx, pool, scanner, coverEnricher, logger.With("component", "scan_run"), @@ -340,7 +352,7 @@ func run() error { if acoustIDSettings != nil { 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 go imp.Run(ctx) @@ -431,8 +443,7 @@ func run() error { // Filesystem watcher: near-instant pickup of new/changed files via // fsnotify, scanning just the affected paths + enriching their albums // inline. Replaces the removed configurable scan scheduler. - watcher := library.NewWatcher(scanner, coverEnricher, - logger.With("component", "watcher"), cfg.Library.ScanPaths) + watcher := library.NewWatcher(scanner, coverEnricher, logger.With("component", "watcher")) go func() { if werr := watcher.Run(ctx); werr != nil { logger.Warn("library watcher exited", "err", werr) diff --git a/config.example.yaml b/config.example.yaml index b1b58ddf..3ab2ce01 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -24,6 +24,10 @@ log: library: # 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) scan_paths: [] # Kick off a scan on server startup. On by default so a fresh install diff --git a/internal/api/admin_library_folders.go b/internal/api/admin_library_folders.go new file mode 100644 index 00000000..a70ccf66 --- /dev/null +++ b/internal/api/admin_library_folders.go @@ -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) +} diff --git a/internal/api/admin_search.go b/internal/api/admin_search.go index cc843ddd..4d7488ad 100644 --- a/internal/api/admin_search.go +++ b/internal/api/admin_search.go @@ -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. diff --git a/internal/api/admin_status.go b/internal/api/admin_status.go index 3a80d418..76f73fbd 100644 --- a/internal/api/admin_status.go +++ b/internal/api/admin_status.go @@ -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)) } } diff --git a/internal/api/api.go b/internal/api/api.go index bdb6ad8e..7dca451c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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() } diff --git a/internal/db/dbq/library_settings.sql.go b/internal/db/dbq/library_settings.sql.go new file mode 100644 index 00000000..40006fd8 --- /dev/null +++ b/internal/db/dbq/library_settings.sql.go @@ -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 +} diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index a159c56f..ff72c52e 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -506,6 +506,12 @@ type LibraryChange struct { ChangedAt pgtype.Timestamptz } +type LibrarySetting struct { + ID bool + ScanPaths []string + UpdatedAt pgtype.Timestamptz +} + type ListenbrainzSimilarRecording struct { SeedTrackID pgtype.UUID RecordingMbid string diff --git a/internal/db/migrations/0093_library_settings.down.sql b/internal/db/migrations/0093_library_settings.down.sql new file mode 100644 index 00000000..793e3ffb --- /dev/null +++ b/internal/db/migrations/0093_library_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS library_settings; diff --git a/internal/db/migrations/0093_library_settings.up.sql b/internal/db/migrations/0093_library_settings.up.sql new file mode 100644 index 00000000..6c4cf946 --- /dev/null +++ b/internal/db/migrations/0093_library_settings.up.sql @@ -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; diff --git a/internal/db/queries/library_settings.sql b/internal/db/queries/library_settings.sql new file mode 100644 index 00000000..2d892500 --- /dev/null +++ b/internal/db/queries/library_settings.sql @@ -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 || '/'); diff --git a/internal/importer/importer.go b/internal/importer/importer.go index 3c5732fd..d075311f 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -63,9 +63,10 @@ type Importer struct { Scanner Scanner Catalogue Catalogue Printer Printer - // Roots are the library's folders (the scan paths). New music is filed - // under the one the settings name, or the first. - Roots []string + // Roots are the library's folders (the scan paths), read on every use + // because they change in Settings (#6129). New music is filed under the + // one the settings name, or the first. + Roots func() []string // DataDir is where cached art lives, for tidying an artist an upgrade // leaves with nothing (#5524). Empty skips it. DataDir string @@ -79,7 +80,7 @@ type Importer struct { // New returns an Importer. printer may be nil: the check then rests on // 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 { 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. func (im *Importer) root(ctx context.Context, q *dbq.Queries) (string, error) { - if len(im.Roots) == 0 { - return "", errors.New("there is no library folder to file new music into (MINSTREL_LIBRARY_SCAN_PATHS)") + roots := im.Roots() + 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) - 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 im.Roots[0], nil + return roots[0], nil } // held reads which of the planned tracks the library already has: by diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go index 36766b81..dd442f1e 100644 --- a/internal/importer/importer_test.go +++ b/internal/importer/importer_test.go @@ -130,7 +130,7 @@ func newStack(t *testing.T, printer Printer) *stack { } s := &stack{pool: pool, q: q, client: &fakeClient{content: content}, scanner: &fakeScanner{}, 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 } return s } diff --git a/internal/library/library_paths.go b/internal/library/library_paths.go new file mode 100644 index 00000000..70e34e77 --- /dev/null +++ b/internal/library/library_paths.go @@ -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+"/") +} diff --git a/internal/library/library_paths_test.go b/internal/library/library_paths_test.go new file mode 100644 index 00000000..1cd5613d --- /dev/null +++ b/internal/library/library_paths_test.go @@ -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()) + } +} diff --git a/internal/library/reconcile.go b/internal/library/reconcile.go index 1f13474b..b384e6e1 100644 --- a/internal/library/reconcile.go +++ b/internal/library/reconcile.go @@ -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 // mount does exactly this. func (s *Scanner) verifyRootsPresent() error { - if len(s.paths) == 0 { + roots := s.Roots() + if len(roots) == 0 { return errors.New("no scan roots configured") } - for _, root := range s.paths { + for _, root := range roots { info, err := os.Stat(root) if err != nil { return fmt.Errorf("scan root %q unavailable: %w", root, err) diff --git a/internal/library/scanner.go b/internal/library/scanner.go index b3670dde..1d0a4c27 100644 --- a/internal/library/scanner.go +++ b/internal/library/scanner.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "time" "github.com/dhowden/tag" @@ -87,7 +88,11 @@ type Stats struct { type Scanner struct { pool *pgxpool.Pool 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 // 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 @@ -99,7 +104,8 @@ type Scanner struct { } 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 @@ -206,7 +212,7 @@ func (s *Scanner) enumerate( ) ([]string, int) { paths := make([]string, 0, 8192) 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, // so a bad root is counted rather than aborting the whole scan — one // 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. -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...) +} diff --git a/internal/library/watcher.go b/internal/library/watcher.go index dc6946c7..f2b42d18 100644 --- a/internal/library/watcher.go +++ b/internal/library/watcher.go @@ -67,18 +67,16 @@ type Watcher struct { scanner *Scanner enricher *coverart.Enricher logger *slog.Logger - roots []string debounce time.Duration } -func NewWatcher( - scanner *Scanner, enricher *coverart.Enricher, logger *slog.Logger, roots []string, -) *Watcher { +// NewWatcher watches the scanner's folders, and moves to the new ones when +// they change in Settings (#6129). +func NewWatcher(scanner *Scanner, enricher *coverart.Enricher, logger *slog.Logger) *Watcher { return &Watcher{ scanner: scanner, enricher: enricher, logger: logger, - roots: roots, debounce: watcherDebounce, } } @@ -87,8 +85,32 @@ func NewWatcher( // fsnotify watcher cannot be created at all; per-path watch failures and // fsnotify errors are logged and tolerated. func (w *Watcher) Run(ctx context.Context) error { - if len(w.roots) == 0 { - w.logger.Info("library watcher: no scan paths configured, watcher disabled") + for { + 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 } fsw, err := fsnotify.NewWatcher() @@ -98,10 +120,10 @@ func (w *Watcher) Run(ctx context.Context) error { } defer func() { _ = fsw.Close() }() - for _, root := range w.roots { + for _, root := range roots { 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) go w.processLoop(ctx, batches) diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 4f6217ee..317a25b2 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -365,6 +365,35 @@ export async function updateLoudnessSettings(s: LoudnessSettings): Promise('/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 { + return api.get('/api/admin/library/folders'); +} + +export async function updateLibraryFolders(folders: LibraryFolderChange[]): Promise { + return api.put('/api/admin/library/folders', { folders }); +} + // AcoustID recording-id lookup (M401) --------------------------------------- export type AcoustIDSettings = { diff --git a/web/src/lib/api/errors.ts b/web/src/lib/api/errors.ts index c02325cd..3a2f5858 100644 --- a/web/src/lib/api/errors.ts +++ b/web/src/lib/api/errors.ts @@ -21,7 +21,9 @@ const DETAIL_CODES: ReadonlySet = new Set([ // The server names the field and its range (#3913). 'invalid_setting', // 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' ]); /** diff --git a/web/src/lib/components/LibraryFoldersSetting.svelte b/web/src/lib/components/LibraryFoldersSetting.svelte new file mode 100644 index 00000000..70b02067 --- /dev/null +++ b/web/src/lib/components/LibraryFoldersSetting.svelte @@ -0,0 +1,154 @@ + + +{#if loadError} +

+ Couldn't load the library folders. + +

+{:else if current === null} +

Loading…

+{:else if !editing} + + + {#each current.folders as f (f.path)}{f.path}{:else}—{/each} + + + + {#each current.folders.filter((f) => !f.present) as f (f.path)} +

+ 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. +

+ {/each} +{:else} +
+ Folders +

+ 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. +

+ {#each rows as row, i (i)} +
+ + +
+ {/each} +
+ +
+
+ {#each moved as m (m.was)} +

{m.was} → {m.path.trim()}: its tracks move with it.

+ {/each} + {#each removed as f (f.path)} +

+ {f.path} will no longer be read{f.tracks > 0 + ? `; its ${plural(f.tracks, 'track is', 'tracks are')} marked missing at the next scan` + : ''}. +

+ {/each} +
+
+ {#if blank} +

Fill in or remove the empty folder.

+ {:else if rows.length === 0} +

The library needs at least one folder.

+ {/if} + + +
+
+{/if} diff --git a/web/src/lib/components/LibraryFoldersSetting.test.ts b/web/src/lib/components/LibraryFoldersSetting.test.ts new file mode 100644 index 00000000..c3134026 --- /dev/null +++ b/web/src/lib/components/LibraryFoldersSetting.test.ts @@ -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(); + }); +}); diff --git a/web/src/lib/styles/error-copy.json b/web/src/lib/styles/error-copy.json index e6706e12..0c5ae5a2 100644 --- a/web/src/lib/styles/error-copy.json +++ b/web/src/lib/styles/error-copy.json @@ -29,6 +29,7 @@ "invalid_paging": "Invalid page size or offset.", "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_folders": "The library folders weren't saved.", "bad_reason": "Invalid quarantine reason.", "mbid_required": "An MBID is required for this lookup.", "system_playlist_readonly": "System playlists can't be edited directly.", diff --git a/web/src/routes/admin/library/+page.svelte b/web/src/routes/admin/library/+page.svelte index 5630c09a..caace2eb 100644 --- a/web/src/routes/admin/library/+page.svelte +++ b/web/src/routes/admin/library/+page.svelte @@ -4,6 +4,7 @@ import StageBadge from '#lib/components/StageBadge.svelte'; import LoudnessSettingsCard from '#lib/components/LoudnessSettingsCard.svelte'; import FingerprintSettingsCard from '#lib/components/FingerprintSettingsCard.svelte'; + import LibraryFoldersSetting from '#lib/components/LibraryFoldersSetting.svelte'; import SettingsColumns from '#lib/components/settings/SettingsColumns.svelte'; import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte'; import SettingRow from '#lib/components/settings/SettingRow.svelte'; @@ -15,7 +16,6 @@ triggerScan, researchMissingArt } from '#lib/api/admin.js'; - import { createSearchSettingsQuery } from '#lib/api/wanted.js'; import { qk } from '#lib/api/queries.js'; import { errMessage } from '#lib/api/errors.js'; import { pushToast } from '#lib/stores/toast.svelte.js'; @@ -31,9 +31,6 @@ 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 searchStore = createSearchSettingsQuery(); - const folders = $derived($searchStore.data?.scan_paths ?? []); - const scanStore = createScanStatusQuery(); const scan = $derived($scanStore.data); const scanInFlight = $derived(scan?.in_flight === true); @@ -150,11 +147,13 @@ {#snippet main()} - - - {#each folders as f (f)}{f}{:else}—{/each} - - + + { + client.invalidateQueries({ queryKey: qk.scanStatus() }); + client.invalidateQueries({ queryKey: qk.searchSettings() }); + }} + />