ytm-player

Changelog

37 releases. Latest: v2.0.0 (2026-07-04). RSS.

  1. New features

    • Recently Played — YT Music account history tab — the Recently Played page now has two tabs: Local (the existing SQLite play history recorded inside this app) and YT Music (your account-wide play history fetched from the server via ytmusicapi get_history()). Switch tabs by clicking the labels or with the keyboard (Tab / Shift+Tab to focus a tab label, Enter to activate) — consistent with the Browse page. Each tab is cached so switching back doesn't refetch; re-clicking the active tab does a hard refresh (Local re-reads SQLite, YT Music refetches the server). Both tabs are capped at 100 rows to keep the TUI responsive. The two views are disjoint: Local shows what you played in this app (a just-played track appears at the top immediately), YT Music shows only plays made elsewhere (phone, browser) — plays this app syncs to your account are filtered out of the server feed, and a track you play locally drops off the YT Music tab live. [▶ Start Radio] and filtering work on both. No new keybinding — both views live under g r. Thanks @Villoh (#116).
    • Plays sync to your YouTube Music history — tracks played in the TUI are now reported back to your YT Music account (via add_history_item()), so they show up in your history and feed recommendations like any other client. Fired best-effort in the background so it never blocks playback. Opt-out via playback.sync_history_to_ytmusic = false. Thanks @Villoh (#116).
    • Configurable listen thresholdplayback.history_min_listen_seconds (default 5) sets how long a track must play before it counts as a play instead of a skip, for both local history and the YT Music account sync. Clamped to 0–3600; 0 still requires at least one full second of playback. Thanks @Villoh (#116).
    • Play Next, everywhere — press X (or Ctrl+X) on any page to slot the focused track in right after the current one. Albums and playlists get a "Play Next" entry in their context menus too, inserting the whole set in order — shuffle-aware, so they still play next even with shuffle on.

    Changes

    • Only one TUI instance at a time — launching ytm while another instance is running now exits with "ytm is already running (PID …)" instead of letting two instances fight over the same session and IPC socket.
    • Add to Queue works on every pageZ / Ctrl+Z silently did nothing on five of the seven pages; it now works everywhere through the same track resolution the actions menu uses, and queue toasts name the track ("Added to queue: …", "Playing next: …").
    • Retired keybindings that promised nothingg A / g a ("context/selected actions") were bound, routed, and listed in Help, but no page ever implemented them (the a actions menu already does that job) — removed, along with the dead Escape close-popup action. The Help page now matches reality.
    • Every color now follows your theme — the last hard-coded widget colors (the search-mode indicator and the Spotify-import status texts and result symbols) now use the theme's primary / success / warning / error variables, so custom themes recolor them too.
    • toggle_search_mode ships unbound — the M-v default was unreliable because most terminals intercept Alt+letter for their own menus; bind your own key in keymap.toml if you want one.

    Fixes

    • Video rows stop crediting "1.2M views" as the artist — YT Music returns the view count as a pseudo-artist on video results; it's now stripped during normalization, so history and video rows show the real artist and artist-based actions (go to artist, radio) built from those rows target the right one. Thanks @Villoh (#116).
    • MPRIS survives broken dbus-fast builds — a dbus-fast that raises TypeError at import (dbus-fast 4.x on Python 3.14) now disables MPRIS with a notice instead of crashing at startup. Thanks @dsafxP (#113).
    • Discord presence can no longer wedge on one bad call — a single failed pypresence call (closed pipe, timeout) silently disabled Rich Presence for the rest of the session, leaving the last song stuck on Discord until app exit. RPC calls are now serialized, reconnect on demand, and retry once after a failure. Thanks for the report @SUPER-MAGIX (#117).
    • Queue: d/J/K after sort or filter — deleting or reordering on a sorted/filtered Queue page acted on the wrong underlying track; the row→track mapping is now correct.
    • Playing a selected track works the same on every page — Search and Browse now rebuild the queue the way Library and Context always did, fixing stale Queue-page contents and duplicate plays from double-fired events.
    • Removing the playing track under shuffle — the queue now advances to the next shuffled track, matching non-shuffle behaviour, instead of landing somewhere arbitrary.
    • playerctl volume and position — MPRIS Volume is wired in both directions and Seeked is emitted on jumps, so desktop applets can set the volume and track the playhead.
    • Rapid track-switch race — starting a track while another was still resolving could double-play or land on the wrong track; play requests are now serialized and the newest wins.
    • IPC hardening — CLI↔TUI messages are newline-framed, and the Windows TCP fallback now requires an auth token instead of trusting anything on localhost.
    • Config resilience — a corrupt keymap.toml is backed up and replaced with defaults instead of aborting startup, settings values are type-validated at load, and cache_dir accepts ~ paths.
    • Navigation state stays fresh — pages no longer restore stale cursor/content state, and deleting a playlist purges its cached pages instead of leaving ghost entries reachable via Back.
    • Popup backdrop clicks — clicking outside any popup now dismisses it consistently, across all popups.
    • Database errors degrade gracefully — SQLite errors at history/session write sites are caught alongside OS errors instead of crashing the operation.
    • Player teardown races — transport actions during mpv shutdown no longer raise, and a failed stream start no longer disturbs end-of-track handling for the track after it.
    • Media-key presses can no longer vanish — Windows and macOS key callbacks were scheduled without holding a reference, so the garbage collector could occasionally reclaim one mid-flight and the press did nothing. A shared dispatcher now keeps every in-flight callback alive.
    • Radio respects shuffle from a cold start — starting radio into an empty queue with shuffle on skipped the shuffle-order build, so tracks played in linear order until shuffle was toggled off and on.
    • Fast track-switching can't wedge stream resolution — cancelling a track that was still resolving could leave the next request for the same track waiting forever, or turn a successful resolve into an error. Both cancellation paths now settle cleanly.
    • Playlist-creation failures say why — creating a playlist (sidebar, picker, or Spotify import) now reports whether it failed from an expired session, a network problem, or a server error, instead of a generic "Failed to create playlist".
    • A corrupt saved volume can't derail startup — a garbage volume in session.json falls back to the default and out-of-range values clamp to 0–100, instead of aborting the session restore midway.
    • Liked Songs distinguishes "empty" from "failed" — a fetch failure now shows a check-the-log message instead of the misleading "No liked songs found."
    • Browse outages say so — when For You or Charts can't be fetched (network, expired session, server error), the sections now show the retryable error copy instead of pretending the account has no recommendations or the region has no charts.
    • Two swallowed-input quirks — a right-click that didn't open a popup no longer eats your next Enter/click, and picking a search suggestion identical to the current input no longer eats the next keystroke.
    • ytm doctor stops counting bystanders — any process whose command line contained a /ytm… path (an editor open on the repo, say) counted as a running instance; only the real ytm / python -m ytm_player entry points match now, including console-script launches (where the interpreter sits in argv[0] and the ytm script path in argv[1]).
    • A crashed session can't lock you out — if the OS handed a crashed instance's PID to an unrelated process, ytm refused to launch until ytm.pid was deleted by hand; the single-instance guard now checks that the recorded process is actually ytm-player and cleans the stale file itself.
    • Parser drift isn't blamed on your connection — when YouTube changes a response shape mid-operation, the error now reads as a server problem instead of "check your internet connection".
    • No phantom crash file from the yt-dlp pre-warm — a failure in the background yt-dlp import was written out as a crash file; it now logs a warning, and the only real cost is a slower first play.

    Internal

    • The five per-page track-filter stacks are unified into one shared mixin, all popups sit on a shared base shell, and the ytmusicapi sort-parameter patch window no longer leaks into unrelated concurrent API calls.
    • First dedicated test coverage for the Spotify import service and the mpv player wrapper.
    • Services dedup sweep: the Python 3.10 StrEnum shim has one home, the three platform media-key services share one thread→loop dispatcher, stream-URL expiry follows a single policy, the lyrics and update-check fetchers use requests, and duplicated CLI/IPC boilerplate, path normalizers, listen-logging, and go-to-artist/album id extraction each collapsed into single implementations. Page-load failures now log their real tracebacks.
    • Packaging hardening: the AUR package now declares python-packaging, requires dbus-fast 5.x (4.x is the #113 crash combo), and caps textual below 9.0; a mistagged release can no longer upload the wrong version to PyPI (the tag is checked against __version__ before anything builds); CI now also runs on pull requests targeting dev.

  2. New features

    • Unified Tab / Shift+Tab section navigationTab and Shift+Tab now move focus between sections (track tables, result panels, Browse tab labels, and any visible sidebar) consistently on every page, with j/k/arrows moving within the focused section and Enter activating it. Previously only the Search page worked this way; other pages ignored Tab or repurposed it. On Browse, Tab highlights a tab label and Enter opens it.
    • Vim-style pane focus navigation — keyboard users can now move focus between the Playlists sidebar, main content, and visible lyrics pane with Ctrl+w h, Ctrl+w l, and Ctrl+w w. The Playlists shortcut auto-shows the sidebar if it is hidden. Thanks @860windtree (#107, #108).

    Changes

    • Queue track reorder moved off Tab — reordering the selected track now uses Shift+J / Shift+K (lowercase j/k move the cursor, as on every other page). It honours a count prefix, so 15 J moves the selected track down 15 positions in one step. This frees Tab / Shift+Tab on the Queue page for section navigation.
    • playerctl / media keys / now-playing work out of the box on Linux — the dbus-fast library that MPRIS needs now ships by default on Linux instead of behind an optional [mpris] extra, so a standard pip install ytm-player (or AUR / Nix install) exposes playerctl, hardware media keys, and desktop now-playing controls with no extra steps. ytm doctor reports MPRIS status and a one-time startup notice flags a broken or incomplete install, so the previous silent no-op can't recur. Thanks for the report @pironha2 (#110).

    Diagnostics

    • Crash files self-identify, and ytm doctor flags stale crashes — every crash log now records the app version, time, Python, and platform it was written under, and ytm doctor warns when the most recent crash predates the installed version (or predates version stamping). A stale, already-fixed crash from an older build no longer reads as a live bug.

  3. A broad release: community playlist and context-menu features, a batch of crash fixes across macOS, Windows, and Nix, and Discord Rich Presence working again.

    New features

    • Create playlist with metadata — the "New Playlist" flow now asks for name, description, and privacy (Private / Public / Unlisted) in a single modal, instead of defaulting to private with no description. New CreatePlaylistPopup replaces the minimal InputPopup. Thanks @Villoh (#79).
    • Edit playlist metadata — right-click any user playlist in the sidebar and choose "Edit Playlist" to rename it, update its description, or change privacy. The sidebar and the open library header update in place without reloading tracks. Thanks @Villoh (#79).
    • Enriched library playlist header — description, privacy status, and year now appear in the header alongside owner and track count. Thanks @Villoh (#79).
    • Sidebar count sync on add — adding tracks via the "Add to Playlist" picker bumps the target playlist's track count in the sidebar immediately, and duplicate adds prompt before re-adding. Thanks @Villoh (#79).
    • Auto-navigate on delete — deleting the currently open playlist from the sidebar returns you to the plain library view instead of leaving a ghost page. Thanks @Villoh (#79).
    • Remove a track from a playlist — the track action menu now offers "Remove from Playlist" when viewing one of your playlists, removing the track in place. Thanks @Villoh (#79).
    • Entity action consolidation — context menu actions (Play All, Shuffle Play, Add to Queue, Start Radio, Go to Artist, Subscribe) now work consistently for albums, playlists, and artists across all dispatch sites: sidebar, search results, track table column right-click, context page, library page, and browse page. "Shuffle Play" pre-shuffles for a one-time random order without enabling ongoing shuffle. "Play All" / "Shuffle Play" from sidebar and search start playback immediately and jump to the queue. Thanks @wgordon17 (#81).
    • Column-aware context menus — right-clicking the Artist column opens artist actions (Go to Artist, Play Top Songs, Start Radio, Subscribe); the Album column opens album actions (Play All, Shuffle Play, Add to Queue, Go to Artist). Multi-artist tracks show a picker first. Thanks @wgordon17 (#81).
    • Corporate SSL proxy support — set ca_bundle under [yt_dlp] to a custom CA certificate bundle so stream resolution works behind SSL-inspecting proxies (Zscaler, Netskope, etc.). A warning is logged if the path doesn't exist. Thanks @glywil (#98).
    • Richer Discord Rich Presence — the now-playing status shows the track's album art (falling back to the app icon), displays as Listening to YouTube Music, and includes elapsed time. Thanks @Wiibleyde (#103).

    Fixes

    • Discord Rich Presence connects again — the bundled application ID had been rejected by Discord, so presence never appeared. Registered a fresh app and made the ID configurable via [discord] client_id for anyone who wants to use their own. Reported by @Villoh (#88).
    • Crash setting ANSI themes — selecting ansi-dark / ansi-light (added in Textual 8.2.5) crashed the app because their colour tokens like ansi_cyan aren't parseable by Rich. They're now translated to the bare ANSI names. Thanks @dmnmsc (#89).
    • mpv not found with Homebrew installs — on macOS and Linuxbrew, libmpv lives outside the default library search path, so a non-brew Python (uv tool, pipx, distro) couldn't load it even though mpv was on PATH. ytm now searches the Homebrew prefixes, and ytm doctor reports libmpv loadability on its own line (#90, #101, #104).
    • Crash on Windows with the mpris extradbus-fast is Linux-only and raised an uncaught error at import; the extra is now marked Linux-only and the import is platform-gated, so uv sync --all-extras elsewhere degrades gracefully instead of crashing at startup. Thanks @Villoh (#106).
    • Spotify import builds on Nix again — the spotifyscraper derivation no longer fails on a sandboxed pip install during the build, so flake builds with the spotify extra work again. Thanks @peternaame-boop for both causing and fixing it (#93).
    • Nix flake: missing packaging dependency — the update checker crashed on flake builds because packaging wasn't declared. Now included, with an import check so it can't regress. Thanks @szx19970521 (#95, #105).

    Infrastructure

    • Faster Nix installs — the nixpkgs pin now points at a cached channel revision, so flake users download prebuilt dependencies instead of compiling them (notably Deno, pulled in by yt-dlp) from source.

    Docs

    • Install and troubleshooting docs now reference dbus-fast instead of the obsolete dbus-next (the code migrated in v1.9.3). Thanks @aaguilar-hub (#99).
  4. A small follow-up release: two crash fixes caught by manual smoke after v1.9.2, two CLI/utility fixes, plus three community PRs.

    New features

    • Configurable default theme — set theme under [ui] in config.toml to control which Textual theme loads on startup (default ytm-dark). Changing theme via Ctrl+PTheme updates the current session only; the startup default is no longer overwritten by session state. To persist the active theme as the new default, use Ctrl+PTheme: Set Current as Default. Thanks @Villoh (#85).
    • Theme: Set Current as Default command palette action — saves the currently active theme to config.toml so it becomes the startup default. Includes rollback on save failure (e.g. read-only filesystem) with an error toast.
    • Discovery round-robinD now cycles deterministically through Charts → Trending → For You → Liked → Artist → Recently Played (was random source selection). Charts sub-rotates through its shelves between presses, and the Discovery label shows the active source (e.g. Discovery (US Daily Top 100)). Mood source dropped (the Moods & Genres tab was removed in v1.9.1 due to upstream crashes). Thanks @wgordon17 (#75).
    • Radio queues prepend their seed tracks — radio playback now starts with the seed before suggestions, matching YouTube Music's native behaviour. Append-mode background refill is unchanged. Thanks @wgordon17 (#75).
    • Persistent queue source header — Queue page shows Generated from: … beneath Now Playing for radio and discovery queues, with up to three seed titles inline and a tooltip for the full list. Toggle via [ui] show_queue_source (default on). Thanks @wgordon17 (#75).

    Fixes

    • Crash on Go to Artist / Album / PlaylistTrackTable and _ArtistAlbumList set up their columns in on_mount, but context._build_artist's nested-mount chain calls load_tracks / load_albums synchronously before on_mount fires, so add_row ran with 0 columns and raised ValueError: More values provided than there are columns. Both widgets now eager-init columns in __init__, eliminating the mount-order race.
    • Update check version comparison_is_newer now uses packaging.version.Version for proper PEP 440 comparison. The hand-rolled tuple parser dropped non-numeric chunks and got post-releases (e.g. 1.6.0.post1) wrong.
    • ytm config with multi-arg $EDITOREDITOR="code -w" and similar now work; previously the whole string was passed as a single argv entry, so subprocess looked for an executable literally named code -w and failed. Malformed quoting (e.g. unbalanced quotes) now exits cleanly via the existing error path instead of crashing.
    • Search race conditions — fixed four interacting bugs that caused searches to require a second Enter, the suggestion overlay to re-appear on top of incoming results, and selecting a suggestion to cancel the in-flight search worker. Thanks @wgordon17 (#84).

    Changed

    • Theme persistence modelconfig.toml is now the authoritative source for the startup theme; session.json stores runtime state only and no longer restores the theme on launch. This separates user-authored configuration from app-managed session state.
    • Command palette provider architecture — app-specific commands moved from get_system_commands() to a dedicated YTMCommandProvider registered in App.COMMANDS. Isolates command definitions in src/ytm_player/app/_commands.py and keeps _app.py focused on app logic.

    Infrastructure

    • Pre-commit hooks + pyright in CI + dbus-fast migration — new .pre-commit-config.yaml runs ruff-format / ruff / pyright on commit and pytest on push (pre-commit install to activate). New CI job runs pyright at standard strictness. The Linux MPRIS service migrates from the unmaintained dbus-next to the maintained dbus-fast fork (identical API). Thanks @wgordon17 (#83).
    • Settings save Windows fallbackSettings.save() now falls back to a direct path.write_text() when os.replace() raises PermissionError (e.g. when config.toml is held open by an external editor on Windows).
  5. 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 DataTable pages 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: a Featured 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 SongsWeekly Top Songs on ShortsTrending 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 + videos from the API. Previously only daily was consulted, which silently dropped Spain (returns its data under videos) and missed the Top 100 Songs / Top 100 Music Videos shelves under weekly for 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 from countries.options) plus six historically-supported codes outside that list (Hong Kong, Malaysia, Singapore, Taiwan, Thailand, Vietnam). Settings default flips region = "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_title no 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 DataTable to TrackTable. Gains right-click context menus, play indicators, column resize, filtering, and sorting — for free, on three pages that previously rolled those manually. Also retires the on_mouse_down right-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).
    • Discovery mix now cycles sources in fixed order (Charts → Trending → For You → Your Liked Songs → Artist → Recently Played) instead of random selection — guarantees variety across consecutive presses of D
    • Radio notifications now list all seed track names as a bulleted list instead of showing only the first seed or a generic label
    • Playlist radio notification now includes the playlist name (e.g. "Playing: Radio from My Playlist")

    Changed

    • Mood source removed from discovery mix — upstream removed Moods & Genres tab; the source was failing silently
    • clean_shelf_title and get_chart_shelf_tracks added as shared utilities for reuse in discovery mix

    Fixes

    • Radio track durations no longer show --:-- — ytmusicapi's get_watch_playlist returns duration under a length key (e.g. "3:07"), not duration. extract_duration() now checks duration_secondsdurationlength in priority order. Thanks @wgordon17 (#74).
    • Play history no longer stores duration as 0log_play was reading raw track.get("duration_seconds", 0), but normalized tracks store the value under duration. Switched to extract_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_column now runs after load_tracks and append_tracks so 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 Rule widget between the pinned-nav block and the LibraryPanel) is now mirrored below the LibraryPanel, so the Playlists panel sits between two matching $border-coloured horizontal rules instead of just one.