The incremental scan's orphan cleanup and RemoveFromLibrary deleted audio_files rows without first filling the playlist phantom columns, so a track removed from the library folder outside YellowJacket (or removed from the library) became a permanently empty playlist row that nothing could re-link — the same bug #183 fixed on the full-rescan and retire paths, on the two paths it missed. Add a scoped PreservePlaylistPhantomsForFiles and run it in the same transaction as the deletes on both paths. Closes #246
252 lines
6.1 KiB
Go
252 lines
6.1 KiB
Go
package library
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"yellowjacket/backend/coverart"
|
|
"yellowjacket/backend/database"
|
|
"yellowjacket/backend/database/sql/sqlcgen"
|
|
)
|
|
|
|
// seedRemovableLibrary builds a library with one track, its recording
|
|
// chain, a cover art row, and a tagging queue entry — the shape a real
|
|
// scan leaves behind.
|
|
func seedRemovableLibrary(
|
|
t *testing.T,
|
|
lib *Library,
|
|
coverPath string,
|
|
) sqlcgen.Library {
|
|
t.Helper()
|
|
|
|
ctx := lib.ctx
|
|
q := lib.db.Queries
|
|
|
|
library, err := q.CreateLibrary(ctx, sqlcgen.CreateLibraryParams{
|
|
Name: "Test Library",
|
|
Path: "/music",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create library: %v", err)
|
|
}
|
|
|
|
if _, err := lib.db.ExecContext(
|
|
`INSERT INTO cover_art (is_embedded, file_path, mime_type)
|
|
VALUES (0, ?, 'image/jpeg')`, coverPath,
|
|
); err != nil {
|
|
t.Fatalf("insert cover art: %v", err)
|
|
}
|
|
|
|
database.InsertTestTrack(t, lib.db, database.TestTrack{
|
|
FilePath: "/music/song.mp3",
|
|
Title: "Test Song",
|
|
Artist: "Test Artist",
|
|
Album: "Test Album",
|
|
LengthMs: 180000,
|
|
LibraryID: library.ID,
|
|
})
|
|
|
|
// Every scanned library gets tagging_items rows, one per album
|
|
// folder. These FK-reference libraries.
|
|
if _, err := lib.db.ExecContext(
|
|
`INSERT INTO tagging_items (group_key, library_id, album_name)
|
|
VALUES ('grp1', ?, 'Test Album')`, library.ID,
|
|
); err != nil {
|
|
t.Fatalf("insert tagging_items: %v", err)
|
|
}
|
|
|
|
if _, err := lib.db.ExecContext(
|
|
`INSERT INTO tagging_candidates (group_key, candidates)
|
|
VALUES ('grp1', '[]')`,
|
|
); err != nil {
|
|
t.Fatalf("insert tagging_candidates: %v", err)
|
|
}
|
|
|
|
return library
|
|
}
|
|
|
|
func countRows(
|
|
t *testing.T,
|
|
lib *Library,
|
|
table string,
|
|
args ...any,
|
|
) int64 {
|
|
t.Helper()
|
|
|
|
query := "SELECT COUNT(*) FROM " + table
|
|
|
|
rows, err := lib.db.QueryContext(query, args...)
|
|
if err != nil {
|
|
t.Fatalf("count %s: %v", table, err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var n int64
|
|
|
|
if rows.Next() {
|
|
if err := rows.Scan(&n); err != nil {
|
|
t.Fatalf("scan count %s: %v", table, err)
|
|
}
|
|
}
|
|
|
|
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.
|
|
func TestRemoveLibrary_WithTaggingItems(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lib, _ := setupTestLibrary(t)
|
|
|
|
library := seedRemovableLibrary(t, lib, "/nonexistent/cover.jpg")
|
|
|
|
summary, err := lib.RemoveLibrary(library.ID)
|
|
if err != nil {
|
|
t.Fatalf("RemoveLibrary: %v", err)
|
|
}
|
|
|
|
if summary.TracksDeleted != 1 {
|
|
t.Errorf("TracksDeleted = %d, want 1", summary.TracksDeleted)
|
|
}
|
|
|
|
// The test DB keeps a sentinel library at id=0 so audio_files rows
|
|
// using the default library_id satisfy their FK, so scope this one
|
|
// to the library actually removed.
|
|
if n := countRows(
|
|
t, lib, "libraries WHERE id = ?", library.ID,
|
|
); n != 0 {
|
|
t.Errorf("library row still present after removal")
|
|
}
|
|
|
|
for _, table := range []string{
|
|
"audio_files",
|
|
"albums",
|
|
"artists",
|
|
"file_genres",
|
|
"tagging_items",
|
|
"tagging_candidates",
|
|
"cover_art",
|
|
} {
|
|
if n := countRows(t, lib, table); n != 0 {
|
|
t.Errorf("%s has %d rows after removal, want 0", table, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Removing a library must delete the cover art original *and* its
|
|
// derived size variants, which are not recorded in the database.
|
|
func TestRemoveLibrary_DeletesCoverArtVariants(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lib, _ := setupTestLibrary(t)
|
|
|
|
dir := t.TempDir()
|
|
|
|
var paths []string
|
|
for _, tier := range thumbnailTiers {
|
|
paths = append(paths, filepath.Join(
|
|
dir, coverart.SizedFilename("abc123.jpg", tier.Suffix),
|
|
))
|
|
}
|
|
|
|
// The largest tier is what cover_art.file_path names.
|
|
cover := filepath.Join(dir, coverart.SizedFilename("abc123.jpg", "_lg"))
|
|
|
|
for _, p := range paths {
|
|
if err := os.WriteFile(p, []byte("img"), 0o600); err != nil {
|
|
t.Fatalf("write %s: %v", p, err)
|
|
}
|
|
}
|
|
|
|
library := seedRemovableLibrary(t, lib, cover)
|
|
|
|
if _, err := lib.RemoveLibrary(library.ID); err != nil {
|
|
t.Fatalf("RemoveLibrary: %v", err)
|
|
}
|
|
|
|
for _, p := range paths {
|
|
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
|
t.Errorf("cover art file still present: %s", filepath.Base(p))
|
|
}
|
|
}
|
|
}
|
|
|
|
// CoverArtFileSet must cover every generated tier, from any of them:
|
|
// cover_art.file_path names the largest, and the others are derived.
|
|
func TestCoverArtFileSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := CoverArtFileSet("/covers/abc123_lg.jpg")
|
|
|
|
want := []string{
|
|
"/covers/abc123_sm.jpg",
|
|
"/covers/abc123_md.jpg",
|
|
"/covers/abc123_lg.jpg",
|
|
}
|
|
|
|
if len(got) != len(want) {
|
|
t.Fatalf("got %d paths, want %d: %v", len(got), len(want), got)
|
|
}
|
|
|
|
for i, w := range want {
|
|
if got[i] != w {
|
|
t.Errorf("path %d = %q, want %q", i, got[i], w)
|
|
}
|
|
}
|
|
}
|