diff --git a/docs/keybindings.md b/docs/keybindings.md index 9a7cc69f..4ecdb861 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -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 | @@ -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 | diff --git a/docs/playlists.md b/docs/playlists.md index 0cdf07eb..5aaa2cc0 100644 --- a/docs/playlists.md +++ b/docs/playlists.md @@ -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. @@ -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. @@ -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 | - diff --git a/docs/streaming.md b/docs/streaming.md index 161943d9..c7f03b2d 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -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. diff --git a/external/local/provider.go b/external/local/provider.go index 2fbf1a20..a817c6d5 100644 --- a/external/local/provider.go +++ b/external/local/provider.go @@ -12,6 +12,7 @@ import ( "slices" "strconv" "strings" + "unicode" "cliamp/internal/appdir" "cliamp/internal/tomlutil" @@ -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() @@ -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") } @@ -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 } @@ -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 @@ -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) @@ -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) @@ -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 { @@ -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 @@ -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 } - diff --git a/external/local/provider_link_test.go b/external/local/provider_link_test.go new file mode 100644 index 00000000..aa861400 --- /dev/null +++ b/external/local/provider_link_test.go @@ -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") + } +} diff --git a/external/local/provider_slug_test.go b/external/local/provider_slug_test.go new file mode 100644 index 00000000..c5e312ad --- /dev/null +++ b/external/local/provider_slug_test.go @@ -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) + } +} diff --git a/ui/commands.go b/ui/commands.go index 4ef3a295..10644d9b 100644 --- a/ui/commands.go +++ b/ui/commands.go @@ -1,10 +1,12 @@ package ui import ( + "fmt" "time" tea "github.com/charmbracelet/bubbletea" + "cliamp/external/local" "cliamp/external/navidrome" "cliamp/lyrics" "cliamp/player" @@ -61,6 +63,20 @@ type ytdlSavedMsg struct { err error } +// urlPlaylistImportedMsg signals that importing a URL into a local playlist completed. +type urlPlaylistImportedMsg struct { + name string + count int + err error +} + +// linkedPlaylistRefreshedMsg signals that syncing a linked local playlist completed. +type linkedPlaylistRefreshedMsg struct { + name string + count int + err error +} + // — Navidrome browser message types — // navArtistsLoadedMsg carries the full artist list from getArtists. @@ -115,6 +131,51 @@ func resolveRemoteCmd(urls []string) tea.Cmd { } } +func importURLPlaylistCmd(localProv *local.Provider, name, rawURL string) tea.Cmd { + return func() tea.Msg { + if localProv == nil { + return urlPlaylistImportedMsg{name: name, err: fmt.Errorf("local playlist provider unavailable")} + } + tracks, err := resolve.Remote([]string{rawURL}) + if err != nil { + return urlPlaylistImportedMsg{name: name, err: err} + } + if len(tracks) == 0 { + return urlPlaylistImportedMsg{name: name, count: 0} + } + if err := localProv.SaveLinkedPlaylist(name, rawURL, tracks); err != nil { + return urlPlaylistImportedMsg{name: name, err: err} + } + return urlPlaylistImportedMsg{name: name, count: len(tracks)} + } +} + +func refreshLinkedPlaylistCmd(localProv *local.Provider, name string) tea.Cmd { + return func() tea.Msg { + if localProv == nil { + return linkedPlaylistRefreshedMsg{name: name, err: fmt.Errorf("local playlist provider unavailable")} + } + sourceURL, err := localProv.SourceURL(name) + if err != nil { + return linkedPlaylistRefreshedMsg{name: name, err: err} + } + if sourceURL == "" { + return linkedPlaylistRefreshedMsg{name: name, err: fmt.Errorf("playlist is not linked to a source URL")} + } + tracks, err := resolve.Remote([]string{sourceURL}) + if err != nil { + return linkedPlaylistRefreshedMsg{name: name, err: err} + } + if len(tracks) == 0 { + return linkedPlaylistRefreshedMsg{name: name, count: 0} + } + if err := localProv.SaveLinkedPlaylist(name, sourceURL, tracks); err != nil { + return linkedPlaylistRefreshedMsg{name: name, err: err} + } + return linkedPlaylistRefreshedMsg{name: name, count: len(tracks)} + } +} + func fetchLyricsCmd(artist, title string) tea.Cmd { return func() tea.Msg { lines, err := lyrics.Fetch(artist, title) diff --git a/ui/keys.go b/ui/keys.go index 9a3a82e0..8ffc381e 100644 --- a/ui/keys.go +++ b/ui/keys.go @@ -420,6 +420,12 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { case "u": m.urlInputting = true + m.urlImporting = false + m.urlInput = "" + + case "U": + m.urlInputting = true + m.urlImporting = true m.urlInput = "" case "N": @@ -748,17 +754,36 @@ func (m *Model) handleURLInputKey(msg tea.KeyMsg) tea.Cmd { switch msg.Type { case tea.KeyEscape: m.urlInputting = false + m.urlImporting = false case tea.KeyEnter: - m.urlInputting = false input := strings.TrimSpace(m.urlInput) - if input != "" { + if input == "" { + return nil + } + if m.urlImporting { + name, rawURL, ok := parseURLPlaylistImportInput(input) + if !ok { + m.status.text = "Format: playlist name | url" + m.status.ttl = 80 + return nil + } + m.urlInputting = false + m.urlImporting = false m.feedLoading = true - m.status.text = "Loading URL..." + m.status.text = "Importing URL into playlist..." m.status.ttl = 120 - return resolveRemoteCmd([]string{input}) + return importURLPlaylistCmd(m.localProvider, name, rawURL) } + m.urlInputting = false + m.urlImporting = false + m.feedLoading = true + m.status.text = "Loading URL..." + m.status.ttl = 120 + return resolveRemoteCmd([]string{input}) case tea.KeyBackspace: m.urlInput = removeLastRune(m.urlInput) + case tea.KeySpace: + m.urlInput += " " default: if msg.Type == tea.KeyRunes { m.urlInput += string(msg.Runes) @@ -767,6 +792,19 @@ func (m *Model) handleURLInputKey(msg tea.KeyMsg) tea.Cmd { return nil } +func parseURLPlaylistImportInput(input string) (name, rawURL string, ok bool) { + left, right, found := strings.Cut(input, "|") + if !found { + return "", "", false + } + name = strings.TrimSpace(left) + rawURL = strings.TrimSpace(right) + if name == "" || rawURL == "" { + return "", "", false + } + return name, rawURL, true +} + // handlePlaylistManagerKey dispatches keys to the active manager screen. func (m *Model) handlePlaylistManagerKey(msg tea.KeyMsg) tea.Cmd { switch m.plManager.screen { @@ -835,6 +873,14 @@ func (m *Model) handlePlMgrListKey(msg tea.KeyMsg) tea.Cmd { if m.plManager.cursor < len(m.plManager.playlists) { m.plManager.confirmDel = true } + case "R": + if m.plManager.cursor < len(m.plManager.playlists) { + name := m.plManager.playlists[m.plManager.cursor].Name + m.feedLoading = true + m.status.text = fmt.Sprintf("Refreshing \"%s\"...", name) + m.status.ttl = 120 + return refreshLinkedPlaylistCmd(m.localProvider, name) + } case "esc", "p": m.plManager.visible = false } diff --git a/ui/model.go b/ui/model.go index f607c583..994d98b9 100644 --- a/ui/model.go +++ b/ui/model.go @@ -150,6 +150,7 @@ type Model struct { // URL input mode (load playlist/stream URL at runtime) urlInputting bool urlInput string + urlImporting bool // true when URL input should import as persistent local playlist // Async feed/M3U URL resolution pendingURLs []string @@ -927,6 +928,44 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case urlPlaylistImportedMsg: + m.feedLoading = false + if msg.err != nil { + m.status.text = fmt.Sprintf("Import failed: %v", msg.err) + m.status.ttl = 90 + return m, nil + } + if msg.count == 0 { + m.status.text = "No tracks found at URL." + m.status.ttl = 60 + return m, nil + } + m.status.text = fmt.Sprintf("Saved %d track(s) to \"%s\"", msg.count, msg.name) + m.status.ttl = 90 + if m.plManager.visible && m.plManager.screen == plMgrScreenList { + m.plMgrRefreshList() + } + return m, nil + + case linkedPlaylistRefreshedMsg: + m.feedLoading = false + if msg.err != nil { + m.status.text = fmt.Sprintf("Refresh failed: %v", msg.err) + m.status.ttl = 90 + return m, nil + } + if msg.count == 0 { + m.status.text = "No tracks found at source URL." + m.status.ttl = 60 + return m, nil + } + m.status.text = fmt.Sprintf("Refreshed \"%s\" (%d track(s))", msg.name, msg.count) + m.status.ttl = 90 + if m.plManager.visible && m.plManager.screen == plMgrScreenList { + m.plMgrRefreshList() + } + return m, nil + case netSearchLoadedMsg: if len(msg) > 0 { startIdx := m.playlist.Len() diff --git a/ui/url_import_test.go b/ui/url_import_test.go new file mode 100644 index 00000000..30abe6ca --- /dev/null +++ b/ui/url_import_test.go @@ -0,0 +1,51 @@ +package ui + +import "testing" + +func TestParseURLPlaylistImportInput(t *testing.T) { + tests := []struct { + name string + input string + wantName string + wantURL string + wantOK bool + }{ + { + name: "valid input", + input: "My Playlist | https://example.com/list.m3u", + wantName: "My Playlist", + wantURL: "https://example.com/list.m3u", + wantOK: true, + }, + { + name: "missing separator", + input: "My Playlist https://example.com/list.m3u", + wantOK: false, + }, + { + name: "empty name", + input: " | https://example.com/list.m3u", + wantOK: false, + }, + { + name: "empty url", + input: "My Playlist | ", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotName, gotURL, gotOK := parseURLPlaylistImportInput(tt.input) + if gotOK != tt.wantOK { + t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK) + } + if gotName != tt.wantName { + t.Fatalf("name = %q, want %q", gotName, tt.wantName) + } + if gotURL != tt.wantURL { + t.Fatalf("url = %q, want %q", gotURL, tt.wantURL) + } + }) + } +} diff --git a/ui/view_overlays.go b/ui/view_overlays.go index 2acddb52..aee1be37 100644 --- a/ui/view_overlays.go +++ b/ui/view_overlays.go @@ -53,7 +53,6 @@ func (m Model) renderKeymapOverlay() string { return m.centerOverlay(strings.Join(lines, "\n")) } - func (m Model) renderThemePicker() string { lines := []string{ titleStyle.Render("T H E M E S"), @@ -135,7 +134,7 @@ func (m Model) renderPlMgrList() []string { lines = append(lines, "", dimStyle.Render(fmt.Sprintf(" %d/%d playlists", m.plManager.cursor+1, count))) } - lines = append(lines, "", helpKey("↑↓", "Navigate ")+helpKey("Enter/→", "Open ")+helpKey("a", "Add track ")+helpKey("d", "Delete ")+helpKey("Esc", "Close")) + lines = append(lines, "", helpKey("↑↓", "Navigate ")+helpKey("Enter/→", "Open ")+helpKey("a", "Add track ")+helpKey("R", "Refresh link ")+helpKey("d", "Delete ")+helpKey("Esc", "Close")) return lines } @@ -317,12 +316,23 @@ func (m Model) renderNetSearchOverlay() string { } func (m Model) renderURLInputOverlay() string { + title := titleStyle.Render("L O A D U R L") + label := " URL: " + m.urlInput + "_" + help := helpKey("Enter", "Load") + " " + helpKey("Esc", "Cancel") + if m.urlImporting { + title = titleStyle.Render("I M P O R T U R L P L A Y L I S T") + label = " Name | URL: " + m.urlInput + "_" + help = helpKey("Enter", "Import") + " " + helpKey("Esc", "Cancel") + } lines := []string{ - titleStyle.Render("L O A D U R L"), + title, "", - playlistSelectedStyle.Render(" URL: " + m.urlInput + "_"), + playlistSelectedStyle.Render(label), "", - helpKey("Enter", "Load") + " " + helpKey("Esc", "Cancel"), + help, + } + if m.urlImporting { + lines = append(lines, "", dimStyle.Render(" Example: My YT Mix | https://music.youtube.com/playlist?list=...")) } return m.centerOverlay(strings.Join(lines, "\n")) }