diff --git a/cmd/playlist_ops_test.go b/cmd/playlist_ops_test.go index d4dc5d4cc..346b4a887 100644 --- a/cmd/playlist_ops_test.go +++ b/cmd/playlist_ops_test.go @@ -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) } } diff --git a/docs/keybindings.md b/docs/keybindings.md index b1fc9fd62..7e4d28100 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -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 | @@ -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 @@ -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 | diff --git a/docs/playlists.md b/docs/playlists.md index 79a7a8b75..32cb985c7 100644 --- a/docs/playlists.md +++ b/docs/playlists.md @@ -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. diff --git a/external/local/dirs.go b/external/local/dirs.go index da625e1d2..9cb087c24 100644 --- a/external/local/dirs.go +++ b/external/local/dirs.go @@ -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 == "" { @@ -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 } @@ -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", }) @@ -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 { @@ -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 @@ -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 @@ -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 } diff --git a/external/local/dirs_test.go b/external/local/dirs_test.go index 16670fe3a..10889e7d0 100644 --- a/external/local/dirs_test.go +++ b/external/local/dirs_test.go @@ -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() @@ -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)) @@ -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 }{ diff --git a/external/local/provider.go b/external/local/provider.go index bb7fa826d..082ebb535 100644 --- a/external/local/provider.go +++ b/external/local/provider.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" + "github.com/bjarneo/cliamp/favorites" "github.com/bjarneo/cliamp/history" "github.com/bjarneo/cliamp/internal/appdir" "github.com/bjarneo/cliamp/internal/fuzzy" @@ -24,20 +25,23 @@ import ( // 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.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.PlaylistDirSourceManager = (*Provider)(nil) + _ provider.FavoritesManager = (*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 + dir string // e.g. ~/.config/cliamp/playlists/ + history *history.Store + favorites *favorites.Store } // New creates a Provider using ~/.config/cliamp/playlists/ as the base directory. @@ -47,8 +51,9 @@ func New() *Provider { return nil } return &Provider{ - dir: filepath.Join(dir, "playlists"), - history: history.New(), + dir: filepath.Join(dir, "playlists"), + history: history.New(), + favorites: favorites.New(), } } @@ -79,11 +84,18 @@ func isHistoryName(name string) bool { return name == history.PlaylistName } +func isFavoritesName(name string) bool { + return name == favorites.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.favoritesInfo(); ok { + lists = append(lists, info) + } if info, ok := p.historyInfo(); ok { lists = append(lists, info) } @@ -111,10 +123,11 @@ func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) { // files they supply. tracks := doc.expand(false) lists = append(lists, playlist.PlaylistInfo{ - ID: name, - Name: name, - TrackCount: len(tracks), - DurationSecs: playlist.TotalDurationSecs(tracks), + ID: name, + Name: name, + TrackCount: len(tracks), + DurationSecs: playlist.TotalDurationSecs(tracks), + DirSourceCount: len(doc.dirs), }) } return lists, nil @@ -138,10 +151,36 @@ func (p *Provider) historyInfo() (playlist.PlaylistInfo, bool) { }, true } +// favoritesInfo returns the synthetic PlaylistInfo entry for "Favorites". +// The entry always appears when the favorites store is available, even when +// empty, so users can discover the feature and see an empty placeholder. +func (p *Provider) favoritesInfo() (playlist.PlaylistInfo, bool) { + if p.favorites == nil { + return playlist.PlaylistInfo{}, false + } + tracks, err := p.favorites.Tracks() + if err != nil { + return playlist.PlaylistInfo{}, false + } + return playlist.PlaylistInfo{ + ID: favorites.PlaylistName, + Name: favorites.PlaylistName, + Section: "Favorites", + TrackCount: len(tracks), + DurationSecs: playlist.TotalDurationSecs(tracks), + }, true +} + // Tracks returns the full track list for the named playlist: explicit // [[track]] entries plus tracks scanned from any [[dir]] sources, in document // order. The reserved "Recently Played" name is served from the history store. func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) { + if isFavoritesName(playlistID) { + if p.favorites == nil { + return nil, nil + } + return p.favorites.Tracks() + } if isHistoryName(playlistID) { if p.history == nil { return nil, nil @@ -178,6 +217,9 @@ func (p *Provider) AddTracks(playlistName string, tracks []playlist.Track) (adde if isHistoryName(playlistName) { return 0, 0, errReservedHistoryName } + if isFavoritesName(playlistName) { + return 0, 0, errReservedFavoritesName + } if err := os.MkdirAll(p.dir, 0o755); err != nil { return 0, 0, err } @@ -243,6 +285,9 @@ func (p *Provider) CreatePlaylist(_ context.Context, name string) (string, error if isHistoryName(name) { return "", errReservedHistoryName } + if isFavoritesName(name) { + return "", errReservedFavoritesName + } if err := os.MkdirAll(p.dir, 0o755); err != nil { return "", err } @@ -274,6 +319,9 @@ func (p *Provider) CreateDirPlaylist(name string, dirs []string) error { if isHistoryName(name) { return errReservedHistoryName } + if isFavoritesName(name) { + return errReservedFavoritesName + } if err := os.MkdirAll(p.dir, 0o755); err != nil { return fmt.Errorf("creating playlist dir: %w", err) } @@ -300,7 +348,7 @@ func (p *Provider) CreateDirPlaylist(name string, dirs []string) error { if i > 0 { b.WriteByte('\n') } - writeDir(&b, DirSource{Path: dir, Recursive: true}) + writeDir(&b, playlist.DirSource{Path: dir, Recursive: true}) } tmp := path + ".tmp" @@ -327,6 +375,9 @@ func (p *Provider) AddDirSources(name string, dirs []string) ([]string, error) { if isHistoryName(name) { return nil, errReservedHistoryName } + if isFavoritesName(name) { + return nil, errReservedFavoritesName + } for _, dir := range dirs { if err := validateDirSource(dir); err != nil { return nil, err @@ -362,7 +413,7 @@ func (p *Provider) AddDirSources(name string, dirs []string) ([]string, error) { } known[target] = struct{}{} added = append(added, dir) - doc.dirs = append(doc.dirs, DirSource{Path: dir, Recursive: true}) + doc.dirs = append(doc.dirs, playlist.DirSource{Path: dir, Recursive: true}) doc.order = append(doc.order, itemDir) } if len(added) == 0 { @@ -383,7 +434,7 @@ func (p *Provider) AddDirSource(name, dir string) (bool, error) { } // DirSources returns the directory sources referenced by a playlist. -func (p *Provider) DirSources(name string) ([]DirSource, error) { +func (p *Provider) DirSources(name string) ([]playlist.DirSource, error) { if isHistoryName(name) { return nil, errReservedHistoryName } @@ -394,6 +445,96 @@ func (p *Provider) DirSources(name string) ([]DirSource, error) { return doc.dirs, nil } +// dirIndexByPath returns the index in doc.dirs of the source whose expanded +// path matches dir (also expanded), or -1 when none matches. Comparison uses +// cleaned filesystem paths so "~/Music" and "/home/user/Music" align. It is a +// pure path check, so it never re-walks the filesystem the load already did. +func dirIndexByPath(doc *playlistDoc, dir string) int { + target := filepath.Clean(ExpandPath(dir)) + for i, src := range doc.dirs { + if filepath.Clean(ExpandPath(src.Path)) == target { + return i + } + } + return -1 +} + +// RemoveDirSource removes the [[dir]] section whose path matches dir from the +// named playlist. Explicit [[track]] sections keep their slots and order. A +// missing source (or a missing playlist) is a no-op rather than an error, so +// callers can remove without first checking existence. +func (p *Provider) RemoveDirSource(name, dir string) error { + if isHistoryName(name) { + return errReservedHistoryName + } + if isFavoritesName(name) { + return errReservedFavoritesName + } + path, err := p.safePath(name) + if err != nil { + return fmt.Errorf("resolving playlist path: %w", err) + } + doc, err := p.loadDoc(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("loading playlist %q: %w", name, err) + } + di := dirIndexByPath(doc, dir) + if di < 0 { + return nil + } + // Drop the dir and its corresponding itemDir slot from doc.order so the + // ti/di counters used by saveDoc stay aligned with the remaining sections. + doc.dirs = append(doc.dirs[:di], doc.dirs[di+1:]...) + seen := 0 + for i, kind := range doc.order { + if kind != itemDir { + continue + } + if seen == di { + doc.order = append(doc.order[:i], doc.order[i+1:]...) + break + } + seen++ + } + return p.saveDoc(name, doc) +} + +// SetDirRecursive sets the recursive flag on the [[dir]] section whose path +// matches dir in the named playlist. It is a no-op (not an error) when the +// source is missing, the playlist is missing, or the flag is already the +// requested value, so callers can toggle without first checking state. +func (p *Provider) SetDirRecursive(name, dir string, recursive bool) error { + if isHistoryName(name) { + return errReservedHistoryName + } + if isFavoritesName(name) { + return errReservedFavoritesName + } + path, err := p.safePath(name) + if err != nil { + return fmt.Errorf("resolving playlist path: %w", err) + } + doc, err := p.loadDoc(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("loading playlist %q: %w", name, err) + } + di := dirIndexByPath(doc, dir) + if di < 0 { + return nil + } + if doc.dirs[di].Recursive == recursive { + return nil + } + doc.dirs[di].Recursive = recursive + return p.saveDoc(name, doc) +} + // saveDoc writes a parsed document back to disk, preserving section order. // The full document is rendered in memory before the atomic rename so a // partial write can never clobber the existing playlist. @@ -444,6 +585,10 @@ func (p *Provider) saveDoc(name string, doc *playlistDoc) error { // whether it refers to the virtual "Recently Played" history with at least // one entry recorded. func (p *Provider) Exists(name string) bool { + if isFavoritesName(name) { + _, ok := p.favoritesInfo() + return ok + } if isHistoryName(name) { _, ok := p.historyInfo() return ok @@ -513,6 +658,10 @@ func (p *Provider) existingDoc(path string) (*playlistDoc, error) { // otherwise mutate the synthetic history playlist. var errReservedHistoryName = errors.New(`"Recently Played" is a virtual history playlist and cannot be modified`) +// errReservedFavoritesName is returned when a caller tries to write to or +// otherwise mutate the synthetic favorites playlist. +var errReservedFavoritesName = errors.New(`"Favorites" is a virtual favorites playlist and cannot be modified`) + // SetBookmark toggles the bookmark flag on a track and rewrites the playlist. // The index refers to the expanded track list (explicit entries plus // directory-scanned ones). Bookmarking a directory-scanned track materializes @@ -522,6 +671,9 @@ func (p *Provider) SetBookmark(playlistName string, idx int) error { if isHistoryName(playlistName) { return errReservedHistoryName } + if isFavoritesName(playlistName) { + return errReservedFavoritesName + } tracks, err := p.expandedTracks(playlistName) if err != nil { return err @@ -543,6 +695,9 @@ func (p *Provider) SetBookmarkByPath(playlistName string, path string) error { if isHistoryName(playlistName) { return errReservedHistoryName } + if isFavoritesName(playlistName) { + return errReservedFavoritesName + } tracks, err := p.expandedTracks(playlistName) if err != nil { return err @@ -571,6 +726,9 @@ func (p *Provider) SavePlaylist(name string, tracks []playlist.Track) error { if isHistoryName(name) { return errReservedHistoryName } + if isFavoritesName(name) { + return errReservedFavoritesName + } return p.savePlaylist(name, tracks) } @@ -666,6 +824,9 @@ func (p *Provider) RenamePlaylist(oldName, newName string) error { if isHistoryName(oldName) || isHistoryName(newName) { return errReservedHistoryName } + if isFavoritesName(oldName) || isFavoritesName(newName) { + return errReservedFavoritesName + } oldPath, err := p.safePath(oldName) if err != nil { return fmt.Errorf("invalid playlist name %q: %w", oldName, err) @@ -694,6 +855,9 @@ func (p *Provider) DeletePlaylist(name string) error { if isHistoryName(name) { return errReservedHistoryName } + if isFavoritesName(name) { + return errReservedFavoritesName + } path, err := p.safePath(name) if err != nil { return err @@ -710,6 +874,47 @@ func (p *Provider) ClearHistory() error { return p.history.Clear() } +// ClearFavorites wipes the favorites list. Returns nil if no favorites exist. +func (p *Provider) ClearFavorites() error { + if p.favorites == nil { + return nil + } + return p.favorites.Clear() +} + +// FavoritesStore returns the underlying favorites store so the UI can toggle +// favorites without going through the playlist write path. +func (p *Provider) FavoritesStore() *favorites.Store { + return p.favorites +} + +// ToggleFavorite toggles a track in the favorites store. +// Implements provider.FavoritesManager. +func (p *Provider) ToggleFavorite(track playlist.Track) (bool, error) { + if p.favorites == nil { + return false, nil + } + return p.favorites.Toggle(track) +} + +// IsFavorited reports whether the given path is in the favorites store. +// Implements provider.FavoritesManager. +func (p *Provider) IsFavorited(path string) bool { + if p.favorites == nil { + return false + } + return p.favorites.IsFavorited(path) +} + +// FavoritesCount returns the number of favorited tracks. +// Implements provider.FavoritesManager. +func (p *Provider) FavoritesCount() int { + if p.favorites == nil { + return 0 + } + return p.favorites.Count() +} + // RemoveTrack removes a track by index from the named playlist. // The index refers to the expanded track list. Directory-scanned tracks // cannot be removed: they are re-derived from the [[dir]] source on every @@ -718,6 +923,9 @@ func (p *Provider) RemoveTrack(name string, index int) error { if isHistoryName(name) { return errReservedHistoryName } + if isFavoritesName(name) { + return errReservedFavoritesName + } tracks, err := p.expandedTracks(name) if err != nil { return err diff --git a/external/local/provider_test.go b/external/local/provider_test.go index eb8368e90..118f3a67d 100644 --- a/external/local/provider_test.go +++ b/external/local/provider_test.go @@ -10,8 +10,10 @@ import ( "testing" "time" + "github.com/bjarneo/cliamp/favorites" "github.com/bjarneo/cliamp/history" "github.com/bjarneo/cliamp/playlist" + "github.com/bjarneo/cliamp/provider" ) func newTestProvider(t *testing.T) *Provider { @@ -726,3 +728,152 @@ func TestSearchTracksLimit(t *testing.T) { t.Fatalf("got %d results, want 2 (limit)", len(got)) } } + +// --- Virtual "Favorites" playlist --- + +func newTestProviderWithFavorites(t *testing.T) *Provider { + t.Helper() + dir := t.TempDir() + favPath := filepath.Join(dir, "favorites.toml") + return &Provider{dir: filepath.Join(dir, "playlists"), favorites: favorites.NewAt(favPath)} +} + +func TestPlaylistsIncludesFavoritesWhenNonEmpty(t *testing.T) { + p := newTestProviderWithFavorites(t) + p.favorites.Toggle(playlist.Track{Path: "/a.mp3", Title: "A"}) + + lists, err := p.Playlists() + if err != nil { + t.Fatalf("Playlists: %v", err) + } + if len(lists) != 1 || lists[0].ID != "Favorites" { + t.Fatalf("Playlists = %+v, want [Favorites]", lists) + } + if lists[0].Section != "Favorites" { + t.Errorf("Section = %q, want %q", lists[0].Section, "Favorites") + } +} + +func TestPlaylistsIncludesFavoritesWhenEmpty(t *testing.T) { + p := newTestProviderWithFavorites(t) + lists, err := p.Playlists() + if err != nil { + t.Fatalf("Playlists: %v", err) + } + if len(lists) != 1 || lists[0].ID != "Favorites" { + t.Fatalf("Playlists = %+v, want [Favorites] even when empty", lists) + } + if lists[0].TrackCount != 0 { + t.Errorf("TrackCount = %d, want 0", lists[0].TrackCount) + } +} + +func TestTracksReadsFromFavorites(t *testing.T) { + p := newTestProviderWithFavorites(t) + p.favorites.Toggle(playlist.Track{Path: "/a.mp3", Title: "A"}) + p.favorites.Toggle(playlist.Track{Path: "/b.mp3", Title: "B"}) + + tracks, err := p.Tracks("Favorites") + if err != nil { + t.Fatalf("Tracks: %v", err) + } + if len(tracks) != 2 { + t.Fatalf("got %d tracks, want 2", len(tracks)) + } + // Newest first (B was toggled after A). + if tracks[0].Title != "B" || tracks[1].Title != "A" { + t.Errorf("order = [%s, %s], want [B, A]", tracks[0].Title, tracks[1].Title) + } +} + +func TestWritesRejectedForFavoritesName(t *testing.T) { + p := newTestProviderWithFavorites(t) + track := playlist.Track{Path: "/a.mp3", Title: "A"} + + tests := []struct { + name string + fn func() error + }{ + {"AddTrack", func() error { return p.AddTrack("Favorites", track) }}, + {"AddTracks", func() error { _, _, err := p.AddTracks("Favorites", []playlist.Track{track}); return err }}, + {"SavePlaylist", func() error { return p.SavePlaylist("Favorites", nil) }}, + {"DeletePlaylist", func() error { return p.DeletePlaylist("Favorites") }}, + {"RemoveTrack", func() error { return p.RemoveTrack("Favorites", 0) }}, + {"SetBookmark", func() error { return p.SetBookmark("Favorites", 0) }}, + {"SetBookmarkByPath", func() error { return p.SetBookmarkByPath("Favorites", "/a.mp3") }}, + {"RenamePlaylist", func() error { return p.RenamePlaylist("Favorites", "NewName") }}, + {"CreatePlaylist", func() error { _, err := p.CreatePlaylist(context.Background(), "Favorites"); return err }}, + {"AddDirSources", func() error { _, err := p.AddDirSources("Favorites", []string{"/some/dir"}); return err }}, + {"RemoveDirSource", func() error { return p.RemoveDirSource("Favorites", "/some/dir") }}, + {"SetDirRecursive", func() error { return p.SetDirRecursive("Favorites", "/some/dir", true) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.fn() + if err == nil { + t.Fatalf("%s: expected error, got nil", tt.name) + } + if !strings.Contains(err.Error(), "Favorites") { + t.Fatalf("%s: error = %q, want message mentioning Favorites", tt.name, err) + } + }) + } +} + +func TestFavoritesManagerInterface(t *testing.T) { + p := newTestProviderWithFavorites(t) + fm, ok := any(p).(provider.FavoritesManager) + if !ok { + t.Fatal("Provider does not implement FavoritesManager") + } + + // Initially empty. + if fm.FavoritesCount() != 0 { + t.Fatalf("Count = %d, want 0", fm.FavoritesCount()) + } + if fm.IsFavorited("/a.mp3") { + t.Fatal("should not be favorited initially") + } + + // Toggle on. + added, err := fm.ToggleFavorite(playlist.Track{Path: "/a.mp3", Title: "A"}) + if err != nil { + t.Fatalf("ToggleFavorite: %v", err) + } + if !added { + t.Fatal("first toggle should return true") + } + if !fm.IsFavorited("/a.mp3") { + t.Fatal("should be favorited after toggle on") + } + if fm.FavoritesCount() != 1 { + t.Fatalf("Count = %d, want 1", fm.FavoritesCount()) + } + + // Toggle off. + added, err = fm.ToggleFavorite(playlist.Track{Path: "/a.mp3", Title: "A"}) + if err != nil { + t.Fatalf("ToggleFavorite: %v", err) + } + if added { + t.Fatal("second toggle should return false") + } + if fm.IsFavorited("/a.mp3") { + t.Fatal("should not be favorited after toggle off") + } +} + +func TestFavoritesTrackCount(t *testing.T) { + p := newTestProviderWithFavorites(t) + p.favorites.Toggle(playlist.Track{Path: "/a.mp3", Title: "A"}) + p.favorites.Toggle(playlist.Track{Path: "/b.mp3", Title: "B"}) + + lists, err := p.Playlists() + if err != nil { + t.Fatalf("Playlists: %v", err) + } + if len(lists) != 1 || lists[0].TrackCount != 2 { + t.Fatalf("TrackCount = %d, want 2", lists[0].TrackCount) + } +} diff --git a/favorites/favorites.go b/favorites/favorites.go new file mode 100644 index 000000000..c8f613038 --- /dev/null +++ b/favorites/favorites.go @@ -0,0 +1,331 @@ +// Package favorites persists the user's favorite tracks to a TOML file in the +// cliamp config directory. Favorites are explicitly toggled by the user and +// span all playlists — a track favorited in playlist A appears when browsing +// the virtual "Favorites" playlist regardless of where it was starred. +// +// The store is safe for concurrent callers and writes atomically (temp file + +// rename) so a crash mid-write cannot leave a half-finished favorites.toml. +package favorites + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/bjarneo/cliamp/internal/appdir" + "github.com/bjarneo/cliamp/internal/tomlutil" + "github.com/bjarneo/cliamp/playlist" +) + +// PlaylistName is the virtual playlist name surfaced to the UI by the local +// provider. Browsing this name returns favorite tracks newest-first. +const PlaylistName = "Favorites" + +// Entry pairs a track with the wall-clock time it was favorited. +type Entry struct { + Track playlist.Track + FavoritedAt time.Time +} + +// Store reads and writes the favorites TOML file. +type Store struct { + path string + + mu sync.Mutex +} + +// New returns a Store backed by ~/.config/cliamp/favorites.toml. Returns nil if +// the config directory cannot be resolved. +func New() *Store { + dir, err := appdir.Dir() + if err != nil { + return nil + } + return &Store{path: filepath.Join(dir, "favorites.toml")} +} + +// NewAt returns a Store rooted at an explicit file path. Used by tests. +func NewAt(path string) *Store { + return &Store{path: path} +} + +// Path returns the on-disk file path. +func (s *Store) Path() string { return s.path } + +// Toggle favorites a track. If the track is already favorited, it is removed +// (unfavorited). Returns true when the track is now favorited after the call. +// Empty paths are ignored and return false. +func (s *Store) Toggle(track playlist.Track) (bool, error) { + if s == nil || strings.TrimSpace(track.Path) == "" { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := s.loadLocked() + if err != nil { + return false, fmt.Errorf("load favorites: %w", err) + } + + idx := slices.IndexFunc(entries, func(e Entry) bool { + return e.Track.Path == track.Path + }) + + if idx >= 0 { + // Already favorited — remove it. + entries = slices.Delete(entries, idx, idx+1) + return false, s.saveLocked(entries) + } + + // Not yet favorited — add it at the front (newest first). + entry := Entry{Track: track, FavoritedAt: time.Now()} + entries = append([]Entry{entry}, entries...) + return true, s.saveLocked(entries) +} + +// Favorite adds a track to favorites. No-op if already present. +// Returns true when the track was newly added. +func (s *Store) Favorite(track playlist.Track) (bool, error) { + if s == nil || strings.TrimSpace(track.Path) == "" { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := s.loadLocked() + if err != nil { + return false, fmt.Errorf("load favorites: %w", err) + } + + if slices.ContainsFunc(entries, func(e Entry) bool { + return e.Track.Path == track.Path + }) { + return false, nil + } + + entry := Entry{Track: track, FavoritedAt: time.Now()} + entries = append([]Entry{entry}, entries...) + return true, s.saveLocked(entries) +} + +// Remove unfavorites a track by path. Returns true when the track was present +// and removed. +func (s *Store) Remove(path string) (bool, error) { + if s == nil || strings.TrimSpace(path) == "" { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := s.loadLocked() + if err != nil { + return false, fmt.Errorf("load favorites: %w", err) + } + + idx := slices.IndexFunc(entries, func(e Entry) bool { + return e.Track.Path == path + }) + if idx < 0 { + return false, nil + } + + entries = slices.Delete(entries, idx, idx+1) + return true, s.saveLocked(entries) +} + +// IsFavorited reports whether the given path is in the favorites store. +func (s *Store) IsFavorited(path string) bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := s.loadLocked() + if err != nil { + return false + } + return slices.ContainsFunc(entries, func(e Entry) bool { + return e.Track.Path == path + }) +} + +// Count returns the number of favorited tracks. +func (s *Store) Count() int { + if s == nil { + return 0 + } + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := s.loadLocked() + if err != nil { + return 0 + } + return len(entries) +} + +// Tracks returns all favorite tracks, newest-first, suitable for handing to a +// playlist.Playlist. The FavoritedAt timestamp is dropped. +func (s *Store) Tracks() ([]playlist.Track, error) { + if s == nil { + return nil, nil + } + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := s.loadLocked() + if err != nil { + return nil, err + } + out := make([]playlist.Track, len(entries)) + for i, e := range entries { + out[i] = e.Track + } + return out, nil +} + +// Clear deletes the favorites file. Returns nil if the file does not exist. +func (s *Store) Clear() error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + err := os.Remove(s.path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +func (s *Store) loadLocked() ([]Entry, error) { + data, err := os.ReadFile(s.path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + return parse(data), nil +} + +func (s *Store) saveLocked(entries []Entry) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + var b strings.Builder + for i, e := range entries { + if i > 0 { + fmt.Fprintln(&b) + } + writeEntry(&b, e) + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +func writeEntry(w io.Writer, e Entry) { + fmt.Fprintf(w, "[[entry]]\n") + fmt.Fprintf(w, "favorited_at = %q\n", e.FavoritedAt.UTC().Format(time.RFC3339)) + fmt.Fprintf(w, "path = %q\n", e.Track.Path) + fmt.Fprintf(w, "title = %q\n", e.Track.Title) + if e.Track.Artist != "" { + fmt.Fprintf(w, "artist = %q\n", e.Track.Artist) + } + if e.Track.Album != "" { + fmt.Fprintf(w, "album = %q\n", e.Track.Album) + } + if e.Track.Genre != "" { + fmt.Fprintf(w, "genre = %q\n", e.Track.Genre) + } + if e.Track.Year != 0 { + fmt.Fprintf(w, "year = %d\n", e.Track.Year) + } + if e.Track.TrackNumber != 0 { + fmt.Fprintf(w, "track_number = %d\n", e.Track.TrackNumber) + } + if e.Track.DurationSecs != 0 { + fmt.Fprintf(w, "duration_secs = %d\n", e.Track.DurationSecs) + } +} + +// parse skips unknown keys to keep the on-disk format forward-compatible. +func parse(data []byte) []Entry { + var entries []Entry + var cur *Entry + + flush := func() { + if cur != nil { + entries = append(entries, *cur) + } + } + + for rawLine := range strings.SplitSeq(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if line == "[[entry]]" { + flush() + cur = &Entry{} + continue + } + if cur == nil { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = tomlutil.Unquote(strings.TrimSpace(val)) + switch key { + case "favorited_at": + if t, err := time.Parse(time.RFC3339, val); err == nil { + cur.FavoritedAt = t + } + case "path": + cur.Track.Path = val + cur.Track.Stream = playlist.IsURL(val) + case "title": + cur.Track.Title = val + case "artist": + cur.Track.Artist = val + case "album": + cur.Track.Album = val + case "genre": + cur.Track.Genre = val + case "year": + if n, err := strconv.Atoi(val); err == nil { + cur.Track.Year = n + } + case "track_number": + if n, err := strconv.Atoi(val); err == nil { + cur.Track.TrackNumber = n + } + case "duration_secs": + if n, err := strconv.Atoi(val); err == nil { + cur.Track.DurationSecs = n + } + } + } + flush() + + // Drop entries that failed to parse a path (the only required field). + entries = slices.DeleteFunc(entries, func(e Entry) bool { + return strings.TrimSpace(e.Track.Path) == "" + }) + return entries +} diff --git a/favorites/favorites_test.go b/favorites/favorites_test.go new file mode 100644 index 000000000..60abca8f0 --- /dev/null +++ b/favorites/favorites_test.go @@ -0,0 +1,203 @@ +package favorites + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/bjarneo/cliamp/playlist" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + return NewAt(filepath.Join(t.TempDir(), "favorites.toml")) +} + +func TestToggleAdd(t *testing.T) { + s := newTestStore(t) + track := playlist.Track{Path: "/a.mp3", Title: "A", Artist: "Art"} + + added, err := s.Toggle(track) + if err != nil { + t.Fatalf("Toggle: %v", err) + } + if !added { + t.Fatal("Toggle should return true when adding") + } + if !s.IsFavorited("/a.mp3") { + t.Fatal("track should be favorited after Toggle") + } + if s.Count() != 1 { + t.Fatalf("count = %d, want 1", s.Count()) + } +} + +func TestToggleRemove(t *testing.T) { + s := newTestStore(t) + track := playlist.Track{Path: "/a.mp3", Title: "A"} + + s.Toggle(track) + added, err := s.Toggle(track) + if err != nil { + t.Fatalf("Toggle: %v", err) + } + if added { + t.Fatal("Toggle should return false when removing") + } + if s.IsFavorited("/a.mp3") { + t.Fatal("track should not be favorited after second Toggle") + } + if s.Count() != 0 { + t.Fatalf("count = %d, want 0", s.Count()) + } +} + +func TestFavoriteIdempotent(t *testing.T) { + s := newTestStore(t) + track := playlist.Track{Path: "/a.mp3", Title: "A"} + + added, _ := s.Favorite(track) + if !added { + t.Fatal("first Favorite should return true") + } + added, _ = s.Favorite(track) + if added { + t.Fatal("second Favorite should return false (already present)") + } + if s.Count() != 1 { + t.Fatalf("count = %d, want 1", s.Count()) + } +} + +func TestRemoveNonexistent(t *testing.T) { + s := newTestStore(t) + removed, err := s.Remove("/nope.mp3") + if err != nil { + t.Fatalf("Remove: %v", err) + } + if removed { + t.Fatal("Remove of nonexistent track should return false") + } +} + +func TestTracksOrdering(t *testing.T) { + s := newTestStore(t) + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + + s.Toggle(playlist.Track{Path: "/a.mp3", Title: "A"}) + // Simulate earlier favorited time by toggling and re-adding with a known time. + // Since Toggle is time.Now()-based, we just add two tracks in sequence. + s.Toggle(playlist.Track{Path: "/b.mp3", Title: "B"}) + + tracks, err := s.Tracks() + if err != nil { + t.Fatalf("Tracks: %v", err) + } + if len(tracks) != 2 { + t.Fatalf("len = %d, want 2", len(tracks)) + } + // Newest first: B was added after A. + if tracks[0].Title != "B" || tracks[1].Title != "A" { + t.Fatalf("order wrong: %+v", tracks) + } + _ = base // used above for documentation +} + +func TestPersistAcrossInstances(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "favorites.toml") + + s1 := NewAt(path) + s1.Toggle(playlist.Track{Path: "/a.mp3", Title: "A", Artist: "Art", Album: "Alb", Year: 2026, DurationSecs: 180}) + + s2 := NewAt(path) + tracks, err := s2.Tracks() + if err != nil { + t.Fatalf("Tracks: %v", err) + } + if len(tracks) != 1 { + t.Fatalf("reloaded %d tracks, want 1", len(tracks)) + } + tr := tracks[0] + if tr.Title != "A" || tr.Artist != "Art" || tr.Album != "Alb" { + t.Errorf("track meta lost: %+v", tr) + } + if tr.Year != 2026 || tr.DurationSecs != 180 { + t.Errorf("numeric meta lost: year=%d dur=%d", tr.Year, tr.DurationSecs) + } +} + +func TestClearRemovesFile(t *testing.T) { + s := newTestStore(t) + s.Toggle(playlist.Track{Path: "/a.mp3", Title: "A"}) + if err := s.Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + if _, err := os.Stat(s.Path()); !os.IsNotExist(err) { + t.Fatalf("file should be gone after Clear, err = %v", err) + } + if s.Count() != 0 { + t.Fatalf("count after Clear = %d, want 0", s.Count()) + } +} + +func TestClearMissingFileNoError(t *testing.T) { + s := newTestStore(t) + if err := s.Clear(); err != nil { + t.Fatalf("Clear on missing file: %v", err) + } +} + +func TestStreamFlagInferredOnReload(t *testing.T) { + s := newTestStore(t) + s.Toggle(playlist.Track{Path: "https://example.com/stream", Title: "Live"}) + + s2 := NewAt(s.Path()) + tracks, _ := s2.Tracks() + if len(tracks) != 1 || !tracks[0].Stream { + t.Fatalf("Stream flag not inferred: %+v", tracks) + } +} + +func TestNilStoreSafe(t *testing.T) { + var s *Store + added, err := s.Toggle(playlist.Track{Path: "/a.mp3"}) + if err != nil || added { + t.Errorf("nil Toggle: added=%v err=%v", added, err) + } + if _, err := s.Tracks(); err != nil { + t.Errorf("nil Tracks: %v", err) + } + if s.IsFavorited("/a.mp3") { + t.Error("nil IsFavorited should return false") + } + if s.Count() != 0 { + t.Errorf("nil Count = %d, want 0", s.Count()) + } + if err := s.Clear(); err != nil { + t.Errorf("nil Clear: %v", err) + } +} + +func TestToggleIgnoresEmptyPath(t *testing.T) { + s := newTestStore(t) + added, err := s.Toggle(playlist.Track{Title: "no path"}) + if err != nil || added { + t.Errorf("empty path Toggle: added=%v err=%v", added, err) + } + if s.Count() != 0 { + t.Fatalf("count = %d, want 0", s.Count()) + } +} + +func TestTracksEmpty(t *testing.T) { + s := newTestStore(t) + tracks, err := s.Tracks() + if err != nil { + t.Fatalf("Tracks: %v", err) + } + if len(tracks) != 0 { + t.Fatalf("empty Tracks = %d, want 0", len(tracks)) + } +} diff --git a/playlist/dirsource.go b/playlist/dirsource.go new file mode 100644 index 000000000..a0c06bbfb --- /dev/null +++ b/playlist/dirsource.go @@ -0,0 +1,9 @@ +package playlist + +// 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) +} diff --git a/playlist/provider.go b/playlist/provider.go index e1f7911f0..a4c3cedae 100644 --- a/playlist/provider.go +++ b/playlist/provider.go @@ -16,12 +16,17 @@ var ErrNeedsAuth = errors.New("sign-in required") // Adjacent rows that share a Section are rendered under one header; a change of // Section emits a "── header ──" divider. The radio provider uses // SectionedList.IDPrefix instead and leaves Section empty. +// +// DirSourceCount is optional: providers that back playlists with [[dir]] +// directory sources set it so the UI can flag them in the list. A zero value +// means "none/unknown" and the UI hides the indicator. type PlaylistInfo struct { - ID string - Name string - TrackCount int - DurationSecs int - Section string + ID string + Name string + TrackCount int + DurationSecs int + Section string + DirSourceCount int } // Provider is the interface for playlist sources (radio, Navidrome, Spotify, etc.). diff --git a/provider/interfaces.go b/provider/interfaces.go index 912546c37..f80bfb060 100644 --- a/provider/interfaces.go +++ b/provider/interfaces.go @@ -119,6 +119,17 @@ type BookmarkSetter interface { SetBookmarkByPath(playlistName string, path string) error } +// PlaylistDirSourceManager is implemented by providers whose playlists can +// reference directory sources that are re-scanned on each load. The local +// TOML provider implements this for its [[dir]] sections; other providers +// leave it unimplemented and the UI hides directory-source controls. +type PlaylistDirSourceManager interface { + DirSources(name string) ([]playlist.DirSource, error) + AddDirSource(name, dir string) (bool, error) + RemoveDirSource(name, dir string) error + SetDirRecursive(name, dir string, recursive bool) error +} + // CustomStreamer is implemented by providers that need a custom audio // decode path for non-standard URI schemes (e.g. spotify:track:xxx). type CustomStreamer interface { @@ -166,3 +177,16 @@ type SectionedList interface { type Closer interface { Close() } + +// FavoritesManager is implemented by providers that support a cross-playlist +// favorites virtual playlist. The UI uses this to toggle favorites from the +// track list without going through the per-playlist write path. +type FavoritesManager interface { + // ToggleFavorite toggles the given track in the favorites store. + // Returns true when the track is now favorited after the call. + ToggleFavorite(track playlist.Track) (bool, error) + // IsFavorited reports whether the given path is in the favorites store. + IsFavorited(path string) bool + // FavoritesCount returns the number of favorited tracks. + FavoritesCount() int +} diff --git a/site/index.html b/site/index.html index 000f0de66..080e8fe85 100644 --- a/site/index.html +++ b/site/index.html @@ -755,7 +755,7 @@
Parametric EQ presets plus a persistent Custom curve that survives preset changes and restarts.
21 contrast-checked built-in themes and spectrum, waveform, particle, and true-stereo modes. Stereo provides dedicated L/R horizontal LED peak meters. Hot-swap with t / v.
TOML playlists with dynamic directory sources (--dir), M3U/M3U8/PLS import/export, duplicate-safe writes, and TUI/CLI sort tools.
TOML playlists with dynamic [[dir]] directory sources you can add, remove, and toggle-recursive from the playlist manager (D), cross-playlist favorites (n), M3U/M3U8/PLS import/export, duplicate-safe writes, and TUI/CLI sort tools.
Auto-recorded listening history. Browse it as a virtual playlist or run cliamp history from the shell.
Play from URLs, internet radio, remote M3U playlists, and HLS (.m3u8) live streams via ffmpeg.
Embedded local lyrics first, then LRCLIB/NetEase fallback. Auto-scrolling for timestamped lyrics.