Files
yellowjacket/backend/playlist/playlist.go
T
yonluandClaude Opus 4.8 65048401e8 feat: autotag scoring overhaul, dump-based explore index, and lyrics search
Consolidates in-progress work across autotag, explore, and library:

- autotag: beets/Picard-informed scoring engine — ID-first matching, VA
  handling, recommendation tiers, and a merged distance/rank cascade, with
  an eval harness for regression tracking.
- explore: offline MusicBrainz dump import/incremental refresh replaces the
  legacy tier crawl; index-first local search with fuzzy matching and a
  dedicated ranker; disk-free guards for dump downloads.
- library: artist-credit extraction and matching.
- lyrics: owned-library lyric search (FTS) with LRCLIB backfill.

Also: rewrite README to be user-focused, and migrate upstream to
git.ljones.me/yonlu/yellowjacket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:14:20 -04:00

2952 lines
63 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,
)
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
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 {
s.logger.Error(
"Failed to create smart playlist",
"name", trimmed, "err", err,
)
return Summary{}, fmt.Errorf(
"failed to create smart playlist: %w", err,
)
}
s.logger.Info(
"Smart playlist created",
"id", id, "name", retName,
)
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)
}
// 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
}