feat(library): remove a track from the library without deleting the file
RemoveFromLibrary deletes the audio_files rows the way the scan's own orphan cleanup does and records each path in excluded_paths. The exclusion is not an enhancement: without it the next scan finds the file, sees no row and imports it again, so the button undoes itself. The soft scan compares files on disk against rows in the database, so surveyAudioFiles and countAudioFiles both take the exclusion set — otherwise an excluded path makes the two disagree forever and queues a full scan on every launch. Deleting a row cascades to queue_tracks, so the removal calls the same CompactQueue hook RemoveLibrary does. Also lands the requested badge: library-status-indicator is a button again where it can act, utils/library-status.ts states once what owning and wanting mean, and the long-declared queued state finally has a producer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
)
|
||||
|
||||
// libraryRoot is one configured library's id and root directory.
|
||||
type libraryRoot struct {
|
||||
id int64
|
||||
path string
|
||||
}
|
||||
|
||||
// libraryRoots returns every configured library's id and root path.
|
||||
func (l *Library) libraryRoots() ([]libraryRoot, error) {
|
||||
libs, err := l.db.Queries.GetAllLibraries(l.ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not load libraries: %w", err)
|
||||
}
|
||||
|
||||
roots := make([]libraryRoot, 0, len(libs))
|
||||
for _, lib := range libs {
|
||||
roots = append(roots, libraryRoot{id: lib.ID, path: lib.Path})
|
||||
}
|
||||
|
||||
return roots, nil
|
||||
}
|
||||
|
||||
// pathWithin reports whether path lies under root. Both are cleaned
|
||||
// first, and the comparison keeps the separator so /music/rock does not
|
||||
// swallow /music/rockabilly.
|
||||
func pathWithin(root, path string) bool {
|
||||
cleanRoot := filepath.Clean(root)
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
if cleanRoot == cleanPath {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.HasPrefix(cleanPath, cleanRoot+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// excludeParams is the insert parameter for one exclusion.
|
||||
func excludeParams(libraryID int64, filePath string) sqlcgen.ExcludePathParams {
|
||||
return sqlcgen.ExcludePathParams{
|
||||
LibraryID: libraryID,
|
||||
FilePath: filePath,
|
||||
}
|
||||
}
|
||||
|
||||
// excludedPathSet loads a library's excluded paths as a set, cleaned
|
||||
// the same way the walk builds its absolute paths so the two compare.
|
||||
//
|
||||
// A failure here returns an empty set and logs: a scan that cannot read
|
||||
// the exclusions imports what it finds, which is the pre-exclusion
|
||||
// behaviour rather than an empty library.
|
||||
func (l *Library) excludedPathSet(libraryID int64) map[string]struct{} {
|
||||
paths, err := l.db.Queries.GetExcludedPathsByLibrary(l.ctx, libraryID)
|
||||
if err != nil {
|
||||
l.logger.Warn("could not load excluded paths, scanning everything",
|
||||
"libraryID", libraryID, "err", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
set := make(map[string]struct{}, len(paths))
|
||||
for _, p := range paths {
|
||||
set[filepath.Clean(p)] = struct{}{}
|
||||
}
|
||||
|
||||
return set
|
||||
}
|
||||
|
||||
// isExcluded reports whether an absolute file path is in the set. A
|
||||
// nil set excludes nothing, which is what every caller wants when
|
||||
// there are no exclusions at all.
|
||||
func isExcluded(set map[string]struct{}, absolutePath string) bool {
|
||||
if len(set) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
_, found := set[filepath.Clean(absolutePath)]
|
||||
|
||||
return found
|
||||
}
|
||||
@@ -334,7 +334,13 @@ func (l *Library) scanInternal(
|
||||
// --- Pre-walk: count audio files for progress reporting ---
|
||||
emitProgress(mkProgress("counting", 0, 0, 0, 0, 0))
|
||||
|
||||
totalFiles := countAudioFiles(basePath)
|
||||
// Paths the user has removed from the library. Loaded once per
|
||||
// scan: the walk consults it per file, and the counts above and
|
||||
// below must agree with it or the progress bar and the soft scan
|
||||
// both describe a library that is not the one being built.
|
||||
excluded := l.excludedPathSet(libraryID)
|
||||
|
||||
totalFiles := countAudioFiles(basePath, excluded)
|
||||
|
||||
l.logger.Debug(
|
||||
"pre-walk file count complete",
|
||||
@@ -449,6 +455,20 @@ func (l *Library) scanInternal(
|
||||
return nil
|
||||
}
|
||||
|
||||
// The user removed this path from the library. Leaving
|
||||
// it out of existingPaths' LoadAndDelete as well is
|
||||
// deliberate: if a row somehow exists for an excluded
|
||||
// path, orphan cleanup below deletes it, which is the
|
||||
// state the user asked for.
|
||||
if isExcluded(excluded, absoluteFilePath) {
|
||||
l.logger.Debug(
|
||||
"skipping excluded path",
|
||||
"path", absoluteFilePath,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stat the entry for the staleness comparison below.
|
||||
// This happens before the file is read, so a file
|
||||
// modified mid-scan records the pre-read mtime and is
|
||||
@@ -1217,21 +1237,27 @@ func (l *Library) pruneOrphanedMetadata() {
|
||||
// countAudioFiles performs a fast walk of the library directory,
|
||||
// counting only files with supported audio extensions. No per-file
|
||||
// I/O is performed — this reads only directory entries.
|
||||
func countAudioFiles(basePath string) int64 {
|
||||
func countAudioFiles(basePath string, excluded map[string]struct{}) int64 {
|
||||
var count int64
|
||||
|
||||
_ = fs.WalkDir(
|
||||
os.DirFS(basePath), ".",
|
||||
func(_ string, d fs.DirEntry, err error) error {
|
||||
func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := filepath.Ext(d.Name())
|
||||
if _, ok := metadata.GetSupportedFileType(ext); ok {
|
||||
count++
|
||||
if _, ok := metadata.GetSupportedFileType(ext); !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isExcluded(excluded, filepath.Join(basePath, path)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
count++
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
@@ -1248,10 +1274,17 @@ func countAudioFiles(basePath string) int64 {
|
||||
// Unlike countAudioFiles this stats every entry, so it is the more
|
||||
// expensive of the two walks. Only the startup soft scan uses it —
|
||||
// the in-scan progress total does not need mtimes.
|
||||
func surveyAudioFiles(basePath string) (count, maxModTime int64) {
|
||||
//
|
||||
// Both walks take the library's excluded paths and skip them, because
|
||||
// both answer "how many files would a scan import", not "how many
|
||||
// files are there".
|
||||
func surveyAudioFiles(
|
||||
basePath string,
|
||||
excluded map[string]struct{},
|
||||
) (count, maxModTime int64) {
|
||||
_ = fs.WalkDir(
|
||||
os.DirFS(basePath), ".",
|
||||
func(_ string, d fs.DirEntry, err error) error {
|
||||
func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
@@ -1261,6 +1294,14 @@ func surveyAudioFiles(basePath string) (count, maxModTime int64) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// An excluded path is not a file this scan would import,
|
||||
// so it must not be counted: the soft scan compares this
|
||||
// count against the database's, and a permanent
|
||||
// disagreement queues a full scan on every launch.
|
||||
if isExcluded(excluded, filepath.Join(basePath, path)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
count++
|
||||
|
||||
info, infoErr := d.Info()
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"yellowjacket/backend/events"
|
||||
)
|
||||
|
||||
// errNoPathsToRemove is returned when RemoveFromLibrary is called with
|
||||
// nothing to remove. A static error because err113 forbids a dynamic
|
||||
// one, and a sentinel because the frontend distinguishes it.
|
||||
var errNoPathsToRemove = errors.New("no file paths given to remove")
|
||||
|
||||
// RemovalResult reports what one RemoveFromLibrary call did.
|
||||
type RemovalResult struct {
|
||||
// TracksRemoved is how many audio_files rows were deleted. It can
|
||||
// be lower than len(filePaths) if a path was already gone.
|
||||
TracksRemoved int64 `json:"tracksRemoved"`
|
||||
// PathsExcluded is how many paths the scanner will now skip.
|
||||
PathsExcluded int64 `json:"pathsExcluded"`
|
||||
}
|
||||
|
||||
// RemoveFromLibrary deletes the database rows for the given file paths
|
||||
// and records each path as excluded, so the next scan does not import
|
||||
// it again. **It does not touch the files on disk** — that is the
|
||||
// promise the confirmation dialog makes, and the reason this operation
|
||||
// is safe to put one keystroke from a focused row.
|
||||
//
|
||||
// The exclusion is not an enhancement. Without it the next scan finds
|
||||
// the file, sees no row, and imports it again — a button that undoes
|
||||
// itself, which is worse than no button.
|
||||
func (l *Library) RemoveFromLibrary(filePaths []string) (*RemovalResult, error) {
|
||||
if len(filePaths) == 0 {
|
||||
return nil, errNoPathsToRemove
|
||||
}
|
||||
|
||||
rows, err := l.db.Queries.GetAudioFilesByPaths(l.ctx, filePaths)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve paths to remove: %w", err)
|
||||
}
|
||||
|
||||
tx, err := l.db.BeginTx()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not begin removal transaction: %w", err)
|
||||
}
|
||||
|
||||
committed := false
|
||||
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
txq := l.db.Queries.WithTx(tx)
|
||||
|
||||
var result RemovalResult
|
||||
|
||||
// Exclude every path the caller named, including one whose row has
|
||||
// already gone: the user asked for that file to stay out, and a row
|
||||
// that disappeared between the click and the commit is not a reason
|
||||
// to let the next scan bring it back. A path with no row at all is
|
||||
// attributed to the library that contains it, resolved below.
|
||||
rowByPath := make(map[string]int64, len(rows))
|
||||
|
||||
for _, row := range rows {
|
||||
rowByPath[row.FilePath] = row.LibraryID
|
||||
|
||||
if err := txq.DeleteAudioFile(l.ctx, row.ID); err != nil {
|
||||
return nil, fmt.Errorf("could not delete audio file row: %w", err)
|
||||
}
|
||||
|
||||
result.TracksRemoved++
|
||||
|
||||
// Keep the file's tagging group in sync, exactly as the scan's
|
||||
// orphan cleanup does: drop the count and clear the group once
|
||||
// it is empty, or the autotag queue keeps a row counting files
|
||||
// that no longer exist.
|
||||
if row.GroupKey != "" {
|
||||
if err := txq.DecrementTaggingItemTrackCount(l.ctx, row.GroupKey); err != nil {
|
||||
return nil, fmt.Errorf("could not decrement tagging group: %w", err)
|
||||
}
|
||||
|
||||
if err := txq.DeleteTaggingItemIfEmpty(l.ctx, row.GroupKey); err != nil {
|
||||
return nil, fmt.Errorf("could not clear emptied tagging group: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
libraryIDs, err := l.libraryIDsForPaths(filePaths, rowByPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, path := range filePaths {
|
||||
libraryID, known := libraryIDs[path]
|
||||
if !known {
|
||||
// Outside every configured library: no scan will ever
|
||||
// visit it, so there is nothing to exclude it from.
|
||||
l.logger.Debug("removal: path belongs to no library, not excluding",
|
||||
"path", path)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := txq.ExcludePath(l.ctx, excludeParams(libraryID, path)); err != nil {
|
||||
return nil, fmt.Errorf("could not exclude path: %w", err)
|
||||
}
|
||||
|
||||
result.PathsExcluded++
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("could not commit removal: %w", err)
|
||||
}
|
||||
|
||||
committed = true
|
||||
|
||||
// Post-commit, and best-effort: the rows are gone either way, and a
|
||||
// failure here leaves stale index entries rather than a wrong
|
||||
// library. The FTS5 index is contentless, so a stale entry is
|
||||
// harmless — searches join track_metadata, which no longer has the
|
||||
// row — but removing it keeps the index from growing forever.
|
||||
for _, row := range rows {
|
||||
if err := l.db.DeleteSearchIndex(row.ID); err != nil {
|
||||
l.logger.Warn("could not delete FTS entry for removed track",
|
||||
"path", row.FilePath, "id", row.ID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting an audio_files row cascades to queue_tracks, so the
|
||||
// queue's in-memory copy now holds tracks the database does not —
|
||||
// including, possibly, the one playing. This is the same reload
|
||||
// RemoveLibrary does, and it unloads the player if the current
|
||||
// track was among them.
|
||||
if l.removalHooks.CompactQueue != nil {
|
||||
l.removalHooks.CompactQueue()
|
||||
}
|
||||
|
||||
// An album, artist or genre whose last track just went is now a row
|
||||
// with nothing behind it, and the album list selects from
|
||||
// release_groups rather than from audio_files — so it would keep
|
||||
// rendering an album the user has no tracks of.
|
||||
l.pruneOrphanedMetadata()
|
||||
|
||||
l.emit(events.TracksRemovedFromLibrary, map[string]any{
|
||||
"filePaths": filePaths,
|
||||
"count": result.TracksRemoved,
|
||||
})
|
||||
|
||||
l.logger.Info("removed tracks from library",
|
||||
"requested", len(filePaths),
|
||||
"rowsDeleted", result.TracksRemoved,
|
||||
"pathsExcluded", result.PathsExcluded,
|
||||
)
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// libraryIDsForPaths maps each path to the library it belongs to. A
|
||||
// path that still had a row takes that row's library_id; one that did
|
||||
// not is matched against the configured library roots by prefix, which
|
||||
// is what the scan walk would do with it.
|
||||
func (l *Library) libraryIDsForPaths(
|
||||
filePaths []string,
|
||||
rowByPath map[string]int64,
|
||||
) (map[string]int64, error) {
|
||||
out := make(map[string]int64, len(filePaths))
|
||||
|
||||
var libs []libraryRoot
|
||||
|
||||
for _, path := range filePaths {
|
||||
if libraryID, ok := rowByPath[path]; ok {
|
||||
out[path] = libraryID
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if libs == nil {
|
||||
var err error
|
||||
|
||||
libs, err = l.libraryRoots()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, lib := range libs {
|
||||
if pathWithin(lib.path, path) {
|
||||
out[path] = lib.id
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
"yellowjacket/backend/events"
|
||||
"yellowjacket/internal/testfixtures"
|
||||
)
|
||||
|
||||
// setupScanLibrary builds a Library over a temp directory holding
|
||||
// copies of `count` real fixture tracks, plus the libraries row the
|
||||
// scan needs. Real files, because the scan extracts tags from them.
|
||||
func setupScanLibrary(
|
||||
t *testing.T,
|
||||
count int,
|
||||
) (lib *Library, dir string, paths []string, rec *events.Recorder, libID int64) {
|
||||
t.Helper()
|
||||
|
||||
m := testfixtures.Load(t)
|
||||
sources := m.Case(t, testfixtures.CaseFLACAlbum)
|
||||
|
||||
if len(sources) < count {
|
||||
t.Fatalf("fixture case has %d tracks, need %d", len(sources), count)
|
||||
}
|
||||
|
||||
dir = t.TempDir()
|
||||
paths = make([]string, 0, count)
|
||||
|
||||
for _, src := range sources[:count] {
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture %s: %v", src, err)
|
||||
}
|
||||
|
||||
dst := filepath.Join(dir, filepath.Base(src))
|
||||
if err := os.WriteFile(dst, data, 0o600); err != nil {
|
||||
t.Fatalf("write fixture copy: %v", err)
|
||||
}
|
||||
|
||||
paths = append(paths, dst)
|
||||
}
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
rec = events.NewRecorder()
|
||||
|
||||
lib = &Library{
|
||||
ctx: events.WithSink(t.Context(), rec),
|
||||
logger: slog.Default(),
|
||||
conf: &Config{},
|
||||
db: db,
|
||||
}
|
||||
|
||||
row, err := db.Queries.CreateLibrary(t.Context(), sqlcgen.CreateLibraryParams{
|
||||
Name: "Test",
|
||||
Path: dir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create library row: %v", err)
|
||||
}
|
||||
|
||||
slices.Sort(paths)
|
||||
|
||||
return lib, dir, paths, rec, row.ID
|
||||
}
|
||||
|
||||
// scannedPaths returns the file paths currently in the database, sorted.
|
||||
func scannedPaths(t *testing.T, lib *Library) []string {
|
||||
t.Helper()
|
||||
|
||||
rows, err := lib.db.Queries.GetAllAudioFilePaths(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("read audio file paths: %v", err)
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, row.FilePath)
|
||||
}
|
||||
|
||||
slices.Sort(out)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRemoveFromLibrary_SurvivesRescan is the assertion the whole phase
|
||||
// rests on: a removed path stays removed across a real scan of the real
|
||||
// directory, and the file it named is still on disk.
|
||||
//
|
||||
// Its positive half is not optional. A guard that excluded everything
|
||||
// would satisfy "the removed path did not come back" for free, so the
|
||||
// same scan must also put back a row deleted *without* an exclusion.
|
||||
func TestRemoveFromLibrary_SurvivesRescan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, dir, paths, _, libID := setupScanLibrary(t, 3)
|
||||
|
||||
lib.scanInternal(libID, "Test", dir)
|
||||
|
||||
if got := scannedPaths(t, lib); len(got) != 3 {
|
||||
t.Fatalf("first scan imported %d tracks, want 3: %v", len(got), got)
|
||||
}
|
||||
|
||||
removed := paths[0]
|
||||
// The control: its row is deleted directly, with no exclusion, so
|
||||
// the same scan has to bring it back.
|
||||
control := paths[1]
|
||||
|
||||
result, err := lib.RemoveFromLibrary([]string{removed})
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveFromLibrary: %v", err)
|
||||
}
|
||||
|
||||
if result.TracksRemoved != 1 || result.PathsExcluded != 1 {
|
||||
t.Fatalf(
|
||||
"RemoveFromLibrary = %+v, want 1 removed and 1 excluded",
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
controlRow, err := lib.db.Queries.GetAudioFileByPath(t.Context(), control)
|
||||
if err != nil {
|
||||
t.Fatalf("look up control track: %v", err)
|
||||
}
|
||||
|
||||
if err := lib.db.Queries.DeleteAudioFile(t.Context(), controlRow.ID); err != nil {
|
||||
t.Fatalf("delete control row: %v", err)
|
||||
}
|
||||
|
||||
lib.scanInternal(libID, "Test", dir)
|
||||
|
||||
after := scannedPaths(t, lib)
|
||||
|
||||
if slices.Contains(after, removed) {
|
||||
t.Errorf("excluded path came back after a rescan: %s\nrows: %v", removed, after)
|
||||
}
|
||||
|
||||
if !slices.Contains(after, control) {
|
||||
t.Errorf(
|
||||
"the rescan did not re-import a path that was NOT excluded (%s)"+
|
||||
" — the exclusion is skipping more than it was asked to\nrows: %v",
|
||||
control, after,
|
||||
)
|
||||
}
|
||||
|
||||
// The promise the confirmation dialog makes.
|
||||
if _, err := os.Stat(removed); err != nil {
|
||||
t.Errorf("removed file is no longer on disk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveFromLibrary_SoftScanSeesNoChange pins the trap that would
|
||||
// otherwise queue a full scan on every launch: the soft scan compares
|
||||
// the number of audio files on disk against the number of rows, and an
|
||||
// excluded path is on disk and deliberately not a row.
|
||||
func TestRemoveFromLibrary_SoftScanSeesNoChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, dir, paths, _, libID := setupScanLibrary(t, 3)
|
||||
|
||||
lib.scanInternal(libID, "Test", dir)
|
||||
|
||||
if _, err := lib.RemoveFromLibrary([]string{paths[0]}); err != nil {
|
||||
t.Fatalf("RemoveFromLibrary: %v", err)
|
||||
}
|
||||
|
||||
dbCount, err := lib.db.Queries.CountAudioFilesByLibrary(t.Context(), libID)
|
||||
if err != nil {
|
||||
t.Fatalf("count rows: %v", err)
|
||||
}
|
||||
|
||||
diskCount, _ := surveyAudioFiles(dir, lib.excludedPathSet(libID))
|
||||
|
||||
if diskCount != dbCount {
|
||||
t.Errorf(
|
||||
"soft scan would see disk %d vs db %d — every launch queues a full scan",
|
||||
diskCount, dbCount,
|
||||
)
|
||||
}
|
||||
|
||||
// And the positive half: without the exclusion set the survey still
|
||||
// counts the file, which is what makes the argument above real
|
||||
// rather than a tautology about a function that counts nothing.
|
||||
if raw, _ := surveyAudioFiles(dir, nil); raw != dbCount+1 {
|
||||
t.Errorf("unfiltered survey = %d, want %d", raw, dbCount+1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveFromLibrary_FullRescanClearsExclusions pins the only route
|
||||
// back for a path removed by mistake.
|
||||
func TestRemoveFromLibrary_FullRescanClearsExclusions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, dir, paths, _, libID := setupScanLibrary(t, 2)
|
||||
|
||||
lib.scanInternal(libID, "Test", dir)
|
||||
|
||||
if _, err := lib.RemoveFromLibrary([]string{paths[0]}); err != nil {
|
||||
t.Fatalf("RemoveFromLibrary: %v", err)
|
||||
}
|
||||
|
||||
if err := lib.clearLibraryTables(); err != nil {
|
||||
t.Fatalf("clearLibraryTables: %v", err)
|
||||
}
|
||||
|
||||
count, err := lib.db.Queries.CountExcludedPathsByLibrary(t.Context(), libID)
|
||||
if err != nil {
|
||||
t.Fatalf("count exclusions: %v", err)
|
||||
}
|
||||
|
||||
if count != 0 {
|
||||
t.Fatalf("full rescan left %d exclusions behind", count)
|
||||
}
|
||||
|
||||
lib.scanInternal(libID, "Test", dir)
|
||||
|
||||
if !slices.Contains(scannedPaths(t, lib), paths[0]) {
|
||||
t.Error("a full rescan did not bring back a previously excluded path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveFromLibrary_EmitsPatchablePayload checks the event carries
|
||||
// what a store needs to patch rather than invalidate.
|
||||
func TestRemoveFromLibrary_EmitsPatchablePayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, dir, paths, rec, libID := setupScanLibrary(t, 2)
|
||||
|
||||
lib.scanInternal(libID, "Test", dir)
|
||||
|
||||
if _, err := lib.RemoveFromLibrary([]string{paths[0]}); err != nil {
|
||||
t.Fatalf("RemoveFromLibrary: %v", err)
|
||||
}
|
||||
|
||||
ev, ok := rec.Last(events.TracksRemovedFromLibrary)
|
||||
if !ok {
|
||||
t.Fatalf("no TracksRemovedFromLibrary emitted; got %v", rec.Names())
|
||||
}
|
||||
|
||||
payload, ok := ev.Payload().(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("payload is %T, want a map", ev.Payload())
|
||||
}
|
||||
|
||||
got, ok := payload["filePaths"].([]string)
|
||||
if !ok || len(got) != 1 || got[0] != paths[0] {
|
||||
t.Errorf("payload filePaths = %v, want [%s]", payload["filePaths"], paths[0])
|
||||
}
|
||||
|
||||
if count, ok := payload["count"].(int64); !ok || count != 1 {
|
||||
t.Errorf("payload count = %v, want 1", payload["count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveFromLibrary_CompactsTheQueue pins the half that is invisible
|
||||
// from the track list: deleting an audio_files row cascades to
|
||||
// queue_tracks, so the queue's in-memory copy — and the player, if it
|
||||
// was the track playing — has to be told.
|
||||
func TestRemoveFromLibrary_CompactsTheQueue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, dir, paths, _, libID := setupScanLibrary(t, 2)
|
||||
|
||||
lib.scanInternal(libID, "Test", dir)
|
||||
|
||||
compacted := 0
|
||||
|
||||
lib.SetRemovalHooks(RemovalHooks{
|
||||
CompactQueue: func() { compacted++ },
|
||||
})
|
||||
|
||||
if _, err := lib.RemoveFromLibrary([]string{paths[0]}); err != nil {
|
||||
t.Fatalf("RemoveFromLibrary: %v", err)
|
||||
}
|
||||
|
||||
if compacted != 1 {
|
||||
t.Errorf("CompactQueue called %d times, want 1", compacted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveFromLibrary_RejectsAnEmptyRequest keeps a stray Delete on
|
||||
// an empty selection from reaching the database at all.
|
||||
func TestRemoveFromLibrary_RejectsAnEmptyRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, _, _, _, _ := setupScanLibrary(t, 1)
|
||||
|
||||
if _, err := lib.RemoveFromLibrary(nil); err == nil {
|
||||
t.Error("RemoveFromLibrary(nil) succeeded, want an error")
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,15 @@ func (l *Library) clearLibraryTables() error {
|
||||
return fmt.Errorf("could not clear queue tracks: %w", err)
|
||||
}
|
||||
|
||||
// A full rescan is the "start over" button, and it is the only way
|
||||
// back for a path removed from the library by mistake: the file is
|
||||
// still on disk, but nothing else will ever import it again while
|
||||
// its exclusion stands. Until there is a UI for managing the list,
|
||||
// clearing it here is the escape hatch.
|
||||
if err := txq.ClearExcludedPaths(l.ctx); err != nil {
|
||||
return fmt.Errorf("could not clear excluded paths: %w", err)
|
||||
}
|
||||
|
||||
// Preserve playlist tracks across rescan: populate phantom
|
||||
// metadata for all linked tracks before audio_files are deleted.
|
||||
// ON DELETE SET NULL will null out audio_file_id, converting them
|
||||
|
||||
@@ -181,7 +181,12 @@ func (l *Library) SoftScanAllLibraries() error {
|
||||
continue
|
||||
}
|
||||
|
||||
diskCount, diskModTime := surveyAudioFiles(lib.Path)
|
||||
// Excluded paths are on disk and deliberately not in the
|
||||
// database, so the survey must skip them or the two counts
|
||||
// disagree forever and every launch queues a full scan.
|
||||
diskCount, diskModTime := surveyAudioFiles(
|
||||
lib.Path, l.excludedPathSet(lib.ID),
|
||||
)
|
||||
|
||||
// A newer file on disk than anything on record means something
|
||||
// was edited in place since the last scan. An older newest-mtime
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestSurveyAudioFiles(t *testing.T) {
|
||||
// the result, or every artwork change would trigger a rescan.
|
||||
setModTime(t, filepath.Join(dir, "cover.jpg"), time.Now())
|
||||
|
||||
count, maxMod := surveyAudioFiles(dir)
|
||||
count, maxMod := surveyAudioFiles(dir, nil)
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("count = %d, want 2", count)
|
||||
@@ -145,7 +145,7 @@ func TestSurveyAudioFiles(t *testing.T) {
|
||||
touched := time.Now()
|
||||
setModTime(t, filepath.Join(dir, "a.mp3"), touched)
|
||||
|
||||
_, afterMod := surveyAudioFiles(dir)
|
||||
_, afterMod := surveyAudioFiles(dir, nil)
|
||||
|
||||
if afterMod != touched.Unix() {
|
||||
t.Errorf(
|
||||
@@ -158,7 +158,7 @@ func TestSurveyAudioFiles(t *testing.T) {
|
||||
func TestSurveyAudioFiles_EmptyDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
count, maxMod := surveyAudioFiles(t.TempDir())
|
||||
count, maxMod := surveyAudioFiles(t.TempDir(), nil)
|
||||
|
||||
if count != 0 || maxMod != 0 {
|
||||
t.Errorf(
|
||||
|
||||
Reference in New Issue
Block a user