From 9f595b7ac10c2191b5469004901cbbc1331c1abb Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 16 Mar 2026 12:47:42 -0400 Subject: [PATCH] fix(13-02): resolve phantom playlist tracks using M3U8 paths after scan - Add ScanHooks callback struct to library package (follows RemovalHooks pattern) - Move phantom resolution from library to playlist service via hook - New ResolvePhantomTracksAfterScan reads M3U8 files and resolves paths against current audio_files using multi-root resolution - Handles pre-existing phantoms (match by M3U8 position) and new ones (match by phantom_file_path) - Delete old resolvePhantomTracks method that required phantom_file_path - Wire ScanHooks in app.go OnStartup --- backend/app.go | 6 + backend/library/library.go | 80 ++---- backend/playlist/playlist.go | 299 ++++++++++++++++++++++ frontend/wailsjs/go/library/Library.d.ts | 2 + frontend/wailsjs/go/library/Library.js | 4 + frontend/wailsjs/go/models.ts | 12 + frontend/wailsjs/go/playlist/Service.d.ts | 2 + frontend/wailsjs/go/playlist/Service.js | 4 + 8 files changed, 356 insertions(+), 53 deletions(-) diff --git a/backend/app.go b/backend/app.go index 867d0ca..649891a 100644 --- a/backend/app.go +++ b/backend/app.go @@ -171,6 +171,12 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { PostScan: yj.playlist.RestoreAllPlaylists, }) + // Wire scan hooks so the playlist service can resolve + // phantom tracks after each library scan completes. + yj.library.SetScanHooks(library.ScanHooks{ + ResolvePhantoms: yj.playlist.ResolvePhantomTracksAfterScan, + }) + // Wire removal hooks so the library can stop playback and // compact the queue during library removal without depending // on the player or queue packages directly. diff --git a/backend/library/library.go b/backend/library/library.go index 059542d..97baa40 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -72,6 +72,15 @@ type RescanHooks struct { PostScan func() } +// ScanHooks contains callbacks invoked after a library scan +// completes. The app layer wires these so the library package +// does not depend on the playlist package directly. +type ScanHooks struct { + // ResolvePhantoms re-links phantom playlist tracks whose + // files now exist in the library after scanning. + ResolvePhantoms func() +} + // Library manages scanning and querying the music collection. type Library struct { // mu protects ctx, conf, and rescanHooks from concurrent @@ -97,6 +106,10 @@ type Library struct { // removalHooks holds callbacks for cross-cutting concerns during // library removal (e.g. stopping playback, compacting queue). removalHooks RemovalHooks + + // scanHooks holds callbacks for post-scan processing + // (e.g. resolving phantom playlist tracks). + scanHooks ScanHooks } // SetRescanHooks provides optional hooks for cross-cutting @@ -108,6 +121,15 @@ func (l *Library) SetRescanHooks(h RescanHooks) { l.rescanHooks = h } +// SetScanHooks provides optional hooks for cross-cutting +// orchestration after each library scan. +func (l *Library) SetScanHooks(h ScanHooks) { + l.mu.Lock() + defer l.mu.Unlock() + + l.scanHooks = h +} + // NewLibrary creates a new library with the given configuration. // A nil config is permitted; scan paths come from the database // rather than from the config's DirectoryPath. @@ -653,12 +675,11 @@ func (l *Library) scanInternal( } // --- 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() + // Delegated to the playlist service via ScanHooks so that + // M3U8-based path resolution can handle both pre-existing + // phantoms (no phantom_file_path) and new ones. + if !cancelled && l.scanHooks.ResolvePhantoms != nil { + l.scanHooks.ResolvePhantoms() } // --- Phase 7: post-scan variant generation --- @@ -717,53 +738,6 @@ 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 diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index 2d02118..4f695a7 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -1494,6 +1494,305 @@ func (s *Service) migrateExistingPlaylists() { // Phantom track resolution // ================================================================= +// ResolvePhantomTracksAfterScan re-links phantom playlist tracks +// whose files now exist in the library. It iterates each playlist +// that has phantoms, reads its M3U8 file, resolves each entry +// against the current audio_files table using multi-root path +// resolution, and updates matching phantom playlist_tracks. This +// handles both pre-existing phantoms (created before migration 7, +// with NULL phantom_file_path) and new ones. +func (s *Service) ResolvePhantomTracksAfterScan() { + // 1. Get distinct playlist IDs that have phantom tracks. + // SAFETY: Hand-crafted SELECT for phantom playlist IDs. + // No user input — reads only system state. + rows, err := s.db.QueryContext( + `SELECT DISTINCT playlist_id + FROM playlist_tracks + WHERE audio_file_id IS NULL`, + ) + if err != nil { + s.logger.Warn( + "could not query phantom playlists", + "err", err, + ) + + return + } + + var phantomPlaylistIDs []int64 + + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + s.logger.Warn( + "could not scan phantom playlist ID", + "err", err, + ) + + continue + } + + phantomPlaylistIDs = append(phantomPlaylistIDs, id) + } + + if err := rows.Close(); err != nil { + s.logger.Warn( + "could not close phantom playlist rows", + "err", err, + ) + } + + if len(phantomPlaylistIDs) == 0 { + return + } + + // 2. Build audio file path→ID map for resolution. + // SAFETY: Hand-crafted SELECT for full audio file path map. + // No user input — reads only system state. + afRows, err := s.db.QueryContext( + `SELECT id, file_path FROM audio_files`, + ) + if err != nil { + s.logger.Warn( + "could not query audio files for phantom resolution", + "err", err, + ) + + return + } + + audioFileByPath := make(map[string]int64) + + for afRows.Next() { + var id int64 + + var fp string + if err := afRows.Scan(&id, &fp); err != nil { + continue + } + + audioFileByPath[fp] = id + } + + if err := afRows.Close(); err != nil { + s.logger.Warn( + "could not close audio file rows", + "err", err, + ) + } + + if len(audioFileByPath) == 0 { + return + } + + // 3. Build knownPaths set for resolveM3UPath. + knownPaths := make( + map[string]struct{}, len(audioFileByPath), + ) + for k := range audioFileByPath { + knownPaths[k] = struct{}{} + } + + libraryRoots := s.getAllLibraryRoots() + + dir, err := s.playlistsDir() + if err != nil { + s.logger.Warn( + "could not get playlists dir for phantom resolution", + "err", err, + ) + + return + } + + var totalResolved int + + // 4. For each playlist with phantoms, resolve via M3U8. + for _, playlistID := range phantomPlaylistIDs { + resolved := s.resolvePlaylistPhantoms( + playlistID, dir, libraryRoots, + knownPaths, audioFileByPath, + ) + totalResolved += resolved + } + + if totalResolved > 0 { + s.logger.Info( + "resolved phantom playlist tracks after scan", + "count", totalResolved, + ) + + s.emitEvent(events.PlaylistTracksChanged, nil) + } +} + +// phantomTrackRow holds the minimal fields needed to match a +// phantom playlist_track against an M3U8 entry. +type phantomTrackRow struct { + id int64 + position int64 + phantomFilePath string +} + +// resolvePlaylistPhantoms resolves phantom tracks for a single +// playlist by reading its M3U8 file and matching entries against +// the audio_files table. Returns the number of resolved tracks. +func (s *Service) resolvePlaylistPhantoms( + playlistID int64, + dir string, + libraryRoots []string, + knownPaths map[string]struct{}, + audioFileByPath map[string]int64, +) int { + m3uPath, err := findPlaylistFile(dir, playlistID) + if err != nil || m3uPath == "" { + return 0 + } + + parsed, err := parseM3U8(m3uPath) + if err != nil { + s.logger.Warn( + "could not parse M3U8 for phantom resolution", + "playlistId", playlistID, + "path", m3uPath, + "err", err, + ) + + return 0 + } + + // Load phantom tracks for this playlist. + // SAFETY: Hand-crafted SELECT for phantom tracks with + // position and phantom_file_path. No user input. + ptRows, err := s.db.QueryContext( + `SELECT id, position, COALESCE(phantom_file_path, '') + FROM playlist_tracks + WHERE playlist_id = ? AND audio_file_id IS NULL`, + playlistID, + ) + if err != nil { + s.logger.Warn( + "could not query phantom tracks", + "playlistId", playlistID, + "err", err, + ) + + return 0 + } + + var phantoms []phantomTrackRow + + for ptRows.Next() { + var pt phantomTrackRow + if err := ptRows.Scan( + &pt.id, &pt.position, &pt.phantomFilePath, + ); err != nil { + continue + } + + phantoms = append(phantoms, pt) + } + + if err := ptRows.Close(); err != nil { + s.logger.Warn( + "could not close phantom track rows", + "err", err, + ) + } + + if len(phantoms) == 0 { + return 0 + } + + // Build a set of already-resolved phantom IDs to avoid + // double-matching. + resolvedIDs := make(map[int64]struct{}) + + var resolved int + + // For each M3U8 entry, resolve its path and try to match + // a phantom track. + for i, entry := range parsed.Entries { + absPath := resolveM3UPath( + entry.RelativePath, libraryRoots, knownPaths, + ) + + audioFileID, exists := audioFileByPath[absPath] + if !exists { + continue + } + + // Find the phantom that corresponds to this entry. + // Priority 1: match by phantom_file_path (exact). + // Priority 2: match by position (M3U8 index). + matchIdx := -1 + + for j, pt := range phantoms { + if _, done := resolvedIDs[pt.id]; done { + continue + } + + if pt.phantomFilePath != "" && + pt.phantomFilePath == absPath { + matchIdx = j + + break + } + } + + if matchIdx == -1 { + for j, pt := range phantoms { + if _, done := resolvedIDs[pt.id]; done { + continue + } + + if pt.position == int64(i) { + matchIdx = j + + break + } + } + } + + if matchIdx == -1 { + continue + } + + pt := phantoms[matchIdx] + + // SAFETY: Hand-crafted UPDATE to resolve a phantom + // playlist track. Sets audio_file_id and clears all + // phantom metadata columns. Parameterized by ID. + if _, err := s.db.ExecContext( + `UPDATE playlist_tracks SET + audio_file_id = ?, + 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 id = ?`, + audioFileID, pt.id, + ); err != nil { + s.logger.Warn( + "could not resolve phantom track", + "playlistTrackId", pt.id, + "audioFileId", audioFileID, + "err", err, + ) + + continue + } + + resolvedIDs[pt.id] = struct{}{} + resolved++ + } + + return resolved +} + // FindPhantomMatches searches the library for matches for the // given phantom file paths. High-confidence matches are returned // as auto-matched pairs; the rest remain in the unmatched list. diff --git a/frontend/wailsjs/go/library/Library.d.ts b/frontend/wailsjs/go/library/Library.d.ts index 5670c3f..891b406 100755 --- a/frontend/wailsjs/go/library/Library.d.ts +++ b/frontend/wailsjs/go/library/Library.d.ts @@ -76,4 +76,6 @@ export function SetRemovalHooks(arg1:library.RemovalHooks):Promise; export function SetRescanHooks(arg1:library.RescanHooks):Promise; +export function SetScanHooks(arg1:library.ScanHooks):Promise; + export function SoftScanAllLibraries():Promise; diff --git a/frontend/wailsjs/go/library/Library.js b/frontend/wailsjs/go/library/Library.js index fc8a319..55a8b26 100755 --- a/frontend/wailsjs/go/library/Library.js +++ b/frontend/wailsjs/go/library/Library.js @@ -146,6 +146,10 @@ export function SetRescanHooks(arg1) { return window['go']['library']['Library']['SetRescanHooks'](arg1); } +export function SetScanHooks(arg1) { + return window['go']['library']['Library']['SetScanHooks'](arg1); +} + export function SoftScanAllLibraries() { return window['go']['library']['Library']['SoftScanAllLibraries'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 9af9d09..0e5caac 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -134,6 +134,18 @@ export namespace library { } } + export class ScanHooks { + + + static createFrom(source: any = {}) { + return new ScanHooks(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + + } + } export class ScanWarning { filePath: string; phase: string; diff --git a/frontend/wailsjs/go/playlist/Service.d.ts b/frontend/wailsjs/go/playlist/Service.d.ts index 4046d6f..1deddc3 100755 --- a/frontend/wailsjs/go/playlist/Service.d.ts +++ b/frontend/wailsjs/go/playlist/Service.d.ts @@ -45,6 +45,8 @@ export function RenamePlaylist(arg1:number,arg2:string):Promise; export function ResolvePhantomTracks(arg1:number,arg2:Record):Promise; +export function ResolvePhantomTracksAfterScan():Promise; + export function RestoreAllPlaylists():Promise; export function SearchLibrary(arg1:string):Promise>; diff --git a/frontend/wailsjs/go/playlist/Service.js b/frontend/wailsjs/go/playlist/Service.js index 5208a13..e0f3a46 100755 --- a/frontend/wailsjs/go/playlist/Service.js +++ b/frontend/wailsjs/go/playlist/Service.js @@ -86,6 +86,10 @@ export function ResolvePhantomTracks(arg1, arg2) { return window['go']['playlist']['Service']['ResolvePhantomTracks'](arg1, arg2); } +export function ResolvePhantomTracksAfterScan() { + return window['go']['playlist']['Service']['ResolvePhantomTracksAfterScan'](); +} + export function RestoreAllPlaylists() { return window['go']['playlist']['Service']['RestoreAllPlaylists'](); }