feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Build & publish Arch package / arch-package (push) Successful in 2m12s
Search index maintenance / maintain-index (push) Successful in 2h22m28s

Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.

Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
2026-08-06 17:12:01 -04:00
co-authored by Claude Sonnet 5
parent d0d86f85d5
commit e190fd75b9
165 changed files with 31088 additions and 5192 deletions
+26
View File
@@ -48,6 +48,32 @@ var thumbnailTiers = []thumbnailTier{
// multi-tier system. Kept for migration purposes only.
const legacyThumbSuffix = "_thumb"
// CoverArtFileSet returns every file on disk belonging to one cover art
// entry: the original plus each generated size variant, plus the legacy
// _thumb file for databases that predate the multi-tier thumbnails.
//
// Only the original is recorded in cover_art.file_path — the variants
// are derived filenames — so any code deleting cover art has to expand
// the set or the thumbnails are orphaned.
func CoverArtFileSet(originalPath string) []string {
dir := filepath.Dir(originalPath)
base := filepath.Base(originalPath)
paths := make([]string, 0, len(thumbnailTiers)+2) //nolint:mnd
paths = append(paths, originalPath)
for _, tier := range thumbnailTiers {
paths = append(paths, filepath.Join(
dir, coverart.SizedFilename(base, tier.Suffix),
))
}
return append(paths, filepath.Join(
dir, coverart.SizedFilename(base, legacyThumbSuffix),
))
}
// isSizedVariant reports whether a filename contains any known size suffix
// (current tiers or legacy).
func isSizedVariant(name string) bool {
+30 -16
View File
@@ -9,8 +9,6 @@ import (
"strings"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
@@ -104,7 +102,7 @@ func (l *Library) AddLibrary(path string) (*sqlcgen.Library, error) {
"libraryID", lib.ID, "claimed", claimed)
}
runtime.EventsEmit(l.ctx, events.LibraryAdded, lib)
l.emit(events.LibraryAdded, lib)
go func() {
if scanErr := l.ScanLibrary(lib.ID); scanErr != nil {
@@ -149,7 +147,7 @@ func (l *Library) RenameLibrary(id int64, newName string) error {
return fmt.Errorf("could not rename library: %w", err)
}
runtime.EventsEmit(l.ctx, events.LibraryRenamed, map[string]any{
l.emit(events.LibraryRenamed, map[string]any{
"id": id,
"name": newName,
})
@@ -427,7 +425,18 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) {
return nil, fmt.Errorf("could not delete orphaned cover_art: %w", err)
}
// 17. Delete library row.
// 17. Delete the library's tagging queue. tagging_items holds a
// FOREIGN KEY to libraries with no ON DELETE clause, so leaving these
// rows behind makes the DELETE below fail the whole transaction and
// the library becomes unremovable. tagging_candidates is tied to
// tagging_items by ON DELETE CASCADE and goes with it.
// SAFETY: Hand-crafted DELETE — sqlc has no query for this. Parameterized.
if _, err := tx.ExecContext(l.ctx,
`DELETE FROM tagging_items WHERE library_id = ?`, id); err != nil {
return nil, fmt.Errorf("could not delete tagging items: %w", err)
}
// 18. Delete library row.
// SAFETY: Hand-crafted DELETE matching sqlc DeleteLibrary but within
// the same transaction. Parameterized.
if _, err := tx.ExecContext(l.ctx,
@@ -435,36 +444,41 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) {
return nil, fmt.Errorf("could not delete library: %w", err)
}
// 18. Commit transaction.
// 19. Commit transaction.
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("could not commit removal transaction: %w", err)
}
committed = true
// 19. FTS5 rebuild skipped — contentless FTS5 (content='') cannot
// 20. FTS5 rebuild skipped — contentless FTS5 (content='') cannot
// delete individual rows, but stale entries are harmless: search
// queries JOIN against track_metadata which filters out deleted
// rows. The index is rebuilt on the next full rescan. Skipping
// avoids a costly full re-index of all remaining tracks (~10s for
// 25K tracks).
// 20. Post-commit: Delete orphaned cover art files.
// 21. Post-commit: Delete orphaned cover art files and their sized
// variants. Only the original is stored in cover_art.file_path; the
// _sm/_md/_lg thumbnails are derived filenames beside it, so they
// have to be removed by name or they accumulate forever.
for _, coverPath := range orphanedCoverArtPaths {
if err := os.Remove(coverPath); err != nil && !os.IsNotExist(err) {
l.logger.Warn("could not remove orphaned cover art file",
"path", coverPath,
"err", err,
)
for _, path := range CoverArtFileSet(coverPath) {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
l.logger.Warn("could not remove orphaned cover art file",
"path", path,
"err", err,
)
}
}
}
// 21. Post-commit: Compact queue.
// 22. Post-commit: Compact queue.
if l.removalHooks.CompactQueue != nil {
l.removalHooks.CompactQueue()
}
// 22. Post-commit: invalidate library-sync markers so the gated
// 23. Post-commit: invalidate library-sync markers so the gated
// index/lyric re-sync runs on the next launch.
if l.removalHooks.PostRemove != nil {
l.removalHooks.PostRemove()
@@ -480,7 +494,7 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) {
}
// 22. Emit events.
runtime.EventsEmit(l.ctx, events.LibraryRemoved, map[string]any{
l.emit(events.LibraryRemoved, map[string]any{
"id": id,
"summary": summary,
})
+129
View File
@@ -0,0 +1,129 @@
package library
import (
"testing"
"yellowjacket/backend/datamap"
)
// staleTolerated lists tables that deliberately keep rows after the data
// they describe is gone. Each needs a reason: the point of this list is
// that tolerating a leak becomes a decision somebody wrote down, not an
// oversight nobody noticed.
var staleTolerated = map[string]string{
"file_types": "static lookup rows seeded from code, not user data",
"search_index": "contentless FTS5 cannot delete individual rows; " +
"stale entries are filtered by joining track_metadata and are " +
"cleared by a full rescan",
"lyrics_index": "contentless FTS5, same constraint as search_index",
}
// Removing the only library must leave no owned or derived rows behind.
//
// The table list comes from the datamap catalog rather than being
// hardcoded, so a newly added table is covered by this test the moment it
// is catalogued — which is the mechanism that would have caught
// tagging_items blocking removal, and the cover art variants leaking.
func TestRemoveLibraryLeavesNoOwnedOrDerivedRows(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
library := seedRemovableLibrary(t, lib, "/nonexistent/cover.jpg")
if _, err := lib.RemoveLibrary(library.ID); err != nil {
t.Fatalf("RemoveLibrary: %v", err)
}
for _, entry := range datamap.Tables() {
if entry.Kind != datamap.Owned && entry.Kind != datamap.Derived {
continue
}
// FTS5 virtual tables do not answer COUNT(*) meaningfully.
if entry.FTS {
continue
}
if reason, exempt := staleTolerated[entry.Name]; exempt {
t.Logf("skipping %s: %s", entry.Name, reason)
continue
}
if n := countRows(t, lib, entry.Name); n != 0 {
t.Errorf(
"%s (%s) has %d rows after the only library was removed. "+
"Either delete them in RemoveLibrary, or add an entry "+
"to staleTolerated explaining why they stay.",
entry.Name, entry.Kind, n,
)
}
}
}
// Authored data must survive removal of the library it was created
// against — losing it is unrecoverable, so it must never be a casualty
// of cleaning up owned data.
func TestRemoveLibraryPreservesAuthoredData(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
library := seedRemovableLibrary(t, lib, "/nonexistent/cover.jpg")
if _, err := lib.db.ExecContext(
`INSERT INTO playlists (name) VALUES ('Keep Me')`,
); err != nil {
t.Fatalf("seed playlist: %v", err)
}
if _, err := lib.RemoveLibrary(library.ID); err != nil {
t.Fatalf("RemoveLibrary: %v", err)
}
if n := countRows(t, lib, "playlists"); n != 1 {
t.Errorf("playlists = %d rows after removal, want 1 preserved", n)
}
}
// Every table the catalog marks as needing an explicit sweep must
// actually reach zero, or be listed as tolerated. This is a narrower
// restatement of the leak test aimed at the Lifetime axis rather than
// the Kind axis, so a table declared "swept" that nothing sweeps is
// caught.
func TestSweptTablesAreActuallySwept(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
library := seedRemovableLibrary(t, lib, "/nonexistent/cover.jpg")
if _, err := lib.RemoveLibrary(library.ID); err != nil {
t.Fatalf("RemoveLibrary: %v", err)
}
for _, entry := range datamap.Tables() {
if entry.Lifetime != datamap.Swept || entry.FTS {
continue
}
// Cache tables are swept by the janitor on their own schedule,
// not by library removal.
if entry.Kind == datamap.Cache {
continue
}
if _, exempt := staleTolerated[entry.Name]; exempt {
continue
}
if n := countRows(t, lib, entry.Name); n != 0 {
t.Errorf(
"%s declares Lifetime swept but still has %d rows after "+
"removal — nothing is sweeping it",
entry.Name, n,
)
}
}
}
+239 -17
View File
@@ -193,6 +193,31 @@ func (l *Library) SetContext(ctx context.Context) {
l.registerEventHandlers()
}
// emit publishes a Wails event, tolerating a context that carries no
// Wails runtime.
//
// runtime.EventsEmit calls log.Fatalf when the context is nil or lacks
// the runtime's "events" value, which terminates the process rather
// than returning an error. Background workers that outlive a context
// and tests that construct a Library directly both hit that path, so
// every emit in this package routes through here.
func (l *Library) emit(event string, data ...any) {
l.mu.Lock()
ctx := l.ctx
l.mu.Unlock()
if ctx == nil || ctx.Value("events") == nil {
l.logger.Debug(
"skipping event emit, no Wails runtime in context",
"event", event,
)
return
}
runtime.EventsEmit(ctx, event, data...)
}
// registerEventHandlers sets up Wails runtime event listeners.
// The legacy LibraryConfigChanged handler was removed — in the
// multi-library model, libraries are managed through the CRUD
@@ -309,11 +334,11 @@ func (l *Library) scanInternal(
// legacy LibraryScanProgress event and the shared job registry.
// Routing everything through here keeps the two from drifting.
emitProgress := func(p ScanProgress) {
runtime.EventsEmit(l.ctx, events.LibraryScanProgress, p)
l.emit(events.LibraryScanProgress, p)
reportScanProgress(jobHandle, p)
}
runtime.EventsEmit(l.ctx, events.LibraryScanStarted, map[string]any{
l.emit(events.LibraryScanStarted, map[string]any{
"libraryId": libraryID,
"libraryName": libraryName,
})
@@ -369,6 +394,12 @@ func (l *Library) scanInternal(
var errMu sync.Mutex
// statBackfill collects staleness baselines for skipped files whose
// rows predate migration 47. Appended to only by the walk goroutine
// and read after workChan closes, which orders the writes before the
// flush.
var statBackfill []sqlcgen.UpdateAudioFileStatParams
// --- Phase 2: directory walk ---
walkStart := time.Now()
@@ -406,14 +437,38 @@ func (l *Library) scanInternal(
return nil
}
// Stat the entry for the staleness comparison below.
// This happens before the file is read, so a file
// modified mid-scan records the pre-read mtime and is
// picked up again next scan — the safe direction.
var (
diskModTime int64
diskSize int64
)
if info, infoErr := d.Info(); infoErr == nil {
diskModTime = info.ModTime().Unix()
diskSize = info.Size()
} else {
l.logger.Debug(
"could not stat file, treating as unchanged",
"path", absoluteFilePath, "err", infoErr,
)
}
// Check if file already exists in database.
if existing, exists := existingPaths.LoadAndDelete(absoluteFilePath); exists {
audioFile := existing.(sqlcgen.AudioFile)
if audioFile.RecordingID == 0 {
contentChanged := fileContentChanged(
audioFile, diskModTime, diskSize,
)
if audioFile.RecordingID == 0 || contentChanged {
l.logger.Debug(
"file needs metadata update",
"path", absoluteFilePath,
"contentChanged", contentChanged,
)
select {
@@ -423,6 +478,8 @@ func (l *Library) scanInternal(
existingFileID: audioFile.ID,
needsUpdate: true,
existingLength: audioFile.LengthMilliseconds,
contentChanged: contentChanged,
modTime: diskModTime,
}:
case <-scanCtx.Done():
return scanCtx.Err()
@@ -438,6 +495,21 @@ func (l *Library) scanInternal(
)
skipped.Add(1)
// Record the baseline for a row that lacks one so the
// next scan can detect edits. Collected here and
// flushed in one transaction after the walk rather
// than issuing an UPDATE per file.
if audioFile.ModifiedAt == 0 && diskModTime != 0 {
statBackfill = append(
statBackfill,
sqlcgen.UpdateAudioFileStatParams{
ModifiedAt: diskModTime,
FileSize: diskSize,
ID: audioFile.ID,
},
)
}
return nil
}
@@ -450,6 +522,7 @@ func (l *Library) scanInternal(
case workChan <- scanWork{
absolutePath: absoluteFilePath,
fileType: fileType,
modTime: diskModTime,
}:
case <-scanCtx.Done():
return scanCtx.Err()
@@ -658,6 +731,11 @@ func (l *Library) scanInternal(
metrics.ThumbnailWallClock = time.Since(thumbStart)
// Establish staleness baselines for unchanged files that lacked one.
// Safe to run even on a cancelled scan: every entry was individually
// confirmed against the file on disk during the walk.
l.flushStatBackfill(statBackfill)
// Skip orphan cleanup if the scan was cancelled — existingPaths
// still contains unvisited files that would be incorrectly deleted.
cancelled := scanCtx.Err() != nil
@@ -776,13 +854,9 @@ func (l *Library) scanInternal(
finishScanJob(jobHandle, metrics, cancelled)
if cancelled {
runtime.EventsEmit(
l.ctx, events.LibraryScanCancelled, metrics,
)
l.emit(events.LibraryScanCancelled, metrics)
} else {
runtime.EventsEmit(
l.ctx, events.LibraryScanComplete, metrics,
)
l.emit(events.LibraryScanComplete, metrics)
}
return metrics
@@ -792,6 +866,84 @@ func (l *Library) scanInternal(
// emitted to the frontend.
const progressInterval = 300 * time.Millisecond
// fileContentChanged reports whether a file on disk differs from what
// was imported, by comparing mtime and size against the recorded
// baseline. This is what catches another application retagging a file
// in place — without it the scan skips every path already in the
// database and the edit stays invisible until a full rescan.
//
// Two cases are deliberately treated as unchanged:
//
// - A recorded mtime of 0 means no baseline exists (the row predates
// migration 47). There is nothing to compare against, so reporting
// a change would re-import the entire library on first upgrade.
// - A disk mtime of 0 means the stat failed. Skipping is preferable
// to re-importing a file on no evidence.
//
// A writer that preserves mtime and lands on an identical file size
// defeats this check. That needs content hashing to catch, which costs
// a full read of every file — deliberately out of scope.
func fileContentChanged(
audioFile sqlcgen.AudioFile,
diskModTime, diskSize int64,
) bool {
if audioFile.ModifiedAt == 0 || diskModTime == 0 {
return false
}
return diskModTime != audioFile.ModifiedAt ||
diskSize != audioFile.FileSize
}
// flushStatBackfill writes mtime/size baselines for files the scan
// skipped but that had no baseline recorded. Failures are logged and
// not fatal — a missing baseline only means the file is re-checked on
// the next scan.
func (l *Library) flushStatBackfill(
entries []sqlcgen.UpdateAudioFileStatParams,
) {
if len(entries) == 0 {
return
}
tx, err := l.db.BeginTx()
if err != nil {
l.logger.Warn(
"could not begin stat backfill transaction",
"count", len(entries), "err", err,
)
return
}
defer func() { _ = tx.Rollback() }() // no-op after commit
txq := l.db.Queries.WithTx(tx)
for _, e := range entries {
if updErr := txq.UpdateAudioFileStat(l.ctx, e); updErr != nil {
l.logger.Warn(
"could not backfill file stat",
"audioFileID", e.ID, "err", updErr,
)
}
}
if err := tx.Commit(); err != nil {
l.logger.Warn(
"could not commit stat backfill",
"count", len(entries), "err", err,
)
return
}
l.logger.Info(
"recorded staleness baselines for existing files",
"count", len(entries),
)
}
// countAudioFiles performs a fast walk of the library directory,
// counting only files with supported audio extensions. No per-file
// I/O is performed — this reads only directory entries.
@@ -817,6 +969,46 @@ func countAudioFiles(basePath string) int64 {
return count
}
// surveyAudioFiles walks the library directory and returns both the
// number of supported audio files and the newest mtime among them
// (Unix seconds). The soft scan compares both against the database:
// the count catches added and removed files, the mtime catches files
// another application edited in place.
//
// Unlike countAudioFiles this stats every entry, so it is the more
// expensive of the two walks. Only the startup soft scan uses it —
// the in-scan progress total does not need mtimes.
func surveyAudioFiles(basePath string) (count, maxModTime int64) {
_ = fs.WalkDir(
os.DirFS(basePath), ".",
func(_ string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
ext := filepath.Ext(d.Name())
if _, ok := metadata.GetSupportedFileType(ext); !ok {
return nil
}
count++
info, infoErr := d.Info()
if infoErr != nil {
return nil
}
if mt := info.ModTime().Unix(); mt > maxModTime {
maxModTime = mt
}
return nil
},
)
return count, maxModTime
}
// hddWorkerCount is the maximum number of concurrent extraction
// workers when the library resides on a spinning disk.
const hddWorkerCount = 2
@@ -851,6 +1043,14 @@ type scanWork struct {
existingFileID int64 // non-zero if this is an update
needsUpdate bool
existingLength int64 // existing length if updating
// contentChanged marks a file whose bytes differ from what was
// imported (mtime/size mismatch), as opposed to one merely missing
// its metadata link. The audio itself may have changed, so cached
// values like duration cannot be reused.
contentChanged bool
// modTime is the file's mtime (Unix seconds) observed during the
// walk, stored as the new staleness baseline.
modTime int64
}
// importResult holds metadata extracted by workers, ready for DB insertion.
@@ -863,6 +1063,7 @@ type importResult struct {
existingFileID int64 // non-zero if this is an update
needsUpdate bool
libraryID int64 // library this file belongs to
modTime int64 // mtime baseline to persist (Unix seconds)
}
// extractAudioMetadata reads and extracts metadata from an audio file.
@@ -877,10 +1078,15 @@ func (l *Library) extractAudioMetadata(
fileType: work.fileType,
existingFileID: work.existingFileID,
needsUpdate: work.needsUpdate,
modTime: work.modTime,
}
// Skip duration decode if we already have it from a previous import.
skipDuration := work.needsUpdate && work.existingLength > 0
// A file whose bytes changed is decoded again — a re-encode or a
// replaced file can have a different duration than the one on record.
skipDuration := work.needsUpdate &&
work.existingLength > 0 &&
!work.contentChanged
tags, lengthMillis, audioProps, timing, err := metadata.ExtractAllMetadata(
work.absolutePath, skipDuration,
@@ -902,6 +1108,19 @@ func (l *Library) extractAudioMetadata(
)
}
// A degraded tag read is reported but never fatal — the track is
// imported either way, falling back to the filename if the tag
// yielded nothing.
if tags.TagReadWarning != nil {
l.logger.Warn(
"degraded tag read",
"path", work.absolutePath,
"err", tags.TagReadWarning,
)
metrics.addWarning(work.absolutePath, "tags", tags.TagReadWarning)
}
result.tags = tags
result.audioProps = audioProps
@@ -1055,6 +1274,7 @@ func (l *Library) saveAudioFile(
LibraryID: result.libraryID,
GroupKey: groupKey,
TagStatus: tagStatus,
ModifiedAt: result.modTime,
})
if err != nil {
return fmt.Errorf(
@@ -1144,13 +1364,15 @@ func (l *Library) updateAudioFileMetadata(
if err := q.UpdateAudioFileRecording(
l.ctx, sqlcgen.UpdateAudioFileRecordingParams{
RecordingID: recordingID,
SampleRate: int64(props.SampleRate),
BitDepth: int64(props.BitDepth),
Channels: int64(props.Channels),
Bitrate: int64(props.Bitrate),
FileSize: props.FileSize,
ID: result.existingFileID,
RecordingID: recordingID,
SampleRate: int64(props.SampleRate),
BitDepth: int64(props.BitDepth),
Channels: int64(props.Channels),
Bitrate: int64(props.Bitrate),
FileSize: props.FileSize,
LengthMilliseconds: result.lengthMillis,
ModifiedAt: result.modTime,
ID: result.existingFileID,
}); err != nil {
return fmt.Errorf(
"could not update audio file recording: %w", err,
+217
View File
@@ -0,0 +1,217 @@
package library
import (
"os"
"path/filepath"
"testing"
"yellowjacket/backend/coverart"
"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)
}
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)
}
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)
}
if _, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{
FilePath: "/music/song.mp3",
LengthMilliseconds: 180000,
RecordingID: rec.ID,
LibraryID: library.ID,
Basename: "song.mp3",
}); err != nil {
t.Fatalf("create audio file: %v", err)
}
// 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
}
// 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",
"recordings",
"artist_credit",
"artists",
"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()
original := filepath.Join(dir, "abc123.jpg")
paths := []string{original}
for _, tier := range thumbnailTiers {
paths = append(paths, filepath.Join(
dir, coverart.SizedFilename("abc123.jpg", tier.Suffix),
))
}
paths = append(paths, filepath.Join(
dir, coverart.SizedFilename("abc123.jpg", legacyThumbSuffix),
))
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, original)
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 the original, every generated tier, and
// the legacy _thumb name.
func TestCoverArtFileSet(t *testing.T) {
t.Parallel()
got := CoverArtFileSet("/covers/abc123.jpg")
want := []string{
"/covers/abc123.jpg",
"/covers/abc123_sm.jpg",
"/covers/abc123_md.jpg",
"/covers/abc123_lg.jpg",
"/covers/abc123_thumb.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)
}
}
}
+2 -4
View File
@@ -3,8 +3,6 @@ package library
import (
"context"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
)
@@ -41,7 +39,7 @@ func (l *Library) PauseScan() {
reg := l.jobs
l.mu.Unlock()
runtime.EventsEmit(l.ctx, events.LibraryScanPaused)
l.emit(events.LibraryScanPaused)
// Confirm the pause on the job — the registry moved it to "pausing"
// when the request came in. Writing the durable pause record is a
@@ -76,7 +74,7 @@ func (l *Library) ResumeScan() {
reg := l.jobs
l.mu.Unlock()
runtime.EventsEmit(l.ctx, events.LibraryScanResumed)
l.emit(events.LibraryScanResumed)
// Clears the durable pause record as a side effect of leaving
// StatePaused.
+42 -7
View File
@@ -3,8 +3,6 @@ package library
import (
"fmt"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
)
@@ -65,7 +63,7 @@ func (l *Library) ScanLibrary(id int64) error {
queueLength := len(l.scanQueue)
l.mu.Unlock()
runtime.EventsEmit(l.ctx, events.LibraryScanQueued, map[string]any{
l.emit(events.LibraryScanQueued, map[string]any{
"libraryId": lib.ID,
"libraryName": lib.Name,
"queueLength": queueLength,
@@ -167,9 +165,31 @@ func (l *Library) SoftScanAllLibraries() error {
continue
}
diskCount := countAudioFiles(lib.Path)
dbModTime, modErr := l.db.Queries.GetLibraryMaxModifiedAt(
l.ctx, lib.ID,
)
if modErr != nil {
l.logger.Warn(
"soft scan: could not read newest mtime, queueing full scan",
"libraryID", lib.ID,
"libraryName", lib.Name,
"err", modErr,
)
if diskCount == dbCount {
_ = l.ScanLibrary(lib.ID)
continue
}
diskCount, diskModTime := surveyAudioFiles(lib.Path)
// A newer file on disk than anything on record means something
// was edited in place since the last scan. An older newest-mtime
// is not evidence of a change: deleting the newest file lowers it
// while the count check already covers that case.
staleTags := diskModTime > dbModTime
if diskCount == dbCount && !staleTags {
l.logger.Info(
"soft scan: library unchanged, skipping",
"libraryID", lib.ID,
@@ -181,11 +201,14 @@ func (l *Library) SoftScanAllLibraries() error {
}
l.logger.Info(
"soft scan: file count mismatch, queueing scan",
"soft scan: library changed, queueing scan",
"libraryID", lib.ID,
"libraryName", lib.Name,
"diskFiles", diskCount,
"dbTracks", dbCount,
"diskModTime", diskModTime,
"dbModTime", dbModTime,
"reason", softScanReason(diskCount != dbCount, staleTags),
)
if err := l.ScanLibrary(lib.ID); err != nil {
@@ -201,6 +224,18 @@ func (l *Library) SoftScanAllLibraries() error {
return nil
}
// softScanReason labels why the soft scan queued a library, for the log.
func softScanReason(countChanged, staleTags bool) string {
switch {
case countChanged && staleTags:
return "file count mismatch and modified files"
case countChanged:
return "file count mismatch"
default:
return "modified files"
}
}
// CancelCurrentScan cancels only the currently scanning library.
// The next queued library (if any) starts automatically when the
// current scan's goroutine completes.
@@ -281,7 +316,7 @@ func (l *Library) drainQueue() {
hooks := l.scanHooks
l.mu.Unlock()
runtime.EventsEmit(l.ctx, events.LibraryScanQueueDrained)
l.emit(events.LibraryScanQueueDrained)
if hooks.OnAllScansComplete != nil {
hooks.OnAllScansComplete()
+287
View File
@@ -0,0 +1,287 @@
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)
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)
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())
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)
}
}