46 Commits

Author SHA1 Message Date
Bjarne Øverli f3a7d643f3 fix(ytmusic): bound cookie playlist loads 2026-08-20 23:17:18 +02:00
Bjarne Øverli 0a1842097f fix(ytmusic): preserve playlist batch boundaries 2026-08-20 23:17:16 +02:00
Bjarne Øverli d11017c121 fix(ytmusic): batch playlists and cancel searches 2026-08-20 23:17:15 +02:00
Praveen Raj 23685fc568 feat(ytmusic): support cookie-backed zero-oauth playlist browsing (#314) 2026-08-20 18:00:30 +02:00
Taha Sadough 78fcefa771 feat: dynamic directory playlists via [[dir]] sources (#308)
* 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.
2026-08-18 17:53:14 +02:00
82Sam 53b2240a7b feat: Progressive playlist loading for youtube music (#287)
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
2026-07-27 22:43:31 +02:00
Bjarne Øverli 02a95fb458 feat(ipc): expand remote player controls 2026-07-20 22:11:17 +02:00
Bjarne Øverli a5922d072e feat: improve local playlist management 2026-07-06 17:39:27 +02:00
Bjarne Øverli 8dad762349 fix: support go install module path 2026-07-05 13:59:18 +02:00
Roberto Gama 213ada6919 fix(resolve,player): play HLS (.m3u8) radio streams via ffmpeg (#259)
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>
2026-06-04 20:03:26 +02:00
GVASTE d6c50ed623 windows: fix config, IPC, path, and Lua compatibility (#258)
* 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>
2026-06-04 19:52:32 +02:00
bjarneo bd8d5d07c0 Fix review findings: concurrency, plugin sandbox, provider bugs, and dedup (#254)
* 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.
2026-05-28 22:42:07 +02:00
Bjarne Øverli d4b596f19f Add SoundCloud as a first-class provider
- 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.
2026-05-05 17:06:40 +02:00
Bjarne Øverli 9c95f2251e Expand test coverage across core 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
2026-04-19 16:10:41 +02:00
Gjermund Garaba 1b53660a63 refactor: go 1.26 modernize (#178)
* refactor: go 1.26 modernize

* coderabbit pr fixes
2026-04-13 18:00:58 +02:00
Bjarne Øverli d89186f371 Fix lint issues 2026-04-02 20:25:56 +02:00
Bjarne Øverli 197e6d5d76 Add core tests 2026-04-02 19:43:44 +02:00
bjarneo 32cb03c687 Pr 158 review (#160)
* 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>
2026-04-02 15:46:17 +02:00
Bjarne Øverli ed036919ac Ensure .mp3 works
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
2026-03-24 20:34:24 +01:00
Bjarne Øverli ccb1770fed Refactor
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
2026-03-23 21:39:55 +01:00
Bjarne Øverli 3f357bd45d Remove dead code 2026-03-20 22:07:04 +01:00
Bjarne Øverli 0aad59437c Extract magic numbers into named constants and add comments for intentionally ignored errors 2026-03-20 21:54:33 +01:00
Patrick Rodrigues ff5387e91d Use explicit realtime stream policy for pause/unpause (#99) 2026-03-18 21:55:32 +01:00
Bjarne Øverli 95fc791b07 Add timeout so it wont freeze 2026-03-17 16:26:15 +01:00
Luca Zhang 14186f3713 feat: parse itunes:duration from RSS feeds to show total track time (#90)
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>
2026-03-16 19:50:00 +01:00
Bjarne Øverli d4933523a7 Fix errors
Release / build (amd64, darwin, macos-latest) (push) Has been cancelled
Release / build (amd64, linux, ubuntu-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
2026-03-14 18:19:12 +01:00
Luca Zhang abfd9f07bf Add Xiaoyuzhou podcast episode playback support (#78)
* 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>
2026-03-14 16:05:25 +01:00
Luca Zhang 1937cdc8cb Add incremental loading for YouTube Radio playlists (#82)
* 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>
2026-03-14 16:01:55 +01:00
Lacy Morrow 325415296b auto commit (#86) 2026-03-14 15:59:16 +01:00
Bjarne Øverli 38a877b06b Remove dead code, extract shared browser helper, fix volume cache, ctx ordering, and doc comments 2026-03-07 16:52:59 +01:00
Bjarne Øverli 9f99b9d9cc Simplify ytmusic: unify decode/session funcs, use appdir, fix cache race and thundering herd 2026-03-07 16:32:24 +01:00
Lacy Morrow 1797a3288f Feat/youtube music provider (#68)
* 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
2026-03-07 16:13:02 +01:00
TakuSemba e86af97f66 fix: prevent yt-dlp streams from restarting on unpause (#50)
* 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>
2026-03-05 19:00:59 +01:00
Bjarne Øverli ff0989211c Add Shift+S download for yt-dlp tracks playing via pipe stream
Publish to AUR / aur (push) Has been cancelled
Release / build (amd64, darwin) (push) Has been cancelled
Release / build (amd64, linux) (push) Has been cancelled
Release / build (amd64, windows) (push) Has been cancelled
Release / build (arm64, darwin) (push) Has been cancelled
Release / build (arm64, linux) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / update-homebrew (push) Has been cancelled
2026-03-04 18:28:58 +01:00
Bjarne Øverli f2411ebb97 Harden codebase: fix path traversal, add Subsonic error checking, crypto-random auth salt, section-aware config save, ffmpeg process leak, and 14 other bug/security fixes 2026-03-02 20:38:20 +01:00
Bjarne Øverli fbd6ade734 Do content sniff to understand xml podcast feeds 2026-03-02 08:22:34 +01:00
Bjarne Øverli c77117f1c3 Fix the UA 2026-03-01 19:44:53 +01:00
Bjarne Øverli 1d3e9d6e43 Add PLS playlist support and fix HTTPS stream EOF on Icecast servers 2026-03-01 13:56:43 +01:00
Ezra Hatt 5795554606 Add embedded tag metadata reading for local files (#11)
* 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>
2026-03-01 13:38:53 +01:00
Bjarne Øverli 3d69ec2644 Fix yt-dlp scanner overflow 2026-02-28 16:59:58 +01:00
Bjarne Øverli 259ce02f06 Refactor
Release / build (amd64, darwin) (push) Has been cancelled
Release / build (amd64, linux) (push) Has been cancelled
Release / build (amd64, windows) (push) Has been cancelled
Release / build (arm64, darwin) (push) Has been cancelled
Release / build (arm64, linux) (push) Has been cancelled
Release / release (push) Has been cancelled
2026-02-28 16:46:23 +01:00
Bjarne Øverli 1baaa46530 Add proper playlist support including m3u files 2026-02-28 13:33:23 +01:00
Bjarne Øverli 5b89f8cfb0 Refactor 2026-02-27 22:49:08 +01:00
RJPushPlay 13d3ac878f Add yt-dlp support for SoundCloud, YouTube, and Bandcamp (#5)
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>
2026-02-27 22:17:06 +01:00
Bjarne Øverli c61818ed68 Fix buffering for streams
Release / build (amd64, darwin) (push) Has been cancelled
Release / build (amd64, linux) (push) Has been cancelled
Release / build (amd64, windows) (push) Has been cancelled
Release / build (arm64, darwin) (push) Has been cancelled
Release / build (arm64, linux) (push) Has been cancelled
Release / release (push) Has been cancelled
2026-02-26 20:07:16 +01:00
Bjarne Øverli 41a79dd52f Refactor 2026-02-26 19:56:40 +01:00