Files
yellowjacket/backend/playlist/playlist.go
T
yonluandClaude Opus 4.8 08da4f2774 feat(smartplaylist): materialize on creation and show track counts
Smart playlists now evaluate and snapshot their rules at creation time
instead of only lazily on first open, so the playlist list can show a
real track count in place of the "Smart" label. A one-time idempotent
startup sweep backfills snapshots for smart playlists created before
creation-time materialization existed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:01:49 -04:00

3024 lines
65 KiB
Go

// Package playlist provides playlist management functionality.
package playlist
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"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.
func (s *Service) SetFavoritesConfig(
provider FavoritesConfigProvider,
) {
s.mu.Lock()
defer s.mu.Unlock()
s.favoritesConf = provider
}
// SetContext sets the Wails runtime context and runs the
// one-time startup migration to bootstrap M3U8 files for
// existing playlists.
func (s *Service) SetContext(ctx context.Context) {
s.mu.Lock()
s.ctx = ctx
s.mu.Unlock()
s.migrateExistingPlaylists()
}
// 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,
) {
if s.ctx == nil {
return
}
runtime.EventsEmit(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
}
// 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.
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
}
// Track which candidates have been claimed by auto-match
// so we don't assign the same candidate to two phantoms.
claimed := make(map[string]struct{})
var result PhantomSearchResult
for _, phantomPath := range phantomPaths {
entry := entryByPath[phantomPath]
candidates := s.searchCandidates(
phantomPath, entry,
)
matched := false
for _, c := range candidates {
if _, taken := claimed[c.FilePath]; taken {
continue
}
if c.Score >= autoMatchMinimum {
result.AutoMatched = append(
result.AutoMatched,
PhantomMatch{
PhantomPath: phantomPath,
PhantomTitle: entry.DisplayTitle,
Candidate: c,
},
)
claimed[c.FilePath] = struct{}{}
matched = true
break
}
}
if !matched {
result.Unmatched = append(
result.Unmatched, phantomPath,
)
}
}
return result, nil
}
// 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,
)
}
// Build M3U path replacements and insert DB rows.
pathReplacements := make(
map[string]string, len(matches),
)
var resolved int
for phantomAbs, resolvedAbs := range matches {
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
}
_, addErr := s.db.Queries.AddPlaylistTrack(
s.db.Ctx,
sqlcgen.AddPlaylistTrackParams{
PlaylistID: playlistID,
AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true},
Position: nextPos + int64(resolved),
},
)
if addErr != nil {
s.logger.Warn(
"Could not add resolved track",
"playlistId", playlistID,
"path", resolvedAbs,
"err", addErr,
)
continue
}
newRel := toRelativePathMultiRoot(
resolvedAbs, libraryRoots,
)
pathReplacements[phantomAbs] = newRel
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.SearchAudioFilesByBasename(
s.db.Ctx,
sqlcgen.SearchAudioFilesByBasenameParams{
Basename: basename,
Limit: 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.Artist,
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,
)
}
// SAFETY: Hand-crafted INSERT for smart playlist with
// is_smart and smart_rules columns not yet in sqlc schema.
// All values are parameterized.
rows, err := s.db.QueryContext(
`INSERT INTO playlists (name, is_smart, smart_rules)
VALUES (?, 1, ?)
RETURNING id, name, created_at, updated_at`,
trimmed, rulesJSON,
)
if err != nil {
s.logger.Error(
"Failed to create smart playlist",
"name", trimmed, "err", err,
)
return Summary{}, fmt.Errorf(
"failed to create smart playlist: %w", err,
)
}
if !rows.Next() {
_ = rows.Close()
return Summary{}, fmt.Errorf(
"failed to create smart playlist: %w",
errNoRowReturned,
)
}
var (
id int64
retName string
createdAt string
updatedAt string
)
if err := rows.Scan(
&id, &retName, &createdAt, &updatedAt,
); err != nil {
_ = rows.Close()
s.logger.Error(
"Failed to create smart playlist",
"name", trimmed, "err", err,
)
return Summary{}, fmt.Errorf(
"failed to create smart playlist: %w", err,
)
}
// Close before RefreshSmartPlaylist issues its own queries
// (MaxOpenConns=1 test DBs would deadlock).
_ = rows.Close()
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
}