// Package playlist provides playlist management functionality. package playlist import ( "cmp" "context" "database/sql" "errors" "fmt" "log/slog" "os" "path/filepath" "slices" "strconv" "strings" "sync" "time" "github.com/wailsapp/wails/v3/pkg/application" "yellowjacket/backend/coverart" "yellowjacket/backend/database" "yellowjacket/backend/database/sql/sqlcgen" "yellowjacket/backend/events" "yellowjacket/backend/library" "yellowjacket/backend/smartplaylist" "yellowjacket/backend/system" ) var ( errEmptyName = errors.New("playlist name cannot be empty") errEmptyFilePath = errors.New("file path cannot be empty") errNoFilePaths = errors.New("no file paths provided") errUnsupportedFileType = errors.New("unsupported file type") ) // playlistsDirName is the subdirectory within the user data // directory where M3U8 playlist files are stored. const playlistsDirName = "playlists" // LibraryDirProvider is a narrow interface for obtaining the // configured library directory path. type LibraryDirProvider interface { GetLibraryDirectory() string } // Summary is a lightweight representation of a playlist for the // picker UI. type Summary struct { ID int64 `json:"ID"` Name string `json:"Name"` CreatedAt string `json:"CreatedAt"` UpdatedAt string `json:"UpdatedAt"` IsSmart bool `json:"IsSmart"` } // Track represents a track within a playlist, including its // metadata. type Track struct { ID int64 `json:"ID"` Position int64 `json:"Position"` FilePath string `json:"FilePath"` Title string `json:"Title"` Artist string `json:"Artist"` Album string `json:"Album"` CoverArtPath string `json:"CoverArtPath"` CoverArtSmall string `json:"CoverArtSmall"` CoverArtMedium string `json:"CoverArtMedium"` CoverArtLarge string `json:"CoverArtLarge"` Duration string `json:"Duration"` Phantom bool `json:"Phantom"` ArtistMBID string `json:"ArtistMBID"` ReleaseGroupMBID string `json:"ReleaseGroupMBID"` RecordingMBID string `json:"RecordingMBID"` } // WithTracks contains a playlist summary and all its tracks. type WithTracks struct { Summary Summary `json:"Summary"` Tracks []Track `json:"Tracks"` } // CandidateTrack represents a potential library match for a // phantom track. type CandidateTrack struct { FilePath string `json:"FilePath"` Title string `json:"Title"` Artist string `json:"Artist"` Album string `json:"Album"` Duration string `json:"Duration"` Score float64 `json:"Score"` } // PhantomMatch represents a high-confidence pairing of a phantom // track to a library track. type PhantomMatch struct { PhantomPath string `json:"PhantomPath"` PhantomTitle string `json:"PhantomTitle"` Candidate CandidateTrack `json:"Candidate"` } // PhantomSearchResult contains auto-matched pairs and remaining // unmatched phantom paths for a batch search operation. type PhantomSearchResult struct { AutoMatched []PhantomMatch `json:"AutoMatched"` Unmatched []string `json:"Unmatched"` } // DuplicateTrackInfo holds metadata for a track that already // exists in a playlist. type DuplicateTrackInfo struct { FilePath string `json:"FilePath"` Title string `json:"Title"` Artist string `json:"Artist"` Album string `json:"Album"` Duration string `json:"Duration"` } // DuplicateCheckResult contains the outcome of checking for // duplicate tracks in a playlist. type DuplicateCheckResult struct { Duplicates []DuplicateTrackInfo `json:"Duplicates"` Unique []string `json:"Unique"` } // Service manages playlist operations. type Service struct { // mu protects ctx and favoritesConf from concurrent access // during initialization. mu sync.Mutex ctx context.Context logger *slog.Logger db *database.DB libraryDir LibraryDirProvider favoritesConf FavoritesConfigProvider // dataDirOverride, when non-empty, replaces the OS user data // directory as the base for the playlists folder. Set by tests to // keep M3U writes out of the real user data directory. dataDirOverride string } // NewService creates a new playlist service. func NewService( logger *slog.Logger, db *database.DB, libraryDir LibraryDirProvider, ) *Service { return &Service{ logger: logger.WithGroup("playlist"), db: db, libraryDir: libraryDir, } } // SetFavoritesConfig sets the provider used to read and write // the default-playlist configuration. // //wails:ignore // internal wiring, not part of the app's IPC surface. func (s *Service) SetFavoritesConfig( provider FavoritesConfigProvider, ) { s.mu.Lock() defer s.mu.Unlock() s.favoritesConf = provider } // ServiceStartup is v3's service lifecycle hook: it runs once the // runtime exists, and ctx is cancelled when the app shuts down. It // replaces v2's SetContext, which had to be called by hand from // OnStartup and was exported, so it was also bound to the frontend. func (s *Service) ServiceStartup( ctx context.Context, _ application.ServiceOptions, ) error { s.mu.Lock() s.ctx = ctx s.mu.Unlock() s.migrateExistingPlaylists() return nil } // GetAllPlaylists returns all playlists ordered by most recently // updated. func (s *Service) GetAllPlaylists() ([]Summary, error) { playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx) if err != nil { s.logger.Error( "Failed to get playlists", "err", err, ) return nil, fmt.Errorf( "failed to get playlists: %w", err, ) } summaries := make([]Summary, 0, len(playlists)) for _, p := range playlists { summaries = append(summaries, Summary{ ID: p.ID, Name: p.Name, CreatedAt: p.CreatedAt.Format(time.RFC3339), UpdatedAt: p.UpdatedAt.Format(time.RFC3339), IsSmart: p.IsSmart != 0, }) } return summaries, nil } // GetAllPlaylistsWithTracks returns all playlists with their // tracks in a single call, merging phantom tracks from M3U8 files. func (s *Service) GetAllPlaylistsWithTracks() ( []WithTracks, error, ) { playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx) if err != nil { s.logger.Error( "Failed to get playlists", "err", err, ) return nil, fmt.Errorf( "failed to get playlists: %w", err, ) } rows, err := s.db.ReadQueries.GetAllPlaylistTracksWithMetadata( s.db.Ctx, ) if err != nil { s.logger.Error( "Failed to get all playlist tracks", "err", err, ) return nil, fmt.Errorf( "failed to get all playlist tracks: %w", err, ) } // Group DB tracks by playlist ID, keyed by absolute file path. dbTracksByPlaylist := make( map[int64]map[string]Track, ) for _, row := range rows { track := trackFromRow( row.ID, row.Position, row.FilePath, row.Title, row.Artist, row.Album, row.LengthMilliseconds, row.CoverArtPath, row.ArtistMbid, row.ReleaseGroupMbid, row.RecordingMbid, ) if dbTracksByPlaylist[row.PlaylistID] == nil { dbTracksByPlaylist[row.PlaylistID] = make( map[string]Track, ) } dbTracksByPlaylist[row.PlaylistID][row.FilePath] = track } result := make([]WithTracks, 0, len(playlists)) for _, p := range playlists { tracks := s.mergeTracksForPlaylist( p.ID, p.Name, dbTracksByPlaylist[p.ID], ) result = append(result, WithTracks{ Summary: Summary{ ID: p.ID, Name: p.Name, CreatedAt: p.CreatedAt.Format(time.RFC3339), UpdatedAt: p.UpdatedAt.Format(time.RFC3339), IsSmart: p.IsSmart != 0, }, Tracks: tracks, }) } return result, nil } // GetPlaylistTracks returns all tracks in a playlist with full // metadata, merging phantom tracks from the M3U8 file. func (s *Service) GetPlaylistTracks( playlistID int64, ) ([]Track, error) { rows, err := s.db.ReadQueries.GetPlaylistTracksWithMetadata( s.db.Ctx, playlistID, ) if err != nil { s.logger.Error( "Failed to get playlist tracks", "playlistId", playlistID, "err", err, ) return nil, fmt.Errorf( "failed to get playlist tracks: %w", err, ) } // Build a map of DB tracks keyed by absolute file path. dbTracks := make(map[string]Track, len(rows)) for _, row := range rows { track := trackFromRow( row.ID, row.Position, row.FilePath, row.Title, row.Artist, row.Album, row.LengthMilliseconds, row.CoverArtPath, row.ArtistMbid, row.ReleaseGroupMbid, row.RecordingMbid, ) dbTracks[row.FilePath] = track } // Get playlist name for M3U file lookup. playlist, err := s.db.Queries.GetPlaylist( s.db.Ctx, playlistID, ) if err != nil { s.logger.Error( "Failed to get playlist", "playlistId", playlistID, "err", err, ) return nil, fmt.Errorf( "failed to get playlist: %w", err, ) } return s.mergeTracksForPlaylist( playlistID, playlist.Name, dbTracks, ), nil } // mergeTracksForPlaylist merges DB tracks with M3U8 entries, // producing phantom tracks for unresolved paths. func (s *Service) mergeTracksForPlaylist( playlistID int64, _ string, dbTracks map[string]Track, ) []Track { dir, err := s.playlistsDir() if err != nil { s.logger.Warn( "Could not get playlists dir for merge", "err", err, ) return dbTracksToSlice(dbTracks) } m3uPath, err := findPlaylistFile(dir, playlistID) if err != nil || m3uPath == "" { return dbTracksToSlice(dbTracks) } parsed, err := parseM3U8(m3uPath) if err != nil { s.logger.Warn( "Could not parse M3U8 for merge", "playlistId", playlistID, "path", m3uPath, "err", err, ) return dbTracksToSlice(dbTracks) } libraryRoots := s.getAllLibraryRoots() // Build a set of known DB paths for multi-root resolution. knownPaths := make( map[string]struct{}, len(dbTracks), ) for k := range dbTracks { knownPaths[k] = struct{}{} } tracks := make([]Track, 0, len(parsed.Entries)) for i, entry := range parsed.Entries { absPath := resolveM3UPath( entry.RelativePath, libraryRoots, knownPaths, ) if dbTrack, ok := dbTracks[absPath]; ok { dbTrack.Position = int64(i) tracks = append(tracks, dbTrack) continue } // Phantom track — file not resolved in DB. tracks = append(tracks, Track{ Position: int64(i), FilePath: absPath, Title: entry.DisplayTitle, Phantom: true, }) } return tracks } // dbTracksToSlice converts a map of tracks to an ordered slice. func dbTracksToSlice(m map[string]Track) []Track { if len(m) == 0 { return []Track{} } tracks := make([]Track, 0, len(m)) for _, t := range m { tracks = append(tracks, t) } return tracks } // trackFromRow converts raw query row fields into a Track. func trackFromRow( id, position int64, filePath, title, artist, album string, lengthMilliseconds int64, coverArtPath string, artistMBID, releaseGroupMBID, recordingMBID string, ) Track { track := Track{ ID: id, Position: position, FilePath: filePath, Title: title, Artist: artist, Album: album, Duration: strconv.FormatInt(lengthMilliseconds, 10), ArtistMBID: artistMBID, ReleaseGroupMBID: releaseGroupMBID, RecordingMBID: recordingMBID, } if coverArtPath != "" { urls := coverart.ResolveURLs(coverArtPath) track.CoverArtPath = urls.Original track.CoverArtSmall = urls.Small track.CoverArtMedium = urls.Medium track.CoverArtLarge = urls.Large } return track } // CreatePlaylist creates a new empty playlist with the given name. func (s *Service) CreatePlaylist( name string, ) (Summary, error) { trimmed := strings.TrimSpace(name) if trimmed == "" { return Summary{}, errEmptyName } created, err := s.db.Queries.CreatePlaylist( s.db.Ctx, trimmed, ) if err != nil { s.logger.Error( "Failed to create playlist", "name", trimmed, "err", err, ) return Summary{}, fmt.Errorf( "failed to create playlist: %w", err, ) } s.logger.Info( "Playlist created", "id", created.ID, "name", created.Name, ) s.savePlaylistFile(created.ID, created.Name) s.emitEvent(events.PlaylistCreated, Summary{ ID: created.ID, Name: created.Name, CreatedAt: created.CreatedAt.Format(time.RFC3339), UpdatedAt: created.UpdatedAt.Format(time.RFC3339), IsSmart: created.IsSmart != 0, }) return Summary{ ID: created.ID, Name: created.Name, CreatedAt: created.CreatedAt.Format(time.RFC3339), UpdatedAt: created.UpdatedAt.Format(time.RFC3339), IsSmart: created.IsSmart != 0, }, nil } // AddTracksToPlaylist adds one or more tracks to an existing // playlist. func (s *Service) AddTracksToPlaylist( playlistID int64, filePaths []string, ) error { if len(filePaths) == 0 { return errNoFilePaths } nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition( s.db.Ctx, playlistID, ) if err != nil { s.logger.Error( "Failed to get next position", "playlistId", playlistID, "err", err, ) return fmt.Errorf( "failed to get next track position: %w", err, ) } for i, fp := range filePaths { if err := s.addSingleTrack( playlistID, fp, nextPos+int64(i), ); err != nil { return err } } s.logger.Info( "Tracks added to playlist", "playlistId", playlistID, "count", len(filePaths), ) s.savePlaylistFileByID(playlistID) s.emitEvent(events.PlaylistTracksChanged, playlistID) return nil } // FindDuplicateTracksInPlaylist checks which of the given file // paths already exist in the specified playlist. Returns metadata // for each duplicate and a list of non-duplicate file paths. func (s *Service) FindDuplicateTracksInPlaylist( playlistID int64, filePaths []string, ) (DuplicateCheckResult, error) { rows, err := s.db.ReadQueries.GetPlaylistTracksWithMetadata( s.db.Ctx, playlistID, ) if err != nil { s.logger.Error( "Failed to get playlist tracks for duplicate check", "playlistId", playlistID, "err", err, ) return DuplicateCheckResult{}, fmt.Errorf( "failed to get playlist tracks: %w", err, ) } existingPaths := make( map[string]sqlcgen.GetPlaylistTracksWithMetadataRow, len(rows), ) for _, row := range rows { existingPaths[row.FilePath] = row } var duplicates []DuplicateTrackInfo var unique []string for _, fp := range filePaths { if row, exists := existingPaths[fp]; exists { duplicates = append(duplicates, DuplicateTrackInfo{ FilePath: fp, Title: row.Title, Artist: row.Artist, Album: row.Album, Duration: strconv.FormatInt( row.LengthMilliseconds, 10, ), }) } else { unique = append(unique, fp) } } return DuplicateCheckResult{ Duplicates: duplicates, Unique: unique, }, nil } // CreatePlaylistWithTracks creates a new playlist and populates // it with tracks. func (s *Service) CreatePlaylistWithTracks( name string, filePaths []string, ) (Summary, error) { trimmed := strings.TrimSpace(name) if trimmed == "" { return Summary{}, errEmptyName } created, err := s.db.Queries.CreatePlaylist( s.db.Ctx, trimmed, ) if err != nil { s.logger.Error( "Failed to create playlist", "name", trimmed, "err", err, ) return Summary{}, fmt.Errorf( "failed to create playlist: %w", err, ) } if len(filePaths) > 0 { nextPos, posErr := s.db.Queries.GetNextPlaylistTrackPosition( s.db.Ctx, created.ID, ) if posErr != nil { return Summary{}, fmt.Errorf( "failed to get next track position: %w", posErr, ) } for i, fp := range filePaths { if err := s.addSingleTrack( created.ID, fp, nextPos+int64(i), ); err != nil { return Summary{}, fmt.Errorf( "playlist created but failed to add tracks: %w", err, ) } } } summary := Summary{ ID: created.ID, Name: created.Name, CreatedAt: created.CreatedAt.Format(time.RFC3339), UpdatedAt: created.UpdatedAt.Format(time.RFC3339), IsSmart: created.IsSmart != 0, } s.logger.Info( "Playlist created with tracks", "id", created.ID, "name", created.Name, "trackCount", len(filePaths), ) s.savePlaylistFile(created.ID, created.Name) s.emitEvent(events.PlaylistCreated, summary) return summary, nil } // RemoveTracksFromPlaylist removes multiple tracks from a playlist // by their playlist_track IDs. func (s *Service) RemoveTracksFromPlaylist( playlistID int64, trackIDs []int64, ) error { if len(trackIDs) == 0 { return nil } for _, id := range trackIDs { if err := s.db.Queries.RemovePlaylistTrack( s.db.Ctx, id, ); err != nil { s.logger.Error( "Failed to remove playlist track", "playlistId", playlistID, "trackId", id, "err", err, ) return fmt.Errorf( "failed to remove track %d from playlist: %w", id, err, ) } } s.logger.Info( "Tracks removed from playlist", "playlistId", playlistID, "count", len(trackIDs), ) s.savePlaylistFileByID(playlistID) s.emitEvent(events.PlaylistTracksChanged, playlistID) return nil } // DeletePlaylist deletes a playlist and its M3U8 file. // If the deleted playlist was the default, a new default // playlist is automatically created. func (s *Service) DeletePlaylist(playlistID int64) error { if err := s.db.Queries.DeletePlaylist( s.db.Ctx, playlistID, ); err != nil { s.logger.Error( "Failed to delete playlist", "playlistId", playlistID, "err", err, ) return fmt.Errorf( "failed to delete playlist: %w", err, ) } s.deletePlaylistFile(playlistID) s.logger.Info( "Playlist deleted", "playlistId", playlistID, ) s.emitEvent(events.PlaylistDeleted, playlistID) // Recreate the default playlist if we just deleted it. if s.defaultPlaylistID() == playlistID { s.EnsureDefaultPlaylist() } return nil } // RenamePlaylist renames a playlist and updates its M3U8 file. func (s *Service) RenamePlaylist( playlistID int64, newName string, ) error { trimmed := strings.TrimSpace(newName) if trimmed == "" { return errEmptyName } if err := s.db.Queries.UpdatePlaylistName( s.db.Ctx, sqlcgen.UpdatePlaylistNameParams{ Name: trimmed, ID: playlistID, }, ); err != nil { s.logger.Error( "Failed to rename playlist", "playlistId", playlistID, "newName", trimmed, "err", err, ) return fmt.Errorf( "failed to rename playlist: %w", err, ) } // Re-save the M3U8 file with the new name (handles rename // of the file on disk). s.savePlaylistFile(playlistID, trimmed) s.logger.Info( "Playlist renamed", "playlistId", playlistID, "newName", trimmed, ) s.emitEvent(events.PlaylistRenamed, Summary{ ID: playlistID, Name: trimmed, }) return nil } // uniquePlaylistName returns a name that doesn't collide with existing // playlists. If "Chill Vibes" exists, returns "Chill Vibes (1)". // If that also exists, returns "Chill Vibes (2)", etc. func (s *Service) uniquePlaylistName(name string) string { count, err := s.db.Queries.CountPlaylistsByName(s.db.Ctx, name) if err != nil || count == 0 { return name } for i := 1; ; i++ { candidate := fmt.Sprintf("%s (%d)", name, i) c, err := s.db.Queries.CountPlaylistsByName(s.db.Ctx, candidate) if err != nil || c == 0 { return candidate } } } // ImportPlaylist imports a playlist from an external M3U/M3U8 // file. It creates a new playlist in the DB, resolves tracks // against the library, and saves an M3U8 file. func (s *Service) ImportPlaylist( filePath string, ) (Summary, error) { if strings.TrimSpace(filePath) == "" { return Summary{}, errEmptyFilePath } ext := filepath.Ext(filePath) if !isValidM3UExtension(ext) { return Summary{}, fmt.Errorf( "%w: %q, expected .m3u or .m3u8", errUnsupportedFileType, ext, ) } parsed, err := parseM3U8(filePath) if err != nil { return Summary{}, fmt.Errorf( "could not parse playlist file: %w", err, ) } playlistName := parsed.Name if playlistName == "" { base := filepath.Base(filePath) playlistName = strings.TrimSuffix( base, filepath.Ext(base), ) } playlistName = s.uniquePlaylistName(playlistName) // Create playlist in DB. created, err := s.db.Queries.CreatePlaylist( s.db.Ctx, playlistName, ) if err != nil { return Summary{}, fmt.Errorf( "could not create playlist for import: %w", err, ) } libraryRoots := s.getAllLibraryRoots() var ( resolved int unresolved int position int ) for _, entry := range parsed.Entries { var audioFile sqlcgen.AudioFile found := false if filepath.IsAbs(entry.RelativePath) { af, err := s.db.Queries.GetAudioFileByPath( s.db.Ctx, entry.RelativePath, ) if err == nil { audioFile = af found = true } } else { for _, root := range libraryRoots { absPath := toAbsolutePath( entry.RelativePath, root, ) af, err := s.db.Queries.GetAudioFileByPath( s.db.Ctx, absPath, ) if err == nil { audioFile = af found = true break } } } if !found { // Track not in library — will appear as phantom. unresolved++ continue } _, addErr := s.db.Queries.AddPlaylistTrack( s.db.Ctx, sqlcgen.AddPlaylistTrackParams{ PlaylistID: created.ID, AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true}, Position: int64(position), }, ) if addErr != nil { s.logger.Warn( "Could not add imported track", "playlistId", created.ID, "path", audioFile.FilePath, "err", addErr, ) continue } position++ resolved++ } // Save the M3U8 file with entries (preserves unresolved // paths for phantom display). s.saveImportedPlaylistFile( created.ID, playlistName, parsed.Entries, libraryRoots, ) s.logger.Info( "Playlist imported", "id", created.ID, "name", playlistName, "resolved", resolved, "unresolved", unresolved, ) summary := Summary{ ID: created.ID, Name: playlistName, CreatedAt: created.CreatedAt.Format(time.RFC3339), UpdatedAt: created.UpdatedAt.Format(time.RFC3339), IsSmart: created.IsSmart != 0, } s.emitEvent(events.PlaylistCreated, summary) return summary, nil } // ImportPlaylists imports multiple playlists from external M3U/M3U8 // files. Each file is imported sequentially using ImportPlaylist. // Errors from individual imports are collected; partial success is // possible. Returns the summaries of successfully imported playlists // and the first error encountered (if any). func (s *Service) ImportPlaylists( filePaths []string, ) ([]Summary, error) { if len(filePaths) == 0 { return nil, errNoFilePaths } summaries := make([]Summary, 0, len(filePaths)) var firstErr error for _, fp := range filePaths { summary, err := s.ImportPlaylist(fp) if err != nil { s.logger.Warn( "Failed to import playlist file", "path", fp, "err", err, ) if firstErr == nil { firstErr = fmt.Errorf( "import %q failed: %w", fp, err, ) } continue } summaries = append(summaries, summary) } return summaries, firstErr } // RestoreAllPlaylists restores playlist tracks from M3U8 files. // This is called after a full library rescan to repopulate // playlist_tracks from the surviving M3U8 files. func (s *Service) RestoreAllPlaylists() { dir, err := s.playlistsDir() if err != nil { s.logger.Warn( "Could not get playlists dir for restore", "err", err, ) return } files, err := listPlaylistFiles(dir) if err != nil { s.logger.Warn( "Could not list playlist files", "err", err, ) return } if len(files) == 0 { return } libraryRoots := s.getAllLibraryRoots() var totalRestored, totalUnresolved int for _, file := range files { playlistID := extractPlaylistID(file) if playlistID == 0 { s.logger.Warn( "Could not extract playlist ID from filename", "file", file, ) continue } restored, unresolved := s.restoreSinglePlaylist( playlistID, file, libraryRoots, ) totalRestored += restored totalUnresolved += unresolved } s.logger.Info( "All playlists restored from M3U8 files", "totalRestored", totalRestored, "totalUnresolved", totalUnresolved, ) s.emitEvent(events.PlaylistsRestored, nil) } // restoreSinglePlaylist restores tracks for a single playlist // from its M3U8 file. Each M3U8 entry is resolved against all // library roots. func (s *Service) restoreSinglePlaylist( playlistID int64, m3uPath string, libraryRoots []string, ) (restored, unresolved int) { parsed, err := parseM3U8(m3uPath) if err != nil { s.logger.Warn( "Could not parse M3U8 for restore", "playlistId", playlistID, "path", m3uPath, "err", err, ) return 0, 0 } // Verify the playlist exists in the DB. _, err = s.db.Queries.GetPlaylist( s.db.Ctx, playlistID, ) if err != nil { s.logger.Warn( "Playlist not found in DB during restore", "playlistId", playlistID, "err", err, ) return 0, 0 } var position int for _, entry := range parsed.Entries { var audioFile sqlcgen.AudioFile found := false if filepath.IsAbs(entry.RelativePath) { af, lookupErr := s.db.Queries.GetAudioFileByPath( s.db.Ctx, entry.RelativePath, ) if lookupErr == nil { audioFile = af found = true } } else { for _, root := range libraryRoots { absPath := toAbsolutePath( entry.RelativePath, root, ) af, lookupErr := s.db.Queries.GetAudioFileByPath( s.db.Ctx, absPath, ) if lookupErr == nil { audioFile = af found = true break } } } if !found { unresolved++ continue } _, addErr := s.db.Queries.AddPlaylistTrack( s.db.Ctx, sqlcgen.AddPlaylistTrackParams{ PlaylistID: playlistID, AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true}, Position: int64(position), }, ) if addErr != nil { s.logger.Warn( "Could not restore track", "playlistId", playlistID, "path", audioFile.FilePath, "err", addErr, ) continue } position++ restored++ } s.logger.Info( "Playlist restored", "playlistId", playlistID, "restored", restored, "unresolved", unresolved, ) return restored, unresolved } // addSingleTrack looks up the audio file by path and inserts it // into the playlist. func (s *Service) addSingleTrack( playlistID int64, filePath string, position int64, ) error { if strings.TrimSpace(filePath) == "" { return errEmptyFilePath } audioFile, err := s.db.Queries.GetAudioFileByPath( s.db.Ctx, filePath, ) if err != nil { s.logger.Error( "Failed to find audio file", "filePath", filePath, "err", err, ) return fmt.Errorf( "failed to find audio file %q: %w", filePath, err, ) } _, err = s.db.Queries.AddPlaylistTrack( s.db.Ctx, sqlcgen.AddPlaylistTrackParams{ PlaylistID: playlistID, AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true}, Position: position, }, ) if err != nil { s.logger.Error( "Failed to add track to playlist", "playlistId", playlistID, "audioFileId", audioFile.ID, "err", err, ) return fmt.Errorf( "failed to add track to playlist: %w", err, ) } return nil } // --- M3U8 file management helpers --- // playlistsDir returns the path to the playlists directory, // creating it if needed. func (s *Service) playlistsDir() (string, error) { dataDir := s.dataDirOverride if dataDir == "" { var err error dataDir, err = system.GetUserDataDirPath() if err != nil { return "", fmt.Errorf( "could not get user data directory: %w", err, ) } } dir := filepath.Join(dataDir, playlistsDirName) if err := os.MkdirAll(dir, os.ModePerm); err != nil { return "", fmt.Errorf( "could not create playlists directory: %w", err, ) } return dir, nil } // getAllLibraryRoots returns the root directory paths of all // configured libraries. It first checks the legacy config // DirectoryPath; if that is set, it returns a single-element // slice. Otherwise it queries all libraries from the database. func (s *Service) getAllLibraryRoots() []string { // Legacy config fallback. if s.libraryDir != nil { if dir := s.libraryDir.GetLibraryDirectory(); dir != "" { return []string{dir} } } libs, err := s.db.Queries.GetAllLibraries(s.db.Ctx) if err != nil || len(libs) == 0 { return nil } roots := make([]string, len(libs)) for i, lib := range libs { roots[i] = lib.Path } return roots } // savePlaylistFile saves the current state of a playlist to its // M3U8 file. func (s *Service) savePlaylistFile( playlistID int64, name string, ) { dir, err := s.playlistsDir() if err != nil { s.logger.Warn( "Could not get playlists dir for save", "err", err, ) return } entries := s.buildM3UEntries(playlistID) if err := writeM3U8( dir, playlistID, name, entries, ); err != nil { s.logger.Warn( "Could not save playlist M3U8 file", "playlistId", playlistID, "err", err, ) } } // savePlaylistFileByID looks up the playlist name and saves. func (s *Service) savePlaylistFileByID(playlistID int64) { playlist, err := s.db.Queries.GetPlaylist( s.db.Ctx, playlistID, ) if err != nil { s.logger.Warn( "Could not get playlist for save", "playlistId", playlistID, "err", err, ) return } s.savePlaylistFile(playlistID, playlist.Name) } // saveImportedPlaylistFile saves an M3U8 file for an imported // playlist, preserving the original entries (including // unresolved paths). func (s *Service) saveImportedPlaylistFile( playlistID int64, name string, entries []m3uEntry, libraryRoots []string, ) { dir, err := s.playlistsDir() if err != nil { s.logger.Warn( "Could not get playlists dir for import save", "err", err, ) return } // Convert any absolute paths in entries to relative // using the correct library root for each track. converted := make([]m3uEntry, len(entries)) for i, entry := range entries { // Resolve against all roots to get the absolute path, // then convert back to relative using the matching root. absPath := resolveM3UPath( entry.RelativePath, libraryRoots, nil, ) converted[i] = m3uEntry{ RelativePath: toRelativePathMultiRoot( absPath, libraryRoots, ), DurationSec: entry.DurationSec, DisplayTitle: entry.DisplayTitle, } } if err := writeM3U8( dir, playlistID, name, converted, ); err != nil { s.logger.Warn( "Could not save imported playlist M3U8 file", "playlistId", playlistID, "err", err, ) } } // buildM3UEntries builds M3U entries from the current DB state // of a playlist. func (s *Service) buildM3UEntries( playlistID int64, ) []m3uEntry { rows, err := s.db.ReadQueries.GetPlaylistTracksWithMetadata( s.db.Ctx, playlistID, ) if err != nil { s.logger.Warn( "Could not get tracks for M3U build", "playlistId", playlistID, "err", err, ) return nil } libraryRoots := s.getAllLibraryRoots() entries := make([]m3uEntry, 0, len(rows)) for _, row := range rows { durationSec := int( row.LengthMilliseconds / 1000, ) entries = append(entries, m3uEntry{ RelativePath: toRelativePathMultiRoot( row.FilePath, libraryRoots, ), DurationSec: durationSec, DisplayTitle: displayTitle( row.Artist, row.Title, ), }) } return entries } // deletePlaylistFile removes the M3U8 file for a playlist. func (s *Service) deletePlaylistFile(playlistID int64) { dir, err := s.playlistsDir() if err != nil { return } existing, err := findPlaylistFile(dir, playlistID) if err != nil || existing == "" { return } if err := os.Remove(existing); err != nil && !os.IsNotExist(err) { s.logger.Warn( "Could not delete playlist file", "playlistId", playlistID, "path", existing, "err", err, ) } } // emitEvent emits a Wails event if the context is available. func (s *Service) emitEvent( eventName string, data any, ) { events.Emit(s.ctx, eventName, data) } // migrateExistingPlaylists generates M3U8 files for any // existing DB playlists that don't already have one. This runs // once at startup to bootstrap the file-based backup for users // who already have playlists. func (s *Service) migrateExistingPlaylists() { dir, err := s.playlistsDir() if err != nil { s.logger.Warn( "Could not get playlists dir for migration", "err", err, ) return } existingFiles, err := listPlaylistFiles(dir) if err != nil { s.logger.Warn( "Could not list existing playlist files", "err", err, ) return } // Build a set of IDs that already have files. existingIDs := make(map[int64]struct{}) for _, file := range existingFiles { id := extractPlaylistID(file) if id > 0 { existingIDs[id] = struct{}{} } } playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx) if err != nil { s.logger.Warn( "Could not get playlists for migration", "err", err, ) return } var migrated int for _, p := range playlists { if _, exists := existingIDs[p.ID]; exists { continue } s.savePlaylistFile(p.ID, p.Name) migrated++ } if migrated > 0 { s.logger.Info( "Migrated existing playlists to M3U8 files", "count", migrated, ) } } // ================================================================= // Playlist repopulation from M3U8 // ================================================================= // RepopulateFromM3U re-imports tracks for playlists that have zero // playlist_tracks rows but still have a corresponding M3U8 file. // This recovers from a FullRescan that deleted playlist tracks // before the ON DELETE SET NULL fix was in place. Each M3U8 entry // is resolved against the audio_files table; unresolved entries // become phantom tracks with metadata preserved from the M3U8. func (s *Service) RepopulateFromM3U() { dir, err := s.playlistsDir() if err != nil { s.logger.Warn( "could not get playlists dir for repopulation", "err", err, ) return } // Get all playlists. playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx) if err != nil { s.logger.Warn("could not get playlists for repopulation", "err", err) return } libraryRoots := s.getAllLibraryRoots() // Build audio file path→ID map for resolution. afRows, err := s.db.QueryContext( `SELECT id, file_path FROM audio_files`, ) if err != nil { s.logger.Warn("could not query audio files for repopulation", "err", err) return } audioFileByPath := make(map[string]int64) for afRows.Next() { var ( id int64 fp string ) if err := afRows.Scan(&id, &fp); err != nil { continue } audioFileByPath[fp] = id } _ = afRows.Close() knownPaths := make(map[string]struct{}, len(audioFileByPath)) for k := range audioFileByPath { knownPaths[k] = struct{}{} } var totalRepopulated int for _, pl := range playlists { // Only repopulate playlists with zero tracks. countRows, err := s.db.QueryContext( `SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = ?`, pl.ID, ) if err != nil { continue } var count int if countRows.Next() { _ = countRows.Scan(&count) } _ = countRows.Close() if count > 0 { continue } m3uPath, err := findPlaylistFile(dir, pl.ID) if err != nil || m3uPath == "" { continue } parsed, err := parseM3U8(m3uPath) if err != nil { s.logger.Warn( "could not parse M3U8 for repopulation", "playlistId", pl.ID, "path", m3uPath, "err", err, ) continue } var resolved, phantom int for i, entry := range parsed.Entries { absPath := resolveM3UPath( entry.RelativePath, libraryRoots, knownPaths, ) audioFileID, exists := audioFileByPath[absPath] if exists { // Linked track. _, addErr := s.db.ExecContext( `INSERT INTO playlist_tracks (playlist_id, audio_file_id, position) VALUES (?, ?, ?)`, pl.ID, audioFileID, i, ) if addErr != nil { s.logger.Warn( "could not add repopulated track", "playlistId", pl.ID, "position", i, "err", addErr, ) continue } resolved++ } else { // Phantom track — preserve what we have from M3U8. _, addErr := s.db.ExecContext( `INSERT INTO playlist_tracks (playlist_id, position, phantom_title, phantom_file_path) VALUES (?, ?, ?, ?)`, pl.ID, i, entry.DisplayTitle, absPath, ) if addErr != nil { s.logger.Warn( "could not add phantom repopulated track", "playlistId", pl.ID, "position", i, "err", addErr, ) continue } phantom++ } } if resolved+phantom > 0 { totalRepopulated += resolved + phantom s.logger.Info( "repopulated playlist from M3U8", "playlistId", pl.ID, "name", pl.Name, "resolved", resolved, "phantom", phantom, ) } } if totalRepopulated > 0 { s.logger.Info( "playlist repopulation complete", "totalTracks", totalRepopulated, ) s.emitEvent(events.PlaylistTracksChanged, nil) } } // ================================================================= // 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 } // phantomRowSet is a playlist's unresolved rows plus the ones a // resolution pass has already consumed, so two matches cannot land on // the same row. type phantomRowSet struct { rows []phantomTrackRow taken map[int64]struct{} } // loadPhantomRows reads the playlist's phantom rows — the tracks whose // file was missing when the M3U8 was imported. func (s *Service) loadPhantomRows(playlistID int64) *phantomRowSet { set := &phantomRowSet{taken: map[int64]struct{}{}} // SAFETY: Hand-crafted SELECT for phantom tracks with position and // phantom_file_path. Parameterized by playlist ID. rows, 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 set } defer func() { if closeErr := rows.Close(); closeErr != nil { s.logger.Warn( "could not close phantom track rows", "err", closeErr, ) } }() for rows.Next() { var pt phantomTrackRow if err := rows.Scan( &pt.id, &pt.position, &pt.phantomFilePath, ); err != nil { continue } set.rows = append(set.rows, pt) } return set } // takePhantomRow finds the phantom row standing for phantomAbs and // marks it consumed. It matches on phantom_file_path first and falls // back to the row sitting at that entry's index in the M3U8, which is // the same two-step resolvePlaylistPhantoms uses: rows imported before // migration 7 carry no phantom_file_path at all. func takePhantomRow( set *phantomRowSet, phantomAbs string, entries []m3uEntry, libraryRoots []string, ) (int64, bool) { _, idx := findM3UEntry(entries, phantomAbs, libraryRoots) return takePhantomRowAt(set, phantomAbs, idx) } // takePhantomRowAt is takePhantomRow for a caller that already knows // the entry's index. A negative index means "no positional fallback": // positions are non-negative, so it simply never matches. func takePhantomRowAt( set *phantomRowSet, phantomAbs string, index int, ) (int64, bool) { for _, pt := range set.rows { if _, done := set.taken[pt.id]; done { continue } if pt.phantomFilePath != "" && pt.phantomFilePath == phantomAbs { set.taken[pt.id] = struct{}{} return pt.id, true } } for _, pt := range set.rows { if _, done := set.taken[pt.id]; done { continue } if pt.position == int64(index) { set.taken[pt.id] = struct{}{} return pt.id, true } } return 0, false } // fillPhantomRow points a phantom playlist_tracks row at a real audio // file and drops the placeholder metadata it was displaying. The row // keeps its position, which is why resolving a phantom does not move // the track to the end of the playlist. func (s *Service) fillPhantomRow( playlistTrackID, audioFileID int64, ) error { // 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, playlistTrackID, ); err != nil { return fmt.Errorf( "could not resolve phantom track %d: %w", playlistTrackID, err, ) } return nil } // 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. phantoms := s.loadPhantomRows(playlistID) if len(phantoms.rows) == 0 { return 0 } 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: // phantom_file_path first, then this entry's index. ptID, found := takePhantomRowAt(phantoms, absPath, i) if !found { continue } if err := s.fillPhantomRow(ptID, audioFileID); err != nil { s.logger.Warn( "could not resolve phantom track", "playlistTrackId", ptID, "audioFileId", audioFileID, "err", err, ) continue } 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. func (s *Service) FindPhantomMatches( playlistID int64, phantomPaths []string, ) (PhantomSearchResult, error) { if len(phantomPaths) == 0 { return PhantomSearchResult{}, nil } dir, err := s.playlistsDir() if err != nil { return PhantomSearchResult{}, fmt.Errorf( "could not get playlists dir: %w", err, ) } libraryRoots := s.getAllLibraryRoots() // Load M3U8 entries for display title / duration data. m3uPath, err := findPlaylistFile(dir, playlistID) if err != nil { return PhantomSearchResult{}, fmt.Errorf( "could not find playlist file: %w", err, ) } var entries []m3uEntry if m3uPath != "" { parsed, parseErr := parseM3U8(m3uPath) if parseErr == nil { entries = parsed.Entries } } // Build a lookup from absolute path to M3U entry. // Use resolveM3UPath with a nil knownPaths to get the // first-root fallback for each entry. entryByPath := make(map[string]m3uEntry, len(entries)) for _, e := range entries { absPath := resolveM3UPath( e.RelativePath, libraryRoots, nil, ) entryByPath[absPath] = e } // Every pairing confident enough to apply without asking. var offers []phantomOffer for _, phantomPath := range phantomPaths { entry := entryByPath[phantomPath] for _, c := range s.searchCandidates(phantomPath, entry) { if c.Score < autoMatchMinimum { // searchCandidates sorts by score, so nothing // below this one qualifies either. break } offers = append(offers, phantomOffer{ phantomPath: phantomPath, phantomTitle: entry.DisplayTitle, candidate: c, }) } } var result PhantomSearchResult result.AutoMatched = assignBestFirst(offers) matched := make(map[string]struct{}, len(result.AutoMatched)) for _, m := range result.AutoMatched { matched[m.PhantomPath] = struct{}{} } // Reported in the order the playlist has them, not the order they // were matched in. for _, phantomPath := range phantomPaths { if _, done := matched[phantomPath]; done { continue } result.Unmatched = append( result.Unmatched, phantomPath, ) } return result, nil } // phantomOffer is one candidate a phantom track could be resolved to, // carrying enough to become a PhantomMatch. type phantomOffer struct { phantomPath string phantomTitle string candidate CandidateTrack } // assignBestFirst pairs phantoms with candidates highest score first, // at most one candidate per phantom and one phantom per candidate. // // A candidate can only stand in for one phantom — two would put that // file in the playlist twice. Taking the offers in *phantom* order, // which is what this replaced, let an early phantom claim a candidate // that was a better answer for a later one, so which of two similar // tracks got the good match depended on the order they happen to sit in // the playlist. func assignBestFirst(offers []phantomOffer) []PhantomMatch { if len(offers) == 0 { return nil } ordered := make([]phantomOffer, len(offers)) copy(ordered, offers) slices.SortStableFunc( ordered, func(a, b phantomOffer) int { return cmp.Compare(b.candidate.Score, a.candidate.Score) }, ) var matches []PhantomMatch claimedCandidates := make(map[string]struct{}, len(ordered)) matchedPhantoms := make(map[string]struct{}, len(ordered)) for _, o := range ordered { if _, taken := claimedCandidates[o.candidate.FilePath]; taken { continue } if _, done := matchedPhantoms[o.phantomPath]; done { continue } matches = append(matches, PhantomMatch{ PhantomPath: o.phantomPath, PhantomTitle: o.phantomTitle, Candidate: o.candidate, }) claimedCandidates[o.candidate.FilePath] = struct{}{} matchedPhantoms[o.phantomPath] = struct{}{} } return matches } // GetPhantomCandidates returns scored candidate matches for a // single phantom track. func (s *Service) GetPhantomCandidates( playlistID int64, phantomPath string, ) ([]CandidateTrack, error) { dir, err := s.playlistsDir() if err != nil { return nil, fmt.Errorf( "could not get playlists dir: %w", err, ) } libraryRoots := s.getAllLibraryRoots() // Find the M3U entry for this phantom. m3uPath, err := findPlaylistFile(dir, playlistID) if err != nil { return nil, fmt.Errorf( "could not find playlist file: %w", err, ) } var entry m3uEntry if m3uPath != "" { parsed, parseErr := parseM3U8(m3uPath) if parseErr == nil { entry, _ = findM3UEntry( parsed.Entries, phantomPath, libraryRoots, ) } } return s.searchCandidates( phantomPath, entry, ), nil } // SearchLibrary searches the entire library by a free-text query // for manual phantom resolution. func (s *Service) SearchLibrary( query string, ) ([]CandidateTrack, error) { trimmed := strings.TrimSpace(query) if trimmed == "" { return []CandidateTrack{}, nil } rows, err := s.db.SearchFTS( trimmed, maxLibrarySearchResults, ) if err != nil { return nil, fmt.Errorf( "library search failed: %w", err, ) } candidates := make([]CandidateTrack, 0, len(rows)) for _, row := range rows { candidates = append(candidates, CandidateTrack{ FilePath: row.FilePath, Title: row.Title, Artist: row.Artist, Album: row.Album, Duration: strconv.FormatInt( row.LengthMilliseconds, 10, ), }) } return candidates, nil } // ResolvePhantomTracks replaces phantom entries in a playlist // with real library tracks. The matches map keys are phantom // absolute paths and values are resolved absolute paths. func (s *Service) ResolvePhantomTracks( playlistID int64, matches map[string]string, ) error { if len(matches) == 0 { return nil } dir, err := s.playlistsDir() if err != nil { return fmt.Errorf( "could not get playlists dir: %w", err, ) } libraryRoots := s.getAllLibraryRoots() m3uPath, err := findPlaylistFile(dir, playlistID) if err != nil || m3uPath == "" { return fmt.Errorf( "could not find M3U8 file for playlist %d: %w", playlistID, err, ) } parsed, err := parseM3U8(m3uPath) if err != nil { return fmt.Errorf( "could not parse M3U8: %w", err, ) } // Get next available DB position. nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition( s.db.Ctx, playlistID, ) if err != nil { return fmt.Errorf( "could not get next position: %w", err, ) } // The phantom rows this is about to fill in, so a resolution // updates the row the user pointed at rather than appending a // second one beside it. phantoms := s.loadPhantomRows(playlistID) // Build M3U path replacements and insert DB rows. pathReplacements := make( map[string]string, len(matches), ) // Two phantoms resolving to one file would put that file in the // playlist twice, which is what FindPhantomMatches' claimed set // already refuses to do on the auto-match path. claimed := make(map[string]struct{}, len(matches)) var ( resolved int appended int ) for phantomAbs, resolvedAbs := range matches { if _, taken := claimed[resolvedAbs]; taken { s.logger.Warn( "Two phantom tracks resolved to the same file", "playlistId", playlistID, "phantomPath", phantomAbs, "resolvedPath", resolvedAbs, ) continue } audioFile, lookupErr := s.db.Queries.GetAudioFileByPath( s.db.Ctx, resolvedAbs, ) if lookupErr != nil { s.logger.Warn( "Resolved path not found in library", "phantomPath", phantomAbs, "resolvedPath", resolvedAbs, "err", lookupErr, ) continue } ptID, found := takePhantomRow( phantoms, phantomAbs, parsed.Entries, libraryRoots, ) switch { case found: if updateErr := s.fillPhantomRow( ptID, audioFile.ID, ); updateErr != nil { s.logger.Warn( "Could not resolve phantom row", "playlistId", playlistID, "playlistTrackId", ptID, "path", resolvedAbs, "err", updateErr, ) continue } default: // No phantom row for this path — the M3U8 and the // database disagree, so fall back to appending. if _, addErr := s.db.Queries.AddPlaylistTrack( s.db.Ctx, sqlcgen.AddPlaylistTrackParams{ PlaylistID: playlistID, AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true}, Position: nextPos + int64(appended), }, ); addErr != nil { s.logger.Warn( "Could not add resolved track", "playlistId", playlistID, "path", resolvedAbs, "err", addErr, ) continue } appended++ } newRel := toRelativePathMultiRoot( resolvedAbs, libraryRoots, ) pathReplacements[phantomAbs] = newRel claimed[resolvedAbs] = struct{}{} resolved++ } // Rewrite the M3U8 with updated paths. if resolved > 0 { updated := replaceM3UEntryPaths( parsed.Entries, pathReplacements, libraryRoots, ) playlist, nameErr := s.db.Queries.GetPlaylist( s.db.Ctx, playlistID, ) if nameErr != nil { return fmt.Errorf( "could not get playlist name: %w", nameErr, ) } if writeErr := writeM3U8( dir, playlistID, playlist.Name, updated, ); writeErr != nil { return fmt.Errorf( "could not rewrite M3U8: %w", writeErr, ) } } s.logger.Info( "Phantom tracks resolved", "playlistId", playlistID, "resolved", resolved, "requested", len(matches), ) s.emitEvent(events.PlaylistTracksChanged, playlistID) return nil } // RemovePhantomTracks removes phantom entries from a playlist's // M3U8 file. Since phantom tracks have no DB rows, only the // M3U8 file is modified. func (s *Service) RemovePhantomTracks( playlistID int64, phantomPaths []string, ) error { if len(phantomPaths) == 0 { return nil } dir, err := s.playlistsDir() if err != nil { return fmt.Errorf( "could not get playlists dir: %w", err, ) } libraryRoots := s.getAllLibraryRoots() m3uPath, err := findPlaylistFile(dir, playlistID) if err != nil || m3uPath == "" { return fmt.Errorf( "could not find M3U8 file for playlist %d: %w", playlistID, err, ) } parsed, err := parseM3U8(m3uPath) if err != nil { return fmt.Errorf( "could not parse M3U8: %w", err, ) } targetSet := make( map[string]struct{}, len(phantomPaths), ) for _, p := range phantomPaths { targetSet[p] = struct{}{} } updated := removeM3UEntries( parsed.Entries, targetSet, libraryRoots, ) playlist, err := s.db.Queries.GetPlaylist( s.db.Ctx, playlistID, ) if err != nil { return fmt.Errorf( "could not get playlist name: %w", err, ) } if err := writeM3U8( dir, playlistID, playlist.Name, updated, ); err != nil { return fmt.Errorf( "could not rewrite M3U8: %w", err, ) } s.logger.Info( "Phantom tracks removed", "playlistId", playlistID, "removed", len(phantomPaths), ) s.emitEvent(events.PlaylistTracksChanged, playlistID) return nil } // searchCandidates finds and scores candidate library tracks // for a single phantom track. func (s *Service) searchCandidates( phantomPath string, entry m3uEntry, ) []CandidateTrack { basename := filepath.Base(phantomPath) seen := make(map[string]struct{}) var combined []database.SearchRow // 1. Exact basename match via indexed column. bnRows, err := s.db.Queries.SearchTracksByBasename( s.db.Ctx, sqlcgen.SearchTracksByBasenameParams{ Basename: basename, Lim: int64(maxCandidates), }, ) if err != nil { s.logger.Warn( "Basename search failed", "basename", basename, "err", err, ) } for _, r := range bnRows { if _, ok := seen[r.FilePath]; ok { continue } seen[r.FilePath] = struct{}{} combined = append(combined, database.SearchRow{ FilePath: r.FilePath, LengthMilliseconds: r.LengthMilliseconds, Title: r.Title, Artist: r.ArtistName, Album: r.Album, }) } // 2. FTS5 filename-token search for fuzzy basename // matches (e.g. different extension). ftsFileRows, err := s.db.SearchFTSByFilename( basename, maxCandidates, ) if err != nil { s.logger.Warn( "FTS filename search failed", "basename", basename, "err", err, ) } for _, r := range ftsFileRows { if _, ok := seen[r.FilePath]; ok { continue } seen[r.FilePath] = struct{}{} combined = append(combined, r) } // 3. FTS5 keyword search from path + display title. keywords := extractKeywords(phantomPath) if entry.DisplayTitle != "" { titleKeywords := extractKeywords( entry.DisplayTitle, ) keywords = append(keywords, titleKeywords...) keywords = dedupStrings(keywords) } if len(keywords) > 0 { kwQuery := strings.Join(keywords, " ") kwRows, kwErr := s.db.SearchFTS( kwQuery, maxCandidates, ) if kwErr != nil { s.logger.Warn( "FTS keyword search failed", "keywords", keywords, "err", kwErr, ) } for _, r := range kwRows { if _, ok := seen[r.FilePath]; ok { continue } seen[r.FilePath] = struct{}{} combined = append(combined, r) } } // Score each candidate. pp := newPhantomProfile( phantomPath, entry.DisplayTitle, entry.DurationSec, ) candidates := make( []CandidateTrack, 0, len(combined), ) for _, row := range combined { score := scoreCandidate( pp, row.FilePath, row.Title, row.Artist, row.LengthMilliseconds, ) candidates = append(candidates, CandidateTrack{ FilePath: row.FilePath, Title: row.Title, Artist: row.Artist, Album: row.Album, Duration: strconv.FormatInt( row.LengthMilliseconds, 10, ), Score: score, }) } // Sort by score descending. sortCandidatesByScore(candidates) if len(candidates) > maxCandidates { candidates = candidates[:maxCandidates] } return candidates } // sortCandidatesByScore sorts candidates by score descending. func sortCandidatesByScore(candidates []CandidateTrack) { slices.SortFunc( candidates, func(a, b CandidateTrack) int { if a.Score > b.Score { return -1 } if a.Score < b.Score { return 1 } return 0 }, ) } // ================================================================= // Smart playlist methods // ================================================================= // errNotSmartPlaylist is returned when an operation that requires // a smart playlist is performed on a regular playlist or a // non-existent playlist. var errNotSmartPlaylist = errors.New( "playlist not found or is not a smart playlist", ) // errNoRowReturned is returned when an INSERT ... RETURNING // query does not return the expected row. var errNoRowReturned = errors.New( "no row returned from insert", ) // CreateSmartPlaylist creates a new smart playlist with the given // name and JSON rule set. The rules are validated before storage. func (s *Service) CreateSmartPlaylist( name, rulesJSON string, ) (Summary, error) { trimmed := strings.TrimSpace(name) if trimmed == "" { return Summary{}, errEmptyName } // Validate rules JSON before storing. if _, err := smartplaylist.ParseRuleSet(rulesJSON); err != nil { return Summary{}, fmt.Errorf( "invalid smart playlist rules: %w", err, ) } var ( id int64 retName string createdAt string updatedAt string ) // SAFETY: Hand-crafted INSERT for smart playlist with // is_smart and smart_rules columns not yet in sqlc schema. // All values are parameterized. // // QueryRowWriter, not QueryContext: this is an INSERT wearing a // query's shape, and QueryContext routes to the query-only read // pool. Through that handle it failed with "attempt to write a // readonly database", i.e. no smart playlist could be created at // all. A RETURNING clause does not make a write a read. if err := s.db.QueryRowWriter( `INSERT INTO playlists (name, is_smart, smart_rules) VALUES (?, 1, ?) RETURNING id, name, created_at, updated_at`, trimmed, rulesJSON, ).Scan(&id, &retName, &createdAt, &updatedAt); err != nil { s.logger.Error( "Failed to create smart playlist", "name", trimmed, "err", err, ) if errors.Is(err, sql.ErrNoRows) { return Summary{}, fmt.Errorf( "failed to create smart playlist: %w", errNoRowReturned, ) } return Summary{}, fmt.Errorf( "failed to create smart playlist: %w", err, ) } s.logger.Info( "Smart playlist created", "id", id, "name", retName, ) // Materialize the rule set once at creation so the playlist has a // track snapshot immediately (track counts, instant open). A failed // evaluation is non-fatal — the lazy path re-materializes on first // open. if err := s.RefreshSmartPlaylist(id); err != nil { s.logger.Warn( "Failed to materialize smart playlist at creation", "id", id, "err", err, ) } summary := Summary{ ID: id, Name: retName, CreatedAt: createdAt, UpdatedAt: updatedAt, IsSmart: true, } s.emitEvent(events.PlaylistCreated, summary) return summary, nil } // UpdateSmartPlaylistRules updates the rule set for an existing // smart playlist. Returns an error if the playlist does not exist // or is not a smart playlist. func (s *Service) UpdateSmartPlaylistRules( playlistID int64, rulesJSON string, ) error { // Validate rules JSON before storing. if _, err := smartplaylist.ParseRuleSet(rulesJSON); err != nil { return fmt.Errorf( "invalid smart playlist rules: %w", err, ) } // SAFETY: Hand-crafted UPDATE for smart_rules column not // yet in sqlc schema. All values are parameterized. // Only updates rows where is_smart = 1. result, err := s.db.ExecContext( `UPDATE playlists SET smart_rules = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_smart = 1`, rulesJSON, playlistID, ) if err != nil { s.logger.Error( "Failed to update smart playlist rules", "playlistId", playlistID, "err", err, ) return fmt.Errorf( "failed to update smart playlist rules: %w", err, ) } affected, err := result.RowsAffected() if err != nil { return fmt.Errorf( "could not check rows affected: %w", err, ) } if affected == 0 { return errNotSmartPlaylist } s.logger.Info( "Smart playlist rules updated", "playlistId", playlistID, ) // Re-materialize the persisted snapshot so it reflects the new // rules. RefreshSmartPlaylist emits PlaylistTracksChanged. if err := s.RefreshSmartPlaylist(playlistID); err != nil { return fmt.Errorf( "failed to refresh smart playlist after rule update: %w", err, ) } return nil } // RefreshSmartPlaylist re-evaluates a smart playlist's rules against // the current library and replaces its persisted membership in // playlist_tracks with the result. This is the only path that // re-evaluates a smart playlist — opening one otherwise reads the // stored snapshot. Triggered on rule save and by the manual Refresh // button. func (s *Service) RefreshSmartPlaylist( playlistID int64, ) error { // EvaluateSmartPlaylist validates that the playlist exists and is // smart, and returns the live rule-matched tracks. tracks, err := s.EvaluateSmartPlaylist(playlistID) if err != nil { return err } if err := s.db.Queries.ClearPlaylistTracks( s.db.Ctx, playlistID, ); err != nil { return fmt.Errorf( "failed to clear smart playlist tracks: %w", err, ) } for i, t := range tracks { if strings.TrimSpace(t.FilePath) == "" { continue } if err := s.addSingleTrack( playlistID, t.FilePath, int64(i), ); err != nil { // A track can vanish between evaluation and insertion // (e.g. a concurrent rescan). Skip it rather than abort // the whole refresh. s.logger.Warn( "Skipping smart playlist track during refresh", "playlistId", playlistID, "filePath", t.FilePath, "err", err, ) continue } } // SAFETY: Hand-crafted UPDATE for smart_snapshot_at column not // yet in sqlc schema. Parameterized by playlist ID. if _, err := s.db.ExecContext( `UPDATE playlists SET smart_snapshot_at = CURRENT_TIMESTAMP WHERE id = ? AND is_smart = 1`, playlistID, ); err != nil { return fmt.Errorf( "failed to mark smart playlist snapshot: %w", err, ) } s.logger.Info( "Smart playlist refreshed", "playlistId", playlistID, "trackCount", len(tracks), ) s.savePlaylistFileByID(playlistID) s.emitEvent(events.PlaylistTracksChanged, playlistID) return nil } // GetSmartPlaylistTracks returns the persisted snapshot of a smart // playlist as regular playlist tracks (with resolved cover art and // phantom entries), identical to a normal playlist. If the playlist // has never been materialized — e.g. it predates snapshot support — // it is evaluated and stored on first access. func (s *Service) GetSmartPlaylistTracks( playlistID int64, ) ([]Track, error) { // SAFETY: Hand-crafted SELECT for smart_snapshot_at column not // yet in sqlc schema. Parameterized by playlist ID. rows, err := s.db.QueryContext( `SELECT smart_snapshot_at FROM playlists WHERE id = ? AND is_smart = 1`, playlistID, ) if err != nil { return nil, fmt.Errorf( "failed to load smart playlist: %w", err, ) } if !rows.Next() { _ = rows.Close() return nil, errNotSmartPlaylist } var snapshotAt sql.NullString if err := rows.Scan(&snapshotAt); err != nil { _ = rows.Close() return nil, fmt.Errorf( "failed to read smart playlist snapshot state: %w", err, ) } // Close before RefreshSmartPlaylist / GetPlaylistTracks issue // their own queries (MaxOpenConns=1 test DBs would deadlock). _ = rows.Close() if !snapshotAt.Valid { if err := s.RefreshSmartPlaylist(playlistID); err != nil { return nil, err } } return s.GetPlaylistTracks(playlistID) } // MaterializeUnmaterializedSmartPlaylists evaluates and snapshots any // smart playlist that has never been materialized (smart_snapshot_at // IS NULL) — e.g. playlists created before creation-time // materialization existed. It runs once at startup and is idempotent: // once every smart playlist has a snapshot it becomes a no-op. Errors // on individual playlists are logged and skipped so one bad rule set // doesn't block the rest. func (s *Service) MaterializeUnmaterializedSmartPlaylists() { // SAFETY: Static SELECT for smart_snapshot_at column not yet in // sqlc schema. No parameters. rows, err := s.db.QueryContext( `SELECT id FROM playlists WHERE is_smart = 1 AND smart_snapshot_at IS NULL`, ) if err != nil { s.logger.Error( "Failed to list unmaterialized smart playlists", "err", err, ) return } var ids []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { s.logger.Error( "Failed to scan smart playlist id", "err", err, ) continue } ids = append(ids, id) } // Close before RefreshSmartPlaylist issues its own queries // (MaxOpenConns=1 test DBs would deadlock). _ = rows.Close() for _, id := range ids { if err := s.RefreshSmartPlaylist(id); err != nil { s.logger.Warn( "Failed to materialize smart playlist snapshot", "id", id, "err", err, ) continue } } } // EvaluateSmartPlaylist loads the rule set for a smart playlist // from the database and evaluates it against the track library, // returning the matching tracks. func (s *Service) EvaluateSmartPlaylist( playlistID int64, ) ([]library.Track, error) { // SAFETY: Hand-crafted SELECT for smart_rules column not // yet in sqlc schema. Parameterized by playlist ID. rows, err := s.db.QueryContext( `SELECT smart_rules FROM playlists WHERE id = ? AND is_smart = 1`, playlistID, ) if err != nil { return nil, fmt.Errorf( "failed to load smart playlist rules: %w", err, ) } if !rows.Next() { _ = rows.Close() return nil, errNotSmartPlaylist } var rulesJSON string if err := rows.Scan(&rulesJSON); err != nil { _ = rows.Close() return nil, fmt.Errorf( "failed to load smart playlist rules: %w", err, ) } // Close rows before calling Evaluate, which opens its own // query. With MaxOpenConns=1 (test DBs), a deferred close // would deadlock. _ = rows.Close() ruleSet, err := smartplaylist.ParseRuleSet(rulesJSON) if err != nil { return nil, fmt.Errorf( "corrupt smart playlist rules for id %d: %w", playlistID, err, ) } tracks, err := smartplaylist.Evaluate(s.db, ruleSet) if err != nil { return nil, fmt.Errorf( "smart playlist evaluation failed for id %d: %w", playlistID, err, ) } return tracks, nil } // PreviewSmartPlaylist evaluates a rule set from raw JSON without // requiring a saved playlist. This powers live preview in the rule // editor — the frontend sends rules as they are being edited and // receives matching tracks immediately. func (s *Service) PreviewSmartPlaylist( rulesJSON string, ) ([]library.Track, error) { ruleSet, err := smartplaylist.ParseRuleSet(rulesJSON) if err != nil { return nil, fmt.Errorf( "invalid smart playlist rules: %w", err, ) } tracks, err := smartplaylist.Evaluate(s.db, ruleSet) if err != nil { return nil, fmt.Errorf( "smart playlist preview failed: %w", err, ) } s.logger.Info( "Smart playlist preview evaluated", "trackCount", len(tracks), ) return tracks, nil } // GetSmartPlaylistRules returns the raw JSON rule string for an // existing smart playlist. This is used when the user opens the // rule editor for an existing smart playlist. func (s *Service) GetSmartPlaylistRules( playlistID int64, ) (string, error) { // SAFETY: Hand-crafted SELECT for smart_rules column not // yet in sqlc schema. Parameterized by playlist ID. rows, err := s.db.QueryContext( `SELECT smart_rules FROM playlists WHERE id = ? AND is_smart = 1`, playlistID, ) if err != nil { return "", fmt.Errorf( "failed to load smart playlist rules: %w", err, ) } defer func() { _ = rows.Close() }() if !rows.Next() { return "", errNotSmartPlaylist } var rulesJSON string if err := rows.Scan(&rulesJSON); err != nil { return "", fmt.Errorf( "failed to scan smart playlist rules: %w", err, ) } s.logger.Info( "Smart playlist rules loaded", "playlistId", playlistID, ) return rulesJSON, nil }