// Package local implements a playlist.Provider backed by TOML files in // ~/.config/cliamp/playlists/. package local import ( "context" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "slices" "sort" "strconv" "strings" "github.com/bjarneo/cliamp/history" "github.com/bjarneo/cliamp/internal/appdir" "github.com/bjarneo/cliamp/internal/fuzzy" "github.com/bjarneo/cliamp/internal/tomlutil" "github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/provider" ) // Compile-time interface checks. var ( _ provider.PlaylistWriter = (*Provider)(nil) _ provider.PlaylistBatchWriter = (*Provider)(nil) _ provider.PlaylistCreator = (*Provider)(nil) _ provider.PlaylistSaver = (*Provider)(nil) _ provider.PlaylistDeleter = (*Provider)(nil) _ provider.PlaylistRenamer = (*Provider)(nil) _ provider.BookmarkSetter = (*Provider)(nil) _ provider.Searcher = (*Provider)(nil) ) // Provider reads and writes TOML-based playlists stored on disk. type Provider struct { dir string // e.g. ~/.config/cliamp/playlists/ history *history.Store } // New creates a Provider using ~/.config/cliamp/playlists/ as the base directory. func New() *Provider { dir, err := appdir.Dir() if err != nil { return nil } return &Provider{ dir: filepath.Join(dir, "playlists"), history: history.New(), } } func (p *Provider) Name() string { return "Local" } // safePath validates a playlist name and returns the absolute path to its TOML // file, ensuring the result stays within p.dir. This prevents path traversal // via names containing ".." or path separators. func (p *Provider) safePath(name string) (string, error) { if strings.ContainsAny(name, "/\\") || strings.TrimSpace(name) == "" { return "", fmt.Errorf("invalid playlist name %q", name) } resolved := filepath.Join(p.dir, name+".toml") if !strings.HasPrefix(resolved, filepath.Clean(p.dir)+string(filepath.Separator)) { return "", fmt.Errorf("playlist path escapes base directory") } return resolved, nil } func validateNewName(name string) error { if strings.ContainsAny(name, "/\\:<>\"|?*") || name == ".." || name == "." || strings.TrimSpace(name) == "" { return fmt.Errorf("invalid playlist name %q", name) } return nil } func isHistoryName(name string) bool { return name == history.PlaylistName } // Playlists scans the directory for .toml files and returns their metadata, // prepending the virtual "Recently Played" entry when the user has any // recorded plays. Returns an empty list (not error) when neither exists. func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) { var lists []playlist.PlaylistInfo if info, ok := p.historyInfo(); ok { lists = append(lists, info) } entries, err := os.ReadDir(p.dir) if errors.Is(err, fs.ErrNotExist) { return lists, nil } if err != nil { return nil, err } for _, e := range entries { if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".toml") { continue } name := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) tracks, err := p.loadTOML(filepath.Join(p.dir, e.Name())) if err != nil { continue } lists = append(lists, playlist.PlaylistInfo{ ID: name, Name: name, TrackCount: len(tracks), DurationSecs: playlist.TotalDurationSecs(tracks), }) } return lists, nil } // historyInfo returns the synthetic PlaylistInfo entry for "Recently Played", // or ok=false when the history store is unavailable or empty. func (p *Provider) historyInfo() (playlist.PlaylistInfo, bool) { if p.history == nil { return playlist.PlaylistInfo{}, false } tracks, err := p.history.Tracks(0) if err != nil || len(tracks) == 0 { return playlist.PlaylistInfo{}, false } return playlist.PlaylistInfo{ ID: history.PlaylistName, Name: history.PlaylistName, TrackCount: len(tracks), DurationSecs: playlist.TotalDurationSecs(tracks), }, true } // Tracks parses the TOML file for the given playlist name and returns its tracks. // The reserved "Recently Played" name is served from the history store. func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) { if isHistoryName(playlistID) { if p.history == nil { return nil, nil } return p.history.Tracks(0) } path, err := p.safePath(playlistID) if err != nil { return nil, err } return p.loadTOML(path) } // AddTrack appends a track to the named playlist, creating the directory and // file if needed. func (p *Provider) AddTrack(playlistName string, track playlist.Track) error { _, _, err := p.AddTracks(playlistName, []playlist.Track{track}) return err } // AddTracks appends multiple tracks, skipping exact path duplicates already in // the playlist or repeated in the input. It creates the playlist file if needed. func (p *Provider) AddTracks(playlistName string, tracks []playlist.Track) (added, skipped int, err error) { if isHistoryName(playlistName) { return 0, 0, errReservedHistoryName } if err := os.MkdirAll(p.dir, 0o755); err != nil { return 0, 0, err } path, err := p.safePath(playlistName) if err != nil { return 0, 0, err } existing, err := p.loadTOML(path) if err != nil { if !errors.Is(err, fs.ErrNotExist) { return 0, 0, err } if err := validateNewName(playlistName); err != nil { return 0, 0, err } existing = nil } seen := make(map[string]struct{}, len(existing)+len(tracks)) for _, t := range existing { seen[t.Path] = struct{}{} } for _, t := range tracks { if _, ok := seen[t.Path]; ok { skipped++ continue } seen[t.Path] = struct{}{} existing = append(existing, t) added++ } if added == 0 { if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { return 0, skipped, p.savePlaylist(playlistName, existing) } else if err != nil { return 0, skipped, err } return 0, skipped, nil } return added, skipped, p.savePlaylist(playlistName, existing) } // CreatePlaylist creates an empty playlist file. func (p *Provider) CreatePlaylist(_ context.Context, name string) (string, error) { if isHistoryName(name) { return "", errReservedHistoryName } if err := os.MkdirAll(p.dir, 0o755); err != nil { return "", err } if err := validateNewName(name); err != nil { return "", err } path, err := p.safePath(name) if err != nil { return "", err } f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { if errors.Is(err, fs.ErrExist) { return "", fmt.Errorf("playlist %q already exists", name) } return "", err } if err := f.Close(); err != nil { return "", err } return name, nil } // Exists reports whether a playlist with the given name exists on disk, or // whether it refers to the virtual "Recently Played" history with at least // one entry recorded. func (p *Provider) Exists(name string) bool { if isHistoryName(name) { _, ok := p.historyInfo() return ok } path, err := p.safePath(name) if err != nil { return false } _, err = os.Stat(path) return err == nil } // savePlaylist overwrites the named playlist with the given tracks. func (p *Provider) savePlaylist(name string, tracks []playlist.Track) error { if err := os.MkdirAll(p.dir, 0o755); err != nil { return err } path, err := p.safePath(name) if err != nil { return err } if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { if err := validateNewName(name); err != nil { return err } } else if err != nil { return err } // Atomic write: write to temp file, then rename. tmp := path + ".tmp" f, err := os.Create(tmp) if err != nil { return err } for i, t := range tracks { if i > 0 { fmt.Fprintln(f) } writeTrack(f, t) } if err := f.Close(); err != nil { os.Remove(tmp) return err } return os.Rename(tmp, path) } // errReservedHistoryName is returned when a caller tries to write to or // otherwise mutate the synthetic history playlist. var errReservedHistoryName = errors.New(`"Recently Played" is a virtual history playlist and cannot be modified`) // SetBookmark toggles the bookmark flag on a track and rewrites the playlist. func (p *Provider) SetBookmark(playlistName string, idx int) error { if isHistoryName(playlistName) { return errReservedHistoryName } tracks, err := p.loadTOMLByName(playlistName) if err != nil { return err } if idx < 0 || idx >= len(tracks) { return fmt.Errorf("index %d out of range (playlist has %d tracks)", idx, len(tracks)) } tracks[idx].Bookmark = !tracks[idx].Bookmark return p.savePlaylist(playlistName, tracks) } // SetBookmarkByPath toggles the bookmark flag on the first track with path and // rewrites the playlist. This avoids corrupting saved playlists when the live // queue has been filtered, reordered, or otherwise diverged from file order. func (p *Provider) SetBookmarkByPath(playlistName string, path string) error { if isHistoryName(playlistName) { return errReservedHistoryName } tracks, err := p.loadTOMLByName(playlistName) if err != nil { return err } for i := range tracks { if tracks[i].Path == path { tracks[i].Bookmark = !tracks[i].Bookmark return p.savePlaylist(playlistName, tracks) } } return fmt.Errorf("track path %q not found in playlist %q", path, playlistName) } // loadTOMLByName loads tracks for a named playlist. func (p *Provider) loadTOMLByName(name string) ([]playlist.Track, error) { path, err := p.safePath(name) if err != nil { return nil, err } return p.loadTOML(path) } // SavePlaylist overwrites a playlist with the given tracks. func (p *Provider) SavePlaylist(name string, tracks []playlist.Track) error { if isHistoryName(name) { return errReservedHistoryName } return p.savePlaylist(name, tracks) } // AddTrackToPlaylist appends a track to the named playlist. // Implements provider.PlaylistWriter. func (p *Provider) AddTrackToPlaylist(_ context.Context, playlistID string, track playlist.Track) error { return p.AddTrack(playlistID, track) } // AddTracksToPlaylist appends multiple tracks to the named playlist. // Implements provider.PlaylistBatchWriter. func (p *Provider) AddTracksToPlaylist(_ context.Context, playlistID string, tracks []playlist.Track) (int, int, error) { return p.AddTracks(playlistID, tracks) } // SearchTracks does a case-insensitive fuzzy search across every saved playlist // for tracks whose title, artist, or album match query, ranked by relevance // (best match first). Returns up to limit results (limit <= 0 means no cap). // Implements provider.Searcher. func (p *Provider) SearchTracks(_ context.Context, query string, limit int) ([]playlist.Track, error) { q := strings.TrimSpace(query) if q == "" { return nil, nil } entries, err := os.ReadDir(p.dir) if errors.Is(err, fs.ErrNotExist) { return nil, nil } if err != nil { return nil, err } type scored struct { track playlist.Track score int } var matches []scored seen := make(map[string]struct{}) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".toml") { continue } tracks, err := p.loadTOML(filepath.Join(p.dir, e.Name())) if err != nil { continue } for _, t := range tracks { if _, dup := seen[t.Path]; dup { continue } score, ok := trackMatchScore(t, q) if !ok { continue } seen[t.Path] = struct{}{} matches = append(matches, scored{t, score}) } } sort.SliceStable(matches, func(a, b int) bool { return matches[a].score > matches[b].score }) if limit > 0 && len(matches) > limit { matches = matches[:limit] } out := make([]playlist.Track, len(matches)) for i, m := range matches { out[i] = m.track } return out, nil } // trackMatchScore returns the best fuzzy score for query across the track's // title, artist, and album, and whether any of them matched. func trackMatchScore(t playlist.Track, query string) (int, bool) { best, ok := 0, false for _, field := range [...]string{t.Title, t.Artist, t.Album} { if field == "" { continue } if s, matched := fuzzy.Match(query, field); matched && (!ok || s > best) { best, ok = s, true } } return best, ok } // RenamePlaylist renames a playlist by renaming its TOML file. // The reserved "Recently Played" history playlist cannot be renamed. func (p *Provider) RenamePlaylist(oldName, newName string) error { if isHistoryName(oldName) || isHistoryName(newName) { return errReservedHistoryName } oldPath, err := p.safePath(oldName) if err != nil { return fmt.Errorf("invalid playlist name %q: %w", oldName, err) } if err := validateNewName(newName); err != nil { return err } newPath, err := p.safePath(newName) if err != nil { return fmt.Errorf("invalid playlist name %q: %w", newName, err) } if _, err := os.Stat(newPath); err == nil { return fmt.Errorf("playlist %q already exists", newName) } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("stat destination playlist %q: %w", newName, err) } if err := os.Rename(oldPath, newPath); err != nil { return fmt.Errorf("rename playlist %q to %q: %w", oldName, newName, err) } return nil } // DeletePlaylist removes the TOML file for the named playlist. // "Recently Played" cannot be deleted via this method — use ClearHistory. func (p *Provider) DeletePlaylist(name string) error { if isHistoryName(name) { return errReservedHistoryName } path, err := p.safePath(name) if err != nil { return err } return os.Remove(path) } // ClearHistory wipes the recorded play history. Returns nil if no history // exists yet. func (p *Provider) ClearHistory() error { if p.history == nil { return nil } return p.history.Clear() } // RemoveTrack removes a track by index from the named playlist. // Empty playlists are kept on disk; deleting a playlist remains explicit. func (p *Provider) RemoveTrack(name string, index int) error { if isHistoryName(name) { return errReservedHistoryName } tracks, err := p.Tracks(name) if err != nil { return err } if index < 0 || index >= len(tracks) { return fmt.Errorf("track index %d out of range", index) } tracks = slices.Delete(tracks, index, index+1) return p.savePlaylist(name, tracks) } // writeTrack writes a single [[track]] TOML section to w. func writeTrack(w io.Writer, t playlist.Track) { fmt.Fprintln(w, "[[track]]") fmt.Fprintf(w, "path = %q\n", t.Path) fmt.Fprintf(w, "title = %q\n", t.Title) if t.Feed { fmt.Fprintln(w, "feed = true") } if t.Artist != "" { fmt.Fprintf(w, "artist = %q\n", t.Artist) } if t.Album != "" { fmt.Fprintf(w, "album = %q\n", t.Album) } if t.Genre != "" { fmt.Fprintf(w, "genre = %q\n", t.Genre) } if t.Year != 0 { fmt.Fprintf(w, "year = %d\n", t.Year) } if t.TrackNumber != 0 { fmt.Fprintf(w, "track_number = %d\n", t.TrackNumber) } if t.DurationSecs != 0 { fmt.Fprintf(w, "duration_secs = %d\n", t.DurationSecs) } if t.EmbeddedLyrics != "" { fmt.Fprintf(w, "embedded_lyrics = %q\n", t.EmbeddedLyrics) } if t.AlbumArtURL != "" { fmt.Fprintf(w, "album_art_url = %q\n", t.AlbumArtURL) } if t.Bookmark { fmt.Fprintln(w, "bookmark = true") } } // loadTOML parses a minimal TOML file with [[track]] sections. // Each section supports path, title, and artist keys. func (p *Provider) loadTOML(path string) ([]playlist.Track, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var tracks []playlist.Track tomlutil.ParseSections(data, "track", func(f map[string]string) { t := playlist.Track{ Path: f["path"], Title: f["title"], Artist: f["artist"], Album: f["album"], Genre: f["genre"], Feed: f["feed"] == "true", } t.EmbeddedLyrics = f["embedded_lyrics"] t.AlbumArtURL = f["album_art_url"] t.Stream = playlist.IsURL(t.Path) // "favorite" is the pre-rename alias for "bookmark"; prefer bookmark. bookmark, ok := f["bookmark"] if !ok { bookmark = f["favorite"] } t.Bookmark = bookmark == "true" if n, err := strconv.Atoi(f["year"]); err == nil { t.Year = n } if n, err := strconv.Atoi(f["track_number"]); err == nil { t.TrackNumber = n } if n, err := strconv.Atoi(f["duration_secs"]); err == nil { t.DurationSecs = n } tracks = append(tracks, t) }) return tracks, nil }