ytm-player

v1.9.1

2026-04-30 · pip install ytm-player==1.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's RDAMPL watch-playlist convention. New service method YTMusicService.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_queue on PlaybackMixin. Thanks @wgordon17 (#71).
  • Multi-seed get_radio — service method now accepts a list of seeds, fetches in parallel via asyncio.gather, dedups by videoId, shuffles, and trims to limit. Backward-compatible at the signature level (single string still accepted). 8 dedicated tests. Thanks @wgordon17 (#71).
  • Single-seed shuffle guardget_radio only shuffles the result pool when len(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 the D keybinding (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. New QueueManager.current_context_id + set_context() underpin the lookup. Session restore preserves queue_context_id so the lock identity survives a restart.
  • Configurable home shelf count — set home_shelves under [ui] in config.toml to 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 a truncate-items CSS class on LibraryPanel plus a _render_text helper 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 via cursor_foreground_priority="renderable" and an on_mount lookup of the app's current playing track. This pattern follows the canonical approach used by spotify-tui, ncmpcpp, and cmus.
  • Charts country supportget_charts(country=) now defaults to "GB" instead of YouTube's empty-data "ZZ" placeholder. New [ui] region config 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's get_charts no 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: 1 with border-bottom: solid on the active tab clipping its label. Now uses height: 4, border-bottom: tall, width: auto for content-sized tabs, hover state, $surface background, $border separator, 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 unused YTMusicService.get_mood_categories / get_mood_playlists wrappers were dropped at the same time — Discovery Mix calls the underlying ytmusicapi client directly, so the wrappers had no remaining callers.
  • KeyError from ytmusicapi parsers no longer counted as "unexpected" — added KeyError to _EXPECTED_API_EXCEPTIONS so _call treats 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 count field from the library payload and never bumping it after add_playlist_items succeeded, so the (N tracks) suffix stayed stale until the next library reload. New LibraryPanel.update_item_count(playlist_id, delta) primitive optimistically updates the cached count and rebuilds the affected row's label text. Wired into both _do_add and _do_create_and_add in playlist_picker. Tolerates VL-prefix mismatches in either direction. Leaves count = 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 (not TrackTable), so the TrackRightClicked handler never fired. Added on_mouse_down on QueuePage that forwards button-3 clicks to _open_actions_for_track using 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 scanning queue.tracks for the track's video_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_back flag 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 in cli.py after setup_logging. SIGSEGV/SIGBUS/SIGFPE/SIGILL/SIGABRT now leave a Python traceback for every thread in ~/.config/ytm-player/crashes/faulthandler.log before the kernel delivers the signal. Critical for catching libmpv/python-mpv C-side crashes that bypass sys.excepthook entirely.
  • asyncio loop exception handlerApp.on_mount installs _asyncio_exception_handler via loop.set_exception_handler. Funnels "Task exception was never retrieved" warnings and any orphan-task errors into crashes/ytm-crash-*.log instead of the invisible-stderr default that Textual's alt-screen swallows.
  • sys.unraisablehookinstall_excepthooks now also captures __del__ / weakref-callback / generator-close errors, labels them Unraisable in <obj>, and writes a crash file before chaining to sys.__unraisablehook__. Fixes a class of silent finaliser failures that were previously lost.
  • mpv log_handler + loglevel="warn" — Player constructs mpv.MPV(log_handler=...) so libmpv's internal warnings/errors hit the Python logger as mpv[<area>]: <message>. ytm doctor greps these out of ytm.log so libmpv issues are surface-level visible.
  • ytm doctor v2 — 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 scrubs Authorization, Cookie, Bearer, token, x-goog-pageid, SAPISID so issue pastes never leak auth.
  • App-level _handle_exception override — the YTMPlayerApp overrides Textual's App._handle_exception (documented "Always results in the app exiting") to write a crash file via write_crash_file(), surface a toast, and not call super(). 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_file self-bootstraps — falls back to paths.CRASH_DIR when install_excepthooks was never called, and logs OSError instead of silently returning None. Fixes the "crashes dir empty after a real crash" diagnostic black hole.

Charts

  • Country picker (c) — press c on 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] region in config.toml and triggers a chart refetch. New Action.PICK_COUNTRY enum entry, default binding c.
  • 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.options advertises 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 like Coachella 2026: and country suffixes). Click a pill to swap the table to that shelf's playlist. HorizontalScroll container keeps pills usable on narrow terminals.
  • OLAK5 playlist fallback — Trending shelves use OLAK5-prefixed auto-generated playlist IDs that ytmusicapi's get_playlist chokes on (tracks[0]['album'] is None → TypeError). ChartsSection._load_active_daily detects the prefix and uses get_watch_playlist instead, which calls a different endpoint and parses correctly. Service-layer YTMusicService.get_watch_playlist extended to accept a playlist_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← Back and Forward → 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 fixAction.PICK_COUNTRY was missing from app/_keys.py:_handle_action's page-delegation allowlist, so c silently no-op'd when first shipped. Added the entry; same _app-header ID-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 show Some long playlist nam…; the full name surfaces in the new selection info bar when the item is highlighted.
  • Bottom-stack layout reflowSelectionInfoBar, PlaybackBar, and FooterBar are now wrapped in a single Vertical(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.py variables ($primary, $text, $surface, $border, $accent, $secondary, $text-muted) and that hardcoded hex colors in widget CSS are forbidden because they break custom theme.toml files. Closes a contributor-docs gap surfaced during PR #69 review.

Refactors / internal

  • type: ignore[attr-defined] removed in PR #70's _start_playlist_radio call sites in context.py and library.py. Replaced with the project-standard cast("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.
  • _BouncingLabel and timing constants (_BOUNCE_INTERVAL, _BOUNCE_PAUSE) removed.

Infrastructure / release engineering

  • AUR auto-publish workflow — new .github/workflows/aur-publish.yml triggered after the Publish workflow succeeds. Clones the AUR ytm-player-git repository via SSH key (stored in secrets.AUR_SSH_PRIVATE_KEY), copies aur/PKGBUILD, regenerates .SRCINFO via the new scripts/regenerate_srcinfo.py helper, commits, and pushes. Gated on event.workflow_run.event == 'push' so TestPyPI dry-runs don't trigger a real AUR push.
  • scripts/regenerate_srcinfo.py — pure-Python .SRCINFO generator that parses aur/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__ → tag vX.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_KEY and AUR_KNOWN_HOSTS repo 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.