Skip to content
Closed
5 changes: 3 additions & 2 deletions cmd/playlist_ops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ func TestPlaylistListEmpty(t *testing.T) {
if err != nil {
t.Fatalf("PlaylistList: %v", err)
}
if !strings.Contains(out, "No playlists") {
t.Errorf("output = %q, want 'No playlists...'", out)
// Favorites always appears as a virtual playlist even when empty.
if !strings.Contains(out, "Favorites") {
t.Errorf("output = %q, want Favorites virtual playlist to appear", out)
}
}

Expand Down
17 changes: 15 additions & 2 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ active when the picker opened. While typing a filter, `Enter` finishes it and

| Key | Action |
|---|---|
| `f` | Toggle bookmark ★ on selected track (or favorite radio station in radio browser) |
| `f` | Toggle bookmark ★ on selected track within the loaded playlist (or favorite a radio station in the radio browser) |
| `n` | Toggle favorite ♥ on selected track (cross-playlist; favorited tracks appear in the "Favorites" virtual playlist) |
| `Ctrl+F` | Search — active provider's native search (Spotify, Qobuz, Navidrome, Jellyfin, Emby, Plex, Audiobookshelf, NetEase, Local) or YouTube fallback. Available from playlist and provider-browser views. |
| `u` | Load URL (stream/playlist) |
| `y` | Show or close lyrics |
Expand Down Expand Up @@ -120,13 +121,24 @@ active when the picker opened. While typing a filter, `Enter` finishes it and
| `[` `]` | Tracks: move highlighted track and save the playlist |
| `s` | Tracks: sort and save, cycling `track`, `title`, `artist`, `album`, `artist+album`, `path` |
| `o` | Tracks: open file browser to add files to this playlist |
| `D` | Tracks: open the directory-sources screen for this playlist |
| `r` | List: rename the playlist |
| `d` | List: delete playlist (confirms). Tracks: remove marked tracks, or highlighted track when none are marked |
| `u` | Undo the last manager edit |
| `←` `Backspace` `h` | Tracks screen: go back to the list |
| `Esc` | Close the playlist manager or go back |

Shift-letter keys are reserved for provider switching, so playlist-manager track actions use lowercase or punctuation keys.
Shift-letter keys are reserved for provider switching, so playlist-manager track actions use lowercase or punctuation keys. `D` is the one exception: it opens the directory-sources screen.

#### Directory sources screen (`D` from the tracks screen)

| Key | Action |
|---|---|
| `↑` `↓` / `j` `k` | Navigate directory sources |
| `a` | Open the file browser to add a directory as a `[[dir]]` source |
| `d` then `y` | Remove the highlighted source (`y` confirms, anything else cancels) |
| `r` | Toggle `recursive` on the highlighted source |
| `←` `Backspace` `h` `Esc` | Back to the tracks screen |

## File browser

Expand All @@ -139,6 +151,7 @@ Shift-letter keys are reserved for provider switching, so playlist-manager track
| `a` | Select/unselect all visible audio files |
| `R` | Replace the current queue with selected files (confirm when it is non-empty) |
| `w` | Write selected files to a local playlist |
| `D` | Add the selected directory (or the current directory when none is selected) as a live `[[dir]]` source to the target playlist |
| `~` `.` | Jump to home / current working directory |
| `Esc` `o` | Close file browser |

Expand Down
37 changes: 37 additions & 0 deletions docs/playlists.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,44 @@ title = "My Radio"
| `s` | Sort tracks, cycling supported sort keys (tracks screen) |
| `w` | Write marked/highlighted tracks, or the current queue from the list screen, to another playlist |
| `o` | Add files to the open playlist (tracks screen) |
| `D` | Open the directory-sources screen for the open playlist (tracks screen) |
| `[` `]` | Move track up/down and save (tracks screen) |
| `d` | Delete playlist (confirms) / Remove marked tracks, or highlighted track if none are marked |
| `u` | Undo the last playlist-manager edit |
| `←` / `Backspace` | Go back from tracks screen to list |

The playlist list flags playlists that reference `[[dir]]` sources with a
`· N dir(s)` indicator next to the track count.

**Directory sources screen (tracks screen → `D`):**

| Key | Action |
|-----|--------|
| `Up` `Down` / `j` `k` | Navigate directory sources |
| `a` | Open the file browser to add a directory as a `[[dir]]` source |
| `d` then `y` | Remove the highlighted source (confirm with `y`, cancel with anything else) |
| `r` | Toggle `recursive` on the highlighted source (re-scans immediately) |
| `←` / `Backspace` / `Esc` | Back to the tracks screen |

From the file browser (opened with `a` above, or with `o` from the tracks
screen), press `D` to add the selected directory — or the directory you are
currently browsing when nothing is selected — as a live `[[dir]]` source
instead of expanding it into explicit tracks. Directories already referenced
are skipped and reported.

## Favorites

Press `n` on any track in the track list to toggle it as a
favorite. Favorited tracks are collected into a virtual **"Favorites"**
playlist that always appears at the top of the playlist list — regardless of
which playlist the track was favorited from, and even when empty.

Favorites are cross-playlist: a track favorited while browsing "gym" shows up
in "Favorites" and vice versa. The "Favorites" playlist is backed by
`~/.config/cliamp/favorites.toml` and behaves like "Recently Played" — it is
a virtual playlist that cannot be renamed, deleted, or modified via the
playlist manager. Use `n` again to unfavorite a track.

Favorited tracks display a `♥` marker in the track list. The bookmark
system (`f` key, `★` marker) is separate — bookmarks are per-playlist,
while favorites span all playlists.
20 changes: 6 additions & 14 deletions external/local/dirs.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,6 @@ import (
"github.com/bjarneo/cliamp/resolve"
)

// DirSource is a [[dir]] section in a playlist file: a directory that is
// scanned for audio files every time the playlist loads, instead of listing
// every file explicitly.
type DirSource struct {
Path string // directory path; supports ~ and environment variables
Recursive bool // scan subdirectories too (default true)
}

// ExpandPath expands a leading ~ and environment variables in p.
func ExpandPath(p string) string {
if p == "" {
Expand All @@ -45,7 +37,7 @@ const (
// [[dir]] sources, with section order preserved for ordered expansion.
type playlistDoc struct {
tracks []playlist.Track
dirs []DirSource
dirs []playlist.DirSource
order []uint8 // itemTrack or itemDir per section, in document order
}

Expand All @@ -62,7 +54,7 @@ func parsePlaylistDoc(data []byte) *playlistDoc {
if f["path"] == "" {
return
}
doc.dirs = append(doc.dirs, DirSource{
doc.dirs = append(doc.dirs, playlist.DirSource{
Path: f["path"],
Recursive: f["recursive"] != "false",
})
Expand Down Expand Up @@ -123,7 +115,7 @@ func (d *playlistDoc) expand(withTags bool) []playlist.Track {
}

// writeDir writes a single [[dir]] TOML section to w.
func writeDir(w io.Writer, src DirSource) {
func writeDir(w io.Writer, src playlist.DirSource) {
fmt.Fprintln(w, "[[dir]]")
fmt.Fprintf(w, "path = %q\n", src.Path)
if !src.Recursive {
Expand All @@ -135,7 +127,7 @@ func writeDir(w io.Writer, src DirSource) {
type playlistSection struct {
kind uint8 // itemTrack or itemDir
track playlist.Track
dir DirSource
dir playlist.DirSource
}

// rebuildDoc merges the caller's explicit tracks back into an existing parsed
Expand All @@ -150,7 +142,7 @@ type playlistSection struct {
// before the directory section that would otherwise supply them, so a
// materialized track keeps its position among the directory's tracks; tracks
// no directory provides are appended at the end.
func rebuildDoc(existing *playlistDoc, explicit []playlist.Track) (tracks []playlist.Track, dirs []DirSource, order []uint8) {
func rebuildDoc(existing *playlistDoc, explicit []playlist.Track) (tracks []playlist.Track, dirs []playlist.DirSource, order []uint8) {
origPaths := make([]string, len(existing.tracks))
for i, t := range existing.tracks {
origPaths[i] = t.Path
Expand Down Expand Up @@ -298,7 +290,7 @@ func validateDirSource(dir string) error {
// non-recursive sources, not below an immediate subdirectory. The check is
// path-only so save-time rewrites do not repeat the filesystem walk done at
// load.
func dirSuppliesFile(dir DirSource, file string) bool {
func dirSuppliesFile(dir playlist.DirSource, file string) bool {
if !player.SupportedExts[strings.ToLower(filepath.Ext(file))] {
return false
}
Expand Down
162 changes: 157 additions & 5 deletions external/local/dirs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,158 @@ func TestAddDirSource(t *testing.T) {
}
}

func TestRemoveDirSource(t *testing.T) {
p := newTestProvider(t)
audio1 := t.TempDir()
audio2 := t.TempDir()
writeAudioFile(t, filepath.Join(audio1, "a.mp3"))
writeAudioFile(t, filepath.Join(audio2, "b.mp3"))
writeAudioFile(t, filepath.Join(audio2, "c.mp3"))

if _, err := p.AddDirSource("music", audio1); err != nil {
t.Fatalf("add audio1: %v", err)
}
if _, err := p.AddDirSource("music", audio2); err != nil {
t.Fatalf("add audio2: %v", err)
}
// A directory-sourced track list between the two dirs should now hold
// all three files.
if tracks, err := p.Tracks("music"); err != nil || len(tracks) != 3 {
t.Fatalf("tracks before remove = %d (err %v), want 3", len(tracks), err)
}

// Removing audio1 drops only its file; audio2's two remain.
if err := p.RemoveDirSource("music", audio1); err != nil {
t.Fatalf("RemoveDirSource: %v", err)
}
dirs, err := p.DirSources("music")
if err != nil || len(dirs) != 1 || dirs[0].Path != audio2 {
t.Fatalf("after remove dirs = %+v err %v, want only audio2", dirs, err)
}
tracks, err := p.Tracks("music")
if err != nil || len(tracks) != 2 {
t.Fatalf("tracks after remove = %d (err %v), want 2", len(tracks), err)
}

// Removing a dir that is not referenced is a no-op (not an error).
if err := p.RemoveDirSource("music", audio1); err != nil {
t.Fatalf("remove missing source should be no-op, got %v", err)
}
// Removing from a playlist that does not exist is a no-op.
if err := p.RemoveDirSource("nope", audio1); err != nil {
t.Fatalf("remove from missing playlist should be no-op, got %v", err)
}
// The history playlist is reserved.
if err := p.RemoveDirSource("Recently Played", audio1); err == nil {
t.Fatal("RemoveDirSource on history should error")
}
}

func TestSetDirRecursive(t *testing.T) {
p := newTestProvider(t)
audio := t.TempDir()
makeAudioTree(t, audio) // two top-level files + one nested

if _, err := p.AddDirSource("music", audio); err != nil {
t.Fatalf("add: %v", err)
}
dirs, _ := p.DirSources("music")
if !dirs[0].Recursive {
t.Fatalf("new dir should default to recursive, got %+v", dirs[0])
}
// Recursive scan sees all three files.
if tracks, _ := p.Tracks("music"); len(tracks) != 3 {
t.Fatalf("recursive tracks = %d, want 3", len(tracks))
}

// Flip to flat: only the two top-level files remain.
if err := p.SetDirRecursive("music", audio, false); err != nil {
t.Fatalf("SetDirRecursive(false): %v", err)
}
dirs, _ = p.DirSources("music")
if dirs[0].Recursive {
t.Fatalf("dir should now be flat, got %+v", dirs[0])
}
if tracks, _ := p.Tracks("music"); len(tracks) != 2 {
t.Fatalf("flat tracks = %d, want 2", len(tracks))
}

// Flip back to recursive.
if err := p.SetDirRecursive("music", audio, true); err != nil {
t.Fatalf("SetDirRecursive(true): %v", err)
}
if tracks, _ := p.Tracks("music"); len(tracks) != 3 {
t.Fatalf("re-enabled recursive tracks = %d, want 3", len(tracks))
}

// Setting the same value is a no-op (no error, no change).
if err := p.SetDirRecursive("music", audio, true); err != nil {
t.Fatalf("idempotent SetDirRecursive: %v", err)
}
// Missing source and missing playlist are no-ops.
if err := p.SetDirRecursive("music", t.TempDir(), false); err != nil {
t.Fatalf("missing source should be no-op, got %v", err)
}
if err := p.SetDirRecursive("nope", audio, false); err != nil {
t.Fatalf("missing playlist should be no-op, got %v", err)
}
// History is reserved.
if err := p.SetDirRecursive("Recently Played", audio, false); err == nil {
t.Fatal("SetDirRecursive on history should error")
}
}

func TestDirIndexByPathMatchesTildeAndAbsolute(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil || home == "" {
t.Skip("no home directory")
}
audio := filepath.Join(home, "Music")
doc := parsePlaylistDoc([]byte("[[dir]]\npath = \"~/Music\"\n"))
if got := dirIndexByPath(doc, "~/Music"); got != 0 {
t.Fatalf("dirIndexByPath ~/Music = %d, want 0", got)
}
if got := dirIndexByPath(doc, audio); got != 0 {
t.Fatalf("dirIndexByPath absolute = %d, want 0 (tilde should match absolute)", got)
}
if got := dirIndexByPath(doc, "/elsewhere"); got != -1 {
t.Fatalf("dirIndexByPath miss = %d, want -1", got)
}
}

func TestPlaylistsDirSourceCount(t *testing.T) {
p := newTestProvider(t)
audio := t.TempDir()
writeAudioFile(t, filepath.Join(audio, "a.mp3"))
writeAudioFile(t, filepath.Join(audio, "b.flac"))

if _, err := p.AddDirSource("music", audio); err != nil {
t.Fatalf("add: %v", err)
}
// A second playlist with no dirs for contrast.
if _, err := p.CreatePlaylist(context.Background(), "plain"); err != nil {
t.Fatalf("create plain: %v", err)
}

lists, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists: %v", err)
}
byName := map[string]playlist.PlaylistInfo{}
for _, l := range lists {
byName[l.Name] = l
}
if byName["music"].DirSourceCount != 1 {
t.Fatalf("music DirSourceCount = %d, want 1", byName["music"].DirSourceCount)
}
if byName["plain"].DirSourceCount != 0 {
t.Fatalf("plain DirSourceCount = %d, want 0", byName["plain"].DirSourceCount)
}
if byName["music"].TrackCount != 2 {
t.Fatalf("music TrackCount = %d, want 2", byName["music"].TrackCount)
}
}

func TestSavePlaylistPreservesDirsAndSkipsDirTracks(t *testing.T) {
p := newTestProvider(t)
audio := t.TempDir()
Expand Down Expand Up @@ -488,8 +640,8 @@ func TestAddTracksPersistsCrossPlaylistDirTrack(t *testing.T) {

func TestWriteDirRoundTrip(t *testing.T) {
var b strings.Builder
writeDir(&b, DirSource{Path: "/music", Recursive: true})
writeDir(&b, DirSource{Path: "/other", Recursive: false})
writeDir(&b, playlist.DirSource{Path: "/music", Recursive: true})
writeDir(&b, playlist.DirSource{Path: "/other", Recursive: false})
doc := parsePlaylistDoc([]byte(b.String()))
if len(doc.dirs) != 2 {
t.Fatalf("round trip dirs = %d", len(doc.dirs))
Expand Down Expand Up @@ -693,11 +845,11 @@ func TestSavePlaylistMultiMaterializedKeepsDirPositions(t *testing.T) {

func TestDirSuppliesFile(t *testing.T) {
dir := t.TempDir()
rec := DirSource{Path: dir, Recursive: true}
nonRec := DirSource{Path: dir, Recursive: false}
rec := playlist.DirSource{Path: dir, Recursive: true}
nonRec := playlist.DirSource{Path: dir, Recursive: false}
tests := []struct {
name string
src DirSource
src playlist.DirSource
file string
want bool
}{
Expand Down
Loading
Loading