go-librespot gained first-party WASAPI output for Windows in v0.9.0
(cliamp was pinned to v0.7.1, which doesn't compile on Windows). Bump
the dependency and remove the Windows CGO stub so the real provider
builds on all platforms.
CI now installs a MinGW toolchain via MSYS2 and builds/tests with
CGO_ENABLED=1 on windows-2025, including a workaround for an MSYS2
libogg packaging issue where libogg-0.dll's export table is missing
ogg_stream_iovecin even though it's present in the static libogg.a.
Verified locally end-to-end on Windows: native CGO build, full test
suite, and real Spotify Premium playback.
Fixes#299
Not in scope for this PR: release.yml still builds Windows with
CGO_ENABLED=0, so Releases binaries won't include Spotify until that
pipeline is updated separately (needs a packaging decision: bundle the
MSYS2 DLLs or pursue a fully static build).
Fetches first 20 tracks from YT music when list=URL is parsed and
plays,remaining tracks are fetched in the background and added in
batches of 20.
Added the --expand-playlist/--no-expand-playlist CLI flags and the
expand_playlist key in the configs. This is switched on by default
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
New stations: ncs-pop, ncs-chill. Wired into the default radio playlist
(main.go) and the site radio player (buttons, stat tabs, stream/pls/name
maps, chart colors).
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
New stations: ncs, ncs-house, ncs-dubstep, ncs-dnb, ncs-trap, ncs-phonk.
Wired into the default radio playlist (main.go) and the site radio player
(channel buttons, stat tabs, stream/pls/name maps, chart colors).
* windows: fix config, IPC, path, and Lua compatibility
* spotify: share API structures and helper so tests compile on Windows
* windows: address PR comments and fix socket unavailable detection, tasklist matching, api_fs tests, and doc comment
* windows: address remaining PR reviews (table-driven tests, m3u resolve comments & tests, blockquote formatting, and site/index.html description)
* feat: implement cross-platform IPC server with Unix and Windows support
* feat: implement Unix socket IPC server and Windows process liveness check
* ipc: detect dead processes via os.ErrProcessDone
os.Process.Signal converts ESRCH to os.ErrProcessDone since Go 1.16, so
comparing against raw syscall.ESRCH never matched and a stale socket from
a crashed instance made NewServer fail instead of cleaning it up.
* windows: simplify fs allowlist normalization, exec env, and ipc error checks
- normalize write allow-dirs once in the memoized writeAllowDirs
- collapse duplicate test helpers and repeated getenv blocks
- drop redundant errors.As branch; name WSAECONNREFUSED
- revert single-entry table test to linear form
- gofmt: trailing newlines and indentation
---------
Co-authored-by: Bjarne Øverli <bjarne.oeverli@gmail.com>
NTS and FIP send no inline ICY metadata, so live radio on these stations
showed no track info. Both publish now-playing through a separate JSON
API instead, so pull it from there.
Add an external/radiometa package that maps a stream URL to a now-playing
fetcher: FIP (and its sub-channels) via the Radio France livemeta API
returning "Artist - Title", and NTS 1/2 via the NTS live API returning
the current show. The player gains a RegisterStreamMetadataResolver hook
and a background poller that feeds titles through setStreamTitle, the
same path as ICY metadata, so display, MPRIS, and lyrics need no changes.
The poller starts in playPipeline (not buildPipelineAt, so preloaded
pipelines don't poll) and is cancelled on Stop and on each new stream;
titles fetched after cancellation are discarded so a stale poller cannot
clobber the next stream.
Closes#241.
Read the playlist (list/count/current) with no permission, and mutate it
(add/jump/remove/move) under the control permission. Mutations route through
prog.Send and the model Update loop so derived state (cursor, current index,
playback) stays consistent; add() reuses the resolve.Args/Remote pipeline off
the UI thread. Indices are 0-based, matching cliamp.queue.current().
queue.save() is intentionally deferred: it needs a shared M3U encoder plus
write-path allowlisting, which belong in their own change.
* fix(playlist): synchronize Playlist for concurrent plugin/UI access
Lua plugin callbacks run on goroutines (hooks, timers, keybinds) and read
playlist state through StateProvider closures, while the Bubbletea loop
mutates the same *Playlist. The struct had no synchronization, so reads of
p.tracks/p.order/p.pos raced with Next/Prev/Add/Remove/shuffle, and
Current()'s unguarded p.tracks[idx] could panic on a stale index.
Add a sync.Mutex and guard every exported method. Reads and writes are now
serialized; no exported method calls another, so defer-unlock cannot deadlock.
Fixes the high-severity track/player getter race and the playlist-mutation
data race.
* fix(luaplugin): block SSRF in cliamp.http
doHTTP passed plugin-supplied URLs straight to the HTTP client with no
validation, letting a plugin reach loopback, RFC1918, and link-local
169.254.169.254 (cloud metadata), and follow redirects into those targets.
Add an ssrfGuard on the dialer Control hook so every connection attempt
(including redirects and DNS-rebound hosts) is checked against the resolved
IP and rejected if non-public, and reject non-http(s) schemes up front.
A per-plugin network permission gate remains a possible follow-up; it is
omitted here to avoid breaking existing plugins that legitimately use http.
* fix(pluginmgr): sandbox plugin VM during metadata extraction
extractMetadata ran DoFile on the entire plugin file with full stdlib, so
any top-level os.execute/io call ran unsandboxed the moment a user listed
installed plugins. Apply the same luaplugin.Sandbox used by the runtime
before executing the file. Exports luaplugin.Sandbox for reuse.
* fix(luaplugin): error on oversized fs.read instead of silent truncation
fs.read used os.ReadFile (whole file into memory) then sliced to 1MB,
returning a silently truncated value as success and not bounding memory for
huge files. Stream through io.LimitReader(maxSize+1) and return an explicit
error when the file exceeds the limit.
* fix(luaplugin): make write allowlist symlink-safe
isWriteAllowed validated paths lexically: the strings.Contains(abs, "..")
check was dead (filepath.Abs already cleans "..", and it false-positived on
filenames like "..bar"), and a symlink planted inside an allowed dir could
redirect a write outside it. Resolve symlinks on the deepest existing
ancestor of the target and on the allow dirs themselves before the prefix
check. Also fixes the same gap in the exec cwd check, which reuses this
function.
* fix(luaplugin): bound timer callbacks with hookTimeout
timer.after/every called CallByParam directly with no context timeout, so a
runaway callback held the plugin mutex forever, blocking every other hook,
keybind, and visualizer for that plugin. Add a shared callBounded helper that
applies hookTimeout (mirroring invokeHook) and use it from both timer paths.
* fix(luaplugin): bound visualizer render/init/destroy with hookTimeout
RenderVis runs on the UI render loop and called CallByParam with no timeout
while holding the plugin mutex; a hanging render() froze the UI with no
escape. InitVis/DestroyVis had the same gap. Route all three through
callBounded so a misbehaving visualizer times out and render falls back to
the previous frame.
* fix(luaplugin): wait for async hook goroutines before closing LStates
Emit spawned untracked goroutines that call CallByParam on a plugin LState.
Close stopped timers/execs then closed every LState without waiting, so a UI
event dispatched just before shutdown could run against an already-closed
LState (p.L.Close does not take the plugin mutex). Track Emit goroutines in a
WaitGroup, set a closing flag under mu to reject late dispatch, and Wait
before closing the LStates.
* fix(ui): guard spectrum visualizers against zero-width panel
renderFirework/renderBubbles/renderSakura compute dotCols = PanelWidth*2 and
then mod by dotCols/dotRows/len(bands); on a narrow terminal PanelWidth can be
0, so seed % uint64(dotCols) panicked and crashed the TUI. Add the same
dotRows<4 || dotCols<4 early-out the braille-grid renderers already use.
* fix(history): do not clobber history on a transient load error
Record discarded loadLocked's error and proceeded as if history were empty,
so a one-off read failure (permission/I-O glitch) made saveLocked atomically
rewrite the file with only the new entry, destroying all prior rows.
Propagate the error instead, leaving the on-disk file untouched.
* fix(history): detect write errors before committing history file
saveLocked ignored errors from writeEntry's formatted writes, so a failure
mid-write (e.g. ENOSPC) could still rename a truncated temp file over the
real history. Thread writes through an errWriter and abort the rename when a
write fails.
* fix(lyrics): stop cleanQuery from erasing titles with label words
The noise regex matched official/lyric/audio/video as bare substrings
followed by .*, so 'Videotape', 'Audioslave', and 'Video Games' were cleaned
to empty or truncated queries, breaking lyric lookups. Restrict bare-label
stripping to genuine trailing labels (dash suffix, or 'official video/audio'
and 'lyric(s) video' phrases). Add regression test cases.
* fix(jellyfin,emby): synchronize client token/userID/album cache
Client.token, userID, and albumCache were lazily read and written from
concurrent tea.Cmd goroutines (Playlists, album browse, SearchTracks all run
in their own goroutines) with no synchronization, a data race on the lazy
auth writes and the cached slice. Add a mutex guarding those fields, taken
only around field access and never across network I/O, with double-checked
auth so at most a redundant (not racy) auth can occur.
* fix(ipc): close in-flight connections on shutdown
handleConn's read loop only observed the 60s per-request read deadline, never
s.done, and Close closed only the listener. A still-connected client (e.g. a
vis-bands polling client) kept its handler goroutine alive, so Close blocked
on wg.Wait for up to 60s. Track live connections and close them in Close so
their scanner.Scan unblocks immediately; addConn shares the conn mutex with
the done check to close the accept-during-shutdown race.
* refactor(ipc): route all reply-waits through waitReply
The reply/timeout/shutdown select was open-coded in load/theme/vis/bands/
status and waitReply was used elsewhere with a generic 'timeout' message.
Parameterize waitReply with a label and timeout and use it everywhere, which
also gives the previously-generic commands specific timeout messages.
* fix(ipc): handle accept errors instead of spinning silently
acceptLoop swallowed every non-shutdown Accept error and retried at 10/s,
treating a permanently closed listener as transient. Return on net.ErrClosed
and log other errors before backing off.
* fix(ui): notify Lua plugins on jump-to-time seek
The jump enter handler called notifyPlayback (MPRIS only) plus notifier.Seeked,
skipping plugin notification. Use finishSeek, which calls notifyAll, so a jump
seek emits the playback-state event to plugins like every other completed
seek. Seek stays synchronous (immediate seek), matching the existing test.
* fix(resolve): bound startup feed sniff with a short timeout
Args runs on the startup path and called sniffFeedURL, which did a HEAD on
the 30s feed/M3U client, so one slow CDN could stall cliamp startup for up to
30s during pure URL classification. Give the sniff a dedicated 5s client.
* fix(pluginmgr): reject empty download body
An empty 200 response left download returning a non-nil empty slice, so
Install wrote a 0-byte plugin file. Treat an empty body as a download error
so the next candidate URL is tried (or the install fails cleanly).
* fix(pluginmgr): reject raw URLs with no usable filename
A raw URL ending in '/' made path.Base yield '.' or '/', producing a
degenerate plugin filename. Return a clear error instead.
* fix(config): let --low-power=false override config-enabled low power
The override only set LowPower when the flag was true, so --low-power=false
could not disable a config.toml low_power=true. Assign the flag value
directly, matching the other boolean overrides.
* fix(main): log MPRIS/NowPlaying init failure instead of dropping it
wireMediaCtl's error was silently discarded in TUI mode, so a dbus/MPRIS
setup failure left media-control silently disabled with no trace. Log it to
the app log (not stderr, which would corrupt the TUI).
* docs(plugins): remove unimplemented player getters
cliamp.player.eq_preset(), visualizer(), and theme() were documented but
never registered in the player API (calling them errors). Implementing them
would require exposing model state to plugin goroutines, which would add a
data race; remove them from the docs so the reference matches the API.
* fix(luaplugin): reject partial bands table in set_eq_preset
A bands table missing entries silently zeroed the unset bands. Require all
10 values and raise an arg error otherwise so partial input can't corrupt
the EQ curve.
* fix(luaplugin): track EmitKey goroutines and gate on shutdown
EmitKey spawned fire-and-forget goroutines per keypress that were untracked
and not gated on shutdown, so a keypress during Close could call into a
closed LState. Track them in the manager WaitGroup and skip dispatch once
closing, matching Emit.
* fix(mediactl): apply MPRIS volume changes in order
Each volume Change spawned its own goroutine to send SetVolumeMsg, so rapid
changes could be delivered out of order and an older volume win. send
(prog.Send) is goroutine-safe and non-blocking, so call it directly.
* fix(mediactl): reject stale-track MPRIS SetPosition
trackid was a fixed constant for every track, so SetPosition ignored its
trackID argument and would seek whatever track became current after the
client read its position. Assign a unique track object path per track change
and ignore SetPosition when its trackID does not match the current track.
* fix(radio): write favorites atomically
save() truncated the real favorites file with os.Create and ignored every
write error, so a partial write could lose all favorites. Build the content
in memory, write a temp file, and rename so a failed write can't corrupt the
existing file and the error surfaces to the caller.
* fix(radio): propagate save errors from ToggleFavorite
ToggleFavorite discarded the error from Add/Remove (which persist favorites),
so a failed save was silently swallowed. Return it to the caller.
* refactor(spotify): drop pointless lock around local slice append
The mutex around appending the Your Music entry only touched the
function-local 'all' slice, guarding nothing shared (the other locks in the
loop legitimately guard trackCache).
* fix(spotify): return cloned playlist list, not the cache slice
Playlists returned the cached slice directly (both on cache hit and after
building it), so a caller mutating the result corrupted the cache. Return a
clone on both paths.
* fix(spotify): return cloned cached tracks
Tracks handed out the cached track slice directly, so a caller mutating it
corrupted the per-playlist cache. Clone on both the cache-hit and freshly
built return paths. (The snapshot-based invalidation within the playlist-list
TTL is an intentional caching tradeoff and is left as-is.)
* fix(spotify): don't sleep before giving up on rate limit
On the final retry the loop slept the full backoff (up to 128s) and then
returned the rate-limited error without making another request. Break out
immediately on the last attempt.
* fix(jellyfin): guard AlbumList against negative offset
A negative offset passed the offset>=len check and then panicked at
out[offset:end]. Clamp offset to 0 first, matching the Emby client.
* docs(playlist): correct MoveQueue comment
MoveQueue swaps any two positions, not only adjacent ones.
* refactor(ui): simplify dead math.Min in flame tier mapping
math.Min(0.65, 0.55) is a constant 0.55; replace with the literal and drop
the now-unused math import.
* refactor(netease): compare app code against its own constant
resp.Code is NetEase's application-level status, not an HTTP status; it was
compared against http.StatusOK which only coincidentally shares the value
200. Introduce neteaseCodeOK and use it at all four comparison sites.
* fix(resolve): strip audio extension from derived title
humanizeBasename left a trailing extension (e.g. .mp3) in the title derived
from a URL basename. Drop a recognized audio extension first, leaving
non-media dotted suffixes untouched.
* fix(resolve): bound RSS feed read size
resolveFeed decoded the response body with no size limit. Wrap it in an
io.LimitReader (32 MB) so an oversized or malicious feed can't exhaust
memory.
* fix(navidrome): return cloned cache slices
Playlists and Tracks handed out the internal cached slices, so a caller
mutating the result corrupted the cache. Return slices.Clone on the cache-hit
and freshly built return paths.
* fix(player): include ffmpeg stderr in decode error
decodeFFmpeg wrapped the exec error but dropped ffmpeg's stderr, where the
actual failure reason is written. Surface ExitError.Stderr in the message.
* refactor(player): forward speedStreamer.Err directly
beep.Streamer already declares Err() error, so the local errorer type
assertion was redundant. Call ss.s.Err() directly, matching the eq and
volume streamers.
* fix(ui): don't drop queued seek/lyric cmds on reconnect
When the scheduled stream reconnect fired, the tick handler returned early
with only playTrack + tick, discarding the seekCmd/lyricCmd computed earlier
in the same tick. Fold them into the reconnect batch.
* fix(ui): nav list footer counts reflect committed filter
The footer only showed the filtered count while the search input bar was
open (searching); once a filter was committed with Enter it fell back to the
full count. Add navFilteredTotal and use the filtered count whenever a query
is active, across the artist, album, and track footers.
* refactor: remove dead internal/control package
internal/control duplicated the message types in internal/playback and had no
importers (only its own test); the live code uses internal/playback. Remove
it.
* fix(providers): implement Refresh for self-hosted providers
Navidrome, Plex, Jellyfin, and Emby cache playlist/track (and album) data but
none implemented playlist.Refresher, so the UI refresh key was a silent
no-op for them. Add Refresh to clear the caches (including the jellyfin/emby
client album cache) so the next fetch hits the server.
* refactor: share minimal-TOML section parser
The [[section]] parse loop was duplicated three times (radio loadStations,
radio loadFavoriteStations, local loadTOML). Extract tomlutil.ParseSections
and rewrite all three against it. Behavior is preserved, including the
single-section-type assumption of the original parsers.
* refactor: extract shared Emby/Jellyfin client into internal/embyapi
The emby and jellyfin clients were ~95% identical (~700 duplicated lines) and
had already drifted (jellyfin lacked the negative-offset guard and error
wrapping). Extract one shared Client into internal/embyapi, isolating the real
differences behind a dialect: auth header scheme (Emby Authorization vs
Jellyfin X-Emby-Authorization), ping endpoint, user-id discovery, error
prefix, and metadata key.
emby and jellyfin become thin wrappers (NewClient, IsStreamURL, type aliases)
plus their providers. Client tests are consolidated in embyapi: shared
behavior once, dialect specifics per dialect. The client now uses a per-
instance http.Client (SetHTTPClient) instead of a package global, which is
what makes it testable from the provider packages.
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
Loading a provider playlist (Spotify, Navidrome, Plex, etc.) or
launching with --playlist would start the first track immediately,
making it impossible to navigate to a specific track before playback
begins. Now the playlist loads and waits for the user to pick a track
or hit play. The explicit --auto-play flag and auto_play config still
work for users who want the old behavior.
Closes#240
Extends the volume floor from -30 dB to -50 dB by default and adds a configurable volume_min key (range [-90, 0]). Adds vis_volume_linked to optionally decouple visualizer bar height from current volume; pipeline tap now sits pre-volume so the visualizer always sees raw samples.
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
Daemon mode skipped pending URL resolution (feeds, M3U, yt-dlp/SoundCloud/
YouTube), so --auto-play with a remote URL left the playlist empty and the
autoplay guard a no-op. It also never instantiated mediactl, so the daemon
was invisible to playerctl and OS media keys.
- Resolve resolved.Pending synchronously before runDaemon when --daemon is set
- Wire mediactl.New in runDaemon; publish playback.State on each tick
- Handle playback.{Play,Pause,Seek,SetPosition,SetVolume,Quit}Msg so MPRIS
controls drive the daemon
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
When the auto-launched browser doesn't reach the user (containers,
headless envs, missing xdg-open), the auth flow used to silently
hang for 5 minutes and then time out. Surface the OAuth URL in the
provider loading view so the user can paste it into a browser
manually. Also log the URL via applog.Info so it's preserved for
support reports.
Closes#220
* plex: route stream URLs through navBuffer pipeline
Plex track URLs were not registered with the buffered URL matcher, so
high-bitrate files (e.g. WAV at 1411 kbps) would stall and skip — the
native decoder timed out waiting to buffer the entire file before
playback could start. Adding plex.IsStreamURL to the matcher routes all
Plex /library/parts/ URLs through the navBuffer + ffmpeg pipeline, which
downloads in the background and decouples network I/O from audio output.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* make param clearer
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
Bring-your-own-client_id has been the only path to Spotify since the
integration landed, but Spotify's Nov 27 2024 dev-mode quota change
silently broke /v1/search for every freshly-registered developer app.
Users in #205 and #214 hit a misleading 400 'Invalid limit' that no
amount of parameter tweaking could resolve — the restriction is on the
app, not the request.
Ship the librespot keymaster client_id (the same fallback ncspot and
spotify-player use) as a built-in alternative. Spotify's loopback
exception lets it work with cliamp's existing :19872 redirect URI, and
it predates the Nov 27 cutoff so /v1/search keeps working.
Setup wizard now prompts for which client_id to use, leading with the
recommendation to register your own app (private rate-limit quota) and
offering the shared built-in for users who specifically need search to
work despite the dev-mode restriction. The trade-off is documented in
the picker intro, docs/spotify.md, config.toml.example, and the site.
Also surface a friendlier search error: when Spotify returns the
canonical 400 'Invalid limit' on /v1/search, rewrite it to explain
that the user's client_id is too new for catalog access and how to
fix it. The rewrite only fires on the exact misleading-error
signature, so unrelated 400s pass through.
Drop the now-unused userCountry cache and market= query param from
SearchTracks. The market parameter never actually fixed anything (the
real cause was dev-mode); user OAuth tokens carry account country
implicitly. /v1/me is still called once per session for userID, used
by playlistAccessible to filter non-owned playlists for users on
their own dev-mode app.
Refs #205, #214
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
Runs cliamp without a TUI: serves the existing IPC over its Unix socket,
auto-advances tracks on Drained(), exits cleanly on SIGINT/SIGTERM. Unlocks
Waybar/Polybar modules, hotkey scripts, systemd units, and cron timers.
UI-only commands (theme, vis) return an error in this mode.
playTrack releases the daemon mutex during the blocking Play/PlayYTDL
setup so concurrent IPC requests (e.g. cliamp status) don't stall for the
1-3s of HTTP/yt-dlp probe time. Player internals are already thread-safe.
Docs: docs/headless.md with use cases (systemd, Waybar, Hyprland, cron,
SSH, embedded), linked from README and remote-control.md.
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
SoundCloud is now off by default and requires [soundcloud] enabled = true
in config.toml to register. Previously it was always enabled and users had
to opt out with enabled = false.
Why: SoundCloud playback requires yt-dlp, and a non-trivial number of
users don't have it installed. Showing the provider when it can't actually
play anything was confusing. Opt-in matches the pattern used by Spotify,
Plex, Jellyfin, Emby, and Navidrome.
- config: rename SoundCloudConfig.Disabled to Enabled and flip the
parser to check for 'true' instead of 'false'.
- soundcloud: rename Config.Disabled to Enabled, NewFromConfig now
returns nil unless cfg.Enabled is true.
- docs/soundcloud.md, docs/configuration.md, config.toml.example,
site/index.html: reframe as opt-in with enabled = true examples.
* Add Emby provider
Adds a new provider for Emby Media Server, mirroring the Jellyfin
provider but with Emby-specific API behaviour:
- Authorization header uses the 'Emby' scheme (Jellyfin uses 'MediaBrowser')
- Ping uses GET /System/Info — Emby API keys are server-level and return
500 on /Users/Me, which Jellyfin's Ping calls
- UserID() falls back from /Users/Me to GET /Users for API key auth,
preferring a user whose name matches the configured username
- Full test coverage: client (MusicLibraries, Albums, Tracks, StreamURL,
password auth, NowPlaying, Scrobble, Ping, API key user fallback) and
provider (Name, Playlists, Tracks, CanReportPlayback)
Also adds:
- 'E' keybinding to switch to Emby from anywhere in the UI
- cliamp setup wizard support (token / username+password picker)
- [emby] config section with same fields as [jellyfin]
- docs/emby.md, updates to docs/cli.md, docs/configuration.md,
docs/keybindings.md, config.toml.example, and site/index.html
* Address CodeRabbit review: emby provider fixes
- postJSON: wrap json.Marshal error with path context
- UserID: return explicit error when configured user name not found in /Users
- AlbumList: clamp negative offset to 0
- Playlists: return copy of cache slice to prevent external mutation
- setup.go: wrap Emby ping error with "emby: validation:" prefix
- docs/emby.md: fix Quick start blurb (references /System/Info, not /Users/Me)
- site/index.html: add E key to Provider Browser quick-switch row
* Convert new Emby tests to table-driven style
* Wrap all bare errors in client.go with operation context
* Wrap provider-level browse errors with operation context
* Clarify that 'user' affects API key auth as well as password login
* Add optional username field to Emby API key setup mode
* Fix Emby empty-state hint to cover both auth modes
* Tweak Emby empty-state hint wording
* Fix Emby empty-state hint wording
* Return defensive copies from Playlists/Tracks cache; fix docs em dash
* Add emby to --provider flag valid values
* emby: drop double-prefixed errors and align cache returns with Jellyfin
Provider methods were wrapping client errors with `fmt.Errorf("emby: <op>: %w", err)`,
but client.go already prefixes every error with `emby: <path>:`. End-users saw
messages like `emby: artists: emby: /Items: http status 401`. Drop the redundant
package prefix from provider.go; keep the operation context.
Also drop the per-call defensive copies (`copyTracks`, the playlist slice
clone). The existing Jellyfin provider — which shares this same caching shape —
returns cached slices and maps directly, and no consumer in ui/model/ mutates
the returned tracks. Aliasing through `ProviderMeta` is theoretically possible
but would be a caller bug to fix at the caller, not papered over per-provider.
Aligning Emby with the Jellyfin pattern keeps the two providers behaviorally
identical and removes per-fetch allocations.
---------
Co-authored-by: Sam Hassell <yeehah@protonmail.com>
Co-authored-by: bjarneo <bjarneo@users.noreply.github.com>
Co-authored-by: Bjarne Øverli <bjarne.oeverli@gmail.com>
- New external/soundcloud/ package implementing playlist.Provider +
provider.Searcher via yt-dlp's scsearch: protocol and resolve.
- Empty-user browse view seeds curated genre playlists (Trending,
Hip-Hop, Electronic, House, Lo-Fi, Indie, Pop) since SoundCloud's
official chart endpoints all 404 through yt-dlp at present.
- [soundcloud] config block with user (profile browse) and
cookies_from (browser session for Go+ / private content).
Mirrors ytmusic's cookies_from pattern; closes the OAuth gap left
by SoundCloud shutting their developer program in 2014.
- resolve.SetYTDLCookiesFrom propagates --cookies-from-browser to
every flat-playlist call; player path picks it up too via
player.SetYTDLCookiesFrom.
- Ctrl+F now opens search directly when a provider implements
Searcher; empty-state hint surfaces this. Removed the stale
Ctrl+R-to-refresh-only message.
- Player surfaces yt-dlp's actual exit error (HTTP 404, region
block, DRM) instead of bare EOF, plus a status notification
("Couldn't play X — track is gated, restricted, or unavailable.")
when stream playback fails. yt-dlp invocations now use --quiet so
errors aren't drowned in download progress.
- Capital C switches to SoundCloud (mnemonic for "Cloud"; S was
taken by Spotify). Added to keymap, reserved-keys list, and the
Ctrl+K overlay.
- New docs/soundcloud.md mirrors the other per-provider guides;
configuration.md, yt-dlp.md, keybindings.md, README.md, and
site/index.html updated in lockstep.
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
* feat: file-based logging with intent-based applog facade
Closes#176.
Adds a slog-backed file logger at ~/.config/cliamp/cliamp.log with a
configurable level (log_level config key, --log-level CLI flag) and
refactors applog into an intent-based facade with three tiers:
- Debug/Info/Warn/Error: file only
- Status: footer only (transient UI feedback)
- UserWarn/UserError: both file and footer
The footer ring buffer is preserved unchanged; the file sink is layered
behind an atomic.Pointer[*slog.Logger] so log calls stay lock-free.
Migrated all 11 spotify call sites: failures saving credentials and the
auth-callback server error to UserError, reconnect/rate-limit warnings
to UserWarn, 're-authenticated successfully' to Info+Status.
Plugin-side logging and log rotation deferred to follow-ups.
* docs(site): add diagnostic logging feature card
Keeps site/index.html in sync with docs/configuration.md after the
log_level config key was added in abfdc37.
* Address CodeRabbit review
- config: silently fall back to default for invalid log_level in TOML,
matching the loader's behavior for other keys (volume, repeat, etc.)
- spotify: extract duplicate re-auth message literal into a const
- main: return applied level from initLogging so the startup log records
the level that's actually in effect, not the raw config string
* Drop Enabled gate from UserWarn/UserError
The footer needs the formatted string regardless of file-log level, so
the gate was paying for two fmt.Sprintf sites and a branch in exchange
for skipping a sub-nanosecond slog dispatch. Diagnostic-only methods
(Debug/Info/Warn/Error/logf) keep the gate where it actually avoids the
Sprintf cost.
* applog: use t.Cleanup for test logger close
Resolves three errcheck violations from `defer closeFn()` by switching
to `t.Cleanup(func() { _ = closeFn() })`. The explicit underscore
documents the discard intent and t.Cleanup is the idiomatic place to
register test resource teardown.
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
Plugins can now call cliamp.message(text, duration_secs?) to display
transient messages in the UI status bar. Closes#175.
Delivery flows through prog.Send to the Bubbletea model thread, so
plugin timers and event handlers never touch UI state directly.
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
* feat: support media controller on mac
* fix(notifications): publish current playback state when media notifier attaches
* fix(darwin): lock startup to the Cocoa main thread
* refactor: cleanup and simplify
* missed cleanup
The Model held a concrete *player.Player and called config.Save()
directly, making it untestable without a live audio backend and
real filesystem. This extracts two interfaces:
- player.Engine: 30-method interface covering all Player methods the
TUI uses. The Model now accepts player.Engine, enabling mock-based
testing of the playback state machine.
- model.ConfigSaver: single-method interface for config persistence.
All 11 config.Save() calls in ui/model/ now go through
m.configSaver, removing the direct filesystem dependency.
Runtime control of playback settings that were previously only
accessible via UI keybindings. All commands use the existing IPC
Unix socket protocol.
New commands:
cliamp shuffle [on|off|toggle]
cliamp repeat [off|all|one|cycle]
cliamp mono [on|off|toggle]
cliamp speed <ratio>
cliamp eq <preset> / cliamp eq --band <0-9> <dB>
cliamp device <name|list>
Status response now includes shuffle, repeat, mono, speed, and EQ
preset fields. Site and docs updated with new remote control section.
* feat: CLI playlist management, Unix socket IPC, and SSH streaming
Three features that make cliamp agent-native and scriptable:
1. `cliamp playlist {list,create,add,show,remove,delete}` — manage TOML
playlists from the CLI with --json output and --ssh HOST for remote
directory walking
2. Unix socket IPC at ~/.config/cliamp/cliamp.sock — play/pause/next/prev/
stop/status/volume/seek/load/queue commands over JSON-RPC, following
the MPRIS prog.Send() pattern
3. SSH streaming via ssh:// URL scheme — pipe audio from remote machines
through the existing decoder pipeline, zero filesystem mounts needed
Zero new Go dependencies. All existing tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(ui): SSH track glyph + duration metadata fallback
- Show ↗ prefix on SSH-streamed tracks in the playlist view
- Store/read duration_secs in local TOML playlists
- Fall back to track metadata duration when player reports 0
(piped SSH streams can't determine total length from audio frames)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: album separators, favorites, playlist resume, enrich command
- Album group separators in playlist view (triggers on album field change)
- Favorites system: * key toggles ★, [★ N] count in header, CLI `playlist favorite`
- [67/123] position counter in playlist header
- Playlist resume: saves and restores playlist + track + position across sessions
- `cliamp playlist enrich` probes duration via afinfo and derives album from path
- `ConnectTimeout=5` on all SSH commands to prevent stale hangs
- `SavePlaylist()` exported on local provider
- `*` added to keymap overlay and bottom help bar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: persist TUI favorites to disk, fix truncation, add cross-playlist favorites view
- TUI * key now persists favorites to TOML via FavoriteSetter interface
- ★ prefix deducted from truncation budget (prevents line overflow)
- JSON output includes duration_secs and favorite fields
- `cliamp playlist favorites` lists all ★ tracks across all playlists
- FavoriteSetter interface in provider/interfaces.go
- Bottom help bar: [*]Fav (no extra ★ symbol)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: accent-color favorites, atomic TOML writes, strict --index parsing
Kotharat design review + silent failure audit fixes:
- ★ renders in accent color (yellow) independently from line style — scannable in long playlists
- savePlaylist uses atomic write (tmp + rename) — no data loss on crash
- --index parsing uses strconv.Atoi — rejects fractional/malformed input
- Bounds check on PlaylistFavorite re-read — no panic on concurrent modification
- Help bar priority 80 → 75 for [*]Fav — yields to navigation hints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(ipc): remote theme switching via cliamp theme <name>
- `cliamp theme list` — list all available themes (built-in + custom)
- `cliamp theme <name>` — change theme in running TUI via IPC
- ThemeMsg with reply channel for error reporting ("theme not found")
- TUI handler reloads themes from disk before applying (picks up new custom themes)
- Added Name field to IPC Request for theme name
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(theme): clean up theme CLI UX from review findings
- `cliamp theme` (no args) prints usage cleanly instead of printing playback state
- `theme list` matching is case-insensitive (prevents routing confusion)
- Documented LoadAll() precedent in ThemeMsg handler (matches openThemePicker pattern)
- Updated CLAUDE.md and agent docs with theme commands
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: Blade Runner visualizers, theme-conditional UI chrome, IPC vis command
Two new visualizer modes inspired by the film's on-screen electronics:
- Esper: phosphor grid scan with CRT afterglow decay (4-level persistence)
- VoightKampff: concentric iris rings with bass-driven bellows indicator
Theme-conditional UI chrome (active when neon-blade-runner theme is set):
- Seek bar: ▰▱ blocks with ◆ thumb (Voight-Kampff analog meter)
- Album separators: ── ◈ ALBUM ◈ ── (LAPD terminal data headers)
- SSH track glyph: ◉ (replicant eye) instead of ↗
- Time format: T+MM:SS (mission elapsed time)
- Position counter: [SUBJ 003/123] (interrogation notation)
- Loading spinner: cycles ENHANCE / SCANNING / PROCESSING
- Title flicker: 2% chance per frame of 2-3 char CRT glitch cluster
- Track transition: 4-frame ░▒▓█ static burst
IPC visualizer command:
- cliamp vis <name> — switch visualizer by name
- cliamp vis next — cycle to next mode (same as v key)
- cliamp vis list — list all modes with * active marker
Also adds neon-blade-runner-amber theme (1982 LAPD terminal amber phosphor).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Refactor PR: urfave/cli v3, consolidate duplications, fix SSH+ffmpeg bug
CLI framework:
- Replace hand-rolled flag parser with urfave/cli v3 command tree
- Move command definitions to commands.go, keep main.go focused on TUI startup
- Rename --volume/--theme flags to --vol/--start-theme to avoid subcommand collisions
- Hide unhelpful (default: 0) on int flags where 0 means auto/config
Code consolidation (cmd/playlist.go):
- Use player.SupportedExts instead of duplicate audioExtensions (adds 4 missing formats)
- Use resolve.CollectAudioFiles instead of duplicate walkDir
- Use playlist.TrackFromFilename instead of local copy (includes sanitizeTag)
- Add bulk AddTracks() to avoid N+1 file open/close per track
- Add provider.Exists() using os.Stat instead of parsing all TOML files
- Extract newProvider() and collectLocalAudio() helpers
Architecture fixes:
- Unify MPRIS/IPC message types via internal/control package with type aliases
- Remove ~30 lines of duplicated handler cases in update.go
- Add SSH+ffmpeg guard in pipeline.go (was silently passing ssh:// to ffmpeg)
- Create internal/sshurl for proper URL parsing with port support
- Replace macOS-only afinfo with cross-platform ffprobe for remote duration probing
- Add consistent StrictHostKeyChecking=yes to all SSH call sites
- Fix StrictHostKeyChecking docs to match code (yes, not accept-new)
IPC fixes:
- Remove TOCTOU os.Stat before DialTimeout in ipc/client.go
- Remove unnecessary 64KB scanner buffer in ipc/server.go
- Add nil-guards on msg.Reply for ThemeMsg and StatusRequestMsg
- Change ipcSend/overridesFromFlags to return errors instead of os.Exit
Removals:
- Remove CLAUDE.md
- Remove Esper and Voight-Kampff visualizers
- Remove neon-blade-runner theme-conditional chrome
- Delete hand-rolled ParseFlags and helpers from config/flags.go
* Remove review file
* Add Local provider pill, L keybinding, default radio startup
- Register Local as a provider pill tab (after Radio)
- Add L keybinding to switch to Local provider for browsing TOML playlists
- Load 3 cliamp radio streams directly on default startup (no async M3U)
- Skip playlist resume on default startup so radio streams aren't replaced
- Only allow position resume (SetResume) when CLI args given, never ResumePlaylist
- Update keybinding docs, keymap overlay, and site
---------
Co-authored-by: Tom di Mino <contact@tomdimino.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
* Add --audio-device flag and TUI device picker (d) with cross-platform backends for Linux/macOS/Windows
* Fix device picker cursor wrapping and overlay height consistency
- Move Navidrome scrobble opt-out into CanReportPlayback, removing the
hardcoded type assertion from the generic UI model. This drops the
navScrobbleEnabled field, the config.NavidromeConfig parameter from
model.New(), and the navidrome import from notifications.go.
- Merge identical playbackStartInfo/playbackProgressInfo into one struct.
- Cache Albums() at the Jellyfin Client level to avoid redundant
full-catalog fetches on every browse, sort, or page turn.
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-latest) (push) Has been cancelled
Release / build (amd64, windows, windows-latest) (push) Has been cancelled
Release / build (arm64, darwin, macos-latest) (push) Has been cancelled
Release / build (arm64, linux, ubuntu-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
* Decouple providers behind capability interfaces
* Add docs to provider vel
* Simplify, refactor and remove dead code
* Completely decouple from the ui
* Simplify
* Dead code cleanup