Files
yellowjacket/backend/library/staleness_test.go
T
yonluandClaude Opus 5 dc890d1fcc feat(library): remove a track from the library without deleting the file
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
2026-08-14 13:12:01 -04:00

288 lines
7.1 KiB
Go

package library
import (
"os"
"path/filepath"
"testing"
"time"
"yellowjacket/backend/database/sql/sqlcgen"
)
// ---------------------------------------------------------------------------
// fileContentChanged — the staleness predicate
// ---------------------------------------------------------------------------
func TestFileContentChanged(t *testing.T) {
t.Parallel()
const (
baseMod int64 = 1700000000
baseSize int64 = 5_000_000
)
tests := []struct {
name string
recordedMod int64
recordedSz int64
diskMod int64
diskSz int64
want bool
}{
{
name: "unchanged file",
recordedMod: baseMod,
recordedSz: baseSize,
diskMod: baseMod,
diskSz: baseSize,
want: false,
},
{
name: "retagged in place, mtime bumped and size grew",
recordedMod: baseMod,
recordedSz: baseSize,
diskMod: baseMod + 60,
diskSz: baseSize + 2048,
want: true,
},
{
name: "mtime bumped, size absorbed by tag padding",
recordedMod: baseMod,
recordedSz: baseSize,
diskMod: baseMod + 60,
diskSz: baseSize,
want: true,
},
{
name: "size changed but mtime preserved by the writer",
recordedMod: baseMod,
recordedSz: baseSize,
diskMod: baseMod,
diskSz: baseSize + 2048,
want: true,
},
{
name: "no recorded baseline is never stale",
recordedMod: 0,
recordedSz: baseSize,
diskMod: baseMod,
diskSz: baseSize + 4096,
want: false,
},
{
name: "failed stat is never stale",
recordedMod: baseMod,
recordedSz: baseSize,
diskMod: 0,
diskSz: 0,
want: false,
},
{
name: "file replaced with an older copy",
recordedMod: baseMod,
recordedSz: baseSize,
diskMod: baseMod - 3600,
diskSz: baseSize,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
af := sqlcgen.AudioFile{
ModifiedAt: tt.recordedMod,
FileSize: tt.recordedSz,
}
got := fileContentChanged(af, tt.diskMod, tt.diskSz)
if got != tt.want {
t.Errorf(
"fileContentChanged() = %v, want %v",
got, tt.want,
)
}
})
}
}
// ---------------------------------------------------------------------------
// surveyAudioFiles — soft scan change signal
// ---------------------------------------------------------------------------
func TestSurveyAudioFiles(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Two audio files plus one unsupported file that must be ignored.
writeFile(t, filepath.Join(dir, "a.mp3"), 1024)
writeFile(t, filepath.Join(dir, "nested", "b.flac"), 2048)
writeFile(t, filepath.Join(dir, "cover.jpg"), 512)
older := time.Now().Add(-48 * time.Hour)
newer := time.Now().Add(-1 * time.Hour)
setModTime(t, filepath.Join(dir, "a.mp3"), older)
setModTime(t, filepath.Join(dir, "nested", "b.flac"), newer)
// The ignored file is the newest on disk — it must not influence
// the result, or every artwork change would trigger a rescan.
setModTime(t, filepath.Join(dir, "cover.jpg"), time.Now())
count, maxMod := surveyAudioFiles(dir, nil)
if count != 2 {
t.Errorf("count = %d, want 2", count)
}
if maxMod != newer.Unix() {
t.Errorf("maxModTime = %d, want %d", maxMod, newer.Unix())
}
// Retagging the older file in place makes it the newest, which is
// what the soft scan compares against the database.
touched := time.Now()
setModTime(t, filepath.Join(dir, "a.mp3"), touched)
_, afterMod := surveyAudioFiles(dir, nil)
if afterMod != touched.Unix() {
t.Errorf(
"maxModTime after touch = %d, want %d",
afterMod, touched.Unix(),
)
}
}
func TestSurveyAudioFiles_EmptyDir(t *testing.T) {
t.Parallel()
count, maxMod := surveyAudioFiles(t.TempDir(), nil)
if count != 0 || maxMod != 0 {
t.Errorf(
"surveyAudioFiles(empty) = (%d, %d), want (0, 0)",
count, maxMod,
)
}
}
// ---------------------------------------------------------------------------
// flushStatBackfill — baseline backfill for pre-migration rows
// ---------------------------------------------------------------------------
func TestFlushStatBackfill(t *testing.T) {
t.Parallel()
lib, db := setupTestLibrary(t)
ctx := lib.ctx
q := db.Queries
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
if err != nil {
t.Fatalf("upsert artist credit: %v", err)
}
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
Name: "Test Song",
ArtistCreditID: ac.ID,
})
if err != nil {
t.Fatalf("create recording: %v", err)
}
// Seed two rows with no baseline, as migration 47 leaves them.
first, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{
FilePath: "/music/first.mp3",
LengthMilliseconds: 180000,
RecordingID: rec.ID,
Basename: "first.mp3",
})
if err != nil {
t.Fatalf("create first audio file: %v", err)
}
second, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{
FilePath: "/music/second.mp3",
LengthMilliseconds: 200000,
RecordingID: rec.ID,
Basename: "second.mp3",
})
if err != nil {
t.Fatalf("create second audio file: %v", err)
}
if first.ModifiedAt != 0 {
t.Fatalf("seeded ModifiedAt = %d, want 0", first.ModifiedAt)
}
lib.flushStatBackfill([]sqlcgen.UpdateAudioFileStatParams{
{ModifiedAt: 1700000000, FileSize: 4096, ID: first.ID},
{ModifiedAt: 1700000500, FileSize: 8192, ID: second.ID},
})
got, err := q.GetAudioFile(ctx, first.ID)
if err != nil {
t.Fatalf("get first audio file: %v", err)
}
if got.ModifiedAt != 1700000000 || got.FileSize != 4096 {
t.Errorf(
"first row = (mtime %d, size %d), want (1700000000, 4096)",
got.ModifiedAt, got.FileSize,
)
}
// A backfilled row now has a baseline, so the same file on disk is
// no longer treated as stale.
if fileContentChanged(got, 1700000000, 4096) {
t.Error("backfilled row reported stale against identical stat")
}
// The backfill must not disturb unrelated columns.
if got.LengthMilliseconds != 180000 {
t.Errorf(
"LengthMilliseconds = %d, want 180000 (backfill overwrote it)",
got.LengthMilliseconds,
)
}
if got.FilePath != "/music/first.mp3" {
t.Errorf("FilePath = %q, want /music/first.mp3", got.FilePath)
}
}
func TestFlushStatBackfill_Empty(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
// Must be a no-op rather than opening an empty transaction.
lib.flushStatBackfill(nil)
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
func writeFile(t *testing.T, path string, size int) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, make([]byte, size), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func setModTime(t *testing.T, path string, mt time.Time) {
t.Helper()
if err := os.Chtimes(path, mt, mt); err != nil {
t.Fatalf("chtimes %s: %v", path, err)
}
}