diff --git a/backend/database/database.go b/backend/database/database.go index f54d0f7..3300ecf 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -312,6 +312,16 @@ func runMigrations( } } + // Migration 7: add phantom_file_path to playlist_tracks + // for automatic phantom resolution after library re-scans. + if version < 7 { + if err := migration7PhantomFilePath( + ctx, db, logger, + ); err != nil { + return err + } + } + return nil } @@ -1030,6 +1040,44 @@ func migration6MultiLibrary( return nil } +// migration7PhantomFilePath adds the phantom_file_path column to +// playlist_tracks so that phantom entries can be automatically +// re-linked to audio_files after a library scan. +func migration7PhantomFilePath( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info( + "applying migration 7: phantom_file_path column", + ) + + // SAFETY: ALTER TABLE ADD COLUMN for new nullable column. + if _, err := db.ExecContext(ctx, + `ALTER TABLE playlist_tracks + ADD COLUMN phantom_file_path TEXT`, + ); err != nil { + if !isDuplicateColumnErr(err) { + return fmt.Errorf( + "migration 7: could not add column: %w", + err, + ) + } + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 7", + ); err != nil { + return fmt.Errorf( + "could not set user_version to 7: %w", err, + ) + } + + logger.Info("migration 7 complete") + + return nil +} + // readLibraryDirFromTOML reads the TOML config file and returns // the Library.DirectoryPath value, or "" if not configured. func readLibraryDirFromTOML(logger *slog.Logger) string { diff --git a/backend/database/database_test.go b/backend/database/database_test.go index 0459cba..09ab4fe 100644 --- a/backend/database/database_test.go +++ b/backend/database/database_test.go @@ -91,6 +91,7 @@ func TestMigration6FreshDB(t *testing.T) { "phantom_duration_ms": false, "phantom_genre": false, "phantom_cover_art_path": false, + "phantom_file_path": false, } audioFileIDNullable := false @@ -171,7 +172,7 @@ func TestMigration6FreshDB(t *testing.T) { t.Error("track_metadata VIEW does not contain library_id") } - // Verify user_version >= 6. + // Verify user_version >= 7. var version int verRows, err := db.QueryContext("PRAGMA user_version") @@ -193,8 +194,8 @@ func TestMigration6FreshDB(t *testing.T) { _ = verRows.Close() - if version < 6 { - t.Errorf("user_version = %d, want >= 6", version) + if version < 7 { + t.Errorf("user_version = %d, want >= 7", version) } // Verify libraries table has only the sentinel row on fresh DB. diff --git a/backend/database/sql/schemas/playlist_tracks.sql b/backend/database/sql/schemas/playlist_tracks.sql index eea4d89..938ff55 100644 --- a/backend/database/sql/schemas/playlist_tracks.sql +++ b/backend/database/sql/schemas/playlist_tracks.sql @@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS playlist_tracks ( phantom_duration_ms INTEGER, phantom_genre TEXT, phantom_cover_art_path TEXT, + phantom_file_path TEXT, FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE SET NULL ); diff --git a/backend/database/sql/sqlcgen/models.go b/backend/database/sql/sqlcgen/models.go index ea86716..3d5e4b4 100644 --- a/backend/database/sql/sqlcgen/models.go +++ b/backend/database/sql/sqlcgen/models.go @@ -90,6 +90,7 @@ type PlaylistTrack struct { PhantomDurationMs sql.NullInt64 PhantomGenre sql.NullString PhantomCoverArtPath sql.NullString + PhantomFilePath sql.NullString } type Queue struct { diff --git a/backend/database/sql/sqlcgen/playlists.sql.go b/backend/database/sql/sqlcgen/playlists.sql.go index 9c13fe2..fd62ecb 100644 --- a/backend/database/sql/sqlcgen/playlists.sql.go +++ b/backend/database/sql/sqlcgen/playlists.sql.go @@ -16,7 +16,7 @@ INSERT INTO playlist_tracks ( phantom_title, phantom_artist, phantom_album, phantom_duration_ms, phantom_genre, phantom_cover_art_path ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) -RETURNING id, playlist_id, audio_file_id, position, phantom_title, phantom_artist, phantom_album, phantom_duration_ms, phantom_genre, phantom_cover_art_path +RETURNING id, playlist_id, audio_file_id, position, phantom_title, phantom_artist, phantom_album, phantom_duration_ms, phantom_genre, phantom_cover_art_path, phantom_file_path ` type AddPlaylistTrackParams struct { @@ -55,6 +55,7 @@ func (q *Queries) AddPlaylistTrack(ctx context.Context, arg AddPlaylistTrackPara &i.PhantomDurationMs, &i.PhantomGenre, &i.PhantomCoverArtPath, + &i.PhantomFilePath, ) return i, err } diff --git a/backend/library/crud.go b/backend/library/crud.go index abfecd2..abddf36 100644 --- a/backend/library/crud.go +++ b/backend/library/crud.go @@ -244,10 +244,12 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) { phantom_album = sub.album, phantom_duration_ms = sub.duration, phantom_genre = sub.genre, - phantom_cover_art_path = sub.cover_art_path + phantom_cover_art_path = sub.cover_art_path, + phantom_file_path = sub.file_path FROM ( SELECT pt.id AS pt_id, + af.file_path AS file_path, COALESCE(r.name, '') AS title, COALESCE(ac.text, '') AS artist, COALESCE(rg.name, '') AS album, diff --git a/backend/library/library.go b/backend/library/library.go index c31a7fb..059542d 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -652,7 +652,16 @@ func (l *Library) scanInternal( metrics.OrphanCleanup = time.Since(orphanStart) } - // --- Phase 6: post-scan variant generation --- + // --- Phase 6: resolve phantom playlist tracks --- + // Phantom tracks (audio_file_id IS NULL) that have a stored + // phantom_file_path matching a newly-scanned audio file are + // automatically re-linked. This handles the case where a + // library directory is removed and later re-added. + if !cancelled { + l.resolvePhantomTracks() + } + + // --- Phase 7: post-scan variant generation --- if !cancelled { variantStart := time.Now() @@ -708,6 +717,53 @@ func (l *Library) scanInternal( return metrics } +// resolvePhantomTracks re-links phantom playlist_tracks entries +// whose phantom_file_path now matches an audio_files row. This +// runs after every successful scan so that re-adding a previously +// removed library automatically restores playlist references. +func (l *Library) resolvePhantomTracks() { + // SAFETY: Hand-crafted UPDATE for phantom track resolution. + // Matches phantom playlist_tracks (audio_file_id IS NULL, + // phantom_file_path IS NOT NULL) against audio_files by + // file_path. Clears phantom metadata on resolved rows. + // No user input — all values come from the database. + result, err := l.db.ExecContext(` + UPDATE playlist_tracks SET + audio_file_id = ( + SELECT af.id FROM audio_files af + WHERE af.file_path = playlist_tracks.phantom_file_path + ), + phantom_title = NULL, + phantom_artist = NULL, + phantom_album = NULL, + phantom_duration_ms = NULL, + phantom_genre = NULL, + phantom_cover_art_path = NULL, + phantom_file_path = NULL + WHERE audio_file_id IS NULL + AND phantom_file_path IS NOT NULL + AND EXISTS ( + SELECT 1 FROM audio_files af + WHERE af.file_path = playlist_tracks.phantom_file_path + )`) + if err != nil { + l.logger.Warn( + "could not resolve phantom playlist tracks", + "err", err, + ) + + return + } + + resolved, _ := result.RowsAffected() + if resolved > 0 { + l.logger.Info( + "resolved phantom playlist tracks", + "count", resolved, + ) + } +} + // progressInterval controls how often scan progress events are // emitted to the frontend. const progressInterval = 300 * time.Millisecond