Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Press `Ctrl+K` in the player to see all keybindings.
| `f` | Find on YouTube (queue play next) |
| `F` | Find on SoundCloud (queue play next) |
| `u` | Load URL (stream/playlist) |
| `U` | Import URL as persistent local playlist (`name | url`) |
| `y` | Show lyrics |
| `S` | Save track to ~/Music |
| `N` | Navidrome browser |
Expand All @@ -57,6 +58,7 @@ Press `Ctrl+K` in the player to see all keybindings.
| `a` | Toggle queue (play next) |
| `A` | Queue manager |
| `p` | Playlist manager |
| `R` | Refresh linked local playlist (in playlist manager) |
| `r` | Cycle repeat (Off / All / One) |
| `z` | Toggle shuffle |

Expand Down
9 changes: 8 additions & 1 deletion docs/playlists.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ Each `[[track]]` section supports:

HTTP/HTTPS paths are automatically treated as streams.

Optional top-level key for linked playlists:

| Key | Description |
|-----|-------------|
| `source_url` | Original URL used to import/sync the playlist |

### Browsing and Loading Playlists

Running `cliamp` without arguments connects to the built-in radio channel. If Navidrome is configured, it opens the provider browser instead.
Expand All @@ -110,6 +116,7 @@ Press `p` from any view to open the playlist manager:
5. **Remove track** — open a playlist, highlight a track, press `d` to remove it
6. **Play all** — press `Enter` on the track list to load all tracks into the player
7. **New playlist** — select "+ New Playlist...", type a name, and press Enter
8. **Refresh linked playlist** — on playlist list screen, press `R` to re-sync from `source_url`

The directory `~/.config/cliamp/playlists/` is created automatically on first use. Removing the last track from a playlist auto-deletes the file.

Expand Down Expand Up @@ -153,6 +160,6 @@ title = "My Radio"
| `Up` `Down` / `j` `k` | Navigate |
| `Enter` / `→` | Open playlist / Play all tracks |
| `a` | Add currently playing track |
| `R` | Refresh linked playlist from `source_url` |
| `d` | Delete playlist (confirms) / Remove track |
| `Esc` / `←` | Close / Go back |

6 changes: 6 additions & 0 deletions docs/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ cliamp https://www.xiaoyuzhoufm.com/episode/xxxx

Press `u` while playing to load a new stream or playlist URL without restarting. Supports the same URL types as CLI arguments: direct audio URLs, M3U/PLS playlists, RSS podcast feeds, and yt-dlp compatible links.

Press `U` to import from URL into a persistent local playlist. Input format:

`Playlist Name | https://example.com/playlist.m3u`

Imported playlists store `source_url` and can be refreshed later from playlist manager with `R`.

## Run Your Own Radio Station

Run your own internet radio with [cliamp-server](https://github.com/bjarneo/cliamp-server). Point it at a directory of audio files and it starts broadcasting. Supports multiple stations, live metadata, and on-the-fly transcoding.
Expand Down
105 changes: 89 additions & 16 deletions external/local/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"slices"
"strconv"
"strings"
"unicode"

"cliamp/internal/appdir"
"cliamp/internal/tomlutil"
Expand All @@ -23,6 +24,10 @@ type Provider struct {
dir string // e.g. ~/.config/cliamp/playlists/
}

type playlistMeta struct {
sourceURL string
}

// New creates a Provider using ~/.config/cliamp/playlists/ as the base directory.
func New() *Provider {
dir, err := appdir.Dir()
Expand All @@ -34,14 +39,47 @@ func New() *Provider {

func (p *Provider) Name() string { return "Local Playlists" }

func slugifyPlaylistName(name string) string {
name = strings.TrimSpace(strings.ToLower(name))
var b strings.Builder
prevDash := false
for _, r := range name {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
prevDash = false
continue
}
if !prevDash {
b.WriteByte('-')
prevDash = true
}
}
s := strings.Trim(b.String(), "-")
if s == "" {
return "playlist"
}
return s
}

// 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.
// file, ensuring the result stays within p.dir. Existing non-slug legacy files
// are respected; new files default to slugified filenames.
func (p *Provider) safePath(name string) (string, error) {
if strings.ContainsAny(name, "/\\") || name == ".." || name == "." || name == "" {
return "", fmt.Errorf("invalid playlist name %q", name)
}
resolved := filepath.Join(p.dir, name+".toml")

exact := filepath.Join(p.dir, name+".toml")
slug := filepath.Join(p.dir, slugifyPlaylistName(name)+".toml")
resolved := slug

// Backward compatibility: if a legacy non-slug file exists, keep using it.
if _, err := os.Stat(exact); err == nil {
resolved = exact
} else if _, err := os.Stat(slug); err == nil {
resolved = slug
}

if !strings.HasPrefix(resolved, filepath.Clean(p.dir)+string(filepath.Separator)) {
return "", fmt.Errorf("playlist path escapes base directory")
}
Expand All @@ -65,7 +103,7 @@ func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) {
continue
}
name := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
tracks, err := p.loadTOML(filepath.Join(p.dir, e.Name()))
tracks, _, err := p.loadTOML(filepath.Join(p.dir, e.Name()))
if err != nil {
continue
}
Expand All @@ -84,7 +122,21 @@ func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) {
if err != nil {
return nil, err
}
return p.loadTOML(path)
tracks, _, err := p.loadTOML(path)
return tracks, err
}

// SourceURL returns the linked source URL for a playlist, if set.
func (p *Provider) SourceURL(playlistID string) (string, error) {
path, err := p.safePath(playlistID)
if err != nil {
return "", err
}
_, meta, err := p.loadTOML(path)
if err != nil {
return "", err
}
return meta.sourceURL, nil
}

// AddTrack appends a track to the named playlist, creating the directory and
Expand Down Expand Up @@ -115,20 +167,38 @@ func (p *Provider) AddTrack(playlistName string, track playlist.Track) error {

// 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 {
path, err := p.safePath(name)
if err != nil {
return err
}
// Preserve existing source_url when overwriting through normal operations.
_, meta, _ := p.loadTOML(path)
return p.writePlaylist(path, tracks, meta.sourceURL)
}

// SaveLinkedPlaylist overwrites the named playlist and sets its source URL.
func (p *Provider) SaveLinkedPlaylist(name, sourceURL string, tracks []playlist.Track) error {
path, err := p.safePath(name)
if err != nil {
return err
}
return p.writePlaylist(path, tracks, strings.TrimSpace(sourceURL))
}

func (p *Provider) writePlaylist(path string, tracks []playlist.Track, sourceURL string) error {
if err := os.MkdirAll(p.dir, 0o755); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()

if sourceURL != "" {
fmt.Fprintf(f, "source_url = %q\n\n", sourceURL)
}

for i, t := range tracks {
if i > 0 {
fmt.Fprintln(f)
Expand Down Expand Up @@ -186,16 +256,17 @@ func writeTrack(w io.Writer, t playlist.Track) {
}
}

// 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) {
// loadTOML parses a minimal TOML file with optional source_url metadata and
// [[track]] sections. Each track section supports path, title, and artist keys.
func (p *Provider) loadTOML(path string) ([]playlist.Track, playlistMeta, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
return nil, playlistMeta{}, err
}

var tracks []playlist.Track
var current *playlist.Track
var meta playlistMeta

for _, rawLine := range strings.Split(string(data), "\n") {
line := strings.TrimSpace(rawLine)
Expand All @@ -214,10 +285,6 @@ func (p *Provider) loadTOML(path string) ([]playlist.Track, error) {
continue
}

if current == nil {
continue
}

// Parse key = "value" lines.
key, val, ok := strings.Cut(line, "=")
if !ok {
Expand All @@ -227,6 +294,13 @@ func (p *Provider) loadTOML(path string) ([]playlist.Track, error) {
val = strings.TrimSpace(val)
val = tomlutil.Unquote(val)

if current == nil {
if key == "source_url" {
meta.sourceURL = val
}
continue
}

switch key {
case "path":
current.Path = val
Expand All @@ -252,6 +326,5 @@ func (p *Provider) loadTOML(path string) ([]playlist.Track, error) {
if current != nil {
tracks = append(tracks, *current)
}
return tracks, nil
return tracks, meta, nil
}

64 changes: 64 additions & 0 deletions external/local/provider_link_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package local

import (
"testing"

"cliamp/playlist"
)

func TestSaveLinkedPlaylistPersistsSourceURL(t *testing.T) {
t.Parallel()

dir := t.TempDir()
p := &Provider{dir: dir}
tracks := []playlist.Track{
{Path: "https://example.com/stream", Title: "Example", Stream: true},
}

if err := p.SaveLinkedPlaylist("linked", "https://example.com/list.m3u", tracks); err != nil {
t.Fatalf("SaveLinkedPlaylist: %v", err)
}

gotURL, err := p.SourceURL("linked")
if err != nil {
t.Fatalf("SourceURL: %v", err)
}
if gotURL != "https://example.com/list.m3u" {
t.Fatalf("SourceURL = %q, want %q", gotURL, "https://example.com/list.m3u")
}

gotTracks, err := p.Tracks("linked")
if err != nil {
t.Fatalf("Tracks: %v", err)
}
if len(gotTracks) != 1 || gotTracks[0].Title != "Example" {
t.Fatalf("Tracks = %#v, want one Example track", gotTracks)
}
}

func TestSavePlaylistPreservesExistingSourceURL(t *testing.T) {
t.Parallel()

dir := t.TempDir()
p := &Provider{dir: dir}

if err := p.SaveLinkedPlaylist("linked", "https://example.com/feed.xml", []playlist.Track{
{Path: "https://example.com/old", Title: "Old", Stream: true},
}); err != nil {
t.Fatalf("SaveLinkedPlaylist: %v", err)
}

if err := p.SavePlaylist("linked", []playlist.Track{
{Path: "https://example.com/new", Title: "New", Stream: true},
}); err != nil {
t.Fatalf("SavePlaylist: %v", err)
}

gotURL, err := p.SourceURL("linked")
if err != nil {
t.Fatalf("SourceURL: %v", err)
}
if gotURL != "https://example.com/feed.xml" {
t.Fatalf("SourceURL after SavePlaylist = %q, want %q", gotURL, "https://example.com/feed.xml")
}
}
64 changes: 64 additions & 0 deletions external/local/provider_slug_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package local

import (
"os"
"path/filepath"
"testing"
)

func TestSlugifyPlaylistName(t *testing.T) {
t.Parallel()

tests := []struct {
in string
want string
}{
{"Ayla - Cover Songs", "ayla-cover-songs"},
{" But It hits different ", "but-it-hits-different"},
{"Nightcore!!!", "nightcore"},
{"___", "playlist"},
}

for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
if got := slugifyPlaylistName(tt.in); got != tt.want {
t.Fatalf("slugifyPlaylistName(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

func TestSafePathCreatesSlugFileForNewPlaylist(t *testing.T) {
t.Parallel()

dir := t.TempDir()
p := &Provider{dir: dir}

path, err := p.safePath("Ayla - Cover Songs")
if err != nil {
t.Fatalf("safePath: %v", err)
}
want := filepath.Join(dir, "ayla-cover-songs.toml")
if path != want {
t.Fatalf("safePath = %q, want %q", path, want)
}
}

func TestSafePathPrefersExistingLegacyFile(t *testing.T) {
t.Parallel()

dir := t.TempDir()
p := &Provider{dir: dir}
legacy := filepath.Join(dir, "Ayla - Cover Songs.toml")
if err := os.WriteFile(legacy, []byte("[[track]]\npath=\"x\"\ntitle=\"x\"\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}

path, err := p.safePath("Ayla - Cover Songs")
if err != nil {
t.Fatalf("safePath: %v", err)
}
if path != legacy {
t.Fatalf("safePath = %q, want legacy %q", path, legacy)
}
}
Loading