v1.9.1
This is the third and final wave of major updates in a rapid release cycle — quieter cadence ahead.
A community-PR-driven feature release. Six PRs from 
@wgordon17 plus user-reported UX work, an AUR auto-publish pipeline, and a per-collection shuffle memory system. Distribution data milestone — crossed 10,000 lifetime PyPI downloads on the day this release was assembled.
Supersedes the same-day v1.9.0 tag, which shipped two small regressions: the browser forward-stack got clobbered when clicking the current page's footer entry a second time, and the playback-bar Shuffle-lock dimming didn't refresh when starting a radio from the sidebar. Both are fixed in this release — install v1.9.1 directly.
New features
- "Start Radio" from playlist — context-menu entry in the sidebar plus a
[▶ Start Radio]header button on the Library and Context pages. Backed by ytmusicapi'sRDAMPLwatch-playlist convention. New service methodYTMusicService.get_playlist_radio(playlist_id). Six unit tests covering VL-prefix stripping, normal flow, exceptions, empty results, non-dict responses. Thanks
@wgordon17 (#70). - Proactive radio refill — when ≤3 tracks remain in queue with autoplay on and repeat off, the app silently fetches more tracks in the background, seeded from up to 5 already-played items in the current queue. New methods
_maybe_extend_queue/_extend_queueon PlaybackMixin. Thanks
@wgordon17 (#71). - Multi-seed
get_radio— service method now accepts a list of seeds, fetches in parallel viaasyncio.gather, dedups byvideoId, shuffles, and trims to limit. Backward-compatible at the signature level (single string still accepted). 8 dedicated tests. Thanks
@wgordon17 (#71). - Single-seed shuffle guard —
get_radioonly shuffles the result pool whenlen(video_ids) > 1. Single-seed callers (the existing "Start Radio on track X" flow) keep their seed-first ordering. Two new tests covering single-seed-deterministic and multi-seed-shuffled behavior. - Discovery roulette — random mix from one of seven sources (trending, mood, charts, home, liked songs, library artist, recent history) with last-source-exclusion rotation to avoid repeats. New service method
get_discovery_mix() -> tuple[list[dict], str]. 6 dedicated tests. Bound to theDkeybinding (added by us alongside the merge — PR description had mentioned the binding but it didn't ship). Discovery is also reachable via the new "♫ Discovery Mix" item in the playlist sidebar's pinned-nav block. Help page lists the new action. Thanks
@wgordon17 (#71). - Shuffle lock (per-playlist) — explicit toggle in the Library page playlist header (
Shuffle lock: ON/Shuffle lock: off). When ON: opening that playlist forces shuffle, and the playback-bar shuffle button is dimmed and rejects clicks (with a toast pointing the user back to the lock toggle). When OFF: normal global shuffle behaviour. Persists per-playlist-ID to~/.config/ytm-player/shuffle_prefs.json(LRU-capped at 1000 entries; thread-safe atomic JSON writes). Replaces the originally-planned implicit "remember last shuffle state" behaviour, which was un-discoverable and didn't actually persist via the playback-bar click path. NewQueueManager.current_context_id+set_context()underpin the lookup. Session restore preservesqueue_context_idso the lock identity survives a restart. - Configurable home shelf count — set
home_shelvesunder[ui]inconfig.tomlto control how many recommendation shelves are fetched on the Browse > For You tab (default 3, range 1–25). Shelves now scroll as a single section instead of independently, with subtle separators ($border) and theme-primary section titles for readability. Thanks
@wgordon17 (#69). - Sidebar overflow config (
[ui] sidebar_overflow) —"truncate"(default) guarantees exactly 1 row per playlist with a…ellipsis on overflow, or"wrap"to let names span multiple lines naturally. Implementation via atruncate-itemsCSS class onLibraryPanelplus a_render_texthelper that picks the right strategy per mode. - Selection info bar — a 1-row strip mounted between the page body and the playback bar, displays the full name of the currently-focused item (sidebar playlist or TrackTable row). Toggleable via
[ui] show_selection_info: bool = True. Replaces the old marquee/bouncing-text animation in playlist sidebars. The bar is gated on widget focus so it shows only what the user is actively navigating, not whatever auto-highlighted in a freshly-mounted panel. - Now-playing row indicator — every TrackTable now uses Textual's row-label feature to show a
▶glyph in a dedicated 1-char column to the left of the index, marking the playing row independently of cursor state. Bold (no color) on data cells of the playing row provides a secondary at-a-glance signal. The glyph survives navigation away and back viacursor_foreground_priority="renderable"and anon_mountlookup of the app's current playing track. This pattern follows the canonical approach used by spotify-tui, ncmpcpp, and cmus. - Charts country support —
get_charts(country=)now defaults to"GB"instead of YouTube's empty-data"ZZ"placeholder. New[ui] regionconfig field (ISO 3166-1 alpha-2) lets users pin Charts to their preferred country without code changes. The Charts page now resolves the top daily chart playlist into a track table (ytmusicapi'sget_chartsno longer returns a flat songs list — daily/weekly/genres/artists chart playlists is the new shape).
Fixes
- macOS built-in keyboard media keys (prev/next) — built-in MacBook keyboards send key codes 19/20 (FAST/REWIND) instead of 17/18 (NEXT/PREVIOUS) that external keyboards use. The Quartz event tap now maps both sets. Verified against Apple's IOKit constants and Rogue Amoeba's canonical reference. Thanks

@wgordon17 (#67). - Browse tab bar visibility — was rendering at
height: 1withborder-bottom: solidon the active tab clipping its label. Now usesheight: 4,border-bottom: tall,width: autofor content-sized tabs, hover state,$surfacebackground,$borderseparator, plus 1-row top padding for breathing room from the page edge. Thanks
@wgordon17 (#68). - yt-dlp android client fallback — madeForKids content (e.g., children's music tracks) was failing with "Video unavailable" on the default web client. yt-dlp now also tries the android client which falls through to a non-PoT legacy format that succeeds. Empirically validated: Baby Shark goes from broken to playing format-18 m4a audio. Normal songs unaffected. The line carries a
# WHY:comment explaining the rationale so a future audit doesn't strip it as cargo-cult config. Thanks
@wgordon17 (#72). - Moods & Genres sub-tab removed entirely — clicking a mood was producing a silent process-exit traceable past every Python-level handler in the audit (see
docs/superpowers/specs/2026-04-30-mood-crash-findings.md). The remaining suspects are sub-Python (libmpv segfault, finaliser raise, etc.) and require runtime data we don't have. Pulled the tab rather than ship a feature that crashes the app on click. Tab order is now (For You, Charts, New Releases). The unusedYTMusicService.get_mood_categories/get_mood_playlistswrappers were dropped at the same time — Discovery Mix calls the underlying ytmusicapi client directly, so the wrappers had no remaining callers. KeyErrorfrom ytmusicapi parsers no longer counted as "unexpected" — addedKeyErrorto_EXPECTED_API_EXCEPTIONSso_calltreats parser drift as a recoverable API-side failure instead of letting it propagate as a "programming error." This was masking real upstream issues and surfacing crash files for routine YouTube response-shape changes.- Sidebar playlist track count now updates immediately after add — was reading the cached
countfield from the library payload and never bumping it afteradd_playlist_itemssucceeded, so the(N tracks)suffix stayed stale until the next library reload. NewLibraryPanel.update_item_count(playlist_id, delta)primitive optimistically updates the cached count and rebuilds the affected row's label text. Wired into both_do_addand_do_create_and_addin playlist_picker. Tolerates VL-prefix mismatches in either direction. Leavescount = None(unknown) untouched rather than fabricating. - Queue right-click now opens the actions popup — previously did nothing because the Queue page uses a raw
DataTable(notTrackTable), so theTrackRightClickedhandler never fired. Addedon_mouse_downonQueuePagethat forwards button-3 clicks to_open_actions_for_trackusing the cursor row. - Track actions popup conditionally swaps "Add to Queue" ↔ "Remove from Queue" — when the track being right-clicked is currently in the playback queue, the menu shows "Remove from Queue" (action_id
remove_from_queue) instead of "Add to Queue". Same menu slot, different label and action based on queue state. Detection happens at popup-open time by scanningqueue.tracksfor the track'svideo_id. - Forward stack no longer clobbered by same-page footer clicks (post-v1.9.0) — clicking the current page's footer entry a second time intentionally pops the previous page off the back stack, but the navigation path was treating it as a fresh forward navigation and clearing the forward stack along with it. Added a
same_page_backflag so the forward stack survives the round-trip. - Sidebar Start Radio refreshes the Shuffle-lock visual state (post-v1.9.0) — starting a radio from the sidebar correctly applied the Shuffle-lock force-shuffle behaviour, but the playback-bar shuffle button's dim/non-dim state wasn't repainted to match. Added an explicit
refresh_shuffle_lock_state()call on the playback bar from the sidebar Start-Radio path.
Diagnostic infrastructure
faulthandler.enable(all_threads=True)— fatal-signal capture wired incli.pyaftersetup_logging. SIGSEGV/SIGBUS/SIGFPE/SIGILL/SIGABRT now leave a Python traceback for every thread in~/.config/ytm-player/crashes/faulthandler.logbefore the kernel delivers the signal. Critical for catching libmpv/python-mpv C-side crashes that bypasssys.excepthookentirely.- asyncio loop exception handler —
App.on_mountinstalls_asyncio_exception_handlervialoop.set_exception_handler. Funnels "Task exception was never retrieved" warnings and any orphan-task errors intocrashes/ytm-crash-*.loginstead of the invisible-stderr default that Textual's alt-screen swallows. sys.unraisablehook—install_excepthooksnow also captures__del__/ weakref-callback / generator-close errors, labels themUnraisable in <obj>, and writes a crash file before chaining tosys.__unraisablehook__. Fixes a class of silent finaliser failures that were previously lost.- mpv
log_handler+loglevel="warn"— Player constructsmpv.MPV(log_handler=...)so libmpv's internal warnings/errors hit the Python logger asmpv[<area>]: <message>.ytm doctorgreps these out ofytm.logso libmpv issues are surface-level visible. ytm doctorv2 — eight sections — version, paths, process status, recent ERROR/WARNING (filtered, last 20), recent mpv warnings, latest faulthandler trace, latest crash file, active-hooks summary. All output passes through a redaction layer that scrubsAuthorization,Cookie,Bearer,token,x-goog-pageid,SAPISIDso issue pastes never leak auth.- App-level
_handle_exceptionoverride — the YTMPlayerApp overrides Textual'sApp._handle_exception(documented "Always results in the app exiting") to write a crash file viawrite_crash_file(), surface a toast, and not callsuper(). Keeps the TUI alive on otherwise-fatal worker / render / event errors so the user doesn't lose their queue position to a transient failure. write_crash_fileself-bootstraps — falls back topaths.CRASH_DIRwheninstall_excepthookswas never called, and logsOSErrorinstead of silently returningNone. Fixes the "crashes dir empty after a real crash" diagnostic black hole.
Charts
- Country picker (
c) — presscon Browse → Charts to open a filterable region modal. Type to filter on ISO code or country name; Enter to select; Esc to cancel. Selection persists to[ui] regioninconfig.tomland triggers a chart refetch. NewAction.PICK_COUNTRYenum entry, default bindingc. - Region list trimmed to 17 empirically-verified working countries (Australia, Brazil, Canada, France, Germany, Hong Kong, Japan, Malaysia, Mexico, Singapore, South Korea, Taiwan, Thailand, UAE, UK, US, Vietnam). YouTube's
countries.optionsadvertises 62 but most return no daily-chart data even with auth; trimmed to the subset that actually displays tracks. - Daily-shelf pills — clickable pill row above the track table shows all available daily chart shelves for the selected country (typically
Daily Top 100/Trending 20/Daily Top Videos/Daily Top Songs (Shorts)— labels stripped of brand prefixes likeCoachella 2026:and country suffixes). Click a pill to swap the table to that shelf's playlist.HorizontalScrollcontainer keeps pills usable on narrow terminals. - OLAK5 playlist fallback — Trending shelves use OLAK5-prefixed auto-generated playlist IDs that ytmusicapi's
get_playlistchokes on (tracks[0]['album']isNone→ TypeError).ChartsSection._load_active_dailydetects the prefix and usesget_watch_playlistinstead, which calls a different endpoint and parses correctly. Service-layerYTMusicService.get_watch_playlistextended to accept aplaylist_id-only call shape (was video-only before). - Empty-region UX — a region picked with no chart data now shows
"No chart data available for <country>. YouTube Music coverage varies by region — press 'c' to pick a different one."instead of the previous catch-all"Failed to load charts.".
Navigation
- Browser-style back / forward —
← BackandForward →buttons in the header bar (next to☰ Playlists). Auto-hide on root pages and at the front of history. Keys:Backspace(back),Shift+Backspace(forward). Forward stack invalidates on any non-back/forward navigation, matching every browser/file-explorer. - Click bubbling fix —
Action.PICK_COUNTRYwas missing fromapp/_keys.py:_handle_action's page-delegation allowlist, socsilently no-op'd when first shipped. Added the entry; same_app-headerID-based HeaderBar lookup as the rest of the codebase.
UI / theming
- Marquee removed from sidebars —
_BouncingLabel(the bouncing-text animation that fired on highlighted playlist names that overflowed) deleted entirely (~60 lines). Replaced with static truncate-with-ellipsis. Long names showSome long playlist nam…; the full name surfaces in the new selection info bar when the item is highlighted. - Bottom-stack layout reflow —
SelectionInfoBar,PlaybackBar, andFooterBarare now wrapped in a singleVertical(id="bottom-stack")docked to the bottom. Order top-to-bottom: info bar (1 row) → playback bar (4 rows) → footer (1 row). Replaces the previous three-sibling-dock-bottom approach which produced inconsistent stacking. truncate()uses Unicode…instead of three ASCII dots. Frees 2 chars of visible content space; matches every modern UI's truncation idiom.- CONTRIBUTING.md "Theming & UI" section — documents the rule that all widget colors flow through
theme.pyvariables ($primary,$text,$surface,$border,$accent,$secondary,$text-muted) and that hardcoded hex colors in widget CSS are forbidden because they break customtheme.tomlfiles. Closes a contributor-docs gap surfaced during PR #69 review.
Refactors / internal
type: ignore[attr-defined]removed in PR #70's_start_playlist_radiocall sites incontext.pyandlibrary.py. Replaced with the project-standardcast("YTMHostBase", self.app)._start_playlist_radio(...)pattern. Added stub method to_base.py:YTMHostBase(CLEANUP-1)._fetch_and_play_radio,_start_discovery_mix,_start_playlist_radio,shuffle_prefs,home_shelves,region,show_selection_info,sidebar_overflow— all added as TYPE_CHECKING stubs / config fields with the project's existing patterns._BouncingLabeland timing constants (_BOUNCE_INTERVAL,_BOUNCE_PAUSE) removed.
Infrastructure / release engineering
- AUR auto-publish workflow — new
.github/workflows/aur-publish.ymltriggered after thePublishworkflow succeeds. Clones the AURytm-player-gitrepository via SSH key (stored insecrets.AUR_SSH_PRIVATE_KEY), copiesaur/PKGBUILD, regenerates.SRCINFOvia the newscripts/regenerate_srcinfo.pyhelper, commits, and pushes. Gated onevent.workflow_run.event == 'push'so TestPyPI dry-runs don't trigger a real AUR push. scripts/regenerate_srcinfo.py— pure-Python.SRCINFOgenerator that parsesaur/PKGBUILD(handles scalar fields, array fields, multi-line arrays, single/double quotes, shell-variable expansion via_expand_vars, and emits the canonical key-value format consumed by AUR). Runs on Ubuntu CI runners without requiring Arch Linux base-devel. 6 unit tests covering parser primitives, repo-PKGBUILD round-trip, and emit format.RELEASING.md— repo-root release procedure. Documents the tag-driven flow (bump__version__→ tagvX.Y.Z→ push), what each automated workflow does (publish.yml,aur-publish.yml), the manual fallback for the AUR push if the action fails, the one-time setup for AUR SSH keys + GitHub secrets + PyPI trusted publishing, the dry-run path via TestPyPI, and the distribution-channel matrix (PyPI/AUR/GitHub Release automated; NixOS flake manual; Gentoo GURU community-maintained by
@dsafxP).AUR_SSH_PRIVATE_KEYandAUR_KNOWN_HOSTSrepo secrets configured (one-time setup).
Coming next
- UX polish (4-8 from the in-session UX review) — Library track-count subtitle, Search panel border deduplication + mode-toggle markup cleanup, Browse active-tab visual weight, Queue "Now Playing" header hierarchy.
Session note — release engineering
This release was assembled across one long session that pushed 50+ commits to public master, including ~14 UI-iteration commits that should have lived on a feature branch. Going forward: UI-iteration sagas land on dev, get squash-merged into master as one clean commit per saga.