Changelog
33 releases. Latest: v1.9.2 (2026-05-01). RSS.
A focused fix release. The Charts page region selector was effectively non-functional — three structural bugs in how we read YouTube's chart response made a global event playlist appear regardless of region. Also includes a TrackTable migration that retires three hand-rolled
DataTablepages and two related bug fixes.Charts — bug report thanks

@dmnmsc (#73)- Events visually separated from country charts. YouTube injects a global event playlist (currently
"Coachella 2026: Daily Top 100 Songs"— same playlist ID worldwide) at position 0 of every country's response. Previously the Charts page default-loaded that slot, so picking a country still showed the global Coachella playlist. The Charts page now renders two stacked pill rows: aFeatured globally:strip for any shelves whose title carries a brand prefix (": "separator) and a country-charts row for the actual regional shelves. Country charts sort by priority —Top 100 Songs→Weekly Top Songs on Shorts→Trending 20→ rest — and the default-loaded pill is the first country chart, never an event. The event row hides automatically on narrow terminals (< 80 cols) to reclaim a row of vertical space. - Now reads
daily + weekly + videosfrom the API. Previously onlydailywas consulted, which silently dropped Spain (returns its data undervideos) and missed the Top 100 Songs / Top 100 Music Videos shelves underweeklyfor premium-supported regions. All three keys are now concatenated, then split into events vs charts. - Region picker expanded 17 → 68 entries. Global (
ZZ) is the new default. The list now mirrors YouTube's full advertised set (62 codes fromcountries.options) plus six historically-supported codes outside that list (Hong Kong, Malaysia, Singapore, Taiwan, Thailand, Vietnam). Settings default flipsregion = "GB"→region = "ZZ". - Locale-style configs auto-normalise. New
services/regions.normalise_region()helper strips locale tails —"ES-ES","en-GB","es_ES"now resolve to bare two-letter codes (ES,EN,ES) before hitting the API. YouTube's chart endpoint silently falls back to Global for any locale-shaped input; this prevents existing configs from being broken by that quirk. _clean_shelf_titleno longer strips brand prefixes. Coachella keeps its"Coachella 2026:"prefix on the pill so users can tell an event from a country chart at a glance.
New
- TrackTable migration on Queue, Liked Songs, Recently Played. Three pages migrated from raw
DataTabletoTrackTable. Gains right-click context menus, play indicators, column resize, filtering, and sorting — for free, on three pages that previously rolled those manually. Also retires theon_mouse_downright-click workaround we added to QueuePage in v1.9.0 (TrackTable already wires this up). Thanks
@wgordon17 (#74). [▶ Start Radio]button added to Liked Songs and Recently Played page headers — seeds a radio from 5 random tracks in the collection. Thanks
@wgordon17 (#74).- Shuffle-lock integration on Liked Songs and Recently Played — selecting a track applies the per-collection shuffle preference. Thanks

@wgordon17 (#74).
Fixes
- Radio track durations no longer show
--:--— ytmusicapi'sget_watch_playlistreturns duration under alengthkey (e.g."3:07"), notduration.extract_duration()now checksduration_seconds→duration→lengthin priority order. Thanks
@wgordon17 (#74). - Play history no longer stores duration as 0 —
log_playwas reading rawtrack.get("duration_seconds", 0), but normalized tracks store the value underduration. Switched toextract_duration()so the value is always correct regardless of source. Thanks
@wgordon17 (#74). - TrackTable Duration column no longer cut off on first paint. The row-label column (which carries the
▶playing indicator) reserves ~3 cells of width that the original column-fit pass didn't account for, so the rightmost column ("Duratio…") got pushed past the visible viewport on initial render._fill_title_columnnow runs afterload_tracksandappend_tracksso the Title column shrinks to compensate as soon as rows exist. - Sidebar gains a bottom separator under the Playlists panel — the existing top separator (a
Rulewidget between the pinned-nav block and the LibraryPanel) is now mirrored below the LibraryPanel, so thePlaylistspanel sits between two matching$border-coloured horizontal rules instead of just one.
- Events visually separated from country charts. YouTube injects a global event playlist (currently
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 intomasteras one clean commit per saga.- "Start Radio" from playlist — context-menu entry in the sidebar plus a
A reliability and quality release driven by a multi-agent expert audit. Hardens error handling across the service/UI cascade so silent-failure UX is replaced with actionable feedback, fixes several latent runtime bugs, and brings the codebase to zero non-exempted Pyright errors (down from 218).
New
- First-run discoverability toast — on first launch, a 1.5s-delayed toast reads "Press ? for help · vim-style keys" (8s timeout). State persists in
session.jsonso the hint shows once. Legacy session files without the field upgrade cleanly. - Per-cause mutation-failure toasts — like/playlist-add operations now distinguish auth-required, auth-expired, network-down, and server-error failures with specific messages (e.g. "Sign in again — run
ytm setup" vs "Check your connection") instead of a single generic "Couldn't update". The classifier inspects ytmusicapi's exception types and parses HTTP status fromYTMusicServerErrormessages. - Page error-fallback states — Recently Played and Context (album/artist/playlist) pages used to show "Loading…" forever on API/disk failure. Now they replace the loading indicator with a clear error message pointing at
~/.config/ytm-player/logs/ytm.log. - 9 new integration tests in
tests/test_integration/covering the search→queue→play flow, track-change fan-out, cache-bypass behaviour, session round-trip, search cancellation, and the mutation cascade. Coverage floor raised 10 % → 47 %.
Fixed
- Album art crashed on Pillow ≥ 10.
Image.LANCZOSwas removed in Pillow 10. Switched toImage.Resampling.LANCZOS. Pyproject pinsPillow>=10, so this had been shipping broken for every modern install. - Spotify single/multi import would have ImportError. The popup imported
_get_video_idfromservices.spotify_import, but that symbol does not exist (the function isget_video_idinutils/formatting.py). Renamed all call sites. gg/Gin browse and playlist sidebar would crash. Code calledListView.action_first()/action_last()— neither method exists on Textual's ListView. Replaced with the standard cursor-index assignment.- New Releases tab in Browse silently empty.
YTMusicService.get_new_releasescalledclient.get_new_releaseswhich doesn't exist onYTMusic. The wrapping broad-except swallowed the AttributeError. Switched toget_explore()['new_releases']per the actual ytmusicapi surface. - Session-save errors disappeared into the void.
_save_session_statenow narrows its catch to(OSError, TypeError)and surfaces a toast on failure instead of silently dropping the user's volume / queue / playback position. - Mutation methods now return
MutationResult—rate_song,add_playlist_items,remove_playlist_items, plusadd_to_library,remove_album_from_library,unsubscribe_artist, anddelete_playlist. A Literal: success / auth_required / auth_expired / network / server_error. Previously returnedNone(orbool) whether the server accepted or not — UI showed "Liked!" or "Added!" toasts even when the API failed silently. Worst case was the Spotify import "Created with N tracks" toast firing when every batch failed. All UI cascade sites now show a per-cause toast suffix viamutation_failure_suffix. - Spotify multi-import partial-failure track count was off by up to 99. When a 350-track import had only the last batch fail, the toast reported "~300/350 added" because the formula assumed every successful batch was a full 100. Now tracks
added_totalcumulatively by summinglen(batch)on success, so the count is exact. - Read-side
logger.debug→logger.exception(~18 sites inytmusic.py). Search, library-list, get_album/artist/playlist/song/lyrics/history etc. previously caught broad exceptions and logged at debug level, so post-mortem of "library page came up empty" required--debug. Now they land in the log file at default level. YTMusicService._callouter catch narrowed. Programming-error exceptions (TypeError, AttributeError) now propagate instead of being swallowed and mistakenly counted toward the consecutive-failure threshold.- Thread-safety on lazy
YTMusicService.clientinit. Concurrent first-access fromasyncio.to_threadworkers is now guarded by athreading.Lockwith double-checked locking. - Credential file writes use
O_NOFOLLOW.auth.json(services/auth.py) andspotify.json(services/spotify_import.py) now refuse to follow a symlink at the target path — defense-in-depth matching the existing pattern inutils/logging.py.
Internal
- Comprehensive broad-except audit at
docs/broad-except-audit.md— categorizes all 263except Exception:sites in the codebase as KEEP / NARROW / PROMOTE with a cross-cutting cascade map. Referenced fromCLAUDE.mdso future contributors check the audit before adding new broad catches. - Pyright clean-up: 218 → 93 errors, with the remaining 93 entirely in
services/mpris.py(D-Bus magic, exempted in CLAUDE.md ruff rules) andservices/macos_eventtap.py(macOS-only AppKit/Quartz). Fixed real bugs along the way:playback_bar._FooterButton.__init__typedkwargsasobject(rejecting all forwarded params),_RepeatButton.repeat_modetyped asstrwhile assigned anint-typed enum value,track_table._filter_timertyped asobject | Noneso.stop()didn't type-check, and several Optional-access defensive gaps. - Codebase-wide
self.app.Xtyping — UI widgets/pages now castself.apptoYTMHostBaseat access points so Pyright can see the host's services. ~42 sites across 6 files. - README polish — badges, tagline, Contributors section.
- Audit-driven follow-up plans at
docs/superpowers/plans/2026-04-28-audit-driven-error-handling-cleanup.mdanddocs/superpowers/plans/2026-04-28-audit-driven-followup.md— written via the superpowers writing-plans + subagent-driven-development workflow.
- First-run discoverability toast — on first launch, a 1.5s-delayed toast reads "Press ? for help · vim-style keys" (8s timeout). State persists in
A combined release covering broader Python compatibility, a monthly Python release watcher, a full README restructure into a landing page + dedicated docs, and the 3.10 backport shims required to support Ubuntu 22.04.
New
- README has been split into a 64-line landing page plus seven dedicated docs (
docs/installation.md,docs/configuration.md,docs/keybindings.md,docs/cli-reference.md,docs/spotify-import.md,docs/troubleshooting.md,docs/architecture.md). The README is now purely an index — every topic lives in exactly one file with full detail. - New monthly workflow
check-python-versions.ymlopens a maintenance issue when CPython releases a new stable major.minor version newer than our CI matrix ceiling. Idempotent — won't reopen if an issue is already open. Defensive regex guard rejects RC/beta strings to avoid bogus issues.
Project
- Python floor lowered from 3.12 to 3.10. Ubuntu 22.04 LTS users can now
pip install ytm-playeragainst the systempython3without installing a newer Python first. Verified locally on Python 3.10 (545/545 tests passing) and via the new CI matrix[3.10, 3.14]. - Note on Python 3.10 lifecycle: CPython 3.10 reaches end-of-life October 2026. Ubuntu 22.04 keeps shipping 3.10 until April 2027 (standard support) or 2032 (Pro), so 22.04 users stay covered well past CPython's EOL. We'll bump the floor when usage data shows nobody on 3.10.
- CI matrix shifted from
[3.12, 3.13]to[3.10, 3.14]— testing the supported floor + the latest stable. Same 6 jobs as before (3 OSes × 2 Pythons), better-targeted coverage. - Lint job + Python release watcher updated to use Python 3.14 (was 3.12), aligning auxiliary tooling with the test matrix ceiling.
- Pyright + ruff configured to type-check and lint against
py310so accidentally-introduced 3.11+ syntax fails locally and in CI. - Classifiers updated: now lists Python 3.10, 3.11, 3.12, 3.13, 3.14.
flake.nixPython pin bumped from 3.12 to 3.13 (a stable middle of the supported range).CLAUDE.mdupdated to document v1.7.x additions: 3.10 backport shims, the new watcher workflow, and theDEFAULT_LYRIC_CURRENTconstant.CONTRIBUTING.mdgained a "Python version compatibility" section explaining thesys.version_infoshim pattern and theYTMHostBasemixin attribute typing pattern for new contributors.- AUR PKGBUILD maintainer email replaced (was a placeholder).
- Replaced hero screenshot (v4 → v5).
- New
publish.ymlworkflow automates the PyPI release. Pushing avX.Y.Ztag now builds wheel + sdist, smoke-tests the wheel by installing it into a fresh venv and runningytm --version, uploads to PyPI via OIDC trusted publishing (no API tokens stored anywhere), and creates the matching GitHub Release with the CHANGELOG section attached. A manualworkflow_dispatchwithtarget=testpypiis wired for paranoid dry-runs against test.pypi.org. AUR is still updated by hand afterward. - Dependabot now opens major-version bumps in their own grouped PR (previously suppressed by
update-types: [minor, patch]). Bothpipandgithub-actionsecosystems split into*-minor-patch(auto-merge candidates) and*-major(review carefully), so security-relevant majors no longer require manual intervention to surface.
Fixes
- Theme cache (
_read_theme_toml_cached) was silently returning{}on Python 3.10 because its function-localimport tomllibwas caught by a broad except clause. The bug was masked on 3.12 (where tomllib is stdlib) but would have shipped a non-functional theme cache to 3.10 users. Caught during the 3.10 verification gate; fixed by moving the import to module-level with asys.version_infoshim. - Stale comments cleaned up:
pyproject.tomlPyright comment now reads as past tense;services/player.pyWindows note no longer claims a 3.12+ requirement that was never accurate (ucrtbase has been the default since 3.5). - Sweep findings absorbed into the new docs:
lkeybinding documented (docs/keybindings.md),[playback] resume_on_launchdocumented (docs/configuration.md), correctedlyrics_current = "#ff4e45"in the theme.toml example (was stale#2ecc71),app/_base.pyadded to the architecture file tree, full CLI subcommand reference now lists everyytmcommand (was missingytm dislike,ytm now,ytm doctor,ytm config, etc.).
Compatibility shims
To support Python 3.10 (where several stdlib symbols don't exist), backport shims were added using
sys.version_info >= (3, 11)checks (which type-checkers narrow correctly):tomllib(3.11+) → falls back totomli(PyPI) on 3.10. Files:config/keymap.py,config/settings.py,ui/theme.py,app/_app.py,tests/test_config/test_settings.py.typing.Self(3.11+) → falls back totyping_extensions.Selfon 3.10. Same first 3 files.enum.StrEnum(3.11+) → falls back to a(str, Enum)polyfill that mirrors stdlib'sauto()lowercase-name behavior. Files:services/queue.py,services/player.py.tomliandtyping_extensionsadded as conditional dependencies (python_version < "3.11"markers) so 3.11+ users don't pull them.
- README has been split into a 64-line landing page plus seven dedicated docs (
A polish release focused on resume-on-launch, lyric metadata cleanup, theming correctness, and a typing overhaul that silences Pyright noise across the mixin-based App. 30 commits, 545 tests (was 491).
New
- Heart toggle on the playback bar — visible
❤indicator between the track info and volume, filled in the theme accent when the current track is liked, muted when not. Pressl(or click) to toggle. Backed byytmusicapi.rate_song(LIKE/INDIFFERENT)so the change syncs to your YouTube Music account in real time. Toast confirms ("Added to Liked songs" / "Removed from Liked songs"); pressinglwhile not signed in surfaces a "Sign in to like songs" warning instead of feeling like a dead key. (Closes #62, thanks
@valkyrieglasc.) - Last-playing track is remembered across launches — the track + queue + position are saved on every exit (not just unclean ones). Relaunch ytm-player and the playback bar shows the same track you were on, ready to go. Default-on; opt out with
[playback] resume_on_launch = falseinconfig.toml. Two safety guards: tracks paused for under 1 second are no longer saved (avoids a startup-crash overwriting a perfectly good prior resume), and the pending slot survives if you start playing a different track first. - Artist context page now fetches ALL top songs —
ytmusicapi.get_artist()returns only ~5 by default with the full list at a separate browseId. ytm-player fetches the first 300 in the background after the page renders, then chainsget_playlist_remainingfor anything beyond. No more silent truncation at 100. (Closes #55, thanks
@dmnmsc.) - Search input auto-focuses on fresh entry — pressing
g sfrom a fresh state focuses the search bar so you can type immediately. Returning to the page with a cached query leaves focus on the results table so you can keep browsing without re-typing. - Search Escape now does the right thing — when the input is focused or the predictive-suggestions dropdown is showing, Escape hides the dropdown and moves focus to the songs results table (or blurs entirely if no results yet). Typing a new query also clears the previous results so a subsequent Escape doesn't strand you on stale rows.
- Lyric title sanitization — strips a wide set of YouTube-style noise patterns (
(Official Music Video),[Audio],(HD),(feat. Bob)/(ft. Bob)/(featuring Bob),(Remix)/(Extended Remix)/(Radio Remix),(Remastered)/(Remastered 2009),(Deluxe)/(Deluxe Edition),(Live)/(Live at Wembley),(Acoustic)/(Acoustic Version), etc.) and theArtist -prefix before LRCLIB lookup. Handles nested parens correctly ((feat. Bob (Junior))→ strips cleanly). Improves match rate for tracks played from YouTube proper (where titles are noisy) without affecting clean YouTube Music tracks. (Closes #62, thanks
@valkyrieglasc.) - Notifications match the active theme — toast border colors now use
$primary/$warning/$errorfrom the theme instead of Textual's hardcoded green default. Notifications no longer stick out on a custom theme. - Notifications shift left when the lyrics sidebar is open — toast rack offsets so notifications don't cover the lyrics.
- Lyric line colors derive from theme tokens — current line uses the theme accent (was hardcoded green); upcoming lines are normal foreground (clearly distinct from the dimmed played lines instead of all looking grey-on-grey).
- Now Playing header in the queue + repeat/shuffle "active" state in the playback bar use the theme accent (
$primary) instead of the hardcoded$success(green).
Fixes
- Search "Searching..." indicator no longer sticks forever when the worker is cancelled.
asyncio.CancelledErrorinherits fromBaseExceptionin Python 3.8+, so the existingexcept Exception:block didn't catch it; the loading-text-clear was outside the try/finally and never ran on cancel. Now an explicit handler clears the indicator before re-raising. - Queue footer no longer duplicates the repeat/shuffle state from the playback bar. Footer now just shows
Tracks: N. (Closes #62, thanks
@valkyrieglasc.) - Sidebar play-from-double-click no longer passes a possibly-
Nonetrack toplay_track— surfaced when the new typing infrastructure (below) tightened the signature. TheNonecase now early-returns gracefully.
Project
- Allow textual 8.x —
pyproject.tomlupper bound bumped from<8.0to<9.0. (Closes #63.) All tests pass on textual 8.2.4. - Mixin attribute typing — new
src/ytm_player/app/_base.pydeclaresYTMHostBase, aTYPE_CHECKING-only stub class that mirrorsYTMPlayerApp's full attribute and cross-mixin method surface. All eight mixins now extend it. At runtimeYTMHostBase = object(zero behaviour change); under Pyright/Pylance the editor sees a fully typedApp[None]subclass and stops emitting "Cannot access attribute X for class FooMixin" noise. Net Pyright count insrc/ytm_player/app/: 0 errors (was 52 before this release). - A new
PageWidgetProtocol replaces bareWidgetreturns where pages are looked up —_get_current_page()now returnsPageWidget | Nonesohandle_actionandget_nav_statecalls type-check correctly withoutcast()at every site. - Pyright now finds the project venv — added
[tool.pyright]topyproject.tomlso editor IDEs (VS Code / Pylance / basedpyright) resolvetextual,pytest,ytmusicapietc. without flooding the Problems panel with false-positive "Import could not be resolved" errors. - Lyric-current default color is now a single
DEFAULT_LYRIC_CURRENT = "#ff4e45"constant intheme.py, referenced by the dataclass default and both fallback paths in_app.py. Previously the three sites disagreed (green vs red) — would have surfaced for users who wrote stripped-down custom themes that defined neitheraccentnorprimary.
Tests
- 545 passing (up from 491 in v1.6). New coverage:
- Lyric title sanitizer — 29 tests covering original noise patterns + the new feat/ft/featuring/remix/remastered/deluxe/live/acoustic patterns + nested parens + negative passthroughs (
Remix Culture,Live and Let Die,Acoustic Sessions Vol 1stay untouched). - Resume-on-launch flow — restore + position-guard boundaries + pending-resume match/non-match.
_toggle_like_current— LIKE↔INDIFFERENT, DISLIKE→LIKE, no-op-with-notify when not signed in, no-op when no current track.
- Lyric title sanitizer — 29 tests covering original noise patterns + the new feat/ft/featuring/remix/remastered/deluxe/live/acoustic patterns + nested parens + negative passthroughs (
- Cleaned up several pre-existing test
ResourceWarnings (unclosed file handles intest_auth_multi_account.py).
- Heart toggle on the playback bar — visible