fix(13-02): auto-resolve phantom playlist tracks after library scan

- Add phantom_file_path column to playlist_tracks (migration 7)
- Store original file_path during RemoveLibrary phantom metadata population
- After each successful scan, UPDATE phantom tracks whose phantom_file_path
  now matches an audio_files row, re-linking them and clearing phantom metadata
- Update schema file, sqlc generated code, and database test for new column
This commit is contained in:
2026-03-16 12:29:06 -04:00
parent f05d2bb603
commit 93262b9ae0
7 changed files with 116 additions and 6 deletions
+3 -1
View File
@@ -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,
+57 -1
View File
@@ -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