Tell it what you feel like hearing, in plain language. An LLM turns that into concrete seed tracks, YouTube Music turns those into radios, and the music keeps playing until you say stop.
The brain runs on your ChatGPT/Codex subscription via the Codex CLI — no API key is ever entered or stored.
» play something upbeat
Putting on a varied, cheerful mix of Czech and international hits across the decades.
▶ Kryštof — Cesta (feat. Tomáš Klus)
» something calmer, I'm going to sleep
Winding it down into a gentle mix of acoustic ballads and dreamy atmosphere.
The DJ persona currently replies in Czech (see Limitations); the examples above are translated.
- Natural-language DJ — "something upbeat", "calmer, I'm going to sleep", "more like the last one" all work; deterministic commands (skip, pause, volume) bypass the model entirely and react instantly
- Runs on your existing subscription — uses the Codex CLI under your ChatGPT plan; no API key, no extra cost
- Music never stops — the queue keeps filling from prefetched pools even when the model is slow or fails
- Web remote control — live now-playing view, queue, controls, and
settings at
http://127.0.0.1:8765, usable from a phone - YouTube Music Premium quality (256 kbps) when you have a subscription and browser cookies — works without them too, at standard quality
- Learns your taste — play history, skips, and a blacklist are kept in a local SQLite database and fed back into seeding
The model does not pick every track — that would be slow, repetitive, and after an hour it would exhaust its mental playlist. Instead:
- From your request it derives 3–5 seed tracks (this is what LLMs are good at — knowing what "upbeat" means in your context). It returns them as a structured JSON decision.
- The app looks them up itself in the YouTube Music catalog and expands each into an independent YouTube radio (~50 tracks).
- The queue is filled round-robin across all pools. This is the trick: a single seed drifts toward one artist within ~20 tracks, but interleaving five seeds holds the mood for hours — for free, because the model is no longer involved.
- The model is consulted again only when you say something, the pools run dry, or you skip a lot.
If Codex fails or is slow, the music keeps playing — the queue filler draws from already-fetched pools and never waits for the model.
Radio tracks don't repeat for repeat_days (30 by default). What you ask
for by name is exempt. A DJ avoids repeating himself, but plays what he is
asked for — so a track you name goes in even if it played yesterday, and it
goes in right behind the current one rather than at the end of the queue.
The model marks such tracks in a separate requested field, apart from the
seeds it picked itself. Those get played and are counted; seeds only shape the
radio. The counts add up in the requests table, and the most-asked-for
tracks travel with every request to the model — so a bare "play something"
leans on what people around here actually keep asking for, rather than on the
model's mood of the moment.
request ──▶ codex exec ──JSON──▶ app: search ──▶ pools (per seed, ~50 tracks)
│ round-robin
▼
queue ──▶ mpv
▲
re-seeding from finished ──┘
The first version exposed the catalog to Codex as an MCP server on localhost.
That only half-works: Codex connects and sees the tools, but in
non-interactive mode it cancels every call (user cancelled MCP tool call) — it waits for an approval nobody is there to give. The only thing
that opens that gate is --dangerously-bypass-approvals-and-sandbox, which
also removes the sandbox around the shell. Verified dead ends:
approval_policy="never" and approvals_reviewer="auto_review" don't help,
and the shell can't be taken away from Codex (ToolsToml only has
web_search and experimental_request_user_input).
Since YouTube track titles — i.e., untrusted third-party input — flow back
into the model's context, trading the sandbox for convenience was not worth
it. Instead, a structured decision is extracted via --output-schema and the
app does its own searching and playback. Side effect: one pass instead of
seven, so it's faster (16–21 s per request instead of ~30 s).
- Linux (uses mpv over a unix socket; tested on Ubuntu 24.04)
- Python 3.12+
- mpv and ffmpeg
- yt-dlp
- Node.js in
PATH(yt-dlp needs a JS runtime to resolve YouTube signatures; an nvm-installed node is detected automatically) - Codex CLI installed and logged in
(
codex login) with a ChatGPT subscription
sudo apt install -y mpv ffmpeg
uv tool install yt-dlp --with secretstorage # secretstorage: Chrome cookie decryption
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python -e .
./run.shOn first run a config file is created at ~/.config/ytdj/config.toml and the
browser profile with a logged-in YouTube account is auto-detected.
ytdj terminal REPL + web UI
ytdj --web-only web UI only (for running in the background)
ytdj --no-web terminal only
./packaging/install-service.shInstalls a systemd user service that runs --web-only, and switches
lingering on for your account so the service comes up at boot — no login
needed. That is the point: a box in the corner that plays, controlled from a
phone over the web UI.
Two units are installed: ytdj itself and ytdj-pot, the PO token provider
that Premium formats depend on (see Premium audio quality).
systemctl --user status ytdj # state
journalctl --user -u ytdj -f # log
systemctl --user restart ytdj # after changing settings marked "restart"
systemctl --user disable --now ytdjA user service, not a system one, because everything it needs lives in the home directory: the config, the Codex login, and the audio session. It restarts on its own — including when mpv dies, which the app notices and ends over, since a web UI that controls nothing is worse than a restart.
Two things behave differently without a logged-in desktop:
-
Cookies. Chrome's cookie jar is encrypted with a key from the keyring, and the keyring is unlocked by logging in. Without it yt-dlp silently drops to 128 kb/s. Export the cookies once on a machine where you are logged in, and point
cookies_fileat the result:yt-dlp --cookies-from-browser chrome --cookies ~/.config/ytdj/cookies.txt \ --skip-download https://music.youtube.com/cookies_filetakes precedence overcookies_browser. The app warns at startup when it is about to run into this. -
Audio. mpv plays through the PipeWire of the same user manager. It is socket-activated, so it starts without a session — but the account has to be lingering (the installer arranges that) and no other user may be holding the device.
To control it from a phone, set web_host = "0.0.0.0". Read the security note
under Web UI & API first: there is no authentication, so anyone
on that network can play music, change settings, and spend your subscription.
Anything you type goes to the DJ — except deterministic commands, which are handled without the model, instantly:
| command | effect |
|---|---|
next / skip / n |
skip |
pause / p |
pause |
resume / play |
resume |
stop |
stop and clear the queue |
+ / - / volume 70 |
volume up / down / set (remembered across restarts) |
status / ? |
now playing |
help / h |
command list |
quit / exit |
exit |
Czech variants (další, pauza, hlasitěji, …) work too — see ui/repl.py.
Everything else is a full Codex request (16–21 s).
Paste a link and it is handled directly — no model, no waiting. There is nothing to interpret about a link, and Codex would spend twenty seconds arriving at the same answer:
| link | what happens |
|---|---|
watch?v=…, youtu.be/…, /shorts/… |
queued right after the current track, counted as a request |
playlist?list=… |
plays the playlist, and seeds the radio from it so it continues afterwards |
/channel/UC…, /@handle |
builds a radio from that artist's top tracks |
A YouTube channel and a YouTube Music artist are not the same thing — many artists have no music profile on their channel id. When the id leads nowhere, the artist is looked up by the channel's name instead.
Asking for a title by name used to take the first search hit, which is how you
end up with a cover version or a live bootleg. Candidates are now scored on
artist and title separately, with the artist weighted higher — the wrong artist
is a worse mistake than a different take of the same song. Diacritics don't
matter (Deda Mladek finds Děda Mládek), and neither does spacing, so
TribalNeed and Tribal Need are the same name. If the text search comes up
empty, the artist's profile is opened and their tracks are searched directly.
A wrong artist is a hard veto, not a low score: a candidate whose artist
doesn't match is dropped even if the title matches perfectly. Ask for
TribalNeed — Tribal Need and you must not get Ballistic Noise — Tribal Need, which is a different band that happens to have a song by that name.
When nothing by the named artist can be found, nothing is returned — silence
beats the wrong band.
Artists who aren't in the catalog at all. Live looping, DJ sets, the
smaller scene — plenty of acts exist on YouTube only as a channel with videos,
with no YouTube Music artist entity. Searching songs for them returns other
people's tracks that share a word in the title. So when the artist filter finds
nothing, videos are searched and those whose channel matches the name are
used; they play like anything else. The model is told to leave title empty
for artists it doesn't know rather than invent one, because an invented title
is exactly what lands on the wrong band.
Hour-long sets are exactly what some artists publish, and dropping them on
max_duration (600 s) means not playing what was asked for. So the ceiling
depends on where the seeds came from:
- A mood ("something upbeat") keeps
max_duration. A 70-minute set in the middle of a mixed set would just block the queue. - A named artist — asked for by name, or via a channel or playlist link —
may go up to
max_duration_request(5400 s, i.e. 1.5 h).
Short tracks always win: the relaxed ceiling only applies to fill what's left
after the normal ones run out. Tracks that were too long are set aside during
the pass rather than discarded, so the second pass doesn't need another fetch.
When it engages, the log says which track and how long (beru delší kus).
Alongside the terminal, the app serves a web remote at http://127.0.0.1:8765 — now playing, queue, controls, DJ prompt box, and settings. It runs in the same event loop as the player, so it's a second view of the same state, not a separate service.
- Smooth position indicator (interpolated locally, no per-second jumps)
- Live updates over SSE (
/api/events) with automatic fallback to polling - Settings form is generated from the config schema; options that need a restart (formats, cookies, language, the web bind itself) are marked
- The settings drawer opens with what the app is actually attached to: the
Google account behind the cookies, which profile they come from, whether the
catalog is anonymous, whether the PO token provider is up — and what the
brain is (
codex CLI, model, subscription). The now-playing line carries the real bitrate, soopus 251 kb/s (Premium)is visible at a glance - Restart button, for the settings that only take effect at startup. It
ends the process and lets systemd start it again, so it only appears when
running as a service (detected via
INVOCATION_ID); ~5 s of silence - Responsive — usable from a phone if you set
web_host = "0.0.0.0"
Disable with web_enabled = false in config.toml.
The API, if you want to script it:
| endpoint | description |
|---|---|
GET /api/status |
player state, queue, history |
GET /api/events |
SSE stream of the same |
POST /api/prompt |
{"text":"..."} → {"reply":"..."}; 409 while Codex is busy |
POST /api/control |
{"action":"play|pause|next|stop|volume","value":int} |
GET/POST /api/config |
read and write config.toml |
GET /api/about |
what it's connected to and what the brain is |
POST /api/restart |
ends the process so systemd restarts it; 409 outside systemd |
No authentication — that's why it binds to
127.0.0.1. Before exposing it to your network, understand that anyone on it can then change the configuration and spend requests against your subscription.
~/.config/ytdj/config.toml, created on first run. The important keys:
| key | default | meaning |
|---|---|---|
codex_model |
"" |
"" = Codex CLI default; e.g. "gpt-5.4-mini" is faster and cheaper on limits |
web_enabled / web_host / web_port |
true / 127.0.0.1 / 8765 |
web remote |
language / location |
cs / CZ |
YouTube Music catalog language and region |
cookies_browser |
auto-detected | browser profile for yt-dlp cookies, e.g. "chrome:Profile 2"; "none" = no cookies |
cookies_file |
"" |
exported cookies.txt; wins over cookies_browser and is the only source that works without a desktop session |
player_client |
"" |
yt-dlp client; empty lets yt-dlp choose (anonymous clients, no Premium). A client that carries the login, e.g. web_music, needs a PO token |
ytdl_format |
774/141/251/140/bestaudio |
audio format preference (Premium first) |
volume |
100 |
last volume set; mpv starts at it. Written by the player, not editable in the settings form — use the slider or volume 70 |
queue_target / queue_low |
5 / 3 |
how far ahead the queue is kept filled |
radio_limit |
50 |
tracks fetched per seed radio |
repeat_days |
30 |
don't repeat a track for N days |
min_duration / max_duration |
60 / 600 |
track length filter (seconds) |
max_duration_request |
5400 |
ceiling when a specific artist was asked for and short tracks are scarce — long sets get in rather than being dropped |
artist_window |
10 |
max 2 tracks per artist within N tracks |
Premium formats (774 Opus 256k / 141 AAC 256k) need four things at once. Miss any one and YouTube quietly serves 130k instead — nothing errors out, the music just sounds worse:
- An account with Premium, in cookies yt-dlp can read.
- A PO token. This is the one that trips everyone up. Every yt-dlp client
that carries cookies (
web,web_music,tv, …) has its formats rejected without a GVS PO token, and the clients that work without one (android_vr) refuse to send cookies at all. No token means no authenticated client means no Premium, whatever else you configure. --js-runtimes=nodeand--remote-components=ejs:github, or signature resolution fails.nodeon thePATHof mpv's subprocess (the config finds it in nvm too), andsecretstorageimportable in yt-dlp's environment for Chrome cookies.
Tokens are minted by bgutil-ytdlp-pot-provider; there is no way to derive one from config alone. Install the plugin into yt-dlp's environment and build the provider:
uv tool install yt-dlp --with secretstorage --with bgutil-ytdlp-pot-provider --force
git clone --depth 1 --branch 1.3.1 https://github.com/Brainicism/bgutil-ytdlp-pot-provider ~/bgutil-ytdlp-pot-provider
cd ~/bgutil-ytdlp-pot-provider/server && npm install && npx tscpackaging/install-service.sh then picks it up and runs it as
ytdj-pot.service, which mints and caches tokens on demand. It also patches
the built server to listen on 127.0.0.1 — upstream binds to every interface,
and on a jukebox that would let anyone on the network mint tokens against your
account. Re-run the installer after updating the provider; the patch applies to
the built file.
Without the service you can paste a token manually into
~/.config/ytdj/env as YTDJ_PO_TOKEN=web_music.gvs+…. That file is created
with mode 0600 and is deliberately not config.toml: the web UI reads and
writes the config and has no authentication, so credentials do not belong there.
ytdj --check-audioRuns yt-dlp with exactly the arguments mpv gets and says what came back — how
many cookies were read, which formats are on offer, and if Premium is missing,
which of the reasons above applies. During playback the real bitrate travels in
the status (quality, e.g. opus 251 kb/s (Premium)) and goes to the log, so
a drop to 130k is visible instead of silent.
Two traps worth knowing:
- The wrong Chrome profile. Several profiles may be logged in while only
one has Premium. A profile that has been signed out keeps its entry in
config.tomland yt-dlp just reports "Extracted 0 cookies" — the app now detects this at startup and switches to a profile that does have a login. - Chrome hasn't flushed. Cookies live in memory for a while before being written to the profile's SQLite, so a freshly signed-in profile can read as empty. Give it a few minutes.
Without cookies it still plays, at 128k opus: cookies_browser = "none".
Risk: yt-dlp with cookies is the only place where your account is
exposed. Documented enforcement by YouTube consists of IP blocks and cookie
invalidation, not account deletion — but it's a real trade-off, not zero
risk. If that bothers you, cookies_browser = "none" removes it entirely
(at the cost of quality).
Search and radio work anonymously. A login is only needed for your library, history, and likes:
./.venv/bin/ytmusicapi browser # paste request headers from DevTools
mv browser.json ~/.config/ytdj/Do not use OAuth — since August 2025 the YouTube Music server rejects the Bearer token (ytmusicapi#813, still open).
Nothing extra — it runs on your subscription. Note, however, that every request is a full Codex session (~12k input tokens due to its own system prompt), so it counts against your plan's limits. Passive listening, when you say nothing, costs nothing.
Faster and lighter on limits: codex_model = "gpt-5.4-mini" in the config.
- No API keys or passwords are stored — Codex CLI keeps its own OAuth
session in
~/.codex/auth.json; this app never sees it. - What leaves your machine: your music requests and track titles go to the model provider through Codex; YouTube Music queries carry your cookies if enabled. Nothing else.
- Local state (
~/.local/share/ytdj/): play history, ratings, blacklist — plain SQLite, delete it anytime. - The web UI is unauthenticated and bound to localhost by default (see Web UI & API).
- The DJ role prompt (
ytdj/agent/prompts.py) is written in Czech, so replies come back in Czech regardless of thelanguagesetting — that key only affects the YouTube Music catalog. An English persona is a matter of editing one prompt file. - Linux only for now: the player talks to mpv over a unix socket.
ytdj/
config.py XDG paths, browser & node auto-detection, config migration
diagnose.py --check-audio: what quality is on offer and what's missing
state.py SQLite: history, ratings, requests, blacklist, long-term taste
music/
catalog.py ytmusicapi → compact Track (thumbnails and feedback tokens stripped)
radio.py seed pools, round-robin, filters (repeats, length, artist cap)
player/
base.py Player interface — agent logic does not depend on mpv
mpv.py JSON IPC over a unix socket
agent/
codex.py `codex exec --output-schema`, decision execution
prompts.py DJ role + JSON response contract
ui/repl.py prompt_toolkit + fast local path for commands
web/
server.py starlette + uvicorn, REST + SSE, config writes
static/index.html the whole frontend in one file, no build step
packaging/
ytdj.service systemd user unit (@INSTALL_DIR@ filled in on install)
ytdj-pot.service PO token provider, without which Premium stays out of reach
install-service.sh installs both units, switches on lingering, starts them
The terminal and the web UI share one lock for Codex calls (App.ask), so
two turns can never interleave and overwrite each other's pools — automatic
re-seeding included.
Swapping the player (e.g. for pear-desktop / YTMDesktop, where Premium plays
through a real web player and yt-dlp drops out) means writing another
implementation of player/base.py:Player. Nothing else changes.
Hard-won details that are easy to re-discover the painful way:
limitin ytmusicapi is a lower bound, not an upper one — YTM paginates by 20; we trim on our side.- ytmusicapi stuffs play counts ("3.4M plays") into the
artistsfield — we filter them out, or they leak all the way into the prompt. get_watch_playlist(radio=True)returns a different mix every time. There is no reproducibility, so actualvideoIds are stored, not the seed.shuffle=Truecrashes onRDCLAK5playlists — we don't use it.- mpv events must not be handled inside the read loop — the handler would wait for a reply that the same loop is supposed to read. Hence a separate dispatch loop.
end-filecarries areasonthat distinguishes finished from skipped — that's the implicit feedback re-seeding is based on.codex exec resumeaccepts neither-Cnor-s; both are inherited from the original session and must be omitted on resume.- Codex writes a
trust_levelfor every working directory into~/.codex/config.toml— that's why ytdj uses a stable working directory (~/.local/share/ytdj/codex-workdir), not amkdtempper run. - The web server runs in the same asyncio loop as the player. Anything that
blocks the loop (say, a synchronous
subprocess.run) blocks the web too — easy to fall for in tests. - mpv's
clear_queuelets the current track finish. On a mood change the app therefore skips once more after the new queue is filled, otherwise the new music would only start three minutes later.
Issues and pull requests are welcome. The codebase is small and commented —
start with Project layout. Please keep changes focused and
describe the observed behavior (logs help: YTDJ_LOG=DEBUG ./run.sh).
