This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
ytm-player is a YouTube Music TUI client built with Python 3.10+ and Textual. It provides vim-style navigation, synced lyrics, playlist management, queue control, and integrations (MPRIS, Discord, Last.fm, Spotify import). Audio playback uses mpv via python-mpv; stream URLs are resolved via yt-dlp.
# Install (editable, all features + dev tools)
# (dbus-fast / MPRIS is a core Linux dep now — no longer an extra)
pip install -e ".[spotify,discord,lastfm,transliteration,dev]"
# Run the TUI
ytm
# Lint
ruff check src/ tests/
ruff format --check src/ tests/
# Auto-format
ruff format src/ tests/
# Tests
pytest
pytest --cov=ytm_player --cov-report=term-missing
# Single test file
pytest tests/test_services/test_queue.py
# Single test
pytest tests/test_services/test_queue.py::test_add_track -vSystem dependency: mpv must be installed (sudo pacman -S mpv on Arch).
Entry point: ytm CLI command → src/ytm_player/cli.py (Click). Running ytm with no args launches the Textual TUI app (app/ package — split into mixins, all extending YTMHostBase from app/_base.py which declares the shared attribute surface as a TYPE_CHECKING-only stub for clean Pyright type-checking). Subcommands (ytm search, ytm play, etc.) communicate with a running TUI instance via Unix socket IPC (ipc.py).
Three-layer structure:
services/— Backend singletons:Player(mpv wrapper),QueueManager(shuffle/repeat),StreamResolver(yt-dlp),YTMusicService(ytmusicapi),CacheManager(LRU audio cache),HistoryManager(SQLite via aiosqlite),AuthManager(browser cookie extraction),lrclib(LRCLIB.net lyrics fallback —get_synced_lyricsruns a title sanitizer that strips(feat. X),(Remix),(Remastered),(Deluxe),(Live),(Acoustic),(Official Music Video), etc., before lookup to improve match rate),DownloadService(offline downloads),SpotifyImport. Platform-specific:MPRISService(Linux D-Bus),MacOSMediaService+MacOSEventTapService(macOS),MediaKeysService(Windows pynput). Optional:DiscordRPC,LastFMService.ui/— Textual widgets:pages/(library, search, browse, context, queue, etc.),sidebars/(playlist list, synced lyrics),popups/(modals),widgets/(track table, progress bar, album art). Styling viatheme.pywith CSS variables.config/—Settingsdataclass loaded from~/.config/ytm-player/config.toml.KeyMapsystem supports multi-key vim sequences and count prefixes. All paths centralized inpaths.py.
Key patterns:
- Event-driven playback:
PlayeremitsPlayerEventenums (TRACK_END,TRACK_CHANGE, etc.) dispatched to the Textual event loop viacall_soon_threadsafe. The app registers callbacks to update UI. - Thread safety:
PlayerandQueueManagerare singletons withthreading.Lock. Player events bridge from mpv's callback thread to asyncio. - Track format: All services use a standardized track dict with keys:
video_id,title,artist,artists(list of dicts withname/id),album,album_id,duration(seconds, int or None),thumbnail_url,is_video. Thenormalize_tracks()function inutils/formatting.pyconverts inconsistent ytmusicapi response shapes into this format — always use it when ingesting API data.extract_duration()readsduration_seconds→duration→lengthin priority order; thelengthfallback exists becauseget_watch_playlistreturns durations as"M:SS"strings under that key. Always go throughextract_duration()rather thantrack.get("duration_seconds", 0)— that pattern silently returns 0 for normalized tracks. - Session persistence: Volume, queue contents, shuffle/repeat state saved to
session.jsonand restored on startup. When[playback] resume_on_launchis true (default), the last-played track + position are staged into_pending_resume_video_id/_pending_resume_positionon the app and consumed the first time the user presses play, instead of auto-playing on launch. - Playback bar keybindings: Standard transport keys plus
lto toggle the like state of the currently playing track. - Prefetching: Next track's stream URL is resolved in background for instant skip.
- Page navigation:
app/_navigation.pymanages a nav stack (max 20) vianavigate_to(). Each page widget implementshandle_action(action, count)for vim-style keybinding dispatch. - Lyric current colour:
theme.pyexportsDEFAULT_LYRIC_CURRENT = "#ff4e45"as the absolute fallback for the synced-lyrics current-line colour. The fallback chain istheme.accent→theme.primary→DEFAULT_LYRIC_CURRENT, identical acrosstheme.from_css_variables,_app.py:get_css_variables, and_app.py:watch_theme. - Python 3.10 compatibility shims: Three stdlib symbols added in 3.11+ are backported via
sys.version_info >= (3, 11)checks (which Pyright narrows correctly):tomllib(inconfig/keymap.py,config/settings.py,ui/theme.py,app/_app.py,tests/test_config/test_settings.py) falls back totomli;typing.Self(in the first three of those files) falls back totyping_extensions.Self;enum.StrEnum/auto(shared once fromutils/compat.py, imported byservices/queue.pyandservices/player.py) falls back to a small(str, Enum)polyfill mirroring stdlib'sauto()lowercase-name behaviour.tomliandtyping_extensionsare conditional dependencies (python_version < "3.11") so 3.11+ users don't pull them. - LC_NUMERIC quirk:
cli.pyforcesLC_NUMERIC=Cat import time — mpv segfaults without it. Don't remove this.
The repo uses pre-commit. After cloning, install hooks once:
pre-commit installThis sets up both pre-commit (ruff-format, ruff, pyright) and pre-push (pytest) hooks automatically via default_install_hook_types in .pre-commit-config.yaml.
Manual fallback — if hooks aren't installed, run BOTH before every commit:
ruff format src/ tests/
ruff check src/ tests/ruff check alone is NOT enough. ruff format catches line length and style issues that ruff check does not. Always format first, then lint.
- Line length: 100, target Python 3.10
- Rules: E, F, I, N, W (E501 ignored — line length handled separately)
- Per-file exemptions:
mpris.py(N802, N803, F821, F722 for D-Bus conventions),spotify_import.py(N803) - CI pins
ruff==0.15.1— match this locally to avoid lint drift
- pytest with
asyncio_mode = "auto"— async test functions are auto-detected, no@pytest.mark.asyncioneeded - UI code (
src/ytm_player/ui/*) is excluded from coverage; services and config are covered - Coverage floor: 47%
- Heavy mocking of mpv, ytmusicapi, yt-dlp, D-Bus — tests never hit real APIs or require mpv installed
- Test fixtures in
tests/conftest.py:sample_track/sample_tracksuse_make_track()helper to create standardized track dicts;queue_managerprovides a freshQueueManagerinstance - CI runs on GitHub Actions (Ubuntu + macOS + Windows, Python 3.10 and 3.14): ruff lint + format check, then pytest with coverage
Logs go to ~/.config/ytm-player/logs/ytm.log via setup_logging() in
src/ytm_player/utils/logging.py. Never use print() in
non-CLI code — Textual's alt-screen swallows stderr.
Conventions:
- Use
logger = logging.getLogger(__name__)at module top. - For caught exceptions you want to surface in bug reports, use
logger.exception("descriptive message")— notlogger.debug(..., exc_info=True), which silently routes to debug level. - Reserve
logger.debugfor verbose tracing only enabled with--debug. - Unhandled crashes are captured by
install_excepthooks()and written to~/.config/ytm-player/crashes/. - For diagnostics, run
ytm doctor— outputs version, paths, recent log, and most recent crash trace, suitable for pasting into issues.
The codebase uses ~263 except Exception: blocks as a deliberate graceful-degrade pattern. Service-layer methods return safe defaults (empty list, False, None) on any failure rather than raising; UI-layer handlers wrap service calls in their own broad catch knowing services don't raise. Every broad-except site has been audited and categorized in docs/broad-except-audit.md as KEEP (intentional graceful-degrade — 176 sites), NARROW (hides real bugs, should specify expected types — 87 sites), or PROMOTE (should let propagate — 0 sites).
Before adding a broad except Exception: block: check the audit doc to confirm the pattern is correct here, or document the new site with its category in the audit. Service-layer methods that can return safe defaults SHOULD do so; methods that change state SHOULD let unexpected exceptions propagate.
The cascade contract: if you narrow an exception in a service-layer method, every UI/app handler that wraps a call to that method may need updating in lockstep — the cascade map in the audit doc lists the dependents. The Phase 4 plan (also in the audit doc) sequences narrowings before cascade updates so regressions don't slip through.
Three GitHub Actions workflows live in .github/workflows/:
ci.yml— runs ruff lint + format check, then pytest on the matrix[3.10, 3.14]×[ubuntu, macos, windows](6 jobs total).check-python-versions.yml— runs monthly (1st of each month, 09:00 UTC) and opens a maintenance issue when CPython releases a new stable major.minor version newer than our matrix ceiling. Idempotent — won't reopen if an issue is already open. Uses pyyaml to parse the matrix robustly.publish.yml— tag-triggered (v*) andworkflow_dispatch. Builds wheel + sdist, smoke-tests the wheel againstytm --versionin a fresh venv, uploads to PyPI via OIDC trusted publishing (no API tokens), then creates a GitHub Release with the matching CHANGELOG section attached. Manual dispatch can target TestPyPI for dry-runs.
.github/dependabot.yml runs weekly on Mondays. Both pip and github-actions ecosystems use two groups: *-minor-patch (auto-merge candidates) and *-major (review carefully — breaking changes possible). Major bumps are not skipped — they just open in their own PR so they're reviewed independently of low-risk updates.
The flow is tag-driven. Pushing a vX.Y.Z tag triggers publish.yml, which handles PyPI + the GitHub Release end-to-end; aur-publish.yml then pushes to AUR automatically, deriving pkgver from __version__ (never hand-bump pkgver= in aur/PKGBUILD — CI overwrites it).
Before the first tag, two PyPI trusted-publisher entries are configured at https://pypi.org/manage/account/publishing/ and https://test.pypi.org/manage/account/publishing/:
| Field | Value |
|---|---|
| Owner | peternaame-boop |
| Repository | ytm-player |
| Workflow | publish.yml |
| Environment | pypi (or testpypi on TestPyPI) |
GitHub Environments pypi and testpypi exist in repo settings. No API tokens stored anywhere — auth is OIDC.
- Bump
__version__insrc/ytm_player/__init__.py. - Add a
### vX.Y.Z (YYYY-MM-DD)section to the top ofCHANGELOG.md(the publish workflow extracts it for the GitHub Release body). - Run
ruff format src/ tests/ && ruff check src/ tests/ && pytest. - Commit (
chore(release): vX.Y.Z), tag (git tag vX.Y.Z), push both (git push && git push --tags). - Watch the
Publishworkflow on GitHub Actions — it builds, smoke-tests, uploads to PyPI, and creates the release in roughly a minute.
Trigger Publish manually from the Actions tab → Run workflow → target testpypi. Builds + uploads to https://test.pypi.org/project/ytm-player/ without touching production. Useful when changing build config or pyproject metadata.
aur-publish.yml runs after Publish succeeds: it checks out the release commit, derives the version from __version__, rewrites pkgver= in the copied aur/PKGBUILD (the committed value is only a placeholder), regenerates .SRCINFO, and pushes to AUR. No manual version step.
Still manual: if dependencies changed, update depends/optdepends/makedepends in aur/PKGBUILD before tagging. Manual push fallback (if the workflow fails) is documented in RELEASING.md.
AUR package URL: https://aur.archlinux.org/packages/ytm-player-git
Published on four channels:
- PyPI:
pip install ytm-player— https://pypi.org/project/ytm-player/ - AUR:
yay -S ytm-player-git— https://aur.archlinux.org/packages/ytm-player-git - NixOS:
flake.nixwithytm-playerandytm-player-fullpackages - Gentoo:
emerge media-sound/ytm-playervia GURU overlay (community-maintained by @dsafxP)