579 lines
16 KiB
Go
579 lines
16 KiB
Go
// 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
|
|
}
|