Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,8 @@ eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
#
# Restrict to specific libraries (default: all).
# libraries = ["Audiobooks", "Podcasts"]

# Destination for TUI Ctrl+S saves. Empty uses ~/Music/cliamp.
# Use an absolute path; mount external drives before saving.
[downloads]
directory = ""
10 changes: 10 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,14 @@ func (a AudiobookshelfConfig) IsSet() bool {
return a.URL != "" && (a.Token != "" || (a.User != "" && a.Password != ""))
}

// DownloadsConfig selects the directory for saved audio. Empty uses ~/Music/cliamp.
type DownloadsConfig struct {
Directory string
}

// Config holds user preferences loaded from the config file.
type Config struct {
Downloads DownloadsConfig
Volume float64 // dB, clamped at runtime to [VolumeMin, +6]
VolumeMin float64 // dB floor, range [-90, 0]; default -50
VisVolumeLinked bool // when true, visualizer bar height follows volume; default true
Expand Down Expand Up @@ -483,6 +489,10 @@ func Load() (Config, error) {
val = strings.TrimSpace(val)

switch section {
case "downloads":
if key == "directory" {
cfg.Downloads.Directory = parseString(val)
}
case "navidrome":
switch key {
case "url":
Expand Down
26 changes: 26 additions & 0 deletions config/downloads_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package config

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

func TestLoadDownloads(t *testing.T) {
for _, directory := range []string{"", "/media/usb/CLAPt/Music"} {
t.Run(directory, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("CLIAMP_CONFIG_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte("[downloads]\ndirectory = \""+directory+"\"\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.Downloads.Directory != directory {
t.Fatalf("directory=%q", cfg.Downloads.Directory)
}
})
}
}
16 changes: 16 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,3 +473,19 @@ brew install ffmpeg
```

MP3, WAV, FLAC, and OGG work without ffmpeg.

## Download directory

By default, TUI `Ctrl+S` saves yt-dlp downloads and temporary audio files in
`~/Music/cliamp`. To use another directory, add this setting and restart cliamp:

```toml
[downloads]
directory = "/media/usb/CLAPt/Music"
```

An empty value keeps the default. Use an absolute path; literal `~` is not
expanded. Missing directories are created. Mount external drives first because
cliamp does not check mount status. This also applies to IPC Save handled by the
TUI; headless daemon saving is unchanged. Files remain ordinary local audio
files and are not automatically substituted into online playlists.
2 changes: 1 addition & 1 deletion docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ and `Esc` clears it.
| `[` / `]` | Adjust synced-lyrics timing offset (−/+250 ms) while lyrics show timestamped lines |
| `i` | From the playlist, open full info for the highlighted item, including Path (`Up`/`Down` or `j`/`k` scroll; `i`/`Esc` closes) |
| `Ctrl+I` | Toggle Metadata below Settings for the highlighted playlist item (remembered in `show_metadata`; requires a terminal that distinguishes Ctrl+I from Tab) |
| `Ctrl+S` | Save track to `~/Music/cliamp` |
| `Ctrl+S` | Save track to `[downloads].directory` (default `~/Music/cliamp`) |
| `w` | Write the highlighted track to a local playlist |
| `N` | Open the active provider browser. On a selected Mixcloud show, open that creator's Uploads/Favorites. In the radio pane, open the country browser. |
| `L` | Browse local playlists (with cliamp radio) |
Expand Down
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@ func run(overrides config.Overrides, positional []string, daemon, visualizer60FP
m.SetSeekStepLarge(cfg.SeekStepLargeDuration())
m.SetLyricsOffset(cfg.LyricsOffsetMs)
m.SetInitialDirectory(cfg.InitialDirectory)
m.SetDownloadsDirectory(cfg.Downloads.Directory)
m.SetPendingURLs(resolved.Pending)
if cfg.Playlist != "" && len(resolved.Tracks) == 0 && len(resolved.Pending) == 0 {
m.SetLoadedPlaylist(cfg.Playlist)
Expand Down
1 change: 1 addition & 0 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ <h2>Every source, one interface</h2>
</div>
<div class="grid g-sources" id="sources-grid"></div>
<p class="sub">Radio favorites follow the station: press <kbd>f</kbd> in Catalog, search results, or the playback playlist after browsing countries or genres. Stations are saved locally under Radio → Favorites by URL, using the station name rather than the current song and retaining station details, including the region, after restart. The header’s star badge counts all starred rows, including offscreen rows, with the same meaning as the row markers. Radio favorites do not reserve a star column in playlists containing no favorited stations. M3U/PLS wrappers keep their original station URL after playback resolution. Toggling a favorite preserves the selected station and completed catalog pages, including during loading. Favoriting does not fetch more catalog stations or interrupt an in-progress station load. IPC clients use the stable URL-based favorite IDs returned by provider.playlists; positional favorite IDs are not supported. On Linux and macOS, favorite mutations preserve other cliamp instances’ changes through a shared file lock. The action follows the displayed star: it favorites an unstarred station or unfavorites a starred station, without reversing the same change already made by another instance. Other instances’ changes become visible in another open player on its next favorite mutation or restart, not through live refresh. Windows assumes one writer per config directory. Press <kbd>f</kbd> again to remove one. In saved local playlists, <kbd>f</kbd> still bookmarks the track; <kbd>n</kbd> remains the separate heart favorite, available when the playback playlist has focus. Remote account favorites are unchanged. <a href="https://github.com/bjarneo/cliamp/blob/main/docs/radio.md#station-favorites">Radio favorites guide</a>.</p>
<p class="sub">Choose where TUI <kbd>Ctrl+S</kbd> saves yt-dlp downloads and temporary audio files: set <code>directory = "/media/usb/CLAPt/Music"</code> under <code>[downloads]</code> in config.toml and restart cliamp. Empty uses <code>~/Music/cliamp</code>. Use an absolute path (literal ~ is not expanded) and mount external drives first; missing directories are created without checking mount status. The setting also applies to IPC Save in the TUI; headless daemon saving is unchanged. Saved files can be opened locally and are not automatically substituted into online playlists.</p>
<p class="sub">Navidrome uses the player's transcoding settings, or streams the original file if none are configured. Set <code>format = "raw"</code> under <code>[navidrome]</code> to always request the original. <a href="https://github.com/bjarneo/cliamp/blob/main/docs/navidrome.md#transcoding">Navidrome guide</a>.</p>
<p class="sub">With Jellyfin as the default provider, reopen cliamp without files, URLs, or a playlist to restore the last track, position, and complete source list. Queued tracks keep their own album context. Press <kbd>Enter</kbd> to resume; restored sessions stay silent even with <code>auto_play</code>. Playback is checkpointed every two seconds and on normal exit, without saving unfinished seeks. Stream URLs use current API-key or password authentication when playback starts. <a href="https://github.com/bjarneo/cliamp/blob/main/docs/jellyfin.md">Jellyfin guide</a>.</p>
<p class="sub">Large Spotify playlists fill in as they load: the first tracks appear right away, later pages append in the background, and playback works while the list is still arriving. <a href="https://github.com/bjarneo/cliamp/blob/main/docs/spotify.md">Spotify guide</a>.</p>
Expand Down
18 changes: 17 additions & 1 deletion tracksave/save.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,27 @@ import (

// Save downloads or copies track into ~/Music/cliamp and returns its path.
func Save(track playlist.Track) (string, error) {
return SaveTo(track, "")
}

// Directory resolves the configured directory, falling back to ~/Music/cliamp.
func Directory(directory string) (string, error) {
if directory != "" {
return filepath.Abs(directory)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
saveDir := filepath.Join(home, "Music", "cliamp")
return filepath.Join(home, "Music", "cliamp"), nil
}

// SaveTo downloads or copies a track to the configured directory.
func SaveTo(track playlist.Track, directory string) (string, error) {
saveDir, err := Directory(directory)
if err != nil {
return "", err
}
if err := os.MkdirAll(saveDir, 0o755); err != nil {
return "", err
}
Expand Down
13 changes: 13 additions & 0 deletions tracksave/save_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,16 @@ func setTestHome(t *testing.T) string {
t.Setenv("USERPROFILE", home)
return home
}

func TestDirectory(t *testing.T) {
home := setTestHome(t)
got, err := Directory("")
if err != nil || got != filepath.Join(home, "Music", "cliamp") {
t.Fatalf("directory=%q err=%v", got, err)
}
custom := t.TempDir()
got, err = Directory(custom)
if err != nil || got != custom {
t.Fatalf("directory=%q err=%v", got, err)
}
}
2 changes: 1 addition & 1 deletion ui/model/command_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ var commandRegistry = []commandSpec{
}, Keymap: true},
{Mode: commandModeMain, Keys: []string{"i"}, KeyLabel: "i", Label: "Track info / metadata", Keymap: true, ContextHelp: true},
{Mode: commandModeMain | commandModeInfo, Keys: []string{"ctrl+i"}, KeyLabel: "Ctrl+I", Label: "Metadata", Keymap: true, ContextHelp: true},
{Mode: commandModeMain, Keys: []string{"ctrl+s"}, KeyLabel: "Ctrl+S", Label: "Save/download track to ~/Music/cliamp", Keymap: true},
{Mode: commandModeMain, Keys: []string{"ctrl+s"}, KeyLabel: "Ctrl+S", Label: "Save/download current track", Keymap: true},
{Mode: commandModeMain, Keys: []string{"ctrl+x"}, KeyLabel: "Ctrl+X", Label: "Expand/collapse view", Enabled: func(m Model) bool { return !m.simplified }, Keymap: true},
{Mode: commandModeMain, Keys: []string{"ctrl+x"}, KeyLabel: "Ctrl+X", Label: "Expand", Enabled: func(m Model) bool {
return !m.simplified && !m.heightExpanded && m.layout.bodyRows > m.plVisible
Expand Down
49 changes: 49 additions & 0 deletions ui/model/download_directory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package model

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

"github.com/bjarneo/cliamp/ipc"
"github.com/bjarneo/cliamp/playlist"
)

func TestSaveUsesConfiguredDirectory(t *testing.T) {
for _, viaIPC := range []bool{false, true} {
name := "keyboard"
if viaIPC {
name = "ipc"
}
t.Run(name, func(t *testing.T) {
source := filepath.Join(t.TempDir(), "source.mp3")
if err := os.WriteFile(source, []byte("synthetic audio"), 0600); err != nil {
t.Fatal(err)
}
destination := filepath.Join(t.TempDir(), "downloads")
tracks := playlist.New()
tracks.Add(playlist.Track{Path: source, Title: "Song", Artist: "Artist"})
m := Model{playlist: tracks}
m.SetDownloadsDirectory(destination)
if viaIPC {
reply := make(chan ipc.Response, 1)
cmd := m.handleIPCSave(ipc.SaveRequestMsg{Reply: reply})
if cmd == nil {
t.Fatal("no save command")
}
cmd()
if response := <-reply; !response.OK {
t.Fatal(response.Error)
}
} else {
if cmd := m.saveTrack(); cmd != nil {
cmd()
}
}
data, err := os.ReadFile(filepath.Join(destination, "Artist - Song.mp3"))
if err != nil || string(data) != "synthetic audio" {
t.Fatalf("data=%q err=%v", data, err)
}
})
}
}
3 changes: 3 additions & 0 deletions ui/model/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,6 @@ func (m *Model) SetRadioFavorites(favorites *radio.Favorites) {
m.radioFavorites = favorites
m.radioMarkers = &radioMarkerCache{}
}

// SetDownloadsDirectory selects the destination for saved tracks.
func (m *Model) SetDownloadsDirectory(dir string) { m.downloadsDirectory = dir }
3 changes: 2 additions & 1 deletion ui/model/ipc_extended.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@ func (m *Model) handleIPCSave(request ipc.SaveRequestMsg) tea.Cmd {
request.Reply <- ipc.Response{OK: false, Error: "nothing to save"}
return nil
}
directory := m.downloadsDirectory
return func() tea.Msg {
path, err := tracksave.Save(track)
path, err := tracksave.SaveTo(track, directory)
if err != nil {
request.Reply <- ipc.Response{OK: false, Error: err.Error()}
} else {
Expand Down
10 changes: 5 additions & 5 deletions ui/model/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/bjarneo/cliamp/internal/fileutil"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
"github.com/bjarneo/cliamp/tracksave"
)

// quit shuts down the player and signals the TUI to exit.
Expand Down Expand Up @@ -1077,7 +1078,7 @@ func (m *Model) handleFullVisualizerKey(msg tea.KeyPressMsg) tea.Cmd {
return nil
}

// saveTrack copies the current track to ~/Music/cliamp/ with a clean filename.
// saveTrack saves the current track in the configured downloads directory.
// For yt-dlp tracks (piped streams), triggers an async download via yt-dlp.
// For local temp files, copies synchronously.
func (m *Model) saveTrack() tea.Cmd {
Expand All @@ -1087,19 +1088,18 @@ func (m *Model) saveTrack() tea.Cmd {
return nil
}

home, err := os.UserHomeDir()
saveDir, err := tracksave.Directory(m.downloadsDirectory)
if err != nil {
m.status.Errorf(statusTTLShort, "Save failed: %s", err)
return nil
}

saveDir := filepath.Join(home, "Music", "cliamp")
if err := os.MkdirAll(saveDir, 0o755); err != nil {
m.status.Errorf(statusTTLShort, "Save failed: %s", err)
return nil
}

// YouTube/yt-dlp tracks: async download directly to ~/Music/cliamp/.
// YouTube/yt-dlp tracks: download asynchronously into the selected directory.
if playlist.IsYouTubeURL(track.Path) || playlist.IsYTDL(track.Path) {
m.status.Clear()
m.save.startDownload()
Expand Down Expand Up @@ -1132,7 +1132,7 @@ func (m *Model) saveTrack() tea.Cmd {
return nil
}

m.status.Showf(statusTTLDefault, "Saved to ~/Music/cliamp/%s", name+ext)
m.status.Showf(statusTTLDefault, "Saved to %s", dest)
return nil
}

Expand Down
1 change: 1 addition & 0 deletions ui/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ const (

// Model is the Bubbletea model for the CLIAMP TUI.
type Model struct {
downloadsDirectory string
// Core playback
player player.Engine
playlist *playlist.Playlist
Expand Down
Loading