Plans 013 and 014, the album page that prompted them, and the smaller fixes they turned up. Changelog, largest first. ## The local library is shaped like files, not like MusicBrainz `audio_files` carries its own tags and points at `albums` and `artists`; `file_genres` is the one real many-to-many. `recordings`, `release_group_recordings`, `artist_credit`, `artist_credit_artist`, `recording_genres`, `release_groups` and `release_to_rg` are gone from the local side, and with them a six-way join in every read, a `MIN(release_group_id)` subquery in eleven queries and a first-credited-artist subquery in nine. Measured on a real 25,966-file library, every many-to-many that model expressed was 1:1 in the data. - Ownership is a file. `GetFilePathsByRecordingMBIDs`, `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812 orphaned recordings, 216 release groups and 260 artists that library carried are now structurally impossible. - One projection: every track query selects from the `track_metadata` view, one row type, one mapper. Nine hand-rolled copies had drifted far enough to report different years on different screens. - `library_id = 0` means every library, so each list query exists once instead of scoped and unscoped with a branch at every call site. - No migration chain. `sql/schemas/` is the one description of the shape; `sql/migrations/`, `applyMigrations` and `schema_migrations` are squashed away, along with the drift between them that had sqlc generating against a stale schema. - `database.InsertTestTrack` is the one test seeder; twenty test files had been assembling the old FK chain each in its own order. ## The catalog stores its ids as bytes `explore_index`'s three 36-char MBID columns and its entity-type text are 16 raw bytes and a small integer. The table and its six indexes go 780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh install is ~0.6 GB rather than ~1.0 GB. - `backend/explore/mbid.go` is the only place the encoding is known; everything above it speaks dashed strings. - `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert rather than silently returning no rows, since SQLite does not coerce between TEXT and BLOB. - The importer asks the artifact what encoding it carries and converts on the way in, so the artifact already published keeps working and no format bump is needed. - `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column list, and `TestStoredEncodingRoundTrips` sweeps every read path. ## An album page that says how much of the album is yours - One question, asked once: is there a file. `filePaths` is filled by a single batched lookup when the tracklist settles, and the badge, the Play count, the dimmed rows and every menu item read it — replacing four claims of decreasing confidence that could show a green tick on an album whose every action did nothing. - Play, Play 7 of 12, or no play button at all. - `total_tracks` on `explore_index` (~2 bytes over 400,677 release groups) and on `audio_files` from tags that have always carried it: a complete MBID-matched album now makes no catalog call at all, where it used to spend the most expensive request the app makes. - A merged cluster shows the running order the most releases agree on, and the version list marks the release you own rather than standing a synthetic entry in for it. - `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed one by a 12-second timer. - Rows not in the library are dimmed in place (with `aria-disabled`) instead of the owned ones wearing a green tick and a legend. ## Caches and cover art get ceilings - Only the three tiers of a cover are stored; the full-resolution copy nothing rendered was 1,134 MB of a 1.4 GB covers directory. - One artist portrait is downloaded and the rest are remembered as URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads. - `browsedArtBudget` and `httpCacheBudget` bound what an age cannot: the same install held art for 5,770 artists in a 1,301-artist library. - `OrphanedArtistImagesJob` joined a bare MBID onto a sharded directory, so it deleted the rows that were the only record of the files it left behind. `explore.ArtistImageDir` is that layout's one definition now. ## The autotag queue asks whether there is work `tagging_items` was a row per album folder, not a queue, and no query read the `tag_status` column that held the answer. The four queue queries ask the files, which matters most where it is least visible: `startPrefetch` was scoring every album in a tagged library against MusicBrainz. ## Phantom playlist tracks resolve in place An M3U8 imported before its files leaves phantom rows; they now match by path and fall back to position, keep their place in the playlist when resolved, and pair best-first so two phantoms cannot claim the same file. ## Playing a track plays the list it is in Double-click, and Play on a single row's menu, queue the list as displayed with `startIndex` on that row — the album page and the track list used to queue one track and discard the album around it. A multi-row selection still plays exactly itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
3169 lines
70 KiB
Go
3169 lines
70 KiB
Go
// Package playlist provides playlist management functionality.
|
|
package playlist
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
|
|
"yellowjacket/backend/coverart"
|
|
"yellowjacket/backend/database"
|
|
"yellowjacket/backend/database/sql/sqlcgen"
|
|
"yellowjacket/backend/events"
|
|
"yellowjacket/backend/library"
|
|
"yellowjacket/backend/smartplaylist"
|
|
"yellowjacket/backend/system"
|
|
)
|
|
|
|
var (
|
|
errEmptyName = errors.New("playlist name cannot be empty")
|
|
errEmptyFilePath = errors.New("file path cannot be empty")
|
|
errNoFilePaths = errors.New("no file paths provided")
|
|
errUnsupportedFileType = errors.New("unsupported file type")
|
|
)
|
|
|
|
// playlistsDirName is the subdirectory within the user data
|
|
// directory where M3U8 playlist files are stored.
|
|
const playlistsDirName = "playlists"
|
|
|
|
// LibraryDirProvider is a narrow interface for obtaining the
|
|
// configured library directory path.
|
|
type LibraryDirProvider interface {
|
|
GetLibraryDirectory() string
|
|
}
|
|
|
|
// Summary is a lightweight representation of a playlist for the
|
|
// picker UI.
|
|
type Summary struct {
|
|
ID int64 `json:"ID"`
|
|
Name string `json:"Name"`
|
|
CreatedAt string `json:"CreatedAt"`
|
|
UpdatedAt string `json:"UpdatedAt"`
|
|
IsSmart bool `json:"IsSmart"`
|
|
}
|
|
|
|
// Track represents a track within a playlist, including its
|
|
// metadata.
|
|
type Track struct {
|
|
ID int64 `json:"ID"`
|
|
Position int64 `json:"Position"`
|
|
FilePath string `json:"FilePath"`
|
|
Title string `json:"Title"`
|
|
Artist string `json:"Artist"`
|
|
Album string `json:"Album"`
|
|
CoverArtPath string `json:"CoverArtPath"`
|
|
CoverArtSmall string `json:"CoverArtSmall"`
|
|
CoverArtMedium string `json:"CoverArtMedium"`
|
|
CoverArtLarge string `json:"CoverArtLarge"`
|
|
Duration string `json:"Duration"`
|
|
Phantom bool `json:"Phantom"`
|
|
ArtistMBID string `json:"ArtistMBID"`
|
|
ReleaseGroupMBID string `json:"ReleaseGroupMBID"`
|
|
RecordingMBID string `json:"RecordingMBID"`
|
|
}
|
|
|
|
// WithTracks contains a playlist summary and all its tracks.
|
|
type WithTracks struct {
|
|
Summary Summary `json:"Summary"`
|
|
Tracks []Track `json:"Tracks"`
|
|
}
|
|
|
|
// CandidateTrack represents a potential library match for a
|
|
// phantom track.
|
|
type CandidateTrack struct {
|
|
FilePath string `json:"FilePath"`
|
|
Title string `json:"Title"`
|
|
Artist string `json:"Artist"`
|
|
Album string `json:"Album"`
|
|
Duration string `json:"Duration"`
|
|
Score float64 `json:"Score"`
|
|
}
|
|
|
|
// PhantomMatch represents a high-confidence pairing of a phantom
|
|
// track to a library track.
|
|
type PhantomMatch struct {
|
|
PhantomPath string `json:"PhantomPath"`
|
|
PhantomTitle string `json:"PhantomTitle"`
|
|
Candidate CandidateTrack `json:"Candidate"`
|
|
}
|
|
|
|
// PhantomSearchResult contains auto-matched pairs and remaining
|
|
// unmatched phantom paths for a batch search operation.
|
|
type PhantomSearchResult struct {
|
|
AutoMatched []PhantomMatch `json:"AutoMatched"`
|
|
Unmatched []string `json:"Unmatched"`
|
|
}
|
|
|
|
// DuplicateTrackInfo holds metadata for a track that already
|
|
// exists in a playlist.
|
|
type DuplicateTrackInfo struct {
|
|
FilePath string `json:"FilePath"`
|
|
Title string `json:"Title"`
|
|
Artist string `json:"Artist"`
|
|
Album string `json:"Album"`
|
|
Duration string `json:"Duration"`
|
|
}
|
|
|
|
// DuplicateCheckResult contains the outcome of checking for
|
|
// duplicate tracks in a playlist.
|
|
type DuplicateCheckResult struct {
|
|
Duplicates []DuplicateTrackInfo `json:"Duplicates"`
|
|
Unique []string `json:"Unique"`
|
|
}
|
|
|
|
// Service manages playlist operations.
|
|
type Service struct {
|
|
// mu protects ctx and favoritesConf from concurrent access
|
|
// during initialization.
|
|
mu sync.Mutex
|
|
ctx context.Context
|
|
logger *slog.Logger
|
|
db *database.DB
|
|
libraryDir LibraryDirProvider
|
|
favoritesConf FavoritesConfigProvider
|
|
|
|
// dataDirOverride, when non-empty, replaces the OS user data
|
|
// directory as the base for the playlists folder. Set by tests to
|
|
// keep M3U writes out of the real user data directory.
|
|
dataDirOverride string
|
|
}
|
|
|
|
// NewService creates a new playlist service.
|
|
func NewService(
|
|
logger *slog.Logger,
|
|
db *database.DB,
|
|
libraryDir LibraryDirProvider,
|
|
) *Service {
|
|
return &Service{
|
|
logger: logger.WithGroup("playlist"),
|
|
db: db,
|
|
libraryDir: libraryDir,
|
|
}
|
|
}
|
|
|
|
// SetFavoritesConfig sets the provider used to read and write
|
|
// the default-playlist configuration.
|
|
//
|
|
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
|
func (s *Service) SetFavoritesConfig(
|
|
provider FavoritesConfigProvider,
|
|
) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.favoritesConf = provider
|
|
}
|
|
|
|
// ServiceStartup is v3's service lifecycle hook: it runs once the
|
|
// runtime exists, and ctx is cancelled when the app shuts down. It
|
|
// replaces v2's SetContext, which had to be called by hand from
|
|
// OnStartup and was exported, so it was also bound to the frontend.
|
|
func (s *Service) ServiceStartup(
|
|
ctx context.Context,
|
|
_ application.ServiceOptions,
|
|
) error {
|
|
s.mu.Lock()
|
|
s.ctx = ctx
|
|
s.mu.Unlock()
|
|
|
|
s.migrateExistingPlaylists()
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetAllPlaylists returns all playlists ordered by most recently
|
|
// updated.
|
|
func (s *Service) GetAllPlaylists() ([]Summary, error) {
|
|
playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx)
|
|
if err != nil {
|
|
s.logger.Error(
|
|
"Failed to get playlists", "err", err,
|
|
)
|
|
|
|
return nil, fmt.Errorf(
|
|
"failed to get playlists: %w", err,
|
|
)
|
|
}
|
|
|
|
summaries := make([]Summary, 0, len(playlists))
|
|
|
|
for _, p := range playlists {
|
|
summaries = append(summaries, Summary{
|
|
ID: p.ID,
|
|
Name: p.Name,
|
|
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
|
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
|
|
IsSmart: p.IsSmart != 0,
|
|
})
|
|
}
|
|
|
|
return summaries, nil
|
|
}
|
|
|
|
// GetAllPlaylistsWithTracks returns all playlists with their
|
|
// tracks in a single call, merging phantom tracks from M3U8 files.
|
|
func (s *Service) GetAllPlaylistsWithTracks() (
|
|
[]WithTracks,
|
|
error,
|
|
) {
|
|
playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx)
|
|
if err != nil {
|
|
s.logger.Error(
|
|
"Failed to get playlists", "err", err,
|
|
)
|
|
|
|
return nil, fmt.Errorf(
|
|
"failed to get playlists: %w", err,
|
|
)
|
|
}
|
|
|
|
rows, err := s.db.ReadQueries.GetAllPlaylistTracksWithMetadata(
|
|
s.db.Ctx,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error(
|
|
"Failed to get all playlist tracks",
|
|
"err", err,
|
|
)
|
|
|
|
return nil, fmt.Errorf(
|
|
"failed to get all playlist tracks: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
// Group DB tracks by playlist ID, keyed by absolute file path.
|
|
dbTracksByPlaylist := make(
|
|
map[int64]map[string]Track,
|
|
)
|
|
|
|
for _, row := range rows {
|
|
track := trackFromRow(
|
|
row.ID,
|
|
row.Position,
|
|
row.FilePath,
|
|
row.Title,
|
|
row.Artist,
|
|
row.Album,
|
|
row.LengthMilliseconds,
|
|
row.CoverArtPath,
|
|
row.ArtistMbid,
|
|
row.ReleaseGroupMbid,
|
|
row.RecordingMbid,
|
|
)
|
|
|
|
if dbTracksByPlaylist[row.PlaylistID] == nil {
|
|
dbTracksByPlaylist[row.PlaylistID] = make(
|
|
map[string]Track,
|
|
)
|
|
}
|
|
|
|
dbTracksByPlaylist[row.PlaylistID][row.FilePath] = track
|
|
}
|
|
|
|
result := make([]WithTracks, 0, len(playlists))
|
|
|
|
for _, p := range playlists {
|
|
tracks := s.mergeTracksForPlaylist(
|
|
p.ID,
|
|
p.Name,
|
|
dbTracksByPlaylist[p.ID],
|
|
)
|
|
|
|
result = append(result, WithTracks{
|
|
Summary: Summary{
|
|
ID: p.ID,
|
|
Name: p.Name,
|
|
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
|
UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
|
|
IsSmart: p.IsSmart != 0,
|
|
},
|
|
Tracks: tracks,
|
|
})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetPlaylistTracks returns all tracks in a playlist with full
|
|
// metadata, merging phantom tracks from the M3U8 file.
|
|
func (s *Service) GetPlaylistTracks(
|
|
playlistID int64,
|
|
) ([]Track, error) {
|
|
rows, err := s.db.ReadQueries.GetPlaylistTracksWithMetadata(
|
|
s.db.Ctx,
|
|
playlistID,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error(
|
|
"Failed to get playlist tracks",
|
|
"playlistId", playlistID,
|
|
"err", err,
|
|
)
|
|
|
|
return nil, fmt.Errorf(
|
|
"failed to get playlist tracks: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
// Build a map of DB tracks keyed by absolute file path.
|
|
dbTracks := make(map[string]Track, len(rows))
|
|
|
|
for _, row := range rows {
|
|
track := trackFromRow(
|
|
row.ID,
|
|
row.Position,
|
|
row.FilePath,
|
|
row.Title,
|
|
row.Artist,
|
|
row.Album,
|
|
row.LengthMilliseconds,
|
|
row.CoverArtPath,
|
|
row.ArtistMbid,
|
|
row.ReleaseGroupMbid,
|
|
row.RecordingMbid,
|
|
)
|
|
|
|
dbTracks[row.FilePath] = track
|
|
}
|
|
|
|
// Get playlist name for M3U file lookup.
|
|
playlist, err := s.db.Queries.GetPlaylist(
|
|
s.db.Ctx, playlistID,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error(
|
|
"Failed to get playlist",
|
|
"playlistId", playlistID,
|
|
"err", err,
|
|
)
|
|
|
|
return nil, fmt.Errorf(
|
|
"failed to get playlist: %w", err,
|
|
)
|
|
}
|
|
|
|
return s.mergeTracksForPlaylist(
|
|
playlistID, playlist.Name, dbTracks,
|
|
), nil
|
|
}
|
|
|
|
// mergeTracksForPlaylist merges DB tracks with M3U8 entries,
|
|
// producing phantom tracks for unresolved paths.
|
|
func (s *Service) mergeTracksForPlaylist(
|
|
playlistID int64,
|
|
_ string,
|
|
dbTracks map[string]Track,
|
|
) []Track {
|
|
dir, err := s.playlistsDir()
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Could not get playlists dir for merge",
|
|
"err", err,
|
|
)
|
|
|
|
return dbTracksToSlice(dbTracks)
|
|
}
|
|
|
|
m3uPath, err := findPlaylistFile(dir, playlistID)
|
|
if err != nil || m3uPath == "" {
|
|
return dbTracksToSlice(dbTracks)
|
|
}
|
|
|
|
parsed, err := parseM3U8(m3uPath)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Could not parse M3U8 for merge",
|
|
"playlistId", playlistID,
|
|
"path", m3uPath,
|
|
"err", err,
|
|
)
|
|
|
|
return dbTracksToSlice(dbTracks)
|
|
}
|
|
|
|
libraryRoots := s.getAllLibraryRoots()
|
|
|
|
// Build a set of known DB paths for multi-root resolution.
|
|
knownPaths := make(
|
|
map[string]struct{}, len(dbTracks),
|
|
)
|
|
for k := range dbTracks {
|
|
knownPaths[k] = struct{}{}
|
|
}
|
|
|
|
tracks := make([]Track, 0, len(parsed.Entries))
|
|
|
|
for i, entry := range parsed.Entries {
|
|
absPath := resolveM3UPath(
|
|
entry.RelativePath, libraryRoots, knownPaths,
|
|
)
|
|
|
|
if dbTrack, ok := dbTracks[absPath]; ok {
|
|
dbTrack.Position = int64(i)
|
|
tracks = append(tracks, dbTrack)
|
|
|
|
continue
|
|
}
|
|
|
|
// Phantom track — file not resolved in DB.
|
|
tracks = append(tracks, Track{
|
|
Position: int64(i),
|
|
FilePath: absPath,
|
|
Title: entry.DisplayTitle,
|
|
Phantom: true,
|
|
})
|
|
}
|
|
|
|
return tracks
|
|
}
|
|
|
|
// dbTracksToSlice converts a map of tracks to an ordered slice.
|
|
func dbTracksToSlice(m map[string]Track) []Track {
|
|
if len(m) == 0 {
|
|
return []Track{}
|
|
}
|
|
|
|
tracks := make([]Track, 0, len(m))
|
|
|
|
for _, t := range m {
|
|
tracks = append(tracks, t)
|
|
}
|
|
|
|
return tracks
|
|
}
|
|
|
|
// trackFromRow converts raw query row fields into a Track.
|
|
func trackFromRow(
|
|
id, position int64,
|
|
filePath, title, artist, album string,
|
|
lengthMilliseconds int64,
|
|
coverArtPath string,
|
|
artistMBID, releaseGroupMBID, recordingMBID string,
|
|
) Track {
|
|
track := Track{
|
|
ID: id,
|
|
Position: position,
|
|
FilePath: filePath,
|
|
Title: title,
|
|
Artist: artist,
|
|
Album: album,
|
|
Duration: strconv.FormatInt(lengthMilliseconds, 10),
|
|
ArtistMBID: artistMBID,
|
|
ReleaseGroupMBID: releaseGroupMBID,
|
|
RecordingMBID: recordingMBID,
|
|
}
|
|
|
|
if coverArtPath != "" {
|
|
urls := coverart.ResolveURLs(coverArtPath)
|
|
track.CoverArtPath = urls.Original
|
|
track.CoverArtSmall = urls.Small
|
|
track.CoverArtMedium = urls.Medium
|
|
track.CoverArtLarge = urls.Large
|
|
}
|
|
|
|
return track
|
|
}
|
|
|
|
// CreatePlaylist creates a new empty playlist with the given name.
|
|
func (s *Service) CreatePlaylist(
|
|
name string,
|
|
) (Summary, error) {
|
|
trimmed := strings.TrimSpace(name)
|
|
if trimmed == "" {
|
|
return Summary{}, errEmptyName
|
|
}
|
|
|
|
created, err := s.db.Queries.CreatePlaylist(
|
|
s.db.Ctx, trimmed,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error(
|
|
"Failed to create playlist",
|
|
"name", trimmed, "err", err,
|
|
)
|
|
|
|
return Summary{}, fmt.Errorf(
|
|
"failed to create playlist: %w", err,
|
|
)
|
|
}
|
|
|
|
s.logger.Info(
|
|
"Playlist created",
|
|
"id", created.ID, "name", created.Name,
|
|
)
|
|
|
|
s.savePlaylistFile(created.ID, created.Name)
|
|
s.emitEvent(events.PlaylistCreated, Summary{
|
|
ID: created.ID,
|
|
Name: created.Name,
|
|
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
|
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
|
IsSmart: created.IsSmart != 0,
|
|
})
|
|
|
|
return Summary{
|
|
ID: created.ID,
|
|
Name: created.Name,
|
|
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
|
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
|
IsSmart: created.IsSmart != 0,
|
|
}, nil
|
|
}
|
|
|
|
// AddTracksToPlaylist adds one or more tracks to an existing
|
|
// playlist.
|
|
func (s *Service) AddTracksToPlaylist(
|
|
playlistID int64,
|
|
filePaths []string,
|
|
) error {
|
|
if len(filePaths) == 0 {
|
|
return errNoFilePaths
|
|
}
|
|
|
|
nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition(
|
|
s.db.Ctx,
|
|
playlistID,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error(
|
|
"Failed to get next position",
|
|
"playlistId", playlistID,
|
|
"err", err,
|
|
)
|
|
|
|
return fmt.Errorf(
|
|
"failed to get next track position: %w", err,
|
|
)
|
|
}
|
|
|
|
for i, fp := range filePaths {
|
|
if err := s.addSingleTrack(
|
|
playlistID, fp, nextPos+int64(i),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
s.logger.Info(
|
|
"Tracks added to playlist",
|
|
"playlistId", playlistID,
|
|
"count", len(filePaths),
|
|
)
|
|
|
|
s.savePlaylistFileByID(playlistID)
|
|
s.emitEvent(events.PlaylistTracksChanged, playlistID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// FindDuplicateTracksInPlaylist checks which of the given file
|
|
// paths already exist in the specified playlist. Returns metadata
|
|
// for each duplicate and a list of non-duplicate file paths.
|
|
func (s *Service) FindDuplicateTracksInPlaylist(
|
|
playlistID int64,
|
|
filePaths []string,
|
|
) (DuplicateCheckResult, error) {
|
|
rows, err := s.db.ReadQueries.GetPlaylistTracksWithMetadata(
|
|
s.db.Ctx,
|
|
playlistID,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error(
|
|
"Failed to get playlist tracks for duplicate check",
|
|
"playlistId", playlistID,
|
|
"err", err,
|
|
)
|
|
|
|
return DuplicateCheckResult{}, fmt.Errorf(
|
|
"failed to get playlist tracks: %w", err,
|
|
)
|
|
}
|
|
|
|
existingPaths := make(
|
|
map[string]sqlcgen.GetPlaylistTracksWithMetadataRow,
|
|
len(rows),
|
|
)
|
|
|
|
for _, row := range rows {
|
|
existingPaths[row.FilePath] = row
|
|
}
|
|
|
|
var duplicates []DuplicateTrackInfo
|
|
|
|
var unique []string
|
|
|
|
for _, fp := range filePaths {
|
|
if row, exists := existingPaths[fp]; exists {
|
|
duplicates = append(duplicates, DuplicateTrackInfo{
|
|
FilePath: fp,
|
|
Title: row.Title,
|
|
Artist: row.Artist,
|
|
Album: row.Album,
|
|
Duration: strconv.FormatInt(
|
|
row.LengthMilliseconds, 10,
|
|
),
|
|
})
|
|
} else {
|
|
unique = append(unique, fp)
|
|
}
|
|
}
|
|
|
|
return DuplicateCheckResult{
|
|
Duplicates: duplicates,
|
|
Unique: unique,
|
|
}, nil
|
|
}
|
|
|
|
// CreatePlaylistWithTracks creates a new playlist and populates
|
|
// it with tracks.
|
|
func (s *Service) CreatePlaylistWithTracks(
|
|
name string,
|
|
filePaths []string,
|
|
) (Summary, error) {
|
|
trimmed := strings.TrimSpace(name)
|
|
if trimmed == "" {
|
|
return Summary{}, errEmptyName
|
|
}
|
|
|
|
created, err := s.db.Queries.CreatePlaylist(
|
|
s.db.Ctx, trimmed,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error(
|
|
"Failed to create playlist",
|
|
"name", trimmed, "err", err,
|
|
)
|
|
|
|
return Summary{}, fmt.Errorf(
|
|
"failed to create playlist: %w", err,
|
|
)
|
|
}
|
|
|
|
if len(filePaths) > 0 {
|
|
nextPos, posErr := s.db.Queries.GetNextPlaylistTrackPosition(
|
|
s.db.Ctx,
|
|
created.ID,
|
|
)
|
|
if posErr != nil {
|
|
return Summary{}, fmt.Errorf(
|
|
"failed to get next track position: %w",
|
|
posErr,
|
|
)
|
|
}
|
|
|
|
for i, fp := range filePaths {
|
|
if err := s.addSingleTrack(
|
|
created.ID, fp, nextPos+int64(i),
|
|
); err != nil {
|
|
return Summary{}, fmt.Errorf(
|
|
"playlist created but failed to add tracks: %w",
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
summary := Summary{
|
|
ID: created.ID,
|
|
Name: created.Name,
|
|
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
|
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
|
IsSmart: created.IsSmart != 0,
|
|
}
|
|
|
|
s.logger.Info(
|
|
"Playlist created with tracks",
|
|
"id", created.ID,
|
|
"name", created.Name,
|
|
"trackCount", len(filePaths),
|
|
)
|
|
|
|
s.savePlaylistFile(created.ID, created.Name)
|
|
s.emitEvent(events.PlaylistCreated, summary)
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// RemoveTracksFromPlaylist removes multiple tracks from a playlist
|
|
// by their playlist_track IDs.
|
|
func (s *Service) RemoveTracksFromPlaylist(
|
|
playlistID int64,
|
|
trackIDs []int64,
|
|
) error {
|
|
if len(trackIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
for _, id := range trackIDs {
|
|
if err := s.db.Queries.RemovePlaylistTrack(
|
|
s.db.Ctx,
|
|
id,
|
|
); err != nil {
|
|
s.logger.Error(
|
|
"Failed to remove playlist track",
|
|
"playlistId", playlistID,
|
|
"trackId", id,
|
|
"err", err,
|
|
)
|
|
|
|
return fmt.Errorf(
|
|
"failed to remove track %d from playlist: %w",
|
|
id,
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
|
|
s.logger.Info(
|
|
"Tracks removed from playlist",
|
|
"playlistId", playlistID,
|
|
"count", len(trackIDs),
|
|
)
|
|
|
|
s.savePlaylistFileByID(playlistID)
|
|
s.emitEvent(events.PlaylistTracksChanged, playlistID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeletePlaylist deletes a playlist and its M3U8 file.
|
|
// If the deleted playlist was the default, a new default
|
|
// playlist is automatically created.
|
|
func (s *Service) DeletePlaylist(playlistID int64) error {
|
|
if err := s.db.Queries.DeletePlaylist(
|
|
s.db.Ctx, playlistID,
|
|
); err != nil {
|
|
s.logger.Error(
|
|
"Failed to delete playlist",
|
|
"playlistId", playlistID,
|
|
"err", err,
|
|
)
|
|
|
|
return fmt.Errorf(
|
|
"failed to delete playlist: %w", err,
|
|
)
|
|
}
|
|
|
|
s.deletePlaylistFile(playlistID)
|
|
|
|
s.logger.Info(
|
|
"Playlist deleted", "playlistId", playlistID,
|
|
)
|
|
|
|
s.emitEvent(events.PlaylistDeleted, playlistID)
|
|
|
|
// Recreate the default playlist if we just deleted it.
|
|
if s.defaultPlaylistID() == playlistID {
|
|
s.EnsureDefaultPlaylist()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RenamePlaylist renames a playlist and updates its M3U8 file.
|
|
func (s *Service) RenamePlaylist(
|
|
playlistID int64,
|
|
newName string,
|
|
) error {
|
|
trimmed := strings.TrimSpace(newName)
|
|
if trimmed == "" {
|
|
return errEmptyName
|
|
}
|
|
|
|
if err := s.db.Queries.UpdatePlaylistName(
|
|
s.db.Ctx,
|
|
sqlcgen.UpdatePlaylistNameParams{
|
|
Name: trimmed,
|
|
ID: playlistID,
|
|
},
|
|
); err != nil {
|
|
s.logger.Error(
|
|
"Failed to rename playlist",
|
|
"playlistId", playlistID,
|
|
"newName", trimmed,
|
|
"err", err,
|
|
)
|
|
|
|
return fmt.Errorf(
|
|
"failed to rename playlist: %w", err,
|
|
)
|
|
}
|
|
|
|
// Re-save the M3U8 file with the new name (handles rename
|
|
// of the file on disk).
|
|
s.savePlaylistFile(playlistID, trimmed)
|
|
|
|
s.logger.Info(
|
|
"Playlist renamed",
|
|
"playlistId", playlistID,
|
|
"newName", trimmed,
|
|
)
|
|
|
|
s.emitEvent(events.PlaylistRenamed, Summary{
|
|
ID: playlistID,
|
|
Name: trimmed,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// uniquePlaylistName returns a name that doesn't collide with existing
|
|
// playlists. If "Chill Vibes" exists, returns "Chill Vibes (1)".
|
|
// If that also exists, returns "Chill Vibes (2)", etc.
|
|
func (s *Service) uniquePlaylistName(name string) string {
|
|
count, err := s.db.Queries.CountPlaylistsByName(s.db.Ctx, name)
|
|
if err != nil || count == 0 {
|
|
return name
|
|
}
|
|
|
|
for i := 1; ; i++ {
|
|
candidate := fmt.Sprintf("%s (%d)", name, i)
|
|
|
|
c, err := s.db.Queries.CountPlaylistsByName(s.db.Ctx, candidate)
|
|
if err != nil || c == 0 {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
|
|
// ImportPlaylist imports a playlist from an external M3U/M3U8
|
|
// file. It creates a new playlist in the DB, resolves tracks
|
|
// against the library, and saves an M3U8 file.
|
|
func (s *Service) ImportPlaylist(
|
|
filePath string,
|
|
) (Summary, error) {
|
|
if strings.TrimSpace(filePath) == "" {
|
|
return Summary{}, errEmptyFilePath
|
|
}
|
|
|
|
ext := filepath.Ext(filePath)
|
|
if !isValidM3UExtension(ext) {
|
|
return Summary{}, fmt.Errorf(
|
|
"%w: %q, expected .m3u or .m3u8",
|
|
errUnsupportedFileType, ext,
|
|
)
|
|
}
|
|
|
|
parsed, err := parseM3U8(filePath)
|
|
if err != nil {
|
|
return Summary{}, fmt.Errorf(
|
|
"could not parse playlist file: %w", err,
|
|
)
|
|
}
|
|
|
|
playlistName := parsed.Name
|
|
if playlistName == "" {
|
|
base := filepath.Base(filePath)
|
|
playlistName = strings.TrimSuffix(
|
|
base, filepath.Ext(base),
|
|
)
|
|
}
|
|
|
|
playlistName = s.uniquePlaylistName(playlistName)
|
|
|
|
// Create playlist in DB.
|
|
created, err := s.db.Queries.CreatePlaylist(
|
|
s.db.Ctx, playlistName,
|
|
)
|
|
if err != nil {
|
|
return Summary{}, fmt.Errorf(
|
|
"could not create playlist for import: %w", err,
|
|
)
|
|
}
|
|
|
|
libraryRoots := s.getAllLibraryRoots()
|
|
|
|
var (
|
|
resolved int
|
|
unresolved int
|
|
position int
|
|
)
|
|
|
|
for _, entry := range parsed.Entries {
|
|
var audioFile sqlcgen.AudioFile
|
|
|
|
found := false
|
|
|
|
if filepath.IsAbs(entry.RelativePath) {
|
|
af, err := s.db.Queries.GetAudioFileByPath(
|
|
s.db.Ctx, entry.RelativePath,
|
|
)
|
|
if err == nil {
|
|
audioFile = af
|
|
found = true
|
|
}
|
|
} else {
|
|
for _, root := range libraryRoots {
|
|
absPath := toAbsolutePath(
|
|
entry.RelativePath, root,
|
|
)
|
|
|
|
af, err := s.db.Queries.GetAudioFileByPath(
|
|
s.db.Ctx, absPath,
|
|
)
|
|
if err == nil {
|
|
audioFile = af
|
|
found = true
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
// Track not in library — will appear as phantom.
|
|
unresolved++
|
|
|
|
continue
|
|
}
|
|
|
|
_, addErr := s.db.Queries.AddPlaylistTrack(
|
|
s.db.Ctx,
|
|
sqlcgen.AddPlaylistTrackParams{
|
|
PlaylistID: created.ID,
|
|
AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true},
|
|
Position: int64(position),
|
|
},
|
|
)
|
|
if addErr != nil {
|
|
s.logger.Warn(
|
|
"Could not add imported track",
|
|
"playlistId", created.ID,
|
|
"path", audioFile.FilePath,
|
|
"err", addErr,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
position++
|
|
resolved++
|
|
}
|
|
|
|
// Save the M3U8 file with entries (preserves unresolved
|
|
// paths for phantom display).
|
|
s.saveImportedPlaylistFile(
|
|
created.ID, playlistName, parsed.Entries, libraryRoots,
|
|
)
|
|
|
|
s.logger.Info(
|
|
"Playlist imported",
|
|
"id", created.ID,
|
|
"name", playlistName,
|
|
"resolved", resolved,
|
|
"unresolved", unresolved,
|
|
)
|
|
|
|
summary := Summary{
|
|
ID: created.ID,
|
|
Name: playlistName,
|
|
CreatedAt: created.CreatedAt.Format(time.RFC3339),
|
|
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
|
|
IsSmart: created.IsSmart != 0,
|
|
}
|
|
|
|
s.emitEvent(events.PlaylistCreated, summary)
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// ImportPlaylists imports multiple playlists from external M3U/M3U8
|
|
// files. Each file is imported sequentially using ImportPlaylist.
|
|
// Errors from individual imports are collected; partial success is
|
|
// possible. Returns the summaries of successfully imported playlists
|
|
// and the first error encountered (if any).
|
|
func (s *Service) ImportPlaylists(
|
|
filePaths []string,
|
|
) ([]Summary, error) {
|
|
if len(filePaths) == 0 {
|
|
return nil, errNoFilePaths
|
|
}
|
|
|
|
summaries := make([]Summary, 0, len(filePaths))
|
|
|
|
var firstErr error
|
|
|
|
for _, fp := range filePaths {
|
|
summary, err := s.ImportPlaylist(fp)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Failed to import playlist file",
|
|
"path", fp,
|
|
"err", err,
|
|
)
|
|
|
|
if firstErr == nil {
|
|
firstErr = fmt.Errorf(
|
|
"import %q failed: %w", fp, err,
|
|
)
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
summaries = append(summaries, summary)
|
|
}
|
|
|
|
return summaries, firstErr
|
|
}
|
|
|
|
// RestoreAllPlaylists restores playlist tracks from M3U8 files.
|
|
// This is called after a full library rescan to repopulate
|
|
// playlist_tracks from the surviving M3U8 files.
|
|
func (s *Service) RestoreAllPlaylists() {
|
|
dir, err := s.playlistsDir()
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Could not get playlists dir for restore",
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
files, err := listPlaylistFiles(dir)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Could not list playlist files",
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if len(files) == 0 {
|
|
return
|
|
}
|
|
|
|
libraryRoots := s.getAllLibraryRoots()
|
|
|
|
var totalRestored, totalUnresolved int
|
|
|
|
for _, file := range files {
|
|
playlistID := extractPlaylistID(file)
|
|
if playlistID == 0 {
|
|
s.logger.Warn(
|
|
"Could not extract playlist ID from filename",
|
|
"file", file,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
restored, unresolved := s.restoreSinglePlaylist(
|
|
playlistID, file, libraryRoots,
|
|
)
|
|
|
|
totalRestored += restored
|
|
totalUnresolved += unresolved
|
|
}
|
|
|
|
s.logger.Info(
|
|
"All playlists restored from M3U8 files",
|
|
"totalRestored", totalRestored,
|
|
"totalUnresolved", totalUnresolved,
|
|
)
|
|
|
|
s.emitEvent(events.PlaylistsRestored, nil)
|
|
}
|
|
|
|
// restoreSinglePlaylist restores tracks for a single playlist
|
|
// from its M3U8 file. Each M3U8 entry is resolved against all
|
|
// library roots.
|
|
func (s *Service) restoreSinglePlaylist(
|
|
playlistID int64,
|
|
m3uPath string,
|
|
libraryRoots []string,
|
|
) (restored, unresolved int) {
|
|
parsed, err := parseM3U8(m3uPath)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Could not parse M3U8 for restore",
|
|
"playlistId", playlistID,
|
|
"path", m3uPath,
|
|
"err", err,
|
|
)
|
|
|
|
return 0, 0
|
|
}
|
|
|
|
// Verify the playlist exists in the DB.
|
|
_, err = s.db.Queries.GetPlaylist(
|
|
s.db.Ctx, playlistID,
|
|
)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Playlist not found in DB during restore",
|
|
"playlistId", playlistID,
|
|
"err", err,
|
|
)
|
|
|
|
return 0, 0
|
|
}
|
|
|
|
var position int
|
|
|
|
for _, entry := range parsed.Entries {
|
|
var audioFile sqlcgen.AudioFile
|
|
|
|
found := false
|
|
|
|
if filepath.IsAbs(entry.RelativePath) {
|
|
af, lookupErr := s.db.Queries.GetAudioFileByPath(
|
|
s.db.Ctx, entry.RelativePath,
|
|
)
|
|
if lookupErr == nil {
|
|
audioFile = af
|
|
found = true
|
|
}
|
|
} else {
|
|
for _, root := range libraryRoots {
|
|
absPath := toAbsolutePath(
|
|
entry.RelativePath, root,
|
|
)
|
|
|
|
af, lookupErr := s.db.Queries.GetAudioFileByPath(
|
|
s.db.Ctx, absPath,
|
|
)
|
|
if lookupErr == nil {
|
|
audioFile = af
|
|
found = true
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
unresolved++
|
|
|
|
continue
|
|
}
|
|
|
|
_, addErr := s.db.Queries.AddPlaylistTrack(
|
|
s.db.Ctx,
|
|
sqlcgen.AddPlaylistTrackParams{
|
|
PlaylistID: playlistID,
|
|
AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true},
|
|
Position: int64(position),
|
|
},
|
|
)
|
|
if addErr != nil {
|
|
s.logger.Warn(
|
|
"Could not restore track",
|
|
"playlistId", playlistID,
|
|
"path", audioFile.FilePath,
|
|
"err", addErr,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
position++
|
|
restored++
|
|
}
|
|
|
|
s.logger.Info(
|
|
"Playlist restored",
|
|
"playlistId", playlistID,
|
|
"restored", restored,
|
|
"unresolved", unresolved,
|
|
)
|
|
|
|
return restored, unresolved
|
|
}
|
|
|
|
// addSingleTrack looks up the audio file by path and inserts it
|
|
// into the playlist.
|
|
func (s *Service) addSingleTrack(
|
|
playlistID int64,
|
|
filePath string,
|
|
position int64,
|
|
) error {
|
|
if strings.TrimSpace(filePath) == "" {
|
|
return errEmptyFilePath
|
|
}
|
|
|
|
audioFile, err := s.db.Queries.GetAudioFileByPath(
|
|
s.db.Ctx, filePath,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error(
|
|
"Failed to find audio file",
|
|
"filePath", filePath,
|
|
"err", err,
|
|
)
|
|
|
|
return fmt.Errorf(
|
|
"failed to find audio file %q: %w",
|
|
filePath, err,
|
|
)
|
|
}
|
|
|
|
_, err = s.db.Queries.AddPlaylistTrack(
|
|
s.db.Ctx,
|
|
sqlcgen.AddPlaylistTrackParams{
|
|
PlaylistID: playlistID,
|
|
AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true},
|
|
Position: position,
|
|
},
|
|
)
|
|
if err != nil {
|
|
s.logger.Error(
|
|
"Failed to add track to playlist",
|
|
"playlistId", playlistID,
|
|
"audioFileId", audioFile.ID,
|
|
"err", err,
|
|
)
|
|
|
|
return fmt.Errorf(
|
|
"failed to add track to playlist: %w", err,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// --- M3U8 file management helpers ---
|
|
|
|
// playlistsDir returns the path to the playlists directory,
|
|
// creating it if needed.
|
|
func (s *Service) playlistsDir() (string, error) {
|
|
dataDir := s.dataDirOverride
|
|
|
|
if dataDir == "" {
|
|
var err error
|
|
|
|
dataDir, err = system.GetUserDataDirPath()
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"could not get user data directory: %w", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
dir := filepath.Join(dataDir, playlistsDirName)
|
|
|
|
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
|
return "", fmt.Errorf(
|
|
"could not create playlists directory: %w", err,
|
|
)
|
|
}
|
|
|
|
return dir, nil
|
|
}
|
|
|
|
// getAllLibraryRoots returns the root directory paths of all
|
|
// configured libraries. It first checks the legacy config
|
|
// DirectoryPath; if that is set, it returns a single-element
|
|
// slice. Otherwise it queries all libraries from the database.
|
|
func (s *Service) getAllLibraryRoots() []string {
|
|
// Legacy config fallback.
|
|
if s.libraryDir != nil {
|
|
if dir := s.libraryDir.GetLibraryDirectory(); dir != "" {
|
|
return []string{dir}
|
|
}
|
|
}
|
|
|
|
libs, err := s.db.Queries.GetAllLibraries(s.db.Ctx)
|
|
if err != nil || len(libs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
roots := make([]string, len(libs))
|
|
for i, lib := range libs {
|
|
roots[i] = lib.Path
|
|
}
|
|
|
|
return roots
|
|
}
|
|
|
|
// savePlaylistFile saves the current state of a playlist to its
|
|
// M3U8 file.
|
|
func (s *Service) savePlaylistFile(
|
|
playlistID int64,
|
|
name string,
|
|
) {
|
|
dir, err := s.playlistsDir()
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Could not get playlists dir for save",
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
entries := s.buildM3UEntries(playlistID)
|
|
|
|
if err := writeM3U8(
|
|
dir, playlistID, name, entries,
|
|
); err != nil {
|
|
s.logger.Warn(
|
|
"Could not save playlist M3U8 file",
|
|
"playlistId", playlistID,
|
|
"err", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// savePlaylistFileByID looks up the playlist name and saves.
|
|
func (s *Service) savePlaylistFileByID(playlistID int64) {
|
|
playlist, err := s.db.Queries.GetPlaylist(
|
|
s.db.Ctx, playlistID,
|
|
)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Could not get playlist for save",
|
|
"playlistId", playlistID,
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
s.savePlaylistFile(playlistID, playlist.Name)
|
|
}
|
|
|
|
// saveImportedPlaylistFile saves an M3U8 file for an imported
|
|
// playlist, preserving the original entries (including
|
|
// unresolved paths).
|
|
func (s *Service) saveImportedPlaylistFile(
|
|
playlistID int64,
|
|
name string,
|
|
entries []m3uEntry,
|
|
libraryRoots []string,
|
|
) {
|
|
dir, err := s.playlistsDir()
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Could not get playlists dir for import save",
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Convert any absolute paths in entries to relative
|
|
// using the correct library root for each track.
|
|
converted := make([]m3uEntry, len(entries))
|
|
|
|
for i, entry := range entries {
|
|
// Resolve against all roots to get the absolute path,
|
|
// then convert back to relative using the matching root.
|
|
absPath := resolveM3UPath(
|
|
entry.RelativePath,
|
|
libraryRoots,
|
|
nil,
|
|
)
|
|
|
|
converted[i] = m3uEntry{
|
|
RelativePath: toRelativePathMultiRoot(
|
|
absPath, libraryRoots,
|
|
),
|
|
DurationSec: entry.DurationSec,
|
|
DisplayTitle: entry.DisplayTitle,
|
|
}
|
|
}
|
|
|
|
if err := writeM3U8(
|
|
dir, playlistID, name, converted,
|
|
); err != nil {
|
|
s.logger.Warn(
|
|
"Could not save imported playlist M3U8 file",
|
|
"playlistId", playlistID,
|
|
"err", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// buildM3UEntries builds M3U entries from the current DB state
|
|
// of a playlist.
|
|
func (s *Service) buildM3UEntries(
|
|
playlistID int64,
|
|
) []m3uEntry {
|
|
rows, err := s.db.ReadQueries.GetPlaylistTracksWithMetadata(
|
|
s.db.Ctx,
|
|
playlistID,
|
|
)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Could not get tracks for M3U build",
|
|
"playlistId", playlistID,
|
|
"err", err,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
libraryRoots := s.getAllLibraryRoots()
|
|
entries := make([]m3uEntry, 0, len(rows))
|
|
|
|
for _, row := range rows {
|
|
durationSec := int(
|
|
row.LengthMilliseconds / 1000,
|
|
)
|
|
|
|
entries = append(entries, m3uEntry{
|
|
RelativePath: toRelativePathMultiRoot(
|
|
row.FilePath, libraryRoots,
|
|
),
|
|
DurationSec: durationSec,
|
|
DisplayTitle: displayTitle(
|
|
row.Artist, row.Title,
|
|
),
|
|
})
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
// deletePlaylistFile removes the M3U8 file for a playlist.
|
|
func (s *Service) deletePlaylistFile(playlistID int64) {
|
|
dir, err := s.playlistsDir()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
existing, err := findPlaylistFile(dir, playlistID)
|
|
if err != nil || existing == "" {
|
|
return
|
|
}
|
|
|
|
if err := os.Remove(existing); err != nil &&
|
|
!os.IsNotExist(err) {
|
|
s.logger.Warn(
|
|
"Could not delete playlist file",
|
|
"playlistId", playlistID,
|
|
"path", existing,
|
|
"err", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// emitEvent emits a Wails event if the context is available.
|
|
func (s *Service) emitEvent(
|
|
eventName string,
|
|
data any,
|
|
) {
|
|
events.Emit(s.ctx, eventName, data)
|
|
}
|
|
|
|
// migrateExistingPlaylists generates M3U8 files for any
|
|
// existing DB playlists that don't already have one. This runs
|
|
// once at startup to bootstrap the file-based backup for users
|
|
// who already have playlists.
|
|
func (s *Service) migrateExistingPlaylists() {
|
|
dir, err := s.playlistsDir()
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Could not get playlists dir for migration",
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
existingFiles, err := listPlaylistFiles(dir)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Could not list existing playlist files",
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Build a set of IDs that already have files.
|
|
existingIDs := make(map[int64]struct{})
|
|
|
|
for _, file := range existingFiles {
|
|
id := extractPlaylistID(file)
|
|
if id > 0 {
|
|
existingIDs[id] = struct{}{}
|
|
}
|
|
}
|
|
|
|
playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Could not get playlists for migration",
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
var migrated int
|
|
|
|
for _, p := range playlists {
|
|
if _, exists := existingIDs[p.ID]; exists {
|
|
continue
|
|
}
|
|
|
|
s.savePlaylistFile(p.ID, p.Name)
|
|
|
|
migrated++
|
|
}
|
|
|
|
if migrated > 0 {
|
|
s.logger.Info(
|
|
"Migrated existing playlists to M3U8 files",
|
|
"count", migrated,
|
|
)
|
|
}
|
|
}
|
|
|
|
// =================================================================
|
|
// Playlist repopulation from M3U8
|
|
// =================================================================
|
|
|
|
// RepopulateFromM3U re-imports tracks for playlists that have zero
|
|
// playlist_tracks rows but still have a corresponding M3U8 file.
|
|
// This recovers from a FullRescan that deleted playlist tracks
|
|
// before the ON DELETE SET NULL fix was in place. Each M3U8 entry
|
|
// is resolved against the audio_files table; unresolved entries
|
|
// become phantom tracks with metadata preserved from the M3U8.
|
|
func (s *Service) RepopulateFromM3U() {
|
|
dir, err := s.playlistsDir()
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"could not get playlists dir for repopulation",
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Get all playlists.
|
|
playlists, err := s.db.ReadQueries.GetAllPlaylists(s.db.Ctx)
|
|
if err != nil {
|
|
s.logger.Warn("could not get playlists for repopulation", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
libraryRoots := s.getAllLibraryRoots()
|
|
|
|
// Build audio file path→ID map for resolution.
|
|
afRows, err := s.db.QueryContext(
|
|
`SELECT id, file_path FROM audio_files`,
|
|
)
|
|
if err != nil {
|
|
s.logger.Warn("could not query audio files for repopulation", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
audioFileByPath := make(map[string]int64)
|
|
|
|
for afRows.Next() {
|
|
var (
|
|
id int64
|
|
fp string
|
|
)
|
|
|
|
if err := afRows.Scan(&id, &fp); err != nil {
|
|
continue
|
|
}
|
|
|
|
audioFileByPath[fp] = id
|
|
}
|
|
|
|
_ = afRows.Close()
|
|
|
|
knownPaths := make(map[string]struct{}, len(audioFileByPath))
|
|
for k := range audioFileByPath {
|
|
knownPaths[k] = struct{}{}
|
|
}
|
|
|
|
var totalRepopulated int
|
|
|
|
for _, pl := range playlists {
|
|
// Only repopulate playlists with zero tracks.
|
|
countRows, err := s.db.QueryContext(
|
|
`SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = ?`,
|
|
pl.ID,
|
|
)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var count int
|
|
if countRows.Next() {
|
|
_ = countRows.Scan(&count)
|
|
}
|
|
|
|
_ = countRows.Close()
|
|
|
|
if count > 0 {
|
|
continue
|
|
}
|
|
|
|
m3uPath, err := findPlaylistFile(dir, pl.ID)
|
|
if err != nil || m3uPath == "" {
|
|
continue
|
|
}
|
|
|
|
parsed, err := parseM3U8(m3uPath)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"could not parse M3U8 for repopulation",
|
|
"playlistId", pl.ID,
|
|
"path", m3uPath,
|
|
"err", err,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
var resolved, phantom int
|
|
|
|
for i, entry := range parsed.Entries {
|
|
absPath := resolveM3UPath(
|
|
entry.RelativePath, libraryRoots, knownPaths,
|
|
)
|
|
|
|
audioFileID, exists := audioFileByPath[absPath]
|
|
|
|
if exists {
|
|
// Linked track.
|
|
_, addErr := s.db.ExecContext(
|
|
`INSERT INTO playlist_tracks
|
|
(playlist_id, audio_file_id, position)
|
|
VALUES (?, ?, ?)`,
|
|
pl.ID, audioFileID, i,
|
|
)
|
|
if addErr != nil {
|
|
s.logger.Warn(
|
|
"could not add repopulated track",
|
|
"playlistId", pl.ID,
|
|
"position", i,
|
|
"err", addErr,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
resolved++
|
|
} else {
|
|
// Phantom track — preserve what we have from M3U8.
|
|
_, addErr := s.db.ExecContext(
|
|
`INSERT INTO playlist_tracks
|
|
(playlist_id, position, phantom_title, phantom_file_path)
|
|
VALUES (?, ?, ?, ?)`,
|
|
pl.ID, i, entry.DisplayTitle, absPath,
|
|
)
|
|
if addErr != nil {
|
|
s.logger.Warn(
|
|
"could not add phantom repopulated track",
|
|
"playlistId", pl.ID,
|
|
"position", i,
|
|
"err", addErr,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
phantom++
|
|
}
|
|
}
|
|
|
|
if resolved+phantom > 0 {
|
|
totalRepopulated += resolved + phantom
|
|
s.logger.Info(
|
|
"repopulated playlist from M3U8",
|
|
"playlistId", pl.ID,
|
|
"name", pl.Name,
|
|
"resolved", resolved,
|
|
"phantom", phantom,
|
|
)
|
|
}
|
|
}
|
|
|
|
if totalRepopulated > 0 {
|
|
s.logger.Info(
|
|
"playlist repopulation complete",
|
|
"totalTracks", totalRepopulated,
|
|
)
|
|
s.emitEvent(events.PlaylistTracksChanged, nil)
|
|
}
|
|
}
|
|
|
|
// =================================================================
|
|
// Phantom track resolution
|
|
// =================================================================
|
|
|
|
// ResolvePhantomTracksAfterScan re-links phantom playlist tracks
|
|
// whose files now exist in the library. It iterates each playlist
|
|
// that has phantoms, reads its M3U8 file, resolves each entry
|
|
// against the current audio_files table using multi-root path
|
|
// resolution, and updates matching phantom playlist_tracks. This
|
|
// handles both pre-existing phantoms (created before migration 7,
|
|
// with NULL phantom_file_path) and new ones.
|
|
func (s *Service) ResolvePhantomTracksAfterScan() {
|
|
// 1. Get distinct playlist IDs that have phantom tracks.
|
|
// SAFETY: Hand-crafted SELECT for phantom playlist IDs.
|
|
// No user input — reads only system state.
|
|
rows, err := s.db.QueryContext(
|
|
`SELECT DISTINCT playlist_id
|
|
FROM playlist_tracks
|
|
WHERE audio_file_id IS NULL`,
|
|
)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"could not query phantom playlists",
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
var phantomPlaylistIDs []int64
|
|
|
|
for rows.Next() {
|
|
var id int64
|
|
if err := rows.Scan(&id); err != nil {
|
|
s.logger.Warn(
|
|
"could not scan phantom playlist ID",
|
|
"err", err,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
phantomPlaylistIDs = append(phantomPlaylistIDs, id)
|
|
}
|
|
|
|
if err := rows.Close(); err != nil {
|
|
s.logger.Warn(
|
|
"could not close phantom playlist rows",
|
|
"err", err,
|
|
)
|
|
}
|
|
|
|
if len(phantomPlaylistIDs) == 0 {
|
|
return
|
|
}
|
|
|
|
// 2. Build audio file path→ID map for resolution.
|
|
// SAFETY: Hand-crafted SELECT for full audio file path map.
|
|
// No user input — reads only system state.
|
|
afRows, err := s.db.QueryContext(
|
|
`SELECT id, file_path FROM audio_files`,
|
|
)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"could not query audio files for phantom resolution",
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
audioFileByPath := make(map[string]int64)
|
|
|
|
for afRows.Next() {
|
|
var id int64
|
|
|
|
var fp string
|
|
if err := afRows.Scan(&id, &fp); err != nil {
|
|
continue
|
|
}
|
|
|
|
audioFileByPath[fp] = id
|
|
}
|
|
|
|
if err := afRows.Close(); err != nil {
|
|
s.logger.Warn(
|
|
"could not close audio file rows",
|
|
"err", err,
|
|
)
|
|
}
|
|
|
|
if len(audioFileByPath) == 0 {
|
|
return
|
|
}
|
|
|
|
// 3. Build knownPaths set for resolveM3UPath.
|
|
knownPaths := make(
|
|
map[string]struct{}, len(audioFileByPath),
|
|
)
|
|
for k := range audioFileByPath {
|
|
knownPaths[k] = struct{}{}
|
|
}
|
|
|
|
libraryRoots := s.getAllLibraryRoots()
|
|
|
|
dir, err := s.playlistsDir()
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"could not get playlists dir for phantom resolution",
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
var totalResolved int
|
|
|
|
// 4. For each playlist with phantoms, resolve via M3U8.
|
|
for _, playlistID := range phantomPlaylistIDs {
|
|
resolved := s.resolvePlaylistPhantoms(
|
|
playlistID, dir, libraryRoots,
|
|
knownPaths, audioFileByPath,
|
|
)
|
|
totalResolved += resolved
|
|
}
|
|
|
|
if totalResolved > 0 {
|
|
s.logger.Info(
|
|
"resolved phantom playlist tracks after scan",
|
|
"count", totalResolved,
|
|
)
|
|
|
|
s.emitEvent(events.PlaylistTracksChanged, nil)
|
|
}
|
|
}
|
|
|
|
// phantomTrackRow holds the minimal fields needed to match a
|
|
// phantom playlist_track against an M3U8 entry.
|
|
type phantomTrackRow struct {
|
|
id int64
|
|
position int64
|
|
phantomFilePath string
|
|
}
|
|
|
|
// phantomRowSet is a playlist's unresolved rows plus the ones a
|
|
// resolution pass has already consumed, so two matches cannot land on
|
|
// the same row.
|
|
type phantomRowSet struct {
|
|
rows []phantomTrackRow
|
|
taken map[int64]struct{}
|
|
}
|
|
|
|
// loadPhantomRows reads the playlist's phantom rows — the tracks whose
|
|
// file was missing when the M3U8 was imported.
|
|
func (s *Service) loadPhantomRows(playlistID int64) *phantomRowSet {
|
|
set := &phantomRowSet{taken: map[int64]struct{}{}}
|
|
|
|
// SAFETY: Hand-crafted SELECT for phantom tracks with position and
|
|
// phantom_file_path. Parameterized by playlist ID.
|
|
rows, err := s.db.QueryContext(
|
|
`SELECT id, position, COALESCE(phantom_file_path, '')
|
|
FROM playlist_tracks
|
|
WHERE playlist_id = ? AND audio_file_id IS NULL`,
|
|
playlistID,
|
|
)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"could not query phantom tracks",
|
|
"playlistId", playlistID,
|
|
"err", err,
|
|
)
|
|
|
|
return set
|
|
}
|
|
|
|
defer func() {
|
|
if closeErr := rows.Close(); closeErr != nil {
|
|
s.logger.Warn(
|
|
"could not close phantom track rows",
|
|
"err", closeErr,
|
|
)
|
|
}
|
|
}()
|
|
|
|
for rows.Next() {
|
|
var pt phantomTrackRow
|
|
if err := rows.Scan(
|
|
&pt.id, &pt.position, &pt.phantomFilePath,
|
|
); err != nil {
|
|
continue
|
|
}
|
|
|
|
set.rows = append(set.rows, pt)
|
|
}
|
|
|
|
return set
|
|
}
|
|
|
|
// takePhantomRow finds the phantom row standing for phantomAbs and
|
|
// marks it consumed. It matches on phantom_file_path first and falls
|
|
// back to the row sitting at that entry's index in the M3U8, which is
|
|
// the same two-step resolvePlaylistPhantoms uses: rows imported before
|
|
// migration 7 carry no phantom_file_path at all.
|
|
func takePhantomRow(
|
|
set *phantomRowSet,
|
|
phantomAbs string,
|
|
entries []m3uEntry,
|
|
libraryRoots []string,
|
|
) (int64, bool) {
|
|
_, idx := findM3UEntry(entries, phantomAbs, libraryRoots)
|
|
|
|
return takePhantomRowAt(set, phantomAbs, idx)
|
|
}
|
|
|
|
// takePhantomRowAt is takePhantomRow for a caller that already knows
|
|
// the entry's index. A negative index means "no positional fallback":
|
|
// positions are non-negative, so it simply never matches.
|
|
func takePhantomRowAt(
|
|
set *phantomRowSet, phantomAbs string, index int,
|
|
) (int64, bool) {
|
|
for _, pt := range set.rows {
|
|
if _, done := set.taken[pt.id]; done {
|
|
continue
|
|
}
|
|
|
|
if pt.phantomFilePath != "" &&
|
|
pt.phantomFilePath == phantomAbs {
|
|
set.taken[pt.id] = struct{}{}
|
|
|
|
return pt.id, true
|
|
}
|
|
}
|
|
|
|
for _, pt := range set.rows {
|
|
if _, done := set.taken[pt.id]; done {
|
|
continue
|
|
}
|
|
|
|
if pt.position == int64(index) {
|
|
set.taken[pt.id] = struct{}{}
|
|
|
|
return pt.id, true
|
|
}
|
|
}
|
|
|
|
return 0, false
|
|
}
|
|
|
|
// fillPhantomRow points a phantom playlist_tracks row at a real audio
|
|
// file and drops the placeholder metadata it was displaying. The row
|
|
// keeps its position, which is why resolving a phantom does not move
|
|
// the track to the end of the playlist.
|
|
func (s *Service) fillPhantomRow(
|
|
playlistTrackID, audioFileID int64,
|
|
) error {
|
|
// SAFETY: Hand-crafted UPDATE to resolve a phantom playlist track.
|
|
// Sets audio_file_id and clears all phantom metadata columns.
|
|
// Parameterized by ID.
|
|
if _, err := s.db.ExecContext(
|
|
`UPDATE playlist_tracks SET
|
|
audio_file_id = ?,
|
|
phantom_title = NULL,
|
|
phantom_artist = NULL,
|
|
phantom_album = NULL,
|
|
phantom_duration_ms = NULL,
|
|
phantom_genre = NULL,
|
|
phantom_cover_art_path = NULL,
|
|
phantom_file_path = NULL
|
|
WHERE id = ?`,
|
|
audioFileID, playlistTrackID,
|
|
); err != nil {
|
|
return fmt.Errorf(
|
|
"could not resolve phantom track %d: %w",
|
|
playlistTrackID, err,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// resolvePlaylistPhantoms resolves phantom tracks for a single
|
|
// playlist by reading its M3U8 file and matching entries against
|
|
// the audio_files table. Returns the number of resolved tracks.
|
|
func (s *Service) resolvePlaylistPhantoms(
|
|
playlistID int64,
|
|
dir string,
|
|
libraryRoots []string,
|
|
knownPaths map[string]struct{},
|
|
audioFileByPath map[string]int64,
|
|
) int {
|
|
m3uPath, err := findPlaylistFile(dir, playlistID)
|
|
if err != nil || m3uPath == "" {
|
|
return 0
|
|
}
|
|
|
|
parsed, err := parseM3U8(m3uPath)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"could not parse M3U8 for phantom resolution",
|
|
"playlistId", playlistID,
|
|
"path", m3uPath,
|
|
"err", err,
|
|
)
|
|
|
|
return 0
|
|
}
|
|
|
|
// Load phantom tracks for this playlist.
|
|
phantoms := s.loadPhantomRows(playlistID)
|
|
|
|
if len(phantoms.rows) == 0 {
|
|
return 0
|
|
}
|
|
|
|
var resolved int
|
|
|
|
// For each M3U8 entry, resolve its path and try to match
|
|
// a phantom track.
|
|
for i, entry := range parsed.Entries {
|
|
absPath := resolveM3UPath(
|
|
entry.RelativePath, libraryRoots, knownPaths,
|
|
)
|
|
|
|
audioFileID, exists := audioFileByPath[absPath]
|
|
if !exists {
|
|
continue
|
|
}
|
|
|
|
// Find the phantom that corresponds to this entry:
|
|
// phantom_file_path first, then this entry's index.
|
|
ptID, found := takePhantomRowAt(phantoms, absPath, i)
|
|
if !found {
|
|
continue
|
|
}
|
|
|
|
if err := s.fillPhantomRow(ptID, audioFileID); err != nil {
|
|
s.logger.Warn(
|
|
"could not resolve phantom track",
|
|
"playlistTrackId", ptID,
|
|
"audioFileId", audioFileID,
|
|
"err", err,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
resolved++
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
|
|
// FindPhantomMatches searches the library for matches for the
|
|
// given phantom file paths. High-confidence matches are returned
|
|
// as auto-matched pairs; the rest remain in the unmatched list.
|
|
func (s *Service) FindPhantomMatches(
|
|
playlistID int64,
|
|
phantomPaths []string,
|
|
) (PhantomSearchResult, error) {
|
|
if len(phantomPaths) == 0 {
|
|
return PhantomSearchResult{}, nil
|
|
}
|
|
|
|
dir, err := s.playlistsDir()
|
|
if err != nil {
|
|
return PhantomSearchResult{}, fmt.Errorf(
|
|
"could not get playlists dir: %w", err,
|
|
)
|
|
}
|
|
|
|
libraryRoots := s.getAllLibraryRoots()
|
|
|
|
// Load M3U8 entries for display title / duration data.
|
|
m3uPath, err := findPlaylistFile(dir, playlistID)
|
|
if err != nil {
|
|
return PhantomSearchResult{}, fmt.Errorf(
|
|
"could not find playlist file: %w", err,
|
|
)
|
|
}
|
|
|
|
var entries []m3uEntry
|
|
|
|
if m3uPath != "" {
|
|
parsed, parseErr := parseM3U8(m3uPath)
|
|
if parseErr == nil {
|
|
entries = parsed.Entries
|
|
}
|
|
}
|
|
|
|
// Build a lookup from absolute path to M3U entry.
|
|
// Use resolveM3UPath with a nil knownPaths to get the
|
|
// first-root fallback for each entry.
|
|
entryByPath := make(map[string]m3uEntry, len(entries))
|
|
|
|
for _, e := range entries {
|
|
absPath := resolveM3UPath(
|
|
e.RelativePath, libraryRoots, nil,
|
|
)
|
|
entryByPath[absPath] = e
|
|
}
|
|
|
|
// Every pairing confident enough to apply without asking.
|
|
var offers []phantomOffer
|
|
|
|
for _, phantomPath := range phantomPaths {
|
|
entry := entryByPath[phantomPath]
|
|
|
|
for _, c := range s.searchCandidates(phantomPath, entry) {
|
|
if c.Score < autoMatchMinimum {
|
|
// searchCandidates sorts by score, so nothing
|
|
// below this one qualifies either.
|
|
break
|
|
}
|
|
|
|
offers = append(offers, phantomOffer{
|
|
phantomPath: phantomPath,
|
|
phantomTitle: entry.DisplayTitle,
|
|
candidate: c,
|
|
})
|
|
}
|
|
}
|
|
|
|
var result PhantomSearchResult
|
|
|
|
result.AutoMatched = assignBestFirst(offers)
|
|
|
|
matched := make(map[string]struct{}, len(result.AutoMatched))
|
|
for _, m := range result.AutoMatched {
|
|
matched[m.PhantomPath] = struct{}{}
|
|
}
|
|
|
|
// Reported in the order the playlist has them, not the order they
|
|
// were matched in.
|
|
for _, phantomPath := range phantomPaths {
|
|
if _, done := matched[phantomPath]; done {
|
|
continue
|
|
}
|
|
|
|
result.Unmatched = append(
|
|
result.Unmatched, phantomPath,
|
|
)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// phantomOffer is one candidate a phantom track could be resolved to,
|
|
// carrying enough to become a PhantomMatch.
|
|
type phantomOffer struct {
|
|
phantomPath string
|
|
phantomTitle string
|
|
candidate CandidateTrack
|
|
}
|
|
|
|
// assignBestFirst pairs phantoms with candidates highest score first,
|
|
// at most one candidate per phantom and one phantom per candidate.
|
|
//
|
|
// A candidate can only stand in for one phantom — two would put that
|
|
// file in the playlist twice. Taking the offers in *phantom* order,
|
|
// which is what this replaced, let an early phantom claim a candidate
|
|
// that was a better answer for a later one, so which of two similar
|
|
// tracks got the good match depended on the order they happen to sit in
|
|
// the playlist.
|
|
func assignBestFirst(offers []phantomOffer) []PhantomMatch {
|
|
if len(offers) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ordered := make([]phantomOffer, len(offers))
|
|
copy(ordered, offers)
|
|
|
|
slices.SortStableFunc(
|
|
ordered,
|
|
func(a, b phantomOffer) int {
|
|
return cmp.Compare(b.candidate.Score, a.candidate.Score)
|
|
},
|
|
)
|
|
|
|
var matches []PhantomMatch
|
|
|
|
claimedCandidates := make(map[string]struct{}, len(ordered))
|
|
matchedPhantoms := make(map[string]struct{}, len(ordered))
|
|
|
|
for _, o := range ordered {
|
|
if _, taken := claimedCandidates[o.candidate.FilePath]; taken {
|
|
continue
|
|
}
|
|
|
|
if _, done := matchedPhantoms[o.phantomPath]; done {
|
|
continue
|
|
}
|
|
|
|
matches = append(matches, PhantomMatch{
|
|
PhantomPath: o.phantomPath,
|
|
PhantomTitle: o.phantomTitle,
|
|
Candidate: o.candidate,
|
|
})
|
|
|
|
claimedCandidates[o.candidate.FilePath] = struct{}{}
|
|
matchedPhantoms[o.phantomPath] = struct{}{}
|
|
}
|
|
|
|
return matches
|
|
}
|
|
|
|
// GetPhantomCandidates returns scored candidate matches for a
|
|
// single phantom track.
|
|
func (s *Service) GetPhantomCandidates(
|
|
playlistID int64,
|
|
phantomPath string,
|
|
) ([]CandidateTrack, error) {
|
|
dir, err := s.playlistsDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"could not get playlists dir: %w", err,
|
|
)
|
|
}
|
|
|
|
libraryRoots := s.getAllLibraryRoots()
|
|
|
|
// Find the M3U entry for this phantom.
|
|
m3uPath, err := findPlaylistFile(dir, playlistID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"could not find playlist file: %w", err,
|
|
)
|
|
}
|
|
|
|
var entry m3uEntry
|
|
|
|
if m3uPath != "" {
|
|
parsed, parseErr := parseM3U8(m3uPath)
|
|
if parseErr == nil {
|
|
entry, _ = findM3UEntry(
|
|
parsed.Entries, phantomPath, libraryRoots,
|
|
)
|
|
}
|
|
}
|
|
|
|
return s.searchCandidates(
|
|
phantomPath, entry,
|
|
), nil
|
|
}
|
|
|
|
// SearchLibrary searches the entire library by a free-text query
|
|
// for manual phantom resolution.
|
|
func (s *Service) SearchLibrary(
|
|
query string,
|
|
) ([]CandidateTrack, error) {
|
|
trimmed := strings.TrimSpace(query)
|
|
if trimmed == "" {
|
|
return []CandidateTrack{}, nil
|
|
}
|
|
|
|
rows, err := s.db.SearchFTS(
|
|
trimmed, maxLibrarySearchResults,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"library search failed: %w", err,
|
|
)
|
|
}
|
|
|
|
candidates := make([]CandidateTrack, 0, len(rows))
|
|
|
|
for _, row := range rows {
|
|
candidates = append(candidates, CandidateTrack{
|
|
FilePath: row.FilePath,
|
|
Title: row.Title,
|
|
Artist: row.Artist,
|
|
Album: row.Album,
|
|
Duration: strconv.FormatInt(
|
|
row.LengthMilliseconds, 10,
|
|
),
|
|
})
|
|
}
|
|
|
|
return candidates, nil
|
|
}
|
|
|
|
// ResolvePhantomTracks replaces phantom entries in a playlist
|
|
// with real library tracks. The matches map keys are phantom
|
|
// absolute paths and values are resolved absolute paths.
|
|
func (s *Service) ResolvePhantomTracks(
|
|
playlistID int64,
|
|
matches map[string]string,
|
|
) error {
|
|
if len(matches) == 0 {
|
|
return nil
|
|
}
|
|
|
|
dir, err := s.playlistsDir()
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"could not get playlists dir: %w", err,
|
|
)
|
|
}
|
|
|
|
libraryRoots := s.getAllLibraryRoots()
|
|
|
|
m3uPath, err := findPlaylistFile(dir, playlistID)
|
|
if err != nil || m3uPath == "" {
|
|
return fmt.Errorf(
|
|
"could not find M3U8 file for playlist %d: %w",
|
|
playlistID, err,
|
|
)
|
|
}
|
|
|
|
parsed, err := parseM3U8(m3uPath)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"could not parse M3U8: %w", err,
|
|
)
|
|
}
|
|
|
|
// Get next available DB position.
|
|
nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition(
|
|
s.db.Ctx, playlistID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"could not get next position: %w", err,
|
|
)
|
|
}
|
|
|
|
// The phantom rows this is about to fill in, so a resolution
|
|
// updates the row the user pointed at rather than appending a
|
|
// second one beside it.
|
|
phantoms := s.loadPhantomRows(playlistID)
|
|
|
|
// Build M3U path replacements and insert DB rows.
|
|
pathReplacements := make(
|
|
map[string]string, len(matches),
|
|
)
|
|
|
|
// Two phantoms resolving to one file would put that file in the
|
|
// playlist twice, which is what FindPhantomMatches' claimed set
|
|
// already refuses to do on the auto-match path.
|
|
claimed := make(map[string]struct{}, len(matches))
|
|
|
|
var (
|
|
resolved int
|
|
appended int
|
|
)
|
|
|
|
for phantomAbs, resolvedAbs := range matches {
|
|
if _, taken := claimed[resolvedAbs]; taken {
|
|
s.logger.Warn(
|
|
"Two phantom tracks resolved to the same file",
|
|
"playlistId", playlistID,
|
|
"phantomPath", phantomAbs,
|
|
"resolvedPath", resolvedAbs,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
audioFile, lookupErr := s.db.Queries.GetAudioFileByPath(
|
|
s.db.Ctx, resolvedAbs,
|
|
)
|
|
if lookupErr != nil {
|
|
s.logger.Warn(
|
|
"Resolved path not found in library",
|
|
"phantomPath", phantomAbs,
|
|
"resolvedPath", resolvedAbs,
|
|
"err", lookupErr,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
ptID, found := takePhantomRow(
|
|
phantoms, phantomAbs, parsed.Entries, libraryRoots,
|
|
)
|
|
|
|
switch {
|
|
case found:
|
|
if updateErr := s.fillPhantomRow(
|
|
ptID, audioFile.ID,
|
|
); updateErr != nil {
|
|
s.logger.Warn(
|
|
"Could not resolve phantom row",
|
|
"playlistId", playlistID,
|
|
"playlistTrackId", ptID,
|
|
"path", resolvedAbs,
|
|
"err", updateErr,
|
|
)
|
|
|
|
continue
|
|
}
|
|
default:
|
|
// No phantom row for this path — the M3U8 and the
|
|
// database disagree, so fall back to appending.
|
|
if _, addErr := s.db.Queries.AddPlaylistTrack(
|
|
s.db.Ctx,
|
|
sqlcgen.AddPlaylistTrackParams{
|
|
PlaylistID: playlistID,
|
|
AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true},
|
|
Position: nextPos + int64(appended),
|
|
},
|
|
); addErr != nil {
|
|
s.logger.Warn(
|
|
"Could not add resolved track",
|
|
"playlistId", playlistID,
|
|
"path", resolvedAbs,
|
|
"err", addErr,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
appended++
|
|
}
|
|
|
|
newRel := toRelativePathMultiRoot(
|
|
resolvedAbs, libraryRoots,
|
|
)
|
|
pathReplacements[phantomAbs] = newRel
|
|
claimed[resolvedAbs] = struct{}{}
|
|
resolved++
|
|
}
|
|
|
|
// Rewrite the M3U8 with updated paths.
|
|
if resolved > 0 {
|
|
updated := replaceM3UEntryPaths(
|
|
parsed.Entries, pathReplacements, libraryRoots,
|
|
)
|
|
|
|
playlist, nameErr := s.db.Queries.GetPlaylist(
|
|
s.db.Ctx, playlistID,
|
|
)
|
|
if nameErr != nil {
|
|
return fmt.Errorf(
|
|
"could not get playlist name: %w", nameErr,
|
|
)
|
|
}
|
|
|
|
if writeErr := writeM3U8(
|
|
dir, playlistID, playlist.Name, updated,
|
|
); writeErr != nil {
|
|
return fmt.Errorf(
|
|
"could not rewrite M3U8: %w", writeErr,
|
|
)
|
|
}
|
|
}
|
|
|
|
s.logger.Info(
|
|
"Phantom tracks resolved",
|
|
"playlistId", playlistID,
|
|
"resolved", resolved,
|
|
"requested", len(matches),
|
|
)
|
|
|
|
s.emitEvent(events.PlaylistTracksChanged, playlistID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemovePhantomTracks removes phantom entries from a playlist's
|
|
// M3U8 file. Since phantom tracks have no DB rows, only the
|
|
// M3U8 file is modified.
|
|
func (s *Service) RemovePhantomTracks(
|
|
playlistID int64,
|
|
phantomPaths []string,
|
|
) error {
|
|
if len(phantomPaths) == 0 {
|
|
return nil
|
|
}
|
|
|
|
dir, err := s.playlistsDir()
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"could not get playlists dir: %w", err,
|
|
)
|
|
}
|
|
|
|
libraryRoots := s.getAllLibraryRoots()
|
|
|
|
m3uPath, err := findPlaylistFile(dir, playlistID)
|
|
if err != nil || m3uPath == "" {
|
|
return fmt.Errorf(
|
|
"could not find M3U8 file for playlist %d: %w",
|
|
playlistID, err,
|
|
)
|
|
}
|
|
|
|
parsed, err := parseM3U8(m3uPath)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"could not parse M3U8: %w", err,
|
|
)
|
|
}
|
|
|
|
targetSet := make(
|
|
map[string]struct{}, len(phantomPaths),
|
|
)
|
|
|
|
for _, p := range phantomPaths {
|
|
targetSet[p] = struct{}{}
|
|
}
|
|
|
|
updated := removeM3UEntries(
|
|
parsed.Entries, targetSet, libraryRoots,
|
|
)
|
|
|
|
playlist, err := s.db.Queries.GetPlaylist(
|
|
s.db.Ctx, playlistID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"could not get playlist name: %w", err,
|
|
)
|
|
}
|
|
|
|
if err := writeM3U8(
|
|
dir, playlistID, playlist.Name, updated,
|
|
); err != nil {
|
|
return fmt.Errorf(
|
|
"could not rewrite M3U8: %w", err,
|
|
)
|
|
}
|
|
|
|
s.logger.Info(
|
|
"Phantom tracks removed",
|
|
"playlistId", playlistID,
|
|
"removed", len(phantomPaths),
|
|
)
|
|
|
|
s.emitEvent(events.PlaylistTracksChanged, playlistID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// searchCandidates finds and scores candidate library tracks
|
|
// for a single phantom track.
|
|
func (s *Service) searchCandidates(
|
|
phantomPath string,
|
|
entry m3uEntry,
|
|
) []CandidateTrack {
|
|
basename := filepath.Base(phantomPath)
|
|
seen := make(map[string]struct{})
|
|
|
|
var combined []database.SearchRow
|
|
|
|
// 1. Exact basename match via indexed column.
|
|
bnRows, err := s.db.Queries.SearchTracksByBasename(
|
|
s.db.Ctx,
|
|
sqlcgen.SearchTracksByBasenameParams{
|
|
Basename: basename,
|
|
Lim: int64(maxCandidates),
|
|
},
|
|
)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"Basename search failed",
|
|
"basename", basename,
|
|
"err", err,
|
|
)
|
|
}
|
|
|
|
for _, r := range bnRows {
|
|
if _, ok := seen[r.FilePath]; ok {
|
|
continue
|
|
}
|
|
|
|
seen[r.FilePath] = struct{}{}
|
|
|
|
combined = append(combined, database.SearchRow{
|
|
FilePath: r.FilePath,
|
|
LengthMilliseconds: r.LengthMilliseconds,
|
|
Title: r.Title,
|
|
Artist: r.ArtistName,
|
|
Album: r.Album,
|
|
})
|
|
}
|
|
|
|
// 2. FTS5 filename-token search for fuzzy basename
|
|
// matches (e.g. different extension).
|
|
ftsFileRows, err := s.db.SearchFTSByFilename(
|
|
basename, maxCandidates,
|
|
)
|
|
if err != nil {
|
|
s.logger.Warn(
|
|
"FTS filename search failed",
|
|
"basename", basename,
|
|
"err", err,
|
|
)
|
|
}
|
|
|
|
for _, r := range ftsFileRows {
|
|
if _, ok := seen[r.FilePath]; ok {
|
|
continue
|
|
}
|
|
|
|
seen[r.FilePath] = struct{}{}
|
|
|
|
combined = append(combined, r)
|
|
}
|
|
|
|
// 3. FTS5 keyword search from path + display title.
|
|
keywords := extractKeywords(phantomPath)
|
|
|
|
if entry.DisplayTitle != "" {
|
|
titleKeywords := extractKeywords(
|
|
entry.DisplayTitle,
|
|
)
|
|
keywords = append(keywords, titleKeywords...)
|
|
keywords = dedupStrings(keywords)
|
|
}
|
|
|
|
if len(keywords) > 0 {
|
|
kwQuery := strings.Join(keywords, " ")
|
|
|
|
kwRows, kwErr := s.db.SearchFTS(
|
|
kwQuery, maxCandidates,
|
|
)
|
|
if kwErr != nil {
|
|
s.logger.Warn(
|
|
"FTS keyword search failed",
|
|
"keywords", keywords,
|
|
"err", kwErr,
|
|
)
|
|
}
|
|
|
|
for _, r := range kwRows {
|
|
if _, ok := seen[r.FilePath]; ok {
|
|
continue
|
|
}
|
|
|
|
seen[r.FilePath] = struct{}{}
|
|
|
|
combined = append(combined, r)
|
|
}
|
|
}
|
|
|
|
// Score each candidate.
|
|
pp := newPhantomProfile(
|
|
phantomPath, entry.DisplayTitle,
|
|
entry.DurationSec,
|
|
)
|
|
|
|
candidates := make(
|
|
[]CandidateTrack, 0, len(combined),
|
|
)
|
|
|
|
for _, row := range combined {
|
|
score := scoreCandidate(
|
|
pp,
|
|
row.FilePath,
|
|
row.Title,
|
|
row.Artist,
|
|
row.LengthMilliseconds,
|
|
)
|
|
|
|
candidates = append(candidates, CandidateTrack{
|
|
FilePath: row.FilePath,
|
|
Title: row.Title,
|
|
Artist: row.Artist,
|
|
Album: row.Album,
|
|
Duration: strconv.FormatInt(
|
|
row.LengthMilliseconds, 10,
|
|
),
|
|
Score: score,
|
|
})
|
|
}
|
|
|
|
// Sort by score descending.
|
|
sortCandidatesByScore(candidates)
|
|
|
|
if len(candidates) > maxCandidates {
|
|
candidates = candidates[:maxCandidates]
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
// sortCandidatesByScore sorts candidates by score descending.
|
|
func sortCandidatesByScore(candidates []CandidateTrack) {
|
|
slices.SortFunc(
|
|
candidates,
|
|
func(a, b CandidateTrack) int {
|
|
if a.Score > b.Score {
|
|
return -1
|
|
}
|
|
|
|
if a.Score < b.Score {
|
|
return 1
|
|
}
|
|
|
|
return 0
|
|
},
|
|
)
|
|
}
|
|
|
|
// =================================================================
|
|
// Smart playlist methods
|
|
// =================================================================
|
|
|
|
// errNotSmartPlaylist is returned when an operation that requires
|
|
// a smart playlist is performed on a regular playlist or a
|
|
// non-existent playlist.
|
|
var errNotSmartPlaylist = errors.New(
|
|
"playlist not found or is not a smart playlist",
|
|
)
|
|
|
|
// errNoRowReturned is returned when an INSERT ... RETURNING
|
|
// query does not return the expected row.
|
|
var errNoRowReturned = errors.New(
|
|
"no row returned from insert",
|
|
)
|
|
|
|
// CreateSmartPlaylist creates a new smart playlist with the given
|
|
// name and JSON rule set. The rules are validated before storage.
|
|
func (s *Service) CreateSmartPlaylist(
|
|
name, rulesJSON string,
|
|
) (Summary, error) {
|
|
trimmed := strings.TrimSpace(name)
|
|
if trimmed == "" {
|
|
return Summary{}, errEmptyName
|
|
}
|
|
|
|
// Validate rules JSON before storing.
|
|
if _, err := smartplaylist.ParseRuleSet(rulesJSON); err != nil {
|
|
return Summary{}, fmt.Errorf(
|
|
"invalid smart playlist rules: %w", err,
|
|
)
|
|
}
|
|
|
|
var (
|
|
id int64
|
|
retName string
|
|
createdAt string
|
|
updatedAt string
|
|
)
|
|
|
|
// SAFETY: Hand-crafted INSERT for smart playlist with
|
|
// is_smart and smart_rules columns not yet in sqlc schema.
|
|
// All values are parameterized.
|
|
//
|
|
// QueryRowWriter, not QueryContext: this is an INSERT wearing a
|
|
// query's shape, and QueryContext routes to the query-only read
|
|
// pool. Through that handle it failed with "attempt to write a
|
|
// readonly database", i.e. no smart playlist could be created at
|
|
// all. A RETURNING clause does not make a write a read.
|
|
if err := s.db.QueryRowWriter(
|
|
`INSERT INTO playlists (name, is_smart, smart_rules)
|
|
VALUES (?, 1, ?)
|
|
RETURNING id, name, created_at, updated_at`,
|
|
trimmed, rulesJSON,
|
|
).Scan(&id, &retName, &createdAt, &updatedAt); err != nil {
|
|
s.logger.Error(
|
|
"Failed to create smart playlist",
|
|
"name", trimmed, "err", err,
|
|
)
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Summary{}, fmt.Errorf(
|
|
"failed to create smart playlist: %w",
|
|
errNoRowReturned,
|
|
)
|
|
}
|
|
|
|
return Summary{}, fmt.Errorf(
|
|
"failed to create smart playlist: %w", err,
|
|
)
|
|
}
|
|
|
|
s.logger.Info(
|
|
"Smart playlist created",
|
|
"id", id, "name", retName,
|
|
)
|
|
|
|
// Materialize the rule set once at creation so the playlist has a
|
|
// track snapshot immediately (track counts, instant open). A failed
|
|
// evaluation is non-fatal — the lazy path re-materializes on first
|
|
// open.
|
|
if err := s.RefreshSmartPlaylist(id); err != nil {
|
|
s.logger.Warn(
|
|
"Failed to materialize smart playlist at creation",
|
|
"id", id, "err", err,
|
|
)
|
|
}
|
|
|
|
summary := Summary{
|
|
ID: id,
|
|
Name: retName,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
IsSmart: true,
|
|
}
|
|
|
|
s.emitEvent(events.PlaylistCreated, summary)
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// UpdateSmartPlaylistRules updates the rule set for an existing
|
|
// smart playlist. Returns an error if the playlist does not exist
|
|
// or is not a smart playlist.
|
|
func (s *Service) UpdateSmartPlaylistRules(
|
|
playlistID int64,
|
|
rulesJSON string,
|
|
) error {
|
|
// Validate rules JSON before storing.
|
|
if _, err := smartplaylist.ParseRuleSet(rulesJSON); err != nil {
|
|
return fmt.Errorf(
|
|
"invalid smart playlist rules: %w", err,
|
|
)
|
|
}
|
|
|
|
// SAFETY: Hand-crafted UPDATE for smart_rules column not
|
|
// yet in sqlc schema. All values are parameterized.
|
|
// Only updates rows where is_smart = 1.
|
|
result, err := s.db.ExecContext(
|
|
`UPDATE playlists
|
|
SET smart_rules = ?, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ? AND is_smart = 1`,
|
|
rulesJSON, playlistID,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error(
|
|
"Failed to update smart playlist rules",
|
|
"playlistId", playlistID, "err", err,
|
|
)
|
|
|
|
return fmt.Errorf(
|
|
"failed to update smart playlist rules: %w", err,
|
|
)
|
|
}
|
|
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"could not check rows affected: %w", err,
|
|
)
|
|
}
|
|
|
|
if affected == 0 {
|
|
return errNotSmartPlaylist
|
|
}
|
|
|
|
s.logger.Info(
|
|
"Smart playlist rules updated",
|
|
"playlistId", playlistID,
|
|
)
|
|
|
|
// Re-materialize the persisted snapshot so it reflects the new
|
|
// rules. RefreshSmartPlaylist emits PlaylistTracksChanged.
|
|
if err := s.RefreshSmartPlaylist(playlistID); err != nil {
|
|
return fmt.Errorf(
|
|
"failed to refresh smart playlist after rule update: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RefreshSmartPlaylist re-evaluates a smart playlist's rules against
|
|
// the current library and replaces its persisted membership in
|
|
// playlist_tracks with the result. This is the only path that
|
|
// re-evaluates a smart playlist — opening one otherwise reads the
|
|
// stored snapshot. Triggered on rule save and by the manual Refresh
|
|
// button.
|
|
func (s *Service) RefreshSmartPlaylist(
|
|
playlistID int64,
|
|
) error {
|
|
// EvaluateSmartPlaylist validates that the playlist exists and is
|
|
// smart, and returns the live rule-matched tracks.
|
|
tracks, err := s.EvaluateSmartPlaylist(playlistID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := s.db.Queries.ClearPlaylistTracks(
|
|
s.db.Ctx, playlistID,
|
|
); err != nil {
|
|
return fmt.Errorf(
|
|
"failed to clear smart playlist tracks: %w", err,
|
|
)
|
|
}
|
|
|
|
for i, t := range tracks {
|
|
if strings.TrimSpace(t.FilePath) == "" {
|
|
continue
|
|
}
|
|
|
|
if err := s.addSingleTrack(
|
|
playlistID, t.FilePath, int64(i),
|
|
); err != nil {
|
|
// A track can vanish between evaluation and insertion
|
|
// (e.g. a concurrent rescan). Skip it rather than abort
|
|
// the whole refresh.
|
|
s.logger.Warn(
|
|
"Skipping smart playlist track during refresh",
|
|
"playlistId", playlistID,
|
|
"filePath", t.FilePath,
|
|
"err", err,
|
|
)
|
|
|
|
continue
|
|
}
|
|
}
|
|
|
|
// SAFETY: Hand-crafted UPDATE for smart_snapshot_at column not
|
|
// yet in sqlc schema. Parameterized by playlist ID.
|
|
if _, err := s.db.ExecContext(
|
|
`UPDATE playlists
|
|
SET smart_snapshot_at = CURRENT_TIMESTAMP
|
|
WHERE id = ? AND is_smart = 1`,
|
|
playlistID,
|
|
); err != nil {
|
|
return fmt.Errorf(
|
|
"failed to mark smart playlist snapshot: %w", err,
|
|
)
|
|
}
|
|
|
|
s.logger.Info(
|
|
"Smart playlist refreshed",
|
|
"playlistId", playlistID,
|
|
"trackCount", len(tracks),
|
|
)
|
|
|
|
s.savePlaylistFileByID(playlistID)
|
|
s.emitEvent(events.PlaylistTracksChanged, playlistID)
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetSmartPlaylistTracks returns the persisted snapshot of a smart
|
|
// playlist as regular playlist tracks (with resolved cover art and
|
|
// phantom entries), identical to a normal playlist. If the playlist
|
|
// has never been materialized — e.g. it predates snapshot support —
|
|
// it is evaluated and stored on first access.
|
|
func (s *Service) GetSmartPlaylistTracks(
|
|
playlistID int64,
|
|
) ([]Track, error) {
|
|
// SAFETY: Hand-crafted SELECT for smart_snapshot_at column not
|
|
// yet in sqlc schema. Parameterized by playlist ID.
|
|
rows, err := s.db.QueryContext(
|
|
`SELECT smart_snapshot_at FROM playlists
|
|
WHERE id = ? AND is_smart = 1`,
|
|
playlistID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"failed to load smart playlist: %w", err,
|
|
)
|
|
}
|
|
|
|
if !rows.Next() {
|
|
_ = rows.Close()
|
|
|
|
return nil, errNotSmartPlaylist
|
|
}
|
|
|
|
var snapshotAt sql.NullString
|
|
|
|
if err := rows.Scan(&snapshotAt); err != nil {
|
|
_ = rows.Close()
|
|
|
|
return nil, fmt.Errorf(
|
|
"failed to read smart playlist snapshot state: %w", err,
|
|
)
|
|
}
|
|
|
|
// Close before RefreshSmartPlaylist / GetPlaylistTracks issue
|
|
// their own queries (MaxOpenConns=1 test DBs would deadlock).
|
|
_ = rows.Close()
|
|
|
|
if !snapshotAt.Valid {
|
|
if err := s.RefreshSmartPlaylist(playlistID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return s.GetPlaylistTracks(playlistID)
|
|
}
|
|
|
|
// MaterializeUnmaterializedSmartPlaylists evaluates and snapshots any
|
|
// smart playlist that has never been materialized (smart_snapshot_at
|
|
// IS NULL) — e.g. playlists created before creation-time
|
|
// materialization existed. It runs once at startup and is idempotent:
|
|
// once every smart playlist has a snapshot it becomes a no-op. Errors
|
|
// on individual playlists are logged and skipped so one bad rule set
|
|
// doesn't block the rest.
|
|
func (s *Service) MaterializeUnmaterializedSmartPlaylists() {
|
|
// SAFETY: Static SELECT for smart_snapshot_at column not yet in
|
|
// sqlc schema. No parameters.
|
|
rows, err := s.db.QueryContext(
|
|
`SELECT id FROM playlists
|
|
WHERE is_smart = 1 AND smart_snapshot_at IS NULL`,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error(
|
|
"Failed to list unmaterialized smart playlists",
|
|
"err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
var ids []int64
|
|
|
|
for rows.Next() {
|
|
var id int64
|
|
|
|
if err := rows.Scan(&id); err != nil {
|
|
s.logger.Error(
|
|
"Failed to scan smart playlist id", "err", err,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
// Close before RefreshSmartPlaylist issues its own queries
|
|
// (MaxOpenConns=1 test DBs would deadlock).
|
|
_ = rows.Close()
|
|
|
|
for _, id := range ids {
|
|
if err := s.RefreshSmartPlaylist(id); err != nil {
|
|
s.logger.Warn(
|
|
"Failed to materialize smart playlist snapshot",
|
|
"id", id, "err", err,
|
|
)
|
|
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
// EvaluateSmartPlaylist loads the rule set for a smart playlist
|
|
// from the database and evaluates it against the track library,
|
|
// returning the matching tracks.
|
|
func (s *Service) EvaluateSmartPlaylist(
|
|
playlistID int64,
|
|
) ([]library.Track, error) {
|
|
// SAFETY: Hand-crafted SELECT for smart_rules column not
|
|
// yet in sqlc schema. Parameterized by playlist ID.
|
|
rows, err := s.db.QueryContext(
|
|
`SELECT smart_rules FROM playlists
|
|
WHERE id = ? AND is_smart = 1`,
|
|
playlistID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"failed to load smart playlist rules: %w", err,
|
|
)
|
|
}
|
|
|
|
if !rows.Next() {
|
|
_ = rows.Close()
|
|
|
|
return nil, errNotSmartPlaylist
|
|
}
|
|
|
|
var rulesJSON string
|
|
|
|
if err := rows.Scan(&rulesJSON); err != nil {
|
|
_ = rows.Close()
|
|
|
|
return nil, fmt.Errorf(
|
|
"failed to load smart playlist rules: %w", err,
|
|
)
|
|
}
|
|
|
|
// Close rows before calling Evaluate, which opens its own
|
|
// query. With MaxOpenConns=1 (test DBs), a deferred close
|
|
// would deadlock.
|
|
_ = rows.Close()
|
|
|
|
ruleSet, err := smartplaylist.ParseRuleSet(rulesJSON)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"corrupt smart playlist rules for id %d: %w",
|
|
playlistID, err,
|
|
)
|
|
}
|
|
|
|
tracks, err := smartplaylist.Evaluate(s.db, ruleSet)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"smart playlist evaluation failed for id %d: %w",
|
|
playlistID, err,
|
|
)
|
|
}
|
|
|
|
return tracks, nil
|
|
}
|
|
|
|
// PreviewSmartPlaylist evaluates a rule set from raw JSON without
|
|
// requiring a saved playlist. This powers live preview in the rule
|
|
// editor — the frontend sends rules as they are being edited and
|
|
// receives matching tracks immediately.
|
|
func (s *Service) PreviewSmartPlaylist(
|
|
rulesJSON string,
|
|
) ([]library.Track, error) {
|
|
ruleSet, err := smartplaylist.ParseRuleSet(rulesJSON)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"invalid smart playlist rules: %w", err,
|
|
)
|
|
}
|
|
|
|
tracks, err := smartplaylist.Evaluate(s.db, ruleSet)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"smart playlist preview failed: %w", err,
|
|
)
|
|
}
|
|
|
|
s.logger.Info(
|
|
"Smart playlist preview evaluated",
|
|
"trackCount", len(tracks),
|
|
)
|
|
|
|
return tracks, nil
|
|
}
|
|
|
|
// GetSmartPlaylistRules returns the raw JSON rule string for an
|
|
// existing smart playlist. This is used when the user opens the
|
|
// rule editor for an existing smart playlist.
|
|
func (s *Service) GetSmartPlaylistRules(
|
|
playlistID int64,
|
|
) (string, error) {
|
|
// SAFETY: Hand-crafted SELECT for smart_rules column not
|
|
// yet in sqlc schema. Parameterized by playlist ID.
|
|
rows, err := s.db.QueryContext(
|
|
`SELECT smart_rules FROM playlists
|
|
WHERE id = ? AND is_smart = 1`,
|
|
playlistID,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"failed to load smart playlist rules: %w", err,
|
|
)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
if !rows.Next() {
|
|
return "", errNotSmartPlaylist
|
|
}
|
|
|
|
var rulesJSON string
|
|
|
|
if err := rows.Scan(&rulesJSON); err != nil {
|
|
return "", fmt.Errorf(
|
|
"failed to scan smart playlist rules: %w", err,
|
|
)
|
|
}
|
|
|
|
s.logger.Info(
|
|
"Smart playlist rules loaded",
|
|
"playlistId", playlistID,
|
|
)
|
|
|
|
return rulesJSON, nil
|
|
}
|