Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b9868ddd0 |
@@ -499,10 +499,6 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
PostRemove: yj.explore.InvalidateLibrarySync,
|
PostRemove: yj.explore.InvalidateLibrarySync,
|
||||||
})
|
})
|
||||||
|
|
||||||
// A deleted playlist must not leave the queue's "Playing from"
|
|
||||||
// label pointing at it.
|
|
||||||
yj.playlist.SetOnPlaylistDeleted(yj.queue.DropSourceForPlaylist)
|
|
||||||
|
|
||||||
// Register playback finished handler to drive queue auto-advance.
|
// Register playback finished handler to drive queue auto-advance.
|
||||||
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
||||||
|
|
||||||
@@ -779,7 +775,6 @@ func (yj *YellowJacketApp) startJanitor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
yj.janitor.Register(maintenance.ExpiredHTTPCacheJob(yj.database))
|
yj.janitor.Register(maintenance.ExpiredHTTPCacheJob(yj.database))
|
||||||
yj.janitor.Register(maintenance.StaleSearchClicksJob(yj.database))
|
|
||||||
yj.janitor.Register(maintenance.OrphanedCoverFilesJob(
|
yj.janitor.Register(maintenance.OrphanedCoverFilesJob(
|
||||||
yj.database, coversDir, library.CoverArtFileSet,
|
yj.database, coversDir, library.CoverArtFileSet,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -151,26 +151,14 @@ func (d *DB) SetLyrics(audioFileID int64, lyrics, source, recordingMBID string)
|
|||||||
return d.upsertLyricsIndex(audioFileID, lyrics)
|
return d.upsertLyricsIndex(audioFileID, lyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteLyricsIndex removes one file's entry from the contentless
|
|
||||||
// lyrics_index. It is called wherever a file row is deleted — the
|
|
||||||
// `lyrics` table cascades with its file, but the FTS entry does not and
|
|
||||||
// would otherwise accumulate for the life of the install (#249).
|
|
||||||
func (d *DB) DeleteLyricsIndex(audioFileID int64) error {
|
|
||||||
if _, err := d.db.ExecContext(d.Ctx,
|
|
||||||
"DELETE FROM lyrics_index WHERE rowid = ?", audioFileID,
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("could not delete lyrics_index row: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// upsertLyricsIndex refreshes a single file's entry in the contentless
|
// upsertLyricsIndex refreshes a single file's entry in the contentless
|
||||||
// lyrics_index. contentless_delete=1 makes the DELETE valid; an empty
|
// lyrics_index. contentless_delete=1 makes the DELETE valid; an empty
|
||||||
// lyrics string leaves the row deleted.
|
// lyrics string leaves the row deleted.
|
||||||
func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error {
|
func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error {
|
||||||
if err := d.DeleteLyricsIndex(audioFileID); err != nil {
|
if _, err := d.db.ExecContext(d.Ctx,
|
||||||
return err
|
"DELETE FROM lyrics_index WHERE rowid = ?", audioFileID,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("could not delete lyrics_index row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(lyrics) == "" {
|
if strings.TrimSpace(lyrics) == "" {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Preserving a playlist entry across the loss of its track is two
|
// Preserving a playlist entry across the loss of its track is two
|
||||||
@@ -89,12 +90,14 @@ const (
|
|||||||
// metadata on the entry itself, so the entry survives the rows being
|
// metadata on the entry itself, so the entry survives the rows being
|
||||||
// deleted underneath it.
|
// deleted underneath it.
|
||||||
//
|
//
|
||||||
// Every path that empties `audio_files` must call this first, inside
|
// Every path that empties `audio_files` must call this (or the scoped
|
||||||
// the same transaction as the delete. There are two such paths and
|
// variant below) first, inside the same transaction as the delete.
|
||||||
// they had drifted: the full rescan in backend/library did this and the
|
// These paths have drifted before: the full rescan in backend/library
|
||||||
// stale-shape retire in this package did not, so the *documented*
|
// did this and the stale-shape retire in this package did not, so the
|
||||||
// repair ("delete and rescan") preserved playlists while the automatic
|
// *documented* repair ("delete and rescan") preserved playlists while
|
||||||
// one that exists to spare the user that work silently emptied them.
|
// the automatic one that exists to spare the user that work silently
|
||||||
|
// emptied them (#183). The incremental scan's orphan cleanup and
|
||||||
|
// RemoveFromLibrary drifted the same way and are #246.
|
||||||
//
|
//
|
||||||
// The display half is skipped, with a warning, when `track_metadata`
|
// The display half is skipped, with a warning, when `track_metadata`
|
||||||
// cannot answer -- see the note above. Skipping it costs a phantom
|
// cannot answer -- see the note above. Skipping it costs a phantom
|
||||||
@@ -103,13 +106,39 @@ const (
|
|||||||
func PreservePlaylistPhantoms(
|
func PreservePlaylistPhantoms(
|
||||||
ctx context.Context, tx *sql.Tx, logger *slog.Logger,
|
ctx context.Context, tx *sql.Tx, logger *slog.Logger,
|
||||||
) error {
|
) error {
|
||||||
if _, err := tx.ExecContext(ctx, preservePhantomPathSQL); err != nil {
|
return preservePlaylistPhantoms(ctx, tx, nil, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreservePlaylistPhantomsForFiles is PreservePlaylistPhantoms scoped to
|
||||||
|
// the given audio file ids, for the two removal paths that delete a
|
||||||
|
// known subset of the table rather than all of it: the incremental
|
||||||
|
// scan's orphan cleanup and RemoveFromLibrary. A bulk pass there would
|
||||||
|
// rewrite every linked playlist row on every scan for nothing.
|
||||||
|
func PreservePlaylistPhantomsForFiles(
|
||||||
|
ctx context.Context, tx *sql.Tx, ids []int64, logger *slog.Logger,
|
||||||
|
) error {
|
||||||
|
return preservePlaylistPhantoms(ctx, tx, ids, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func preservePlaylistPhantoms(
|
||||||
|
ctx context.Context, tx *sql.Tx, ids []int64, logger *slog.Logger,
|
||||||
|
) error {
|
||||||
|
clause, args, skip := phantomIDFilter(ids)
|
||||||
|
if skip {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(
|
||||||
|
ctx, preservePhantomPathSQL+clause, args...,
|
||||||
|
); err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"could not preserve playlist track file paths: %w", err,
|
"could not preserve playlist track file paths: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx, preservePhantomDisplaySQL); err != nil {
|
if _, err := tx.ExecContext(
|
||||||
|
ctx, preservePhantomDisplaySQL+clause, args...,
|
||||||
|
); err != nil {
|
||||||
// A failed statement does not roll back a SQLite transaction,
|
// A failed statement does not roll back a SQLite transaction,
|
||||||
// so the path half above stands and the entries remain
|
// so the path half above stands and the entries remain
|
||||||
// re-linkable.
|
// re-linkable.
|
||||||
@@ -123,3 +152,26 @@ func PreservePlaylistPhantoms(
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// phantomIDFilter builds the extra WHERE terms and arguments that scope
|
||||||
|
// a preservation pass to a set of audio file ids. A nil ids returns the
|
||||||
|
// empty clause (a bulk run over every linked entry); an empty slice
|
||||||
|
// reports skip, since there is nothing to preserve.
|
||||||
|
func phantomIDFilter(ids []int64) (clause string, args []any, skip bool) {
|
||||||
|
switch {
|
||||||
|
case ids == nil:
|
||||||
|
return "", nil, false
|
||||||
|
case len(ids) == 0:
|
||||||
|
return "", nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
clause = " AND audio_file_id IN (" +
|
||||||
|
strings.Repeat("?,", len(ids)-1) + "?)"
|
||||||
|
|
||||||
|
args = make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
return clause, args, false
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPreservePlaylistPhantomsForFilesScopesToTheRequestedIDs is the
|
||||||
|
// scoping half of the scoped variant: a run over one file's id must
|
||||||
|
// fill that file's playlist entries and leave every other entry alone,
|
||||||
|
// because the incremental scan calls this once per orphan batch and a
|
||||||
|
// pass that rewrote the whole table would touch every playlist row on
|
||||||
|
// every scan.
|
||||||
|
func TestPreservePlaylistPhantomsForFilesScopesToTheRequestedIDs(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db := openRaw(t, t.TempDir())
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
|
||||||
|
t.Fatalf("pragma: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := applySchema(ctx, db); err != nil {
|
||||||
|
t.Fatalf("applySchema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO playlists (id, name) VALUES (1, 'keepme');
|
||||||
|
INSERT INTO libraries (id, name, path) VALUES (0, 'test', '/music');
|
||||||
|
INSERT INTO artists (id, name) VALUES (3, 'Aurora Fields');
|
||||||
|
INSERT INTO cover_art (id, file_path, mime_type)
|
||||||
|
VALUES (9, 'covers/7.jpg', 'image/jpeg');
|
||||||
|
INSERT INTO albums (id, name, artist_id, cover_art_id)
|
||||||
|
VALUES (4, 'Tideline', 3, 9);
|
||||||
|
INSERT INTO audio_files
|
||||||
|
(id, file_path, file_type_id, length_milliseconds,
|
||||||
|
title, artist_credit, artist_id, album_id)
|
||||||
|
VALUES
|
||||||
|
(7, '/music/a.flac', 1, 1000,
|
||||||
|
'Slack Water', 'Aurora Fields', 3, 4),
|
||||||
|
(8, '/music/b.flac', 1, 2000,
|
||||||
|
'Second Tide', 'Aurora Fields', 3, 4);
|
||||||
|
INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
|
||||||
|
VALUES (1, 7, 0), (1, 8, 1);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if err := PreservePlaylistPhantomsForFiles(
|
||||||
|
ctx, tx, []int64{7}, testLogger(),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("preserve: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
t.Fatalf("commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var filled, untouched sql.NullString
|
||||||
|
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT phantom_file_path FROM playlist_tracks WHERE audio_file_id = 7",
|
||||||
|
).Scan(&filled); err != nil {
|
||||||
|
t.Fatalf("read the requested entry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filled.String != "/music/a.flac" {
|
||||||
|
t.Errorf(
|
||||||
|
"requested entry phantom_file_path = %q, want %q",
|
||||||
|
filled.String, "/music/a.flac",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT phantom_file_path FROM playlist_tracks WHERE audio_file_id = 8",
|
||||||
|
).Scan(&untouched); err != nil {
|
||||||
|
t.Fatalf("read the untouched entry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if untouched.Valid {
|
||||||
|
t.Errorf(
|
||||||
|
"untouched entry got phantom_file_path = %q, want NULL "+
|
||||||
|
"(a scoped run must not rewrite the whole table)",
|
||||||
|
untouched.String,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
-43
@@ -911,30 +911,96 @@ func (l *Library) scanInternal(
|
|||||||
|
|
||||||
orphanStart := time.Now()
|
orphanStart := time.Now()
|
||||||
|
|
||||||
|
// Snapshot the orphan set first. The playlist-phantom
|
||||||
|
// preservation and the deletes are one transaction (the
|
||||||
|
// preservation has to land before the ON DELETE SET NULL, and
|
||||||
|
// both have to succeed or neither does), and the ids are what
|
||||||
|
// scope that preservation to just these files instead of
|
||||||
|
// rewriting every playlist row on a routine scan.
|
||||||
|
var (
|
||||||
|
orphans []sqlcgen.AudioFile
|
||||||
|
orphanPaths []string
|
||||||
|
)
|
||||||
|
|
||||||
existingPaths.Range(func(key, value any) bool {
|
existingPaths.Range(func(key, value any) bool {
|
||||||
path := key.(string)
|
orphanPaths = append(orphanPaths, key.(string))
|
||||||
audioFile := value.(sqlcgen.AudioFile)
|
orphans = append(orphans, value.(sqlcgen.AudioFile))
|
||||||
|
|
||||||
l.logger.Debug(
|
return true
|
||||||
"removing orphaned database entry",
|
})
|
||||||
"path", path, "id", audioFile.ID,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := l.db.Queries.DeleteAudioFile(
|
deleted := make([]bool, len(orphans))
|
||||||
l.ctx, audioFile.ID,
|
|
||||||
); err != nil {
|
|
||||||
l.logger.Warn(
|
|
||||||
"failed to delete orphaned audio file",
|
|
||||||
"path", path,
|
|
||||||
"id", audioFile.ID,
|
|
||||||
"err", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
metrics.addWarning(path, "orphan", err)
|
if len(orphans) > 0 {
|
||||||
|
orphanIDs := make([]int64, len(orphans))
|
||||||
return true
|
for i, f := range orphans {
|
||||||
|
orphanIDs[i] = f.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tx, beginErr := l.db.BeginTx()
|
||||||
|
if beginErr != nil {
|
||||||
|
metrics.addWarning("", "orphan", beginErr)
|
||||||
|
} else {
|
||||||
|
defer func() { _ = tx.Rollback() }() // no-op after commit
|
||||||
|
|
||||||
|
if err := database.PreservePlaylistPhantomsForFiles(
|
||||||
|
l.ctx, tx, orphanIDs, l.logger,
|
||||||
|
); err != nil {
|
||||||
|
// Deleting without the phantoms is exactly the
|
||||||
|
// playlist-emptying bug the preservation exists to
|
||||||
|
// prevent, so leave the rows for the next scan
|
||||||
|
// rather than empty the playlists now.
|
||||||
|
l.logger.Error(
|
||||||
|
"skipping orphan deletion: could not preserve "+
|
||||||
|
"playlist entries",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics.addWarning("", "orphan", err)
|
||||||
|
|
||||||
|
_ = tx.Rollback()
|
||||||
|
} else {
|
||||||
|
txq := l.db.Queries.WithTx(tx)
|
||||||
|
|
||||||
|
for i, f := range orphans {
|
||||||
|
if err := txq.DeleteAudioFile(
|
||||||
|
l.ctx, f.ID,
|
||||||
|
); err != nil {
|
||||||
|
l.logger.Warn(
|
||||||
|
"failed to delete orphaned audio file",
|
||||||
|
"path", orphanPaths[i],
|
||||||
|
"id", f.ID,
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics.addWarning(orphanPaths[i], "orphan", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted[i] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
l.logger.Error(
|
||||||
|
"could not commit orphan deletion",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics.addWarning("", "orphan", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-commit bookkeeping for the files that actually went.
|
||||||
|
for i, f := range orphans {
|
||||||
|
if !deleted[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := orphanPaths[i]
|
||||||
|
|
||||||
// Keep the file's tagging group in sync: drop the group's
|
// Keep the file's tagging group in sync: drop the group's
|
||||||
// track count and clear it out once empty, mirroring the
|
// track count and clear it out once empty, mirroring the
|
||||||
// bookkeeping maybeRebindTaggingGroup does for a group_key
|
// bookkeeping maybeRebindTaggingGroup does for a group_key
|
||||||
@@ -942,25 +1008,25 @@ func (l *Library) scanInternal(
|
|||||||
// and replaced leaves a stale tagging_items row behind —
|
// and replaced leaves a stale tagging_items row behind —
|
||||||
// its track_count still counts the deleted files, and it
|
// its track_count still counts the deleted files, and it
|
||||||
// never clears from the autotag queue.
|
// never clears from the autotag queue.
|
||||||
if audioFile.GroupKey != "" {
|
if f.GroupKey != "" {
|
||||||
if err := l.db.Queries.DecrementTaggingItemTrackCount(
|
if err := l.db.Queries.DecrementTaggingItemTrackCount(
|
||||||
l.ctx, audioFile.GroupKey,
|
l.ctx, f.GroupKey,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
l.logger.Warn(
|
l.logger.Warn(
|
||||||
"failed to decrement tagging group for orphan",
|
"failed to decrement tagging group for orphan",
|
||||||
"path", path,
|
"path", path,
|
||||||
"group_key", audioFile.GroupKey,
|
"group_key", f.GroupKey,
|
||||||
"err", err,
|
"err", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
metrics.addWarning(path, "orphan", err)
|
metrics.addWarning(path, "orphan", err)
|
||||||
} else if err := l.db.Queries.DeleteTaggingItemIfEmpty(
|
} else if err := l.db.Queries.DeleteTaggingItemIfEmpty(
|
||||||
l.ctx, audioFile.GroupKey,
|
l.ctx, f.GroupKey,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
l.logger.Warn(
|
l.logger.Warn(
|
||||||
"failed to clean up emptied tagging group for orphan",
|
"failed to clean up emptied tagging group for orphan",
|
||||||
"path", path,
|
"path", path,
|
||||||
"group_key", audioFile.GroupKey,
|
"group_key", f.GroupKey,
|
||||||
"err", err,
|
"err", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -968,25 +1034,11 @@ func (l *Library) scanInternal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from FTS5 search index and the lyrics index.
|
// Remove from FTS5 search index.
|
||||||
if err := l.db.DeleteSearchIndex(
|
if err := l.db.DeleteSearchIndex(f.ID); err != nil {
|
||||||
audioFile.ID,
|
|
||||||
); err != nil {
|
|
||||||
l.logger.Warn(
|
l.logger.Warn(
|
||||||
"failed to delete FTS entry for orphan",
|
"failed to delete FTS entry for orphan",
|
||||||
"id", audioFile.ID,
|
"id", f.ID,
|
||||||
"err", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
metrics.addWarning(path, "orphan", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := l.db.DeleteLyricsIndex(
|
|
||||||
audioFile.ID,
|
|
||||||
); err != nil {
|
|
||||||
l.logger.Warn(
|
|
||||||
"failed to delete lyrics index entry for orphan",
|
|
||||||
"id", audioFile.ID,
|
|
||||||
"err", err,
|
"err", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -994,9 +1046,7 @@ func (l *Library) scanInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
removed.Add(1)
|
removed.Add(1)
|
||||||
|
}
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
metrics.OrphanCleanup = time.Since(orphanStart)
|
metrics.OrphanCleanup = time.Since(orphanStart)
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,57 @@ func countRows(
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveFromLibrary is the one path that empties a track *deliberately*:
|
||||||
|
// the file stays on disk but is excluded, so nothing re-imports it. The
|
||||||
|
// playlist entry must still survive as a re-linkable phantom rather than
|
||||||
|
// an empty row, because a later full rescan clears the exclusion and is
|
||||||
|
// what re-links the entry then (#246).
|
||||||
|
func TestRemoveFromLibrary_PreservesPlaylistPhantoms(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, db := setupTestLibrary(t)
|
||||||
|
|
||||||
|
seedRemovableLibrary(t, lib, "/nonexistent/cover.jpg")
|
||||||
|
|
||||||
|
if _, err := lib.db.ExecContext(
|
||||||
|
`INSERT INTO playlists (name) VALUES ('keepme')`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed playlist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistID := queryInt(
|
||||||
|
t, db, `SELECT id FROM playlists WHERE name = 'keepme'`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if _, err := lib.db.ExecContext(
|
||||||
|
`INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
|
||||||
|
SELECT ?, id, 0 FROM audio_files
|
||||||
|
WHERE file_path = '/music/song.mp3'`,
|
||||||
|
playlistID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed playlist_tracks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := lib.RemoveFromLibrary([]string{"/music/song.mp3"}); err != nil {
|
||||||
|
t.Fatalf("RemoveFromLibrary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
phantomPath := queryString(
|
||||||
|
t, db,
|
||||||
|
`SELECT phantom_file_path FROM playlist_tracks
|
||||||
|
WHERE playlist_id = ?`,
|
||||||
|
playlistID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if phantomPath != "/music/song.mp3" {
|
||||||
|
t.Fatalf(
|
||||||
|
"phantom_file_path is %q, want %q -- the entry cannot be "+
|
||||||
|
"re-linked after a later full rescan",
|
||||||
|
phantomPath, "/music/song.mp3",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A library with tagging_items must still be removable. tagging_items
|
// A library with tagging_items must still be removable. tagging_items
|
||||||
// FK-references libraries with no ON DELETE clause, so leaving those
|
// FK-references libraries with no ON DELETE clause, so leaving those
|
||||||
// rows behind fails the DELETE and rolls back the entire removal.
|
// rows behind fails the DELETE and rolls back the entire removal.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"yellowjacket/backend/database"
|
||||||
"yellowjacket/backend/events"
|
"yellowjacket/backend/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,6 +58,25 @@ func (l *Library) RemoveFromLibrary(filePaths []string) (*RemovalResult, error)
|
|||||||
|
|
||||||
var result RemovalResult
|
var result RemovalResult
|
||||||
|
|
||||||
|
// Preserve the playlist entries before the rows go, so they survive
|
||||||
|
// as re-linkable phantoms rather than empty rows. The track is
|
||||||
|
// excluded and will not be re-imported on its own, but a later full
|
||||||
|
// rescan clears the exclusion and this is what lets the entry
|
||||||
|
// re-link then — the same preservation every other path that empties
|
||||||
|
// audio_files performs (#246).
|
||||||
|
rowIDs := make([]int64, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
rowIDs[i] = row.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.PreservePlaylistPhantomsForFiles(
|
||||||
|
l.ctx, tx, rowIDs, l.logger,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"could not preserve playlist entries for removal: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Exclude every path the caller named, including one whose row has
|
// 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
|
// 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
|
// that disappeared between the click and the commit is not a reason
|
||||||
@@ -127,11 +147,6 @@ func (l *Library) RemoveFromLibrary(filePaths []string) (*RemovalResult, error)
|
|||||||
l.logger.Warn("could not delete FTS entry for removed track",
|
l.logger.Warn("could not delete FTS entry for removed track",
|
||||||
"path", row.FilePath, "id", row.ID, "err", err)
|
"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
|
// Deleting an audio_files row cascades to queue_tracks, so the
|
||||||
|
|||||||
@@ -231,3 +231,98 @@ func TestScan_MultipleDirectoriesDoNotCrossContaminate(t *testing.T) {
|
|||||||
t.Errorf("Album A and Album B must not share a group_key: %+v", keys)
|
t.Errorf("Album A and Album B must not share a group_key: %+v", keys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestScan_OrphanCleanupPreservesPlaylistPhantoms guards #246: a file
|
||||||
|
// deleted from the library folder *outside* YellowJacket is discovered
|
||||||
|
// as an orphan by the next scan, and its playlist entry must survive as
|
||||||
|
// a re-linkable phantom — the same preservation the full rescan and
|
||||||
|
// stale-retire paths already perform, scoped here to just the orphaned
|
||||||
|
// file. Before the fix the entry became an empty row (audio_file_id
|
||||||
|
// NULL and no phantom_file_path), which nothing can ever re-link.
|
||||||
|
func TestScan_OrphanCleanupPreservesPlaylistPhantoms(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lib, db := setupTestLibrary(t)
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
track := filepath.Join(root, "gone.mp3")
|
||||||
|
|
||||||
|
writeTestTrack(t, track, 0)
|
||||||
|
|
||||||
|
library, err := db.Queries.CreateLibrary(lib.ctx, sqlcgen.CreateLibraryParams{
|
||||||
|
Name: "orphans",
|
||||||
|
Path: root,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create library: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metrics := lib.scanInternal(library.ID, library.Name, library.Path); metrics == nil {
|
||||||
|
t.Fatal("first scan returned nil metrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
trackID := queryInt(
|
||||||
|
t, db, "SELECT id FROM audio_files WHERE file_path = ?", track,
|
||||||
|
)
|
||||||
|
if trackID == 0 {
|
||||||
|
t.Fatal("first scan did not import the track")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
`INSERT INTO playlists (name) VALUES ('keepme')`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed playlist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistID := queryInt(
|
||||||
|
t, db, "SELECT id FROM playlists WHERE name = 'keepme'",
|
||||||
|
)
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
`INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
|
||||||
|
VALUES (?, ?, 0)`,
|
||||||
|
playlistID, trackID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed playlist_tracks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file goes away outside the app.
|
||||||
|
if err := os.Remove(track); err != nil {
|
||||||
|
t.Fatalf("remove track: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metrics := lib.scanInternal(library.ID, library.Name, library.Path); metrics == nil {
|
||||||
|
t.Fatal("second scan returned nil metrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := queryInt(
|
||||||
|
t, db, "SELECT COUNT(*) FROM audio_files WHERE file_path = ?", track,
|
||||||
|
); n != 0 {
|
||||||
|
t.Fatalf("audio_files still holds the removed path: %d rows", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := queryInt(
|
||||||
|
t, db,
|
||||||
|
"SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = ? "+
|
||||||
|
"AND audio_file_id IS NULL",
|
||||||
|
playlistID,
|
||||||
|
); n != 1 {
|
||||||
|
t.Fatalf(
|
||||||
|
"playlist entry did not become a phantom: %d null-id rows, want 1",
|
||||||
|
n,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
phantomPath := queryString(
|
||||||
|
t, db,
|
||||||
|
"SELECT phantom_file_path FROM playlist_tracks WHERE playlist_id = ?",
|
||||||
|
playlistID,
|
||||||
|
)
|
||||||
|
if phantomPath != track {
|
||||||
|
t.Fatalf(
|
||||||
|
"phantom_file_path = %q, want %q -- the entry cannot be "+
|
||||||
|
"re-linked if the file comes back",
|
||||||
|
phantomPath, track,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -666,52 +666,3 @@ func TestExpiredHTTPCacheJob_TrimsToBudget(t *testing.T) {
|
|||||||
t.Errorf("kept %q, want the longest-lived row", kept)
|
t.Errorf("kept %q, want the longest-lived row", kept)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStaleSearchClicksJob deletes only the clicks old enough to have
|
|
||||||
// left the retention window (#249).
|
|
||||||
func TestStaleSearchClicksJob(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := database.NewTestDB(t)
|
|
||||||
|
|
||||||
count := func(mbid string) int {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var n int
|
|
||||||
if err := db.QueryRowWriter(
|
|
||||||
"SELECT COUNT(*) FROM search_clicks WHERE entity_mbid = ?", mbid,
|
|
||||||
).Scan(&n); err != nil {
|
|
||||||
t.Fatalf("count %s: %v", mbid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
seed := func(query, mbid, lastClicked string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
`INSERT INTO search_clicks
|
|
||||||
(query, entity_mbid, entity_type, click_count, last_clicked)
|
|
||||||
VALUES (?, ?, 'recording', 1, ?)`,
|
|
||||||
query, mbid, lastClicked,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed search_clicks: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
seed("tide", "aaaa", "2024-01-01 00:00:00") // stale
|
|
||||||
seed("tide", "bbbb", "2999-01-01 00:00:00") // recent
|
|
||||||
|
|
||||||
if _, err := StaleSearchClicksJob(db).Run(context.Background()); err != nil {
|
|
||||||
t.Fatalf("run job: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := count("bbbb"); n != 1 {
|
|
||||||
t.Errorf("recent click was deleted: %d rows, want 1", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := count("aaaa"); n != 0 {
|
|
||||||
t.Errorf("stale click survived: %d rows, want 0", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -628,35 +628,3 @@ func dirSize(dir string) (bytes, files int64) {
|
|||||||
|
|
||||||
return bytes, files
|
return bytes, files
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchClicksRetention is how long a search-click ranking signal stays
|
|
||||||
// useful. search_clicks is authored behavioural data — nothing that
|
|
||||||
// owns a row ever drops it — so age is the ceiling that keeps the table
|
|
||||||
// from growing without bound for the life of the install (#249).
|
|
||||||
const searchClicksRetention = "-180 days"
|
|
||||||
|
|
||||||
// StaleSearchClicksJob deletes search-click ranking rows older than the
|
|
||||||
// retention window. Rows are small and the table grows slowly, so this
|
|
||||||
// runs daily and does almost nothing most runs.
|
|
||||||
func StaleSearchClicksJob(db *database.DB) Job {
|
|
||||||
return Job{
|
|
||||||
Name: "search-clicks-sweep",
|
|
||||||
MinInterval: dailyInterval,
|
|
||||||
Run: func(_ context.Context) (Result, error) {
|
|
||||||
res, err := db.ExecContext(
|
|
||||||
`DELETE FROM search_clicks
|
|
||||||
WHERE last_clicked < datetime('now', ?)`,
|
|
||||||
searchClicksRetention,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, fmt.Errorf(
|
|
||||||
"delete stale search_clicks rows: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, _ := res.RowsAffected()
|
|
||||||
|
|
||||||
return Result{RowsDeleted: rows}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -134,12 +134,6 @@ type Service struct {
|
|||||||
libraryDir LibraryDirProvider
|
libraryDir LibraryDirProvider
|
||||||
favoritesConf FavoritesConfigProvider
|
favoritesConf FavoritesConfigProvider
|
||||||
|
|
||||||
// onDeleted, when set, is called after a playlist is deleted so
|
|
||||||
// cross-cutting state that points at it (the queue's "Playing
|
|
||||||
// from" label) can stop pointing at a playlist that no longer
|
|
||||||
// exists. Wired from app.go, like Library.SetRemovalHooks.
|
|
||||||
onDeleted func(playlistID int64)
|
|
||||||
|
|
||||||
// dataDirOverride, when non-empty, replaces the OS user data
|
// dataDirOverride, when non-empty, replaces the OS user data
|
||||||
// directory as the base for the playlists folder. Set by tests to
|
// directory as the base for the playlists folder. Set by tests to
|
||||||
// keep M3U writes out of the real user data directory.
|
// keep M3U writes out of the real user data directory.
|
||||||
@@ -172,17 +166,6 @@ func (s *Service) SetFavoritesConfig(
|
|||||||
s.favoritesConf = provider
|
s.favoritesConf = provider
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetOnPlaylistDeleted registers a callback invoked after a playlist is
|
|
||||||
// deleted, for cross-cutting invalidation.
|
|
||||||
//
|
|
||||||
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
|
||||||
func (s *Service) SetOnPlaylistDeleted(onDeleted func(playlistID int64)) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
s.onDeleted = onDeleted
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceStartup is v3's service lifecycle hook: it runs once the
|
// ServiceStartup is v3's service lifecycle hook: it runs once the
|
||||||
// runtime exists, and ctx is cancelled when the app shuts down. It
|
// runtime exists, and ctx is cancelled when the app shuts down. It
|
||||||
// replaces v2's SetContext, which had to be called by hand from
|
// replaces v2's SetContext, which had to be called by hand from
|
||||||
@@ -783,17 +766,6 @@ func (s *Service) DeletePlaylist(playlistID int64) error {
|
|||||||
|
|
||||||
s.emitEvent(events.PlaylistDeleted, playlistID)
|
s.emitEvent(events.PlaylistDeleted, playlistID)
|
||||||
|
|
||||||
// Cross-cutting invalidation: the queue's "Playing from" label may
|
|
||||||
// point at this playlist, and a link to a playlist that no longer
|
|
||||||
// exists is worse than none.
|
|
||||||
s.mu.Lock()
|
|
||||||
onDeleted := s.onDeleted
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
if onDeleted != nil {
|
|
||||||
onDeleted(playlistID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recreate the default playlist if we just deleted it.
|
// Recreate the default playlist if we just deleted it.
|
||||||
if s.defaultPlaylistID() == playlistID {
|
if s.defaultPlaylistID() == playlistID {
|
||||||
s.EnsureDefaultPlaylist()
|
s.EnsureDefaultPlaylist()
|
||||||
|
|||||||
@@ -1581,21 +1581,6 @@ func (q *Queue) dropSource() {
|
|||||||
q.source = Source{}
|
q.source = Source{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DropSourceForPlaylist clears the queue's "Playing from" label when
|
|
||||||
// its source playlist is deleted. A link back to a playlist that no
|
|
||||||
// longer exists is worse than none, and the label otherwise survives
|
|
||||||
// the deletion until the next SetQueue (#249).
|
|
||||||
func (q *Queue) DropSourceForPlaylist(playlistID int64) {
|
|
||||||
q.mu.Lock()
|
|
||||||
defer q.mu.Unlock()
|
|
||||||
|
|
||||||
if (q.source.Type == "playlist" || q.source.Type == "smartPlaylist") &&
|
|
||||||
q.source.ID == playlistID {
|
|
||||||
q.dropSource()
|
|
||||||
q.persistState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// commitMutation persists the current queue state after a mutation.
|
// commitMutation persists the current queue state after a mutation.
|
||||||
// When reindex is true, track positions are renumbered first.
|
// When reindex is true, track positions are renumbered first.
|
||||||
// The caller must hold q.mu.
|
// The caller must hold q.mu.
|
||||||
|
|||||||
@@ -535,35 +535,3 @@ func TestCycleRepeat_CyclesThroughModes(t *testing.T) {
|
|||||||
t.Errorf("after third cycle: got %q, want %q", state.RepeatMode, RepeatOff)
|
t.Errorf("after third cycle: got %q, want %q", state.RepeatMode, RepeatOff)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDropSourceForPlaylist clears the "Playing from" label when the
|
|
||||||
// queue's source playlist is deleted, and leaves it alone otherwise
|
|
||||||
// (#249).
|
|
||||||
func TestDropSourceForPlaylist(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
q, db := setupTestQueue(t)
|
|
||||||
paths := seedAudioFiles(t, db, 2)
|
|
||||||
|
|
||||||
q.SetQueue(paths, 0, false, Source{Type: "playlist", ID: 42, Label: "Road Trip"})
|
|
||||||
q.DropSourceForPlaylist(42)
|
|
||||||
|
|
||||||
if got := q.GetState().Source; got != (Source{}) {
|
|
||||||
t.Errorf("source = %+v, want empty after playlist 42 deleted", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDropSourceForPlaylistIgnoresOtherPlaylists(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
q, db := setupTestQueue(t)
|
|
||||||
paths := seedAudioFiles(t, db, 2)
|
|
||||||
|
|
||||||
source := Source{Type: "smartPlaylist", ID: 42, Label: "Road Trip"}
|
|
||||||
q.SetQueue(paths, 0, false, source)
|
|
||||||
q.DropSourceForPlaylist(7)
|
|
||||||
|
|
||||||
if got := q.GetState().Source; got != source {
|
|
||||||
t.Errorf("source = %+v, want %+v unchanged for a different playlist", got, source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -56,16 +56,6 @@ export function CycleRepeat(): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(3510519482);
|
return $Call.ByID(3510519482);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* DropSourceForPlaylist clears the queue's "Playing from" label when
|
|
||||||
* its source playlist is deleted. A link back to a playlist that no
|
|
||||||
* longer exists is worse than none, and the label otherwise survives
|
|
||||||
* the deletion until the next SetQueue (#249).
|
|
||||||
*/
|
|
||||||
export function DropSourceForPlaylist(playlistID: number): $CancellablePromise<void> {
|
|
||||||
return $Call.ByID(1435106374, playlistID);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EmitCurrentState emits the current queue state to the frontend.
|
* EmitCurrentState emits the current queue state to the frontend.
|
||||||
* This is called after the frontend DOM is ready.
|
* This is called after the frontend DOM is ready.
|
||||||
|
|||||||
Reference in New Issue
Block a user