Compare commits

...
3 Commits
Author SHA1 Message Date
logan 41a4dd7148 feat(shortcuts): bind tracklist.delete to the confirmation
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m5s
The binding has been in the defaults and in Settings since it was
written, with nothing on the other end of it, because "remove from
library" did not exist. It does now — and Delete only *opens* the
dialog, never performs the removal, which is the only version
defensible one keystroke from a focused row.

The e2e case asserts the two things that matter and neither is the row
count: the file is still on disk, and a real scan of the real directory
does not bring the row back. It watches a control path survive the same
scan, because a guard that excluded everything would pass the negative
assertion for free — and it restores the database it spends.
2026-08-13 13:28:38 -04:00
logan 6d97e3c872 feat(tracks): remove from library behind a confirmation
The context menu's one destructive command. Its impact line says the
files are not deleted, because a user who reads "remove" as "delete"
and finds their music gone was failed by the copy rather than by the
operation.

The store patches rather than invalidates: the event carries the paths,
so the tracks array — the expensive collection — is spliced in place
and only the album/artist/genre summaries, whose counts really did
change, are refetched. It falls back to a full invalidate when a tracks
fetch is already in flight, which is the one case a patch cannot be
shown to be equivalent to.

Deleting an audio_files row cascades to queue_tracks, so the removal
also compacts the queue — the same reload RemoveLibrary does, which
unloads the player if the removed track was the one playing.
2026-08-13 13:11:31 -04:00
logan acbe7c4676 feat(library): remove tracks from the library without touching the file
"Remove from library" deletes the audio_files row and records the path
as excluded, so the next scan does not import it again. Without the
exclusion the operation undoes itself on the next scan, which is worse
than not having it at all; the file on disk is never touched, which is
the promise the confirmation copy will make.

The soft scan compares the number of audio files on disk against the
number of rows, so both walks now skip excluded paths — otherwise the
two counts disagree forever and every launch queues a full scan of the
whole library. A full rescan clears the exclusions, which is the only
way back for a path removed by mistake until there is a UI for it.
2026-08-13 13:04:59 -04:00
28 changed files with 1313 additions and 23 deletions
@@ -343,3 +343,7 @@ JOIN audio_files af ON af.recording_id = r.id
WHERE r.mbid IN (sqlc.slice('mbids'))
AND af.library_id = ?
ORDER BY af.file_path;
-- name: GetAudioFilesByPaths :many
SELECT id, library_id, file_path, group_key FROM audio_files
WHERE file_path IN (sqlc.slice('paths'));
@@ -0,0 +1,15 @@
-- name: ExcludePath :exec
INSERT INTO excluded_paths (library_id, file_path)
VALUES (?, ?)
ON CONFLICT(library_id, file_path) DO NOTHING;
-- name: GetExcludedPathsByLibrary :many
SELECT file_path FROM excluded_paths
WHERE library_id = ?;
-- name: CountExcludedPathsByLibrary :one
SELECT COUNT(*) FROM excluded_paths
WHERE library_id = ?;
-- name: ClearExcludedPaths :exec
DELETE FROM excluded_paths;
@@ -0,0 +1,25 @@
-- Paths the user has removed from the library, which the scanner must
-- not import again.
--
-- "Remove from library" deletes the audio_files row and leaves the file
-- on disk. Without this table the next scan finds the file, sees no
-- row for it, and imports it again — so the exclusion is not an
-- enhancement, it is what makes the operation mean anything.
--
-- A row is keyed by (library_id, file_path) rather than by audio_file
-- id, because the row it names has just been deleted. ON DELETE
-- CASCADE from libraries means removing a library takes its exclusions
-- with it; a full rescan clears the table outright, which is the only
-- way back for a path removed by mistake until there is a UI for it.
CREATE TABLE IF NOT EXISTS excluded_paths (
id INTEGER PRIMARY KEY,
library_id INTEGER NOT NULL,
file_path TEXT NOT NULL,
excluded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(library_id, file_path),
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_excluded_paths_library
ON excluded_paths(library_id);
@@ -648,6 +648,56 @@ func (q *Queries) GetAudioFilesByLibrary(ctx context.Context, libraryID int64) (
return items, nil
}
const getAudioFilesByPaths = `-- name: GetAudioFilesByPaths :many
SELECT id, library_id, file_path, group_key FROM audio_files
WHERE file_path IN (/*SLICE:paths*/?)
`
type GetAudioFilesByPathsRow struct {
ID int64
LibraryID int64
FilePath string
GroupKey string
}
func (q *Queries) GetAudioFilesByPaths(ctx context.Context, paths []string) ([]GetAudioFilesByPathsRow, error) {
query := getAudioFilesByPaths
var queryParams []interface{}
if len(paths) > 0 {
for _, v := range paths {
queryParams = append(queryParams, v)
}
query = strings.Replace(query, "/*SLICE:paths*/?", strings.Repeat(",?", len(paths))[1:], 1)
} else {
query = strings.Replace(query, "/*SLICE:paths*/?", "NULL", 1)
}
rows, err := q.db.QueryContext(ctx, query, queryParams...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAudioFilesByPathsRow
for rows.Next() {
var i GetAudioFilesByPathsRow
if err := rows.Scan(
&i.ID,
&i.LibraryID,
&i.FilePath,
&i.GroupKey,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAudioFilesByReleaseGroup = `-- name: GetAudioFilesByReleaseGroup :many
SELECT
af.file_path,
@@ -0,0 +1,75 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: excluded_paths.sql
package sqlcgen
import (
"context"
)
const clearExcludedPaths = `-- name: ClearExcludedPaths :exec
DELETE FROM excluded_paths
`
func (q *Queries) ClearExcludedPaths(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, clearExcludedPaths)
return err
}
const countExcludedPathsByLibrary = `-- name: CountExcludedPathsByLibrary :one
SELECT COUNT(*) FROM excluded_paths
WHERE library_id = ?
`
func (q *Queries) CountExcludedPathsByLibrary(ctx context.Context, libraryID int64) (int64, error) {
row := q.db.QueryRowContext(ctx, countExcludedPathsByLibrary, libraryID)
var count int64
err := row.Scan(&count)
return count, err
}
const excludePath = `-- name: ExcludePath :exec
INSERT INTO excluded_paths (library_id, file_path)
VALUES (?, ?)
ON CONFLICT(library_id, file_path) DO NOTHING
`
type ExcludePathParams struct {
LibraryID int64
FilePath string
}
func (q *Queries) ExcludePath(ctx context.Context, arg ExcludePathParams) error {
_, err := q.db.ExecContext(ctx, excludePath, arg.LibraryID, arg.FilePath)
return err
}
const getExcludedPathsByLibrary = `-- name: GetExcludedPathsByLibrary :many
SELECT file_path FROM excluded_paths
WHERE library_id = ?
`
func (q *Queries) GetExcludedPathsByLibrary(ctx context.Context, libraryID int64) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getExcludedPathsByLibrary, libraryID)
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.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+7
View File
@@ -139,6 +139,13 @@ type DownloadRequest struct {
UpdatedAt time.Time
}
type ExcludedPath struct {
ID int64
LibraryID int64
FilePath string
ExcludedAt time.Time
}
type ExploreChampionFt struct {
Title string
ArtistName string
+8
View File
@@ -193,6 +193,14 @@ var tables = []Table{
Note: "Build metadata for explore_index: dump version, coverage " +
"tiers, last refresh.",
},
{
Name: "excluded_paths", Kind: Authored, Lifetime: Cascade,
Note: "Paths the user removed from the library, which the scanner " +
"must not import again. Authored: it is a decision, not " +
"derivable from disk. Cascades with its library, and a full " +
"rescan clears it \u2014 the only way back for a path removed by " +
"mistake.",
},
{
Name: "file_types", Kind: Derived, Lifetime: Retained,
Note: "Static lookup rows seeded from code, not user data.",
+8
View File
@@ -240,6 +240,14 @@ func TestAuthoredCascadesAreDeliberate(t *testing.T) {
// unsubscribing from an artist must stop the albums it queued
// on the user's behalf.
"download_requests": true,
// An exclusion says "do not import this path into library 3".
// Remove that library and no scan will ever visit the path
// again, so the row has nothing left to exclude it from — and
// the data it protects is the *absence* of a row, which the
// library removal has already achieved for everything. Adding
// the library back is the user asking to import it afresh.
"excluded_paths": true,
}
for _, entry := range datamap.ByKind(datamap.Authored) {
+13
View File
@@ -100,6 +100,19 @@ const (
BatchWriteProgress = "BatchWriteProgress"
)
// Track removal events.
//
// TracksRemovedFromLibrary means "these rows are gone and these paths
// will not be imported again", and like TrackPlayCountChanged it
// carries everything a consumer needs to patch rather than invalidate:
// {filePaths: []string, count: int}. The library store splices those
// paths out of its tracks array — which is the expensive collection —
// and refetches only the album/artist/genre summaries, whose counts
// really did change.
const (
TracksRemovedFromLibrary = "TracksRemovedFromLibrary"
)
// Play statistics events.
//
// TrackPlayCountChanged carries everything needed to patch the one
+92
View File
@@ -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
}
+48 -7
View File
@@ -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",
@@ -423,6 +429,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
@@ -1088,21 +1108,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
},
)
@@ -1119,10 +1145,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
}
@@ -1132,6 +1165,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()
+199
View File
@@ -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
}
+296
View File
@@ -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")
}
}
+9
View File
@@ -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
+6 -1
View File
@@ -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
+3 -3
View File
@@ -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(
+10 -9
View File
@@ -33,15 +33,16 @@ func DefaultBindings() map[string]string {
"app.selectAll": "Ctrl+A",
"app.shortcuts": "?",
// Panel-specific (track list). There is no `tracklist.delete`:
// it was bound to Delete and advertised in Settings as
// configurable while nothing listened for it, because "remove
// from library" does not exist and it is not clear what it would
// remove — the row (which the next scan puts back unless the path
// is also excluded) or the file (a delete-your-music button one
// keystroke from a focused row). Advertise it again when it does
// something.
"tracklist.play": "Enter",
// Panel-specific (track list). `tracklist.delete` spent six
// phases advertised in Settings with nothing on the other end of
// it, because "remove from library" did not exist and it was not
// clear what it would remove. It now removes the row and
// excludes the path from future scans, and leaves the file on
// disk — and the key only *opens the confirmation*, never
// performs the removal, which is the only version defensible one
// keystroke from a focused row.
"tracklist.play": "Enter",
"tracklist.delete": "Delete",
// Panel-specific (autotag review). These are the keys the
// autotag page used to bind on its own document listener, which
+149
View File
@@ -0,0 +1,149 @@
import { existsSync } from 'node:fs';
import { test, expect, resetEvents, callBinding, eventNames } from '../support/fixtures.js';
/**
* "Remove from library" removes the row and leaves the file.
*
* Two assertions carry this spec and neither is about the row count.
* The first is that the **file is still on disk** — that is the promise
* the confirmation copy makes, and the only thing standing between this
* feature and a user's music. The second is that a **real scan does not
* bring the row back**: without the exclusion the operation undoes
* itself on the next scan, which is worse than not having it.
*
* The suite shares one backend process in file order, so this restores
* the database it spent.
*/
const SNAPSHOT = 'e2e-pre-remove';
/** The file paths of the first n rows, in list order. */
const firstPaths = (n: number): string[] => Array.from(
document.querySelector('track-list')
?.shadowRoot?.querySelectorAll('[data-file-path]') ?? [],
).map((r) => r.getAttribute('data-file-path') ?? '').slice(0, n);
test.describe('remove from library', () => {
test.beforeAll(async ({ baseURL }) => {
// VACUUM INTO copies the whole file and the restore copies every row
// back, which is well over the 30 s a hook gets by default once
// earlier specs have staged an explore catalog.
test.setTimeout(180_000);
const res = await fetch(`${baseURL}/__test/db/snapshot?name=${SNAPSHOT}`, {
method: 'POST',
signal: AbortSignal.timeout(120_000),
});
expect(res.ok, 'could not snapshot the database before spending it').toBe(true);
});
test.afterAll(async ({ baseURL }) => {
test.setTimeout(180_000);
const res = await fetch(`${baseURL}/__test/db/restore?name=${SNAPSHOT}`, {
method: 'POST',
signal: AbortSignal.timeout(120_000),
});
expect(res.ok, 'could not restore the database this spec spent').toBe(true);
});
test.beforeEach(async ({ app }) => {
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('track-row').first()).toBeVisible();
});
/**
* Delete is bound to *opening* the confirmation and to nothing else.
* A key that asks is defensible one row from the user's music; a key
* that acts is not.
*/
test('Delete asks, and cancelling is a true no-op', async ({ app }) => {
const before = await app.getByTestId('track-row').count();
await resetEvents(app);
await app.evaluate(() => {
const rows = document.querySelector('track-list')
?.shadowRoot?.querySelectorAll('[data-testid="track-row"]');
rows?.[2]?.dispatchEvent(new MouseEvent('click', {
bubbles: true, composed: true,
}));
});
await app.keyboard.press('Delete');
const dialog = app.getByRole('dialog', { name: /from the library\?/ });
await expect(dialog).toBeVisible();
// The copy is the user's only protection, so it is asserted rather
// than assumed: it has to say the file is not deleted.
await expect(app.getByTestId('confirm-dialog')).toContainText(
/not deleted/,
);
await app.getByTestId('confirm-cancel').click();
await expect(dialog).toBeHidden();
expect(await app.getByTestId('track-row').count()).toBe(before);
expect((await eventNames(app))['TracksRemovedFromLibrary'] ?? 0).toBe(0);
});
test('confirming removes the row, keeps the file, and survives a scan', async ({
app,
}) => {
test.setTimeout(120_000);
const [target, control] = await app.evaluate(firstPaths, 2);
expect(target, 'no tracks in the library to remove').toBeTruthy();
expect(existsSync(target!), 'fixture file missing before the test').toBe(true);
const before = await app.getByTestId('track-row').count();
await resetEvents(app);
await app.getByTestId('track-row').first().click({ button: 'right' });
await app.getByRole('menuitem', { name: 'Remove from Library' }).click();
await app.getByTestId('confirm-accept').click();
const removed = await app.evaluate(
() => window.__yjEvents.wait('TracksRemovedFromLibrary', {
timeoutMs: 15_000,
}),
);
expect((removed.data as Array<Record<string, unknown>>)[0]).toMatchObject({
filePaths: [target],
count: 1,
});
await expect(app.getByTestId('track-row')).toHaveCount(before - 1);
// The promise the copy makes.
expect(existsSync(target!), 'the file was deleted from disk').toBe(true);
// And the half that makes the rest true: a real scan of the real
// directory must not import it again.
await resetEvents(app);
await callBinding(app, 'library.Library.ScanAllLibraries', []);
await app.evaluate(
() => window.__yjEvents.wait('LibraryScanComplete', { timeoutMs: 90_000 }),
);
await app.waitForTimeout(1000);
const paths = await app.evaluate(
() => Array.from(
document.querySelector('track-list')
?.shadowRoot?.querySelectorAll('[data-file-path]') ?? [],
).map((r) => r.getAttribute('data-file-path') ?? ''),
);
expect(paths, 'the excluded path came back on the next scan')
.not.toContain(target);
// The positive half: a guard that excluded everything would pass
// the assertion above for free.
expect(paths, 'the scan lost a path nobody excluded').toContain(control);
expect(existsSync(target!), 'the file was deleted from disk').toBe(true);
});
});
@@ -62,6 +62,9 @@ import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js'
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { describeError } from '@utils/describe-error';
import { notificationStore } from '@store/notification-store';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import { RemoveFromLibrary } from '@go/library/Library';
import { loadTrackDetails } from '@utils/lazy-track-details.js';
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
import '@components/playlist-picker/playlist-picker.js';
@@ -1188,8 +1191,29 @@ export class TrackList
'shortcut:tracklist-play',
this.handleShortcutPlay,
);
this.listenWhileActive(
document,
'shortcut:tracklist-delete',
this.handleShortcutDelete,
);
}
/**
* Delete opens the confirmation and does nothing else.
*
* That is the whole design of the binding: one keystroke from a
* focused row, a key that *asks* is defensible and a key that
* *acts* is not — so this is the same dialog the menu command
* opens, reached by a different route.
*/
private handleShortcutDelete = (): void => {
const filePaths = this.selection.getSelectedKeysOrdered();
if (filePaths.length === 0) return;
void this.removeFromLibrary(filePaths);
};
/** Enter plays the selection — the `tracklist.play` binding, which
* has existed in the defaults and in Settings since it was written
* and has never had anything on the other end of it. */
@@ -1615,12 +1639,79 @@ export class TrackList
void this.openBatchTrackDetails(filePaths);
}
break;
case 'remove-from-library':
// The only destructive command in this menu: it asks
// first, and it keeps the selection until the user has
// answered — the dialog names a count, and clearing the
// selection under it would make that count a claim
// about nothing.
this.ctxMenu.close();
void this.removeFromLibrary(filePaths);
return;
}
this.selection.clear();
this.ctxMenu.close();
}
/**
* "Remove from library", behind a confirmation that says what it
* does *and* what it does not.
*
* The second half is the point. This deletes the database rows and
* stops the scanner importing those paths again; the audio files
* are left exactly where they are. A user who reads "remove" as
* "delete" and finds their music gone would have been failed by the
* copy, not by the operation — so the copy says so in the impact
* line, where the consequence of every other destructive action in
* the app is written.
*/
private async removeFromLibrary(filePaths: string[]) {
const count = filePaths.length;
const only =
count === 1
? tracksByFilePath(this.tracks).get(filePaths[0]!)
: undefined;
const ok = await confirmAction({
title:
count === 1
? `Remove “${only?.TrackName ?? filePaths[0]!}” from the library?`
: `Remove ${count.toLocaleString()} tracks from the library?`,
message:
count === 1
? 'It is removed from YellowJacket and will not be added' +
' back by a future scan.'
: 'They are removed from YellowJacket and will not be' +
' added back by a future scan.',
impact:
count === 1
? 'The file is not deleted — it stays on disk exactly' +
' where it is. A full rescan brings it back.'
: 'The files are not deleted — they stay on disk exactly' +
' where they are. A full rescan brings them back.',
confirmLabel:
count === 1
? 'Remove track'
: `Remove ${count.toLocaleString()} tracks`,
danger: true,
});
if (!ok) return;
try {
await RemoveFromLibrary(filePaths);
this.selection.clear();
} catch (error) {
console.error('Error removing tracks from library:', error);
notificationStore.persistent({
title: 'Could not remove from library',
text: `${count === 1 ? 'That track is' : `Those ${count.toLocaleString()} tracks are`} still in your library. ${describeError(error)}`,
});
}
}
private onContextMenuFavoriteToggle() {
const filePaths =
this.selection.getSelectedKeysOrdered();
@@ -2081,6 +2172,20 @@ export class TrackList
></wa-icon>
Track Details
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'remove-from-library',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
Remove from Library
</wa-dropdown-item>
</div>
`
: nothing}
+11
View File
@@ -66,6 +66,17 @@ export const Events = {
TrackMetadataChanged: "TrackMetadataChanged",
BatchWriteProgress: "BatchWriteProgress",
// Track removal events.
//
// TracksRemovedFromLibrary means "these rows are gone and these paths
// will not be imported again", and like TrackPlayCountChanged it
// carries everything a consumer needs to patch rather than invalidate:
// {filePaths: []string, count: int}. The library store splices those
// paths out of its tracks array — which is the expensive collection —
// and refetches only the album/artist/genre summaries, whose counts
// really did change
TracksRemovedFromLibrary: "TracksRemovedFromLibrary",
// Play statistics events.
//
// TrackPlayCountChanged carries everything needed to patch the one
@@ -438,9 +438,14 @@ async function dispatch(action: string): Promise<void> {
);
break;
// No `tracklist.delete`: it dispatched an event nothing
// listened for, from a binding Settings advertised as
// configurable. See backend/shortcuts/config.go.
// `tracklist.delete` opens the confirmation and nothing else:
// the key is a request, not an action. See
// backend/shortcuts/config.go.
case 'tracklist.delete':
document.dispatchEvent(
new CustomEvent('shortcut:tracklist-delete'),
);
break;
// Panel-specific: autotag review. The view listens for these
// while it is the view on screen, and for nothing while it is
+6
View File
@@ -121,6 +121,12 @@ export const SHORTCUT_META: Record<string, ShortcutMeta> = {
scope: 'panel:track-list',
defaultKey: 'Enter',
},
'tracklist.delete': {
label: 'Remove from Library',
category: 'Navigation',
scope: 'panel:track-list',
defaultKey: 'Delete',
},
'autotag.apply': {
label: 'Apply Match',
category: 'Autotag',
+68
View File
@@ -108,6 +108,9 @@ class LibraryStore {
EventsOn(Events.TrackPlayCountChanged, (payload: unknown) => {
this.applyPlayCount(payload);
});
EventsOn(Events.TracksRemovedFromLibrary, (payload: unknown) => {
this.applyTracksRemoved(payload);
});
this.loadCoverSize();
this.deferEagerFetch();
@@ -559,6 +562,71 @@ class LibraryStore {
this.notify();
}
/**
* Splice removed tracks out in place, and refetch only the
* summaries whose counts changed.
*
* `invalidate()` would be correct and is the expensive answer: it
* nulls `tracks` and eagerly refetches it, which is ~37 MB across
* the IPC at 50 000 tracks for an operation that removed three
* rows. The event carries the paths precisely so this does not have
* to happen — the same bargain `TrackPlayCountChanged` makes.
*
* The album, artist and genre lists really do change (their track
* counts, and the row itself when its last track goes), so they are
* dropped and refetched. They are the small collections.
*/
private applyTracksRemoved(payload: unknown): void {
const p = payload as { filePaths?: string[] } | null;
const removed = p?.filePaths;
if (!removed || removed.length === 0) return;
// A tracks fetch already in flight would land holding the rows
// that were just deleted, and it captured the cache generation
// this patch is about to leave behind. There is no patch that
// is equivalent to that, so fall back.
if (this.inFlight.has('tracks')) {
this.invalidate();
return;
}
if (this.tracks !== null) {
const gone = new Set(removed);
const kept = this.tracks.filter((t) => !gone.has(t.FilePath));
// A new array identity even when nothing matched would
// invalidate every memoized filter/sort cache keyed on it
// for no reason.
if (kept.length !== this.tracks.length) {
this.tracks = kept;
}
}
this.albums = null;
this.artists = null;
this.genres = null;
// Bumping the cache generation is what stops an album fetch
// issued before the removal from committing its pre-removal
// answer. Safe for the tracks slot precisely because the guard
// above established there is nothing in flight for it.
this.cacheGen++;
this.inFlight.delete('albums');
this.inFlight.delete('artists');
this.inFlight.delete('genres');
this.changeGen++;
this.notify();
const logged = (what: string) => (err: unknown) =>
console.error(`library: could not reload ${what}`, err);
void this.getAlbums().catch(logged('albums'));
void this.getArtists().catch(logged('artists'));
void this.getGenres().catch(logged('genres'));
}
private invalidate(): void {
this.tracks = null;
this.albums = null;
@@ -383,6 +383,35 @@ describe('shortcut dispatch: scope', () => {
expect(fired).toBe(1);
});
/**
* `tracklist.delete` was advertised in Settings for six phases with
* nothing dispatching for it. What it dispatches now only *opens* a
* confirmation, which is what makes a destructive action defensible
* on an unmodified key one row from the user's music.
*/
it('dispatches for tracklist.delete, which had nothing on the other end', () => {
bindings({ 'tracklist.delete': 'Delete' });
const panel = mount(document.createElement('div'));
const row = document.createElement('div');
row.tabIndex = 0;
panel.dataset['shortcutScope'] = 'tracklist';
panel.append(row);
row.focus();
let fired = 0;
const listener = (): void => {
fired += 1;
};
document.addEventListener('shortcut:tracklist-delete', listener);
press('Delete');
document.removeEventListener('shortcut:tracklist-delete', listener);
expect(fired).toBe(1);
});
});
// ===================================================================
@@ -175,6 +175,55 @@ describe('library store: caching', () => {
expect(calls()).toEqual([]);
});
/**
* Removing tracks is the same bargain the play count makes, one
* collection wider: the event carries the paths so the tracks array
* — the expensive one — is patched rather than refetched, while the
* album/artist/genre summaries, whose counts really did change, are
* dropped and reloaded.
*/
describe('tracks removed from the library', () => {
beforeEach(async () => {
emit(Events.TracksRemovedFromLibrary, {
filePaths: ['/a.mp3'],
count: 1,
});
await flush();
});
it('does not refetch the tracks', () => {
expect(calls('library.Library.GetAllTracks')).toHaveLength(0);
});
it('splices the removed track out in place', () => {
expect(libraryStore.getCachedTracks()?.map((t) => t.FilePath)).toEqual([
'/b.mp3',
]);
});
it('reloads the summaries, whose counts changed', () => {
expect(
[
'library.Library.GetAllAlbums',
'library.Library.GetAllArtists',
'library.Library.GetAllGenresWithCounts',
].map((path) => calls(path).length),
).toEqual([1, 1, 1]);
});
});
it('ignores a removal naming a track it does not hold, without dropping the array', async () => {
const before = libraryStore.getCachedTracks();
emit(Events.TracksRemovedFromLibrary, {
filePaths: ['/not-in-this-library.mp3'],
count: 1,
});
await flush();
expect(libraryStore.getCachedTracks()).toBe(before);
});
it('resets scroll positions on invalidation, so a shorter list is not scrolled past its end', async () => {
libraryStore.setScrollPosition('albums', 4200);
emit(Events.LibraryScanComplete);
+2
View File
@@ -71,6 +71,8 @@ export function QueuedLibraryNames():Promise<Array<string>>;
export function ReleasePipelineLock():Promise<void>;
export function RemoveFromLibrary(arg1:Array<string>):Promise<library.RemovalResult>;
export function RemoveLibrary(arg1:number):Promise<library.RemovalSummary>;
export function RenameLibrary(arg1:number,arg2:string):Promise<void>;
+4
View File
@@ -134,6 +134,10 @@ export function ReleasePipelineLock() {
return window['go']['library']['Library']['ReleasePipelineLock']();
}
export function RemoveFromLibrary(arg1) {
return window['go']['library']['Library']['RemoveFromLibrary'](arg1);
}
export function RemoveLibrary(arg1) {
return window['go']['library']['Library']['RemoveLibrary'](arg1);
}
+14
View File
@@ -1760,6 +1760,20 @@ export namespace library {
this.queueItemCount = source["queueItemCount"];
}
}
export class RemovalResult {
tracksRemoved: number;
pathsExcluded: number;
static createFrom(source: any = {}) {
return new RemovalResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.tracksRemoved = source["tracksRemoved"];
this.pathsExcluded = source["pathsExcluded"];
}
}
export class RemovalSummary {
tracksDeleted: number;
artistsRemoved: number;