Skip to content

Latest commit

 

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ytdj — an AI DJ for YouTube Music

Python 3.12+ License: MIT Platform: Linux

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.

ytdj web UI — now playing, queue, mood and seed pools

Features

  • 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

How it works

The model does not pick every track — that would be slow, repetitive, and after an hour it would exhaust its mental playlist. Instead:

  1. 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.
  2. The app looks them up itself in the YouTube Music catalog and expands each into an independent YouTube radio (~50 tracks).
  3. 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.
  4. 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.

Requests beat the no-repeat rule

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 ──┘

Why the LLM gets no tools

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).

Requirements

  • 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

Installation

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.sh

On 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

Running as a service (headless jukebox)

./packaging/install-service.sh

Installs 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 ytdj

A 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_file at the result:

    yt-dlp --cookies-from-browser chrome --cookies ~/.config/ytdj/cookies.txt \
           --skip-download https://music.youtube.com/

    cookies_file takes precedence over cookies_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.

Usage

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).

YouTube links

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.

Finding the right track

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.

Long tracks, but only when they're the point

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).

Web UI & API

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, so opus 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.

Configuration

~/.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 audio quality

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:

  1. An account with Premium, in cookies yt-dlp can read.
  2. 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.
  3. --js-runtimes=node and --remote-components=ejs:github, or signature resolution fails.
  4. node on the PATH of mpv's subprocess (the config finds it in nvm too), and secretstorage importable in yt-dlp's environment for Chrome cookies.

The PO token

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 tsc

packaging/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.

When it doesn't work

ytdj --check-audio

Runs 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.toml and 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).

ytmusicapi login (optional)

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).

Cost

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.

Privacy & security

  • 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).

Limitations

  • The DJ role prompt (ytdj/agent/prompts.py) is written in Czech, so replies come back in Czech regardless of the language setting — 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.

Project layout

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.

Development notes

Hard-won details that are easy to re-discover the painful way:

  • limit in 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 artists field — 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 actual videoIds are stored, not the seed.
  • shuffle=True crashes on RDCLAK5 playlists — 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-file carries a reason that distinguishes finished from skipped — that's the implicit feedback re-seeding is based on.
  • codex exec resume accepts neither -C nor -s; both are inherited from the original session and must be omitted on resume.
  • Codex writes a trust_level for every working directory into ~/.codex/config.toml — that's why ytdj uses a stable working directory (~/.local/share/ytdj/codex-workdir), not a mkdtemp per 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_queue lets 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.

Contributing

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).

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages