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:
@@ -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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1030,6 +1040,44 @@ func migration6MultiLibrary(
|
|||||||
return nil
|
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
|
// readLibraryDirFromTOML reads the TOML config file and returns
|
||||||
// the Library.DirectoryPath value, or "" if not configured.
|
// the Library.DirectoryPath value, or "" if not configured.
|
||||||
func readLibraryDirFromTOML(logger *slog.Logger) string {
|
func readLibraryDirFromTOML(logger *slog.Logger) string {
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ func TestMigration6FreshDB(t *testing.T) {
|
|||||||
"phantom_duration_ms": false,
|
"phantom_duration_ms": false,
|
||||||
"phantom_genre": false,
|
"phantom_genre": false,
|
||||||
"phantom_cover_art_path": false,
|
"phantom_cover_art_path": false,
|
||||||
|
"phantom_file_path": false,
|
||||||
}
|
}
|
||||||
|
|
||||||
audioFileIDNullable := false
|
audioFileIDNullable := false
|
||||||
@@ -171,7 +172,7 @@ func TestMigration6FreshDB(t *testing.T) {
|
|||||||
t.Error("track_metadata VIEW does not contain library_id")
|
t.Error("track_metadata VIEW does not contain library_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user_version >= 6.
|
// Verify user_version >= 7.
|
||||||
var version int
|
var version int
|
||||||
|
|
||||||
verRows, err := db.QueryContext("PRAGMA user_version")
|
verRows, err := db.QueryContext("PRAGMA user_version")
|
||||||
@@ -193,8 +194,8 @@ func TestMigration6FreshDB(t *testing.T) {
|
|||||||
|
|
||||||
_ = verRows.Close()
|
_ = verRows.Close()
|
||||||
|
|
||||||
if version < 6 {
|
if version < 7 {
|
||||||
t.Errorf("user_version = %d, want >= 6", version)
|
t.Errorf("user_version = %d, want >= 7", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify libraries table has only the sentinel row on fresh DB.
|
// Verify libraries table has only the sentinel row on fresh DB.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS playlist_tracks (
|
|||||||
phantom_duration_ms INTEGER,
|
phantom_duration_ms INTEGER,
|
||||||
phantom_genre TEXT,
|
phantom_genre TEXT,
|
||||||
phantom_cover_art_path TEXT,
|
phantom_cover_art_path TEXT,
|
||||||
|
phantom_file_path TEXT,
|
||||||
FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE SET NULL
|
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ type PlaylistTrack struct {
|
|||||||
PhantomDurationMs sql.NullInt64
|
PhantomDurationMs sql.NullInt64
|
||||||
PhantomGenre sql.NullString
|
PhantomGenre sql.NullString
|
||||||
PhantomCoverArtPath sql.NullString
|
PhantomCoverArtPath sql.NullString
|
||||||
|
PhantomFilePath sql.NullString
|
||||||
}
|
}
|
||||||
|
|
||||||
type Queue struct {
|
type Queue struct {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ INSERT INTO playlist_tracks (
|
|||||||
phantom_title, phantom_artist, phantom_album,
|
phantom_title, phantom_artist, phantom_album,
|
||||||
phantom_duration_ms, phantom_genre, phantom_cover_art_path
|
phantom_duration_ms, phantom_genre, phantom_cover_art_path
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) 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 {
|
type AddPlaylistTrackParams struct {
|
||||||
@@ -55,6 +55,7 @@ func (q *Queries) AddPlaylistTrack(ctx context.Context, arg AddPlaylistTrackPara
|
|||||||
&i.PhantomDurationMs,
|
&i.PhantomDurationMs,
|
||||||
&i.PhantomGenre,
|
&i.PhantomGenre,
|
||||||
&i.PhantomCoverArtPath,
|
&i.PhantomCoverArtPath,
|
||||||
|
&i.PhantomFilePath,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,10 +244,12 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) {
|
|||||||
phantom_album = sub.album,
|
phantom_album = sub.album,
|
||||||
phantom_duration_ms = sub.duration,
|
phantom_duration_ms = sub.duration,
|
||||||
phantom_genre = sub.genre,
|
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 (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
pt.id AS pt_id,
|
pt.id AS pt_id,
|
||||||
|
af.file_path AS file_path,
|
||||||
COALESCE(r.name, '') AS title,
|
COALESCE(r.name, '') AS title,
|
||||||
COALESCE(ac.text, '') AS artist,
|
COALESCE(ac.text, '') AS artist,
|
||||||
COALESCE(rg.name, '') AS album,
|
COALESCE(rg.name, '') AS album,
|
||||||
|
|||||||
@@ -652,7 +652,16 @@ func (l *Library) scanInternal(
|
|||||||
metrics.OrphanCleanup = time.Since(orphanStart)
|
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 {
|
if !cancelled {
|
||||||
variantStart := time.Now()
|
variantStart := time.Now()
|
||||||
|
|
||||||
@@ -708,6 +717,53 @@ func (l *Library) scanInternal(
|
|||||||
return metrics
|
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
|
// progressInterval controls how often scan progress events are
|
||||||
// emitted to the frontend.
|
// emitted to the frontend.
|
||||||
const progressInterval = 300 * time.Millisecond
|
const progressInterval = 300 * time.Millisecond
|
||||||
|
|||||||
Reference in New Issue
Block a user