Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b9868ddd0 |
@@ -499,10 +499,6 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
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.
|
||||
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.StaleSearchClicksJob(yj.database))
|
||||
yj.janitor.Register(maintenance.OrphanedCoverFilesJob(
|
||||
yj.database, coversDir, library.CoverArtFileSet,
|
||||
))
|
||||
|
||||
@@ -151,26 +151,14 @@ func (d *DB) SetLyrics(audioFileID int64, lyrics, source, recordingMBID string)
|
||||
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
|
||||
// lyrics_index. contentless_delete=1 makes the DELETE valid; an empty
|
||||
// lyrics string leaves the row deleted.
|
||||
func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error {
|
||||
if err := d.DeleteLyricsIndex(audioFileID); err != nil {
|
||||
return err
|
||||
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)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(lyrics) == "" {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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
|
||||
// deleted underneath it.
|
||||
//
|
||||
// Every path that empties `audio_files` must call this first, inside
|
||||
// the same transaction as the delete. There are two such paths and
|
||||
// they had drifted: the full rescan in backend/library did this and the
|
||||
// stale-shape retire in this package did not, so the *documented*
|
||||
// repair ("delete and rescan") preserved playlists while the automatic
|
||||
// one that exists to spare the user that work silently emptied them.
|
||||
// Every path that empties `audio_files` must call this (or the scoped
|
||||
// variant below) first, inside the same transaction as the delete.
|
||||
// These paths have drifted before: the full rescan in backend/library
|
||||
// did this and the stale-shape retire in this package did not, so the
|
||||
// *documented* repair ("delete and rescan") preserved playlists while
|
||||
// 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`
|
||||
// cannot answer -- see the note above. Skipping it costs a phantom
|
||||
@@ -103,13 +106,39 @@ const (
|
||||
func PreservePlaylistPhantoms(
|
||||
ctx context.Context, tx *sql.Tx, logger *slog.Logger,
|
||||
) 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(
|
||||
"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,
|
||||
// so the path half above stands and the entries remain
|
||||
// re-linkable.
|
||||
@@ -123,3 +152,26 @@ func PreservePlaylistPhantoms(
|
||||
|
||||
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()
|
||||
|
||||
// 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 {
|
||||
path := key.(string)
|
||||
audioFile := value.(sqlcgen.AudioFile)
|
||||
orphanPaths = append(orphanPaths, key.(string))
|
||||
orphans = append(orphans, value.(sqlcgen.AudioFile))
|
||||
|
||||
l.logger.Debug(
|
||||
"removing orphaned database entry",
|
||||
"path", path, "id", audioFile.ID,
|
||||
)
|
||||
return true
|
||||
})
|
||||
|
||||
if err := l.db.Queries.DeleteAudioFile(
|
||||
l.ctx, audioFile.ID,
|
||||
); err != nil {
|
||||
l.logger.Warn(
|
||||
"failed to delete orphaned audio file",
|
||||
"path", path,
|
||||
"id", audioFile.ID,
|
||||
"err", err,
|
||||
)
|
||||
deleted := make([]bool, len(orphans))
|
||||
|
||||
metrics.addWarning(path, "orphan", err)
|
||||
|
||||
return true
|
||||
if len(orphans) > 0 {
|
||||
orphanIDs := make([]int64, len(orphans))
|
||||
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
|
||||
// track count and clear it out once empty, mirroring the
|
||||
// bookkeeping maybeRebindTaggingGroup does for a group_key
|
||||
@@ -942,25 +1008,25 @@ func (l *Library) scanInternal(
|
||||
// and replaced leaves a stale tagging_items row behind —
|
||||
// its track_count still counts the deleted files, and it
|
||||
// never clears from the autotag queue.
|
||||
if audioFile.GroupKey != "" {
|
||||
if f.GroupKey != "" {
|
||||
if err := l.db.Queries.DecrementTaggingItemTrackCount(
|
||||
l.ctx, audioFile.GroupKey,
|
||||
l.ctx, f.GroupKey,
|
||||
); err != nil {
|
||||
l.logger.Warn(
|
||||
"failed to decrement tagging group for orphan",
|
||||
"path", path,
|
||||
"group_key", audioFile.GroupKey,
|
||||
"group_key", f.GroupKey,
|
||||
"err", err,
|
||||
)
|
||||
|
||||
metrics.addWarning(path, "orphan", err)
|
||||
} else if err := l.db.Queries.DeleteTaggingItemIfEmpty(
|
||||
l.ctx, audioFile.GroupKey,
|
||||
l.ctx, f.GroupKey,
|
||||
); err != nil {
|
||||
l.logger.Warn(
|
||||
"failed to clean up emptied tagging group for orphan",
|
||||
"path", path,
|
||||
"group_key", audioFile.GroupKey,
|
||||
"group_key", f.GroupKey,
|
||||
"err", err,
|
||||
)
|
||||
|
||||
@@ -968,25 +1034,11 @@ func (l *Library) scanInternal(
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from FTS5 search index and the lyrics index.
|
||||
if err := l.db.DeleteSearchIndex(
|
||||
audioFile.ID,
|
||||
); err != nil {
|
||||
// Remove from FTS5 search index.
|
||||
if err := l.db.DeleteSearchIndex(f.ID); err != nil {
|
||||
l.logger.Warn(
|
||||
"failed to delete FTS entry for orphan",
|
||||
"id", audioFile.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,
|
||||
"id", f.ID,
|
||||
"err", err,
|
||||
)
|
||||
|
||||
@@ -994,9 +1046,7 @@ func (l *Library) scanInternal(
|
||||
}
|
||||
|
||||
removed.Add(1)
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
metrics.OrphanCleanup = time.Since(orphanStart)
|
||||
|
||||
|
||||
@@ -94,6 +94,57 @@ func countRows(
|
||||
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
|
||||
// FK-references libraries with no ON DELETE clause, so leaving those
|
||||
// rows behind fails the DELETE and rolls back the entire removal.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/events"
|
||||
)
|
||||
|
||||
@@ -57,6 +58,25 @@ func (l *Library) RemoveFromLibrary(filePaths []string) (*RemovalResult, error)
|
||||
|
||||
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
|
||||
// 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
|
||||
@@ -127,11 +147,6 @@ func (l *Library) RemoveFromLibrary(filePaths []string) (*RemovalResult, error)
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
// directory as the base for the playlists folder. Set by tests to
|
||||
// keep M3U writes out of the real user data directory.
|
||||
@@ -172,17 +166,6 @@ func (s *Service) SetFavoritesConfig(
|
||||
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
|
||||
// runtime exists, and ctx is cancelled when the app shuts down. It
|
||||
// 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)
|
||||
|
||||
// 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.
|
||||
if s.defaultPlaylistID() == playlistID {
|
||||
s.EnsureDefaultPlaylist()
|
||||
|
||||
@@ -1581,21 +1581,6 @@ func (q *Queue) dropSource() {
|
||||
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.
|
||||
// When reindex is true, track positions are renumbered first.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user