RemoveFromLibrary deletes the audio_files rows the way the scan's own orphan cleanup does and records each path in excluded_paths. The exclusion is not an enhancement: without it the next scan finds the file, sees no row and imports it again, so the button undoes itself. The soft scan compares files on disk against rows in the database, so surveyAudioFiles and countAudioFiles both take the exclusion set — otherwise an excluded path makes the two disagree forever and queues a full scan on every launch. Deleting a row cascades to queue_tracks, so the removal calls the same CompactQueue hook RemoveLibrary does. Also lands the requested badge: library-status-indicator is a button again where it can act, utils/library-status.ts states once what owning and wanting mean, and the long-declared queued state finally has a producer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
200 lines
5.9 KiB
Go
200 lines
5.9 KiB
Go
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
|
|
}
|