The `sshurl.Parse()` function and `openSSHSource()` function in cliamp were
vulnerable to SSH option injection through the URL host field. Go's `url.Parse`
accepts `-oProxyCommand=...` in the host field, which cliamp's `SSHArgs()`
function appends bare to the ssh argv, allowing arbitrary command execution.
This commit adds defense-in validation at two layers?
1. `internal/sshurl/sshurl.go:50` - Rejects hostnames starting with `-` (the
`-o` ssh option prefix) or containing `=` (key-value separator) during URL
parsing.
2. `player/decode.go:101` - Defense-in-depth check in `openSSHSource()` that
validates the parsed host after `sshurl.Parse()` returns, rejecting the same
disallowed patterns before constructing the ssh command.
On OpenSSH version above 9.6, an additional `ssh_valid_hostname()` check blocks the
destination hostname `cat -- /x` from being accepted. However, the code-layer
validation is still necessary because:
- The `-oProxyCommand=...` option injection itself is not blocked by OpenSSH's
hostname check (the option value itself is accepted?)
- On OpenSSH below 9.6 (the vast deployed base: Ubuntu 22.04/24.04, Debian 12,
RHEL/CentOS, macOS), the code-layer validation is the only protection
Both checks use `strings.HasPrefix(host, "-") || strings.Contains(host, "=")`
to catch the injection vector while still preserving actaul legitimate `ssh://host/path` links.
* feat(spotify): return albums from search
/v1/search was asked for type=track,episode, so an album could never appear
in the results. Searching an artist returned whichever of their tracks
Spotify ranked highest, and there was no way to reach a record as a record.
Albums are now requested too and lead the results, as an album placeholder:
a Track carrying the album's name, artist and year, marked through
ProviderMeta so the UI can tell it apart without knowing which provider
produced it. Placeholders are not streamable, because spotify:album: URIs
are not something go-librespot can play, so SpotifyProvider now implements
provider.AlbumTrackLoader to expand a chosen one into its tracks.
/v1/albums/{id}/tracks returns simplified track objects without the album
they belong to, so the album's own metadata is fetched once and filled in
on every track for display.
* feat(ui): play a whole album from the search results
Enter, a and q on an album expand it through AlbumTrackLoader and then act
on the full record, matching what they already did for a single track:
Enter starts it now, a appends it, q queues it next. Like playTrackImmediate
they add rather than replace, so a queue built up over an evening survives
picking an album.
The overlay stays open while the expansion runs, showing "Loading album...":
closing it would bump the request generation and drop the response. The
in-flight flag is separate from the playlist fetch's so the results screen
only claims to be loading an album when it is.
p is refused on an album with an explanation. The playlist picker adds one
track, an album is many, and Spotify has no single call to add a record to
a playlist.
* feat(ui): group search results into labeled sections
With albums and tracks in one flat list an album read exactly like one of
its own tracks. The results now carry "Albums" and "Tracks" separators in
the same style the playlist already uses for album headers, and the label
repeats at the top of the viewport when it opens mid-section.
Separators take rows of their own, so scrolling counts rendered rows rather
than results, the way albumSeparatorRows does for the playlist. Without it
the cursor could sit below the bottom of the window.
* feat(plex): browse server playlists alongside albums
Expose Plex audio playlists (smart and user-created) in the provider
browse view under a "Playlists" section, ahead of the existing album
list which now sits under an "Albums" section.
Playlist IDs are prefixed with "pl:" so Tracks() can route them to the
server playlists items endpoint instead of the album children endpoint.
Playlist items are paginated (1000 per page) to handle smart playlists
with tens of thousands of tracks. Removes the documented "No Plex
playlists" limitation.
* fix(plex): address review feedback
- Do not cache playlist track results: smart playlists change server-side,
so Tracks() now bypasses the track cache for "pl:" prefixed IDs and
always reflects current contents (albums keep caching).
- Wrap new playlist-list and playlist-items errors with operation context.
- Add language identifier to the docs example block (markdownlint MD040).
- Add test asserting playlist tracks are refetched on every load.
---------
Co-authored-by: Petar Salinovic <5ar.salinovic@gmail.com>
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).
* feat(ipc): report live radio metadata in status
A stream's playlist entry only carries the station name, so status
reported that with no artist while the TUI showed the actual song.
Stations broadcast now-playing text inline as ICY metadata (the
StreamTitle field of the SHOUTcast/Icecast protocol), which the UI polls
but never reached the IPC response.
Apply the UI's own resolution in both the TUI and daemon status handlers,
and add TrackInfo.StreamTitle (the raw StreamTitle value) plus
TrackInfo.Station so a client can show station, artist/song, and progress
together.
Resolved at the status handlers rather than in ipcTrackInfo/trackInfo,
since only the now-playing track has stream context.
* fix(ipc): keep the stored title when an ICY tag has no song
A StreamTitle of "Artist - " cuts to an empty title, which blanked the
display instead of falling back to the station name. Guard the split in
resolveTrackDisplay (fixing the TUI and Lua events too, not just status)
and in the daemon, and derive Station from the resolved title so both
paths agree.
Also handle a stream title with no artist in the docs jq example.
Addresses review feedback on #319.
* test(ipc): cover the daemon's stream-title contract
Extract the daemon's stream branch into applyStreamTitle so the test
exercises the real implementation rather than a copy of it, and cover the
"Artist - Title", "Artist - ", title-only, no-metadata, and non-stream
cases. statusResponse needs a live player, so the helper is the seam.
Also guard the docs jq example against a missing .track, which printed a
literal "null" when nothing was loaded.
Addresses review feedback on #319.
* docs: add herdr-cliamp to community plugins
A herdr plugin rather than a Lua one: it floats cliamp in a detached herdr
session so closing the float only detaches, and adds now-playing and
transport keybinds that work from any workspace.
* docs: match the site description to the docs entry
Addresses review feedback on #320.
/v1/search accepts at most 10 results per request for an app in
Development Mode. Asking for more returns 400 "Invalid limit".
SearchTracks asked for up to 50, and friendlySearchError reported that
400 as "search blocked, your client_id is too new", pointing users at the
shared built-in client_id instead. Search is not blocked for these apps,
only paged smaller.
Measured against a Development Mode app (client credentials token):
limit=10 200, 10 tracks
limit=11 / 20 / 50 400 "Invalid limit"
no limit parameter 200, default page
type=track,episode&limit=10 200, 10 tracks + 10 episodes
limit=10&offset=0/10/20/40 200 for every offset
GET /v1/tracks/{id} 200
GET /v1/browse/new-releases 403
So the cap is real but narrow: offset paging past it works, and neither
the episode type nor a missing market parameter is involved. Other
catalog endpoints are restricted for these apps, which is likely where
the "search is blocked" theory came from, but /v1/search is not one of
them and Extended Quota Mode is not needed for it.
Try the requested limit first, so an app with Extended Quota Mode keeps
its single request, and fall back to pages of 10 only after Spotify
rejects the limit. Rewrite the error message to describe the cap, and
correct the claim in docs/spotify.md.
Fixes#205
* feat: enrich playlist album via file metadata
* Refactor enrich command to use normalized source key
- Rename 'from' option to 'source' in command documentation- Update PlaylistEnrich function to correctly handle normalized source keys- Improve source key handling logic for album metadata extraction- Clarify source usage in the 'cliamp playlist enrich' command description
* feat: normalize song path
Replacing forward-slash instead of backslash to have less interference when sorting playlist by path
* feat: enrich elements year via file metadata
* 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.
PlaybackReporter and ProgressReporter now return an error. The UI logs it at
the three fire-and-forget call sites, so jellyfin, emby, and navidrome gain
the observability audiobookshelf had — they were dropping their client errors
silently. Player state is read on the UI goroutine and passed into the
closures, so the reports stay race-free.