Files
yellowjacket/backend/library/remove_tracks.go
T
yonlu 88f5524aa2
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Failing after 3m44s
CI / e2e (pull_request) Skipped
fix(maintenance): bound lyrics search and clicks, clear queue source
Three unbounded or stale surfaces, each small on its own:

- lyrics_index rows were never pruned on track removal, so the FTS index
  grew forever. Delete the entry where the library search FTS entry is
  already deleted, on the orphan and RemoveFromLibrary paths.
- search_clicks had no ceiling; age out ranking rows after a retention
  window via a daily janitor job.
- queue.source_* kept a "Playing from X" label after its playlist was
  deleted. Drop the source when the queue's own playlist goes, wired
  through a playlist-service hook like Library.SetRemovalHooks.

Closes #249
2026-09-11 17:14:41 -04:00

205 lines
6.1 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)
}
if err := l.db.DeleteLyricsIndex(row.ID); err != nil {
l.logger.Warn("could not delete lyrics index 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.pruneEmptyEntities()
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
}