* feat(tomlutil): add ParseNamedSections for multi-section documents
* feat(resolve): add AudioFiles and TracksFromPaths helpers
* feat(playlist): add DirSourced flag to Track
* feat(local): support [[dir]] directory sources in playlists
Playlists can now reference directories with [[dir]] sections instead of
listing every track. Directory sources are scanned at load time, so new
files appear and removed files disappear automatically.
- parsePlaylistDoc keeps explicit tracks and dir sources in document order
- expand resolves dirs into tracks, marking them DirSourced; explicit
[[track]] entries always shadow a directory scan of the same path
- savePlaylist preserves [[dir]] sections and skips DirSourced tracks
- bookmarking a dir-sourced track materializes it as an explicit entry so
the bookmark persists
- RemoveTrack refuses dir-sourced tracks; AddTracks dedupes against them
- Playlists()/SearchTracks operate on the expanded view
- CreateDirPlaylist, AddDirSource (deduped), DirSources added
* feat(cli): add --dir flags and playlist dirs subcommand
playlist create and add accept repeatable --dir flags that reference a
directory as a [[dir]] source, and a new 'playlist dirs' subcommand lists
them. --dir cannot be combined with --ssh. enrich skips dir-sourced tracks
and sort notes that they reload in scan order.
* feat(ui): guard edits on dir-sourced playlist tracks
* docs: document [[dir]] directory sources
* docs: show playlist file layout and multi-file pickup
* docs: show adding files/directories across one or many playlists
* fix: address code review findings for directory playlists
- Save playlists with interleaved [[track]]/[[dir]] section order instead of
flattening dirs first, so removals, reorders, enrichment, and bookmark
materialization keep each section's original position.
- Remove UI tracks by matching the persisted explicit track by path, so a
rescan between load and save cannot remove the wrong track.
- Render playlist documents in memory before the atomic rename so a short
write can never truncate an existing playlist.
- Validate all inputs (audio paths, directory sources) before persisting:
create and add fail without leaving partially-written playlists behind.
- Persist directory sources as one atomic batch (AddDirSources).
- Skip unreadable entries during recursive directory scans instead of
aborting the whole scan.
- Wrap directory operations with contextual errors; document directory
sources on the site.
- Regression tests for section-order preservation, atomic batch validation,
no-partial-playlist-on-failure, and unreadable-subdir scans.
* fix: resolve remaining code review findings
- tomlutil: flush and clear state on unrecognized array-table headers so
fields cannot leak into the previous section
- local: propagate playlist read errors instead of rewriting the file
without its [[dir]] sections
- cmd: use plural helpers for the created-playlist message and wrap
playlist load errors in playlist bookmark
- resolve: wrap filesystem errors with operation context
- docs: describe the .toml discovery rule accurately and label the
directory-tree fence
* fix: keep leftover track insertion positions aligned
Two leftovers materialized in one save could land in the wrong slot:
each insertion shifts later sections, so directory positions tracked in
dirPos must be re-aligned after every insertion. Replace the supplier
scan with a pure path check so saves never re-walk the filesystem, and
skip the unreadable-subdir test on Windows where os.Chmod maps to the
read-only attribute instead of Unix permissions.
* fix: persist cross-playlist tracks as explicit entries
A track added from a directory-backed playlist carried its DirSourced
flag into the destination playlist. savePlaylist then dropped it (the
destination has no owning [[dir]] section), so the track was reported as
added but silently lost. Clear the flag on incoming tracks in the
AddTracks merge. Clarify that the playlist listing omits unknown
durations (browser already hides them) and still walks directory sources
to count files.
* fix: only treat supported audio files as dir-supplied
dirSuppliesFile now validates the candidate extension against
player.SupportedExts before the path-containment checks, so non-audio
files added as explicit tracks (e.g. cover.jpg under a [[dir]]) are
appended at the end instead of being inserted before the directory
section.
* test: table-driven coverage for dirSuppliesFile predicate
* test: fix Windows path assertions in dir tests
- Normalize ExpandPath's env-expanded result with filepath.Clean before
comparing: on Windows the raw expansion mixes / and \ separators.
- Assert TestSavePlaylistPreservesDirsAndSkipsDirTracks against the parsed
document instead of raw text: the writer escapes backslashes via %q, so
substring matching of a Windows temp path never matched.
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
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
* fix(resolve,player): play HLS (.m3u8) radio streams via ffmpeg
HLS playlists were parsed as track lists, so the master playlist's relative chunklist_*.m3u8 URI became a bogus local-file track (open source: ... no such file or directory). Detect HLS in resolveM3U and return a single stream Track with the original URL; route .m3u8 stream URLs to ffmpeg-by-URL in the player pipeline so ffmpeg resolves relative chunklist/segment URIs and follows the live segment window.
* docs(streaming): document HLS playback
* hls: reuse formatExt result, note VOD realtime caveat, sync site
- hoist formatExt so the HLS branch and the format fallback share one parse
- comment that EXT-X-ENDLIST detection is conservative behind master playlists
- mention HLS streaming on the site (docs/site sync convention)
- docs style: drop em dash
---------
Co-authored-by: Bjarne Øverli <bjarne.oeverli@gmail.com>
* 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>
* 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.
- 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.
* 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
* feat: parse itunes:duration from RSS feeds to show total track time
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: handle invalid/negative itunes:duration and add tests
- Return 0 on any parse error instead of silently computing partial results
- Support fractional seconds (e.g. "3661.5")
- Clamp negative values to 0
- Add TestParseItunesDuration covering all formats and edge cases
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add Xiaoyuzhou (小宇宙) podcast episode playback support
Xiaoyuzhou (xiaoyuzhoufm.com) is one of the largest podcast platforms
in China. This adds support for resolving episode pages by extracting
audio URLs from og:audio meta tags and schema.org JSON-LD data.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix elapsed time stuck at 00:00 for ffmpeg-piped streams and address PR review feedback
- Track sample position in ffmpegPipeStreamer so Position() returns
actual elapsed time (fixes Xiaoyuzhou .m4a playback showing 00:00)
- Handle JSON-LD unmarshal error when og:audio is absent
- Cap HTML response body to 2 MB via io.LimitReader
- Pre-compile meta tag regexes instead of recompiling per call
- Use t.Errorf instead of t.Fatalf in httptest handler goroutine
- Strip "m." prefix for mobile Xiaoyuzhou URLs
- Add test for og:audio meta tag precedence path
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add Xiaoyuzhou (小宇宙) to README and streaming docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: bjarneo <bjarneo@users.noreply.github.com>
* Add incremental loading for YouTube Radio playlists
YouTube Radio/Mix playlists (list=RD...) are dynamically generated and
can contain hundreds of tracks. Loading them all upfront is slow.
This adds incremental batch loading: fetch 20 tracks initially for fast
startup, then chain-load subsequent batches of 100 in the background
until the full playlist is loaded.
Key design decisions:
- feedsLoadedMsg carries source URLs so batch init works for both
CLI args and interactive URL input (u key)
- Batch offset uses resolve.YTDLRadioInitialItems constant (not
len(tracks)) to stay correct when mixed with other feed/M3U URLs
- Generation counter (uint64) on each batch session prevents stale
in-flight responses from polluting a new playlist, even when the
same Radio URL is reloaded
- All playlist.Replace() paths call resetYTDLBatch() to invalidate
the current session
- Single scheduling path: chain-loading only (no dual trigger logic)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Address PR review: fix stale gen race, surface batch errors, fix comment
- Check msg.gen before clearing ytdlBatchLoading so a stale response
doesn't flip the loading flag for the current session
- Show a status message when a batch fetch fails instead of silently
truncating the playlist
- Fix resetYTDLBatch comment: "invalidates" not "cancels" (the yt-dlp
process still runs to completion, only the result is discarded)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add YouTube Music library browser provider
Add YouTube Music as a provider so user's YT Music playlists appear
in the N menu alongside Spotify/Navidrome/Radio.
Implementation:
- external/ytmusic/session.go: Google OAuth2 Desktop flow with PKCE,
localhost callback on port 19873, token caching, silent refresh
- external/ytmusic/provider.go: playlist.Provider + Authenticator using
YouTube Data API v3 (playlists.list, playlistItems.list, videos.list)
- config.go: YouTubeMusicConfig struct + [ytmusic] section parsing
- main.go: wire up ytmusic provider entry + defer Close
Audio playback uses existing yt-dlp pipeline — tracks returned as
https://music.youtube.com/watch?v=<id> URLs, zero audio changes needed.
Auth mirrors Spotify UX: browser opens → Google sign-in → localhost
callback → auto-close tab → refresh token cached for silent re-auth.
* fix: add ytmusic to --provider flag validation
* fix: add client_secret to YouTube Music OAuth config
Google Desktop OAuth requires both client_id and client_secret (unlike
Spotify which supports PKCE-only public clients). Thread client_secret
through session, provider, and config. Update docs.
* debug: add stderr logging to ytmusic provider for playlist loading
Adds logging to diagnose where playlist loading hangs:
- ensureSession: silent auth attempt/result
- Playlists: API call start/page results/total
- Reduced timeout from 5min to 30s
* feat: native YouTube audio streaming via kkdai/youtube
Replace yt-dlp dependency for YouTube/YouTube Music URLs with pure Go
streaming using github.com/kkdai/youtube/v2.
New files:
- player/youtube.go: youtubeStreamer that uses kkdai/youtube Client to
get audio-only stream, pipes through ffmpeg for PCM conversion
Changes:
- playlist/playlist.go: add IsYouTubeURL() to identify YouTube URLs
- player/player.go: add PlayYouTube/PreloadYouTube/buildYouTubePipeline
- ui/commands.go: add playYouTubeStreamCmd/preloadYouTubeStreamCmd
- ui/model.go: route YouTube URLs to native player, keep yt-dlp fallback
for non-YouTube URLs (SoundCloud, Bandcamp)
- resolve/resolve.go: handle YouTube URL resolution natively
YouTube Music URLs no longer require yt-dlp to be installed.
yt-dlp is still used as fallback for SoundCloud/Bandcamp/other sites.
* fix: revert native YouTube streaming, use yt-dlp for playback
kkdai/youtube gets blocked by Google's bot detection (redirects to
google.com/sorry CAPTCHA). This caused tracks to fail silently and
skip to the next one.
Revert YouTube URL routing back to yt-dlp pipe chain for audio
playback. The YouTube Data API v3 playlist browsing (which uses
proper OAuth) remains unaffected.
player/youtube.go is kept in the tree for potential future use with
cookie injection, but is no longer in the active playback path.
* feat: auto-detect and install yt-dlp for YouTube Music
When YouTube Music is configured but yt-dlp is missing:
1. Print platform-specific install command (brew/apt/pacman/pip)
2. Attempt automatic install via detected package manager
3. If auto-install fails, disable YouTube Music provider with message
4. If auto-install succeeds, continue normally
Also improves error messages when yt-dlp/ffmpeg are missing during
playback to show the exact install command for the user's platform.
* fix: prompt user before auto-installing yt-dlp
Wait for Enter keypress before installing instead of immediately
running the package manager on launch.
* fix: avoid HLS/m3u8 formats in yt-dlp pipe chain
HLS streams require segment downloading and muxing which doesn't
pipe cleanly to stdout, causing ffmpeg exit status 183 (INVALIDDATA).
Prefer direct HTTPS/HTTP audio streams. Also add --no-warnings to
keep yt-dlp stderr clean for error detection.
* debug: add verbose logging for yt-dlp/ffmpeg pipe chain
Log the exact URL being played, enable yt-dlp verbose mode (-v),
capture and print ffmpeg stderr, to diagnose exit status 183.
* fix: route YouTube URLs through yt-dlp pipe, not ffmpeg decode
IsYTDL was excluding YouTube URLs (returning false for IsYouTubeURL
matches) because the native kkdai/youtube player was supposed to
handle them. But that was reverted due to bot detection, so YouTube
URLs fell through to decodeFFmpeg which tried to decode the URL as
a local file — causing ffmpeg exit status 183 (INVALIDDATA).
Fix: IsYTDL now returns true for YouTube URLs, routing them correctly
through the yt-dlp | ffmpeg pipe chain.
* fix: fetch actual track count for Liked Music playlist
Query the LL playlist directly via playlists.list?id=LL to get the
real item count instead of hardcoding -1.
* feat: bundled YouTube Music credentials with fallback pool
Mirror the Spotify zero-config pattern. Users no longer need to create
a Google Cloud project. When no client_id/client_secret are configured,
a random credential pair is selected from a built-in fallback pool.
- Add external/ytmusic/fallback.go with FallbackCredentials()
- Add YouTubeMusicConfig.ResolveCredentials() with user > fallback priority
- YouTubeMusicConfig.IsSet() now returns true when [ytmusic] section
exists (even without explicit credentials)
- Enable provider when [ytmusic] section is present in config
To use: just add '[ytmusic]' to config.toml — no credentials needed.
Users can still override with their own client_id/client_secret.
* fix: hide uploaded/private tracks when cookies_from is not configured
Tracks from 'Music Library Uploads' channel require browser cookies
for yt-dlp to access. When cookies_from is not set, filter these
tracks out at fetch time instead of showing them and failing on play.
When cookies_from IS configured, all tracks are shown as before.
* fix: hide empty playlists and deduplicate by ID
Filter out playlists with zero tracks and skip duplicates that
YouTube's API sometimes returns across paginated results.
* fix: track playback position for yt-dlp streamed tracks
Position() was hardcoded to return 0. Now counts samples consumed
in Stream() so the seek time ticks up during playback.
* feat: split into YouTube and YouTube Music providers
Both providers share a single OAuth session and classify playlists
automatically by sampling one video from each and checking its
YouTube category (10 = Music).
- YouTube Music: Liked Music + playlists with music content
- YouTube: Liked Videos + playlists with non-music content
Classification runs in parallel (10 concurrent requests) on first
load and is cached to ~/.config/cliamp/ytmusic_classification.json.
Subsequent launches skip classification entirely.
New provider key: 'youtube' (alongside existing 'ytmusic').
Flag: --provider youtube
* feat: add 'yt' provider showing all playlists unfiltered
Three YouTube providers now available:
- yt: all playlists (music + video, unfiltered)
- youtube: non-music playlists only
- ytmusic: music playlists only
All share the same OAuth session and credentials.
* fix: restore bundled OAuth credentials for zero-config setup
* fix: any YouTube config section enables all three providers
[yt], [youtube], and [ytmusic] are all aliases for the same config.
Any one of them enables all three YouTube providers (yt, youtube,
ytmusic). Fallback credentials also auto-enable all three even
without any config section. --provider yt/youtube/ytmusic flag
also triggers enablement.
* cleanup: remove all debug logging from YouTube providers
Remove all fmt.Fprintf(os.Stderr, ...) debug lines from provider,
classify, and ytdl. Reset ffmpeg loglevel back to 'error'.
These were leaking into the TUI and causing text to stick on screen.
* feat: seek support for yt-dlp streams via restart
Seeking YouTube/yt-dlp tracks now works by restarting the yt-dlp
pipeline with --download-sections to skip to the target position.
The old pipeline is killed and replaced seamlessly.
Requires known duration (provided by YouTube API metadata).
Uses the same seek UX as local files (arrow keys / shift+arrows).
* fix: debounce yt-dlp seeking to prevent UI freeze
Rapid seek keypresses on YouTube tracks would spawn multiple yt-dlp
processes simultaneously while holding the speaker lock, freezing
the app. Now seek presses accumulate for ~300ms before firing a
single async seek. The UI stays responsive during the seek.
Local file seeking remains immediate (no debounce needed).
* fix: don't hold speaker lock during yt-dlp seek
The speaker lock was held for the entire yt-dlp spawn duration
(1-3 seconds of network I/O), blocking the audio thread and
freezing the app. Now SeekYTDL() builds the new pipeline without
the speaker lock, then briefly locks only to swap streams.
* fix: show target seek position immediately during debounce
When seeking YouTube tracks, the time display and seek bar now
show the target position instantly as you press arrow keys,
instead of staying frozen at the old position until yt-dlp restarts.
The UI updates on every keypress; the actual seek fires after
the debounce window (~300ms of no presses).
* fix: prevent seek position bouncing during yt-dlp restart
The display was bouncing: target → old position → new position.
Now the target position is held on screen until the async yt-dlp
seek actually completes (seekTickMsg), preventing any visual reset
to the old position during the restart gap.
* fix: prevent deadlock on seek and quit
Three fixes:
1. Double Wait() on yt-dlp cmd (monitor goroutine + pw.Close goroutine
both called Wait) — merged into single goroutine
2. Close() blocked on process Wait — now fully async via sync.Once
with background goroutine cleanup
3. closePipelines after seek runs in background goroutine
4. Cmd closure captured Model pointer — capture player directly
These caused the app to freeze when seeking or quitting after a
YouTube track had been seeked.
* fix: increase seek debounce to 800ms, handle seek-during-seek
300ms was too fast — yt-dlp hadn't finished loading before the
debounce fired, causing intermediate positions to briefly play.
Also: pressing seek while a previous seek is in-flight now
accumulates from the target position instead of resetting.
* fix: cancel stale yt-dlp seeks when new seek is requested
Added a generation counter to Player. When a new seek fires,
it increments the counter (CancelSeekYTDL), causing any in-flight
SeekYTDL to discard its pipeline instead of swapping it in.
This prevents the 'load wrong position briefly, then jump' behavior
when pressing seek multiple times while yt-dlp is still loading.
* fix: remove Liked Music from YouTube Music provider
The LL playlist is actually Liked Videos (shared between YouTube
and YouTube Music). It now only appears under the YouTube and
YouTube (All) providers, not YouTube Music.
* fix: allow yt-dlp seeking even without known duration
SeekYTDL was silently skipping when knownDuration was 0, which
could happen for some YouTube Music tracks. Now seeking works
regardless — duration is only used to clamp the upper bound.
Also added ytdlSeek to Seekable() so the UI shows seek hints.
* fix: suppress reconnect during yt-dlp seek
When seeking kills the old yt-dlp pipeline, StreamErr() returns an
error. The reconnect logic was treating this as a connection drop and
restarting the song from the beginning. Now reconnect is suppressed
while a seek is pending or in-flight.
* fix: add grace period after yt-dlp seek to suppress stale errors
The old pipeline's error can persist in the decoder for a few ticks
after the new pipeline is swapped in. Added a ~1 second grace period
after seek completion during which reconnect is suppressed.
* fix: no-op enter on already-loading track, fix help row overflow
- Enter/play on a track that's already loading or actively playing
now does nothing instead of restarting the load
- Help hints row now fits to panelWidth (inner frame width) instead
of terminal width, preventing Quit from wrapping to next line
* fix: use ffmpeg -ss for seeking instead of yt-dlp --download-sections
--download-sections causes yt-dlp to re-mux output into WebM and mix
ffmpeg stderr into stdout, breaking the pipe chain and producing
silence or errors. Instead, download the full stream with yt-dlp and
use ffmpeg's -ss flag to skip to the desired position. This is
slightly less efficient (downloads from the start) but works
reliably with pipe:0 input.
* fix: stretch frame to full terminal width
Frame was leaving a 2-column margin (msg.Width-2), making panelWidth
8 columns narrower than terminal. Help hints and other content didn't
fill the visible width. Now the frame uses the full terminal width.
* fix: allow continuous seek accumulation during yt-dlp loading
Pressing seek keys while a previous seek is loading now continues
to accumulate and show visual feedback. The display position never
bounces back — seekTickMsg only clears seekInFlight when no new
seek is pending.
* refactor: simplify yt-dlp seek to single target position model
Replace the complex pendingSeek/seekBasePos/seekInFlight state
machine with a simpler model:
- seekActive: true from first keypress until seek completes
- seekTargetPos: absolute target position (accumulates directly)
- seekTimer: debounce countdown
Each keypress updates seekTargetPos directly and resets the timer.
CancelSeekYTDL is called on every keypress to discard any in-flight
seek. Display always shows seekTargetPos while seekActive is true.
This eliminates the base position + delta calculation that was
causing display issues when seeking forward then backward.
* optimize and fix freezes
* auto commit
* caching layer + optimizations
* removed secret
* fix: prevent yt-dlp streams from restarting on unpause
Pausing and resuming YouTube Music (and other yt-dlp sources) would
restart the track from the beginning. This happened because the
togglePlayPause logic treated all HTTP streams as live streams,
reconnecting on unpause. yt-dlp streams are on-demand and can safely
resume from the pause point.
- Add Track.IsLive() to distinguish live streams (no known duration)
from on-demand streams
- Parse duration from yt-dlp --flat-playlist JSON into DurationSecs
- Use IsLive() instead of Stream flag in togglePlayPause
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: simplify comment
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add embedded tag metadata reading for local audio files
Read ID3v2, Vorbis comments, and MP4 atoms from local files using
dhowden/tag instead of relying solely on filename parsing. Falls back
to filename parsing when tags are absent or unreadable.
- New playlist/tags.go with ReadTags() for tag extraction
- Track struct gains Genre, Year, TrackNumber fields
- TOML persistence and MPRIS D-Bus metadata updated for new fields
- Album shown as subtitle in the now-playing display
- New 'i' key opens a track info overlay with full metadata
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add embedded tag reading and track info key to features list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Scan local file tags concurrently for faster playlist loading
Use 8 worker goroutines to read tags in parallel instead of
sequentially. Order is preserved. ~4x speedup on 766 files
(102ms → 25ms).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Shell out to yt-dlp to resolve and play audio from SoundCloud playlists,
YouTube, and Bandcamp URLs. Playlists are enumerated instantly via
--flat-playlist, then each track is downloaded to a temp file on demand
for seekable playback. Adds [S] save key to keep downloaded tracks in
~/Music/cliamp/.
Co-authored-by: RJPushPlay <RJPushPlay@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: bjarneo <bjarneo@users.noreply.github.com>