Architecture
bester-ytm is a layered, local-first application. UI layers stay thin and
testable; all external systems (YouTube Music, mpv) are isolated behind one
module each.
CLI (cli.py, cli_play.py, cli_config.py) TUI (tui.py + tui_* mixins)
\ /
services: playlist_builder, playlist_create, resolver, similar, stores,
local_files, radio
| |
ytm_client.py playback.py
(YouTube Music access: a facade (only mpv process control)
over ytm_session / ytm_search / |
ytm_library / ytm_models) transitions.py / deck.py / fader.py / mpv_ipc.py
Every ytmusicapi response is normalized into pydantic models (ytm_models.py)
at the ytm_client boundary; nothing above it depends on raw API shapes. The
only other module touching ytmusicapi is auth.py, for login setup.
Playback: the dual-deck transition engine
Playback uses mpv with --no-video --ytdl-format=bestaudio, controlled over
a JSON IPC unix socket (mpv_ipc.py). DJ-style transitions come from running
up to two mpv processes at once, like a two-deck DJ setup:
- Deck (
deck.py): one mpv process plus its own IPC socket. The deck lifecycle is: spawned paused at volume 0 (prebuffering) -> ready -> promoted to live -> draining (fading out) -> stopped. - TransitionEngine (
transitions.py): tick-driven;PlaybackController. status()callstick(), so the TUI's 0.75s refresh loop and the CLI wait loop both drive it without threads of their own. Within the prebuffer window (effective_fade + 12sbefore track end) it spawns the idle deck for the next queued track. Ateffective_faderemaining it promotes the prebuffered deck: the queue advances, controllerprocess/ipc_socketswap atomically to the new live deck, and the fade starts. - Fader (
fader.py): equal-power crossfade (gain_out = cos(t*pi/2),gain_in = sin(t*pi/2)) scaled by the controller's master volume, stepped every 100ms on a short-lived daemon thread. The clock and sleep functions are injected so tests run the whole ramp synchronously. - effective_fade =
max(1, min(fade_seconds, duration / 3)), so short tracks never spend most of their runtime mid-mix.
Invariants
- No double-advance. The TUI keeps a fallback auto-advance for cut mode:
when the live mpv process dies with tracks still queued, it calls
next(). The engine therefore guaranteesstatus().runningnever dips during a crossfade (the live process swaps atomically insidetick()), and the engine itself never advances when the live process is dead -tick()returns before reading timing. Dead-process handling always belongs to the frontends, mixing always belongs to the engine. - Transactional promotion. Queue mutation, deck swap, and fade start happen together or not at all; a failed prebuffer or IPC error aborts the promotion and the track ends with a plain cut.
- Failed fades restore volume. If a fader thread errors, the live deck's volume is restored to the master volume so playback is never left quiet.
- Both decks are always reaped. Retiring a deck never blocks the tick
thread: the
DeckReaper(deck.py) sends SIGTERM and unlinks the socket immediately, polls the dying process on subsequent ticks, and escalates to SIGKILL after a 5-second grace period.stop()(and TUI quit) shuts down the engine and ends with a blocking flush so no mpv outlives the app.
Manual next during crossfade mode performs a quick-mix (ramp capped at 2s);
previous and pause snap the mix immediately before acting. Mute is mirrored
to the draining deck so a muted mix stays silent.
Playlist planning pipeline
seeds (favorites, pasted text) or a prose brief
-> intelligence provider (heuristic | codex | openai | anthropic)
-> resolver (search candidates, penalize live/cover/remix, confidence)
-> plan JSON/Markdown in ~/.local/share/bester-ytm/plans/
-> playlist create/update via ytm_client -> verification against the plan
Low-confidence resolutions are recorded in the plan for review rather than silently accepted.
Storage and configuration
~/.config/bester-ytm/config.toml [playback], [ui], [builder], [intelligence]
~/.config/bester-ytm/browser.json browser login headers (0600, never in git)
~/.config/bester-ytm/oauth*.json OAuth client and token (0600, never in git)
~/.local/share/bester-ytm/ plans, favorites, local playlists
config.py owns all paths (XDG-aware) and enforces private file modes.
save_transition_settings refuses to rewrite a config file containing
sections it does not own, so user edits are never destroyed.
Testing strategy
The suite (550+ tests, ~15 seconds, no network/mpv/sleeps) fakes mpv at the
subprocess.Popen and IPC seams, drives the fader with injected clocks, and
exercises the Textual app through run_test() pilots. tests/conftest.py
isolates XDG config/data into temp dirs for every test and tunes Textual's
idle polling so pilot tests stay fast. Manual, audio-producing checks live in
Manual Testing.