Compare commits
3
Commits
91bab4e73e
...
41a4dd7148
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41a4dd7148 | ||
|
|
6d97e3c872 | ||
|
|
acbe7c4676 |
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user