feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
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:
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user