ytmv — YouTube music videos for old MP3 players¶
ytmv downloads YouTube music videos and prepares them for playback on old MP3 players,
with lyrics visible on the device.
yt-dlp alone gets you a file; it does not get you a file a 2008 player can use. That gap
is what ytmv fills: ID3v2.3 instead of v2.4, optional raw Big5/GBK text frames, a sidecar
.lrc in the encoding the device expects, FAT32-safe filenames, small JPEG cover art, and
real synced lyrics from LRCLIB — official music videos usually carry
no YouTube captions at all.
Binary: dot_dotfiles/bin/executable_ytmv (→ ~/.dotfiles/bin/ytmv). Modules: scripts/ytmv/.
Sibling of yth, which searches your watch history but deliberately never downloads.
Install¶
Deployed automatically by chezmoi:
ytmvself-bootstraps its Python deps via auv run --scriptshebang (PEP 723 inline metadata declaringyt-dlp[default],tyro,rich,platformdirs,mutagen,httpx). Thedefaultextra supplies the yt-dlp-matchedyt-dlp-ejschallenge solver; remote component downloads are deliberately disabled.- Embedded yt-dlp calls enable the repo-managed Node runtime only when it is Node 22+
(installed through mise). Standalone
~/.config/yt-dlp/configis not read by the Python API, so this is code, not a user config knob.ytmv doctorchecks both EJS and Node. Legacy armv7/armv6 (managed Node 20) and EL7 (normally Node 16) cannot solve current EJS challenges; doctor fails instead of claiming the old runtime works. EL7 x86_64 can use the documented micromamba Node 22 escape hatch; armv7 has no managed supported runtime. ffmpegis required forytmv get(audio extraction and every video path). It comes from themedia_toolsansible role, which is gated behind theinstallMediaToolschezmoi prompt — answer yes, or setffmpeg = "…"in the config.--burn-subsadditionally needs libass. homebrew-core's plainffmpeg8.x is built without it, so the same role also installsffmpeg-full, which is keg-only and therefore does not shadow or conflict withffmpegon PATH.ytmvfinds it by explicit path. Debian'sffmpegalready links libass, so Linux needs nothing extra.mutagenis intentionally not apython_uv_toolsentry — it ships no binary, so it lives only in the PEP 723 block.
Run ytmv doctor first; it tells you exactly what is missing and how to fix it.
The full deployed guide is also available offline as ytmv help.
Public-first setup and diagnosis¶
A public YouTube video normally needs no account cookie. Start with:
dotcfg --set installMediaTools=true --yes
command -v node && node --version
ytmv doctor
ytmv get 'https://www.youtube.com/watch?v=...'
If YouTube says Sign in to confirm you're not a bot, diagnose in order:
ytmv doctor: fix requiredyt-dlp-ejs, Node, ffmpeg, or public metadata failures.- Try a clean residential network. VPN/proxy/cloud/datacenter exits are commonly challenged.
- Stop repeated retries, wait, and raise batch pacing (
--sleep 10). - Use an account cookie only for genuinely private/restricted content, or as a deliberate last-resort bot-check retry. An advanced PO-token provider comes after these steps.
ytmv doctor --offline --json keeps network checks as skip and validates the local stack.
ytmv doctor --cookies explicitly tests loading/decrypting the configured source; it does
not prove that the account may access every URL.
The four outputs¶
| Want | Command | Notes |
|---|---|---|
mp3 + sidecar .lrc |
ytmv get <URL> |
The default. What most old players read. |
| mp3 with lyrics inside the file | ytmv get <URL> |
USLT frame, written by default alongside the sidecar. --embed-sylt adds SYLT. |
| mp4 with soft subtitles | ytmv get <URL> --video |
A real subtitle track. Most old players ignore it. |
| mp4 with lyrics burned in | ytmv get <URL> --video --burn-subs |
Always visible, because it is part of the picture. Needs libass. |
Quick start¶
ytmv help # complete setup / cookie-safety guide
ytmv doctor # check local + public-download stack
ytmv get 'https://www.youtube.com/watch?v=...' # mp3 + .lrc + tags + cover
ytmv get '<playlist-url>' --number --m3u roadtrip # numbered files + a playlist
ytmv get '<url>' --video --burn-subs --max-height 480
ytmv tag ~/Music/ytmv --profile cjk-big5 # player showed mojibake → re-encode
ytmv lyrics ~/Music/ytmv --pick # fix a wrong lyrics match by hand
Subcommands¶
| Command | What it does |
|---|---|
get <URL>… |
Download → convert → tag → attach lyrics. Accepts video URLs, playlist URLs, bare 11-char ids, and --from-file. |
lyrics <PATH>… |
Find and attach lyrics to mp3s that already exist. --pick resolves ambiguous LRCLIB matches interactively; --artist/--track force the query. |
tag <PATH>… |
Re-apply a profile in place — ID3 version/encoding, .lrc encoding, cover size, filenames. Never touches the network. |
doctor |
Probe yt-dlp/EJS/Node, public YouTube metadata, ffmpeg/libass, mutagen, LRCLIB, cookies, output dir, and print the fully resolved profile. --offline skips network; --cookies opts into source loading/decryption; --list-profiles feeds completions. |
help [SUBCOMMAND] |
Full import-free setup/security guide, or delegate to one leaf command's Tyro help. -h/--help intentionally stay concise. |
Compatibility profiles — the important part¶
You usually do not know what your player wants. So every compatibility knob is a setting,
profiles bundle sensible combinations, and ytmv tag re-applies any profile to files you
already downloaded — bisecting costs no bandwidth.
| Setting | safe (default) |
cjk-big5 |
cjk-gbk |
ipod |
modern |
|---|---|---|---|---|---|
id3_version |
3 | 3 | 3 | 3 | 4 |
id3_encoding |
utf16 |
raw-big5 |
raw-gbk |
utf16 |
utf8 |
lrc_sidecar |
yes | yes | yes | no | yes |
lrc_encoding |
utf-8 |
cp950 |
gbk |
— | utf-8 |
lrc_on_unencodable |
strict |
strict |
strict |
— | strict |
embed_uslt / embed_sylt |
yes / no | yes / no | yes / no | yes / no | yes / no |
cover / cover_max |
jpeg / 300 | jpeg / 300 | jpeg / 300 | jpeg / 300 | jpeg / 600 |
filename_charset |
unicode |
unicode |
unicode |
unicode |
unicode |
filename_max |
120 | 120 | 120 | 120 | 200 |
number_prefix |
no | no | no | no | no |
Select with --profile NAME; override any individual setting with its own flag
(--id3-version, --lrc-encoding, --skip-lrc-sidecar, --cover-max, …). Resolution
order: built-in profile → user [profiles.<name>] → config top-level defaults → CLI flag.
Suggested bisect when the player misbehaves¶
| Symptom on the device | Try |
|---|---|
Title/artist blank or ???? |
ytmv tag <dir> --profile cjk-big5 (or cjk-gbk) |
| Lyrics never appear | ytmv tag <dir> --profile ipod (embeds only, drops the sidecar) |
| Filenames blank/garbled | ytmv tag <dir> --ascii-filenames |
| Tracks play in the wrong order | re-fetch with ytmv get … --number |
| Cover art missing or file rejected | ytmv tag <dir> --cover-max 200, or --skip-cover |
Two deliberate oddities¶
raw-big5/raw-gbkviolate the ID3 spec on purpose. Many old Chinese-market players ignore a frame's declared encoding byte and assume the local codepage, so those modes encode text to Big5/GBK and then declare the frame as latin-1. The file ends up holding exactly the bytes such a player expects. Do not "fix" this.cp950, not barebig5. cp950 is the Microsoft superset that player firmware actually implements; strictbig5rejects characters real players render fine (碁,~,‧). Same reasoning applies togbkovergb2312.
Lyrics sources¶
--lyrics auto (the default) walks this chain and stops at the first hit:
- LRCLIB exact —
/api/getwith artist + track + duration. Accepted within ±3 s. - LRCLIB fuzzy —
/api/search, ranked by duration proximity then name similarity. Auto-picked only when unambiguous; otherwise skipped (useytmv lyrics --pickto choose). - YouTube captions — fetched via yt-dlp and converted to LRC.
- Unsynced text — goes into the USLT frame only, never into a
.lrcwith invented timestamps. - Give up: a note on stderr, exit 0. A song with no lyrics on LRCLIB is a fact, not an error.
--lyrics lrclib / youtube / none restrict the chain. Responses are cached for 30 days
under the cache dir; --refresh-lyrics bypasses it.
Why YouTube captions are the fallback, not the primary
Official music videos frequently have no captions at all, and auto-generated ones are
ASR transcripts of singing — often wrong. yt-dlp's own --convert-subs lrc also does no
deduplication of YouTube's rolling caption repetition, so ytmv dedupes the result
itself before writing it.
Cookies¶
ytmv is cookie-free by default and deliberately reuses yth's source only when
--cookies is passed; an explicit flag fails rather than silently falling back when no safe source exists. Configure it in ~/.config/yth/config.toml; a ytmv-local
cookiefile / from_browser remains an explicit override.
A cookie file is a login credential
yt-dlp use with a logged-in account can cause temporary or permanent account suspension.
Never print, paste, screenshot, commit, chezmoi add, or cloud-backup cookie contents.
Prefer a dedicated YouTube-only profile/account, not a daily primary account. The target
~/.config/{yth,ytmv}/cookies.txt are explicitly excluded from chezmoi management.
Every file-backed sink rejects non-0600, malformed, expired/empty, or multi-domain
jars before yt-dlp sees them, and ytmv get accepts only YouTube URL hosts.
Preferred repeat-use source: dedicated Firefox/Zen profile¶
The profile directory must contain cookies.sqlite. Zen is Firefox-compatible and is
auto-detected when installed; use the firefox: prefix, not zen:.
Arc / Chromium: isolated YouTube-only export¶
Arc is not a supported yt-dlp browser name. Do not export a daily browser profile with
--cookies-from-browser … --cookies FILE: that can write cookies for every site.
- Open a private/incognito session and sign in to YouTube.
- Keep exactly one tab: https://www.youtube.com/robots.txt.
- Export only
youtube.comcookies with the exact extension Get cookies.txt LOCALLY. The similarly named former extension without “LOCALLY” was reported as malware. - Save as
~/.config/yth/cookies.txt; runchmod 600 ~/.config/yth/cookies.txt. - Close the private session. Replace/delete the file after expiry or invalidation.
Validate with ytmv doctor --cookies, then opt into the actual URL with
ytmv get '<url>' --cookies.
macOS Chrome: find-generic-password failed, no popup¶
Chrome's handler queries Keychain account Chrome, service Chrome Safe Storage. Diagnose
without printing its secret:
security find-generic-password -a Chrome -s 'Chrome Safe Storage' >/dev/null
echo $?
security error -25300
Exit 44 / OSStatus -25300 means the item is not addressable. Authorization is never
reached, so no popup is expected. cannot decrypt v10 cookies: no key found means yt-dlp
found the DB but cannot derive its key (v10 is an encryption marker, not Chrome version
10). A newly-created same-named item cannot decrypt values written with the missing original.
Use a new dedicated supported-browser profile or the isolated export above. Never add the
Keychain option that prints the Safe Storage value.
Manual web-converter fallback¶
A web converter runs the same broad fetch → audio-extract → transcode pipeline on its own servers, often behind different IP pools/proxies/cache. It may bypass a challenge attached to the local IP, but it does not provide ytmv's ID3/LRC/player-compatibility layer. Never send a converter Google credentials or cookies; it sees the requested URL/IP and may cache the converted file. A “320 kbps” MP3 cannot restore quality absent from YouTube's lossy source.
For normal personal-use fallback, download manually and then process the local file:
ffprobe -v error ~/Downloads/song.mp3
ytmv tag ~/Downloads/song.mp3 --profile safe --artist 'Artist' --track 'Song'
ytmv lyrics ~/Downloads/song.mp3 --artist 'Artist' --track 'Song'
Do not hard-code a site's private web endpoint: interfaces drift, and a service may prohibit
scraping/automation. An automated backend belongs in ytmv only when it has a documented API
permitting CLI use or is an audited self-hosted service. Until then the existing tag +
lyrics path is the backend-neutral fallback.
Config — ~/.config/ytmv/config.toml¶
# profile = "safe" # default profile for every subcommand
# out = "~/Music/ytmv" # default output directory
# ffmpeg = "/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg" # force a specific ffmpeg
# langs = ["en", "zh-Hant"] # caption languages (falls back to yth's)
# cookiefile / from_browser belong in ~/.config/yth/config.toml by default
# Any profile setting may be given here as a cross-profile default:
# cover_max = 200
# Your own profile:
# [profiles.my-player]
# id3_version = 3
# id3_encoding = "raw-big5"
# lrc_encoding = "cp950"
Storage and output layout¶
Resolved via platformdirs (honours XDG_*): config at ~/.config/ytmv/config.toml, cache
at ~/.cache/ytmv/lrclib/. Downloads default to ~/Music/ytmv (user_music_dir(), so
XDG_MUSIC_DIR is respected on Linux); $YTMV_OUT overrides it for tests.
Output is flat — Artist - Track.mp3 plus a matching Artist - Track.lrc. Old players
navigate folders badly, and a flat directory is what you drag onto a card. Write straight to
the device with --out /Volumes/MP3. Filenames are sanitised for FAT32: illegal characters
and control codes stripped, trailing dots/spaces removed, reserved DOS names avoided, length
capped, and the sidecar stem always identical to the audio stem (a player finds the .lrc
no other way).
Cross-file invariants¶
Touching ytmv means keeping these in sync (see the CLAUDE.md cross-file table row):
dot_dotfiles/bin/executable_ytmv— launcher (PEP 723 deps + dict dispatch). Shared helpers inscripts/ytmv/__init__.py; leaf subcommands inscripts/ytmv/*.py.ytmvimportsscripts.yth.cookie_opts/load_configfor its cookie source — changingyth's config schema changesytmv's behaviour.- Public YouTube needs packaged EJS + managed Node — keep
yt-dlp[default]in both launchers andpython_uv_tools, keep theyt-dlp-ejsdistribution guard, mergescripts.yth.yt_dlp_runtime_opts()into every embedded call, and never suppress warnings. --burn-subsdepends onffmpeg-fullbeing indot_ansible/roles/media_tools/tasks/main.yml(macOS). Dropping it silently makes burned-in subtitles impossible;ytmv doctoris the diagnosis.dot_config/zsh/tools/60_ytmv_completion.zsh+dot_config/bash/60_ytmv_completion.bash— keep the two in sync (Strategy B);tests/unit/ytmv.batsenforces it.- Docs: this page,
docs/shells/aliases.md,docs/zsh/zsh-completions.md§ F,docs/this_repo/tool-managers.mdA–Z,mkdocs.ymlnav,dot_agents/skills/chezmoi-dotfiles/SKILL.md.tmpl.
No tv ytmv channel, deliberately. Every Television channel in this repo wraps a fast
local single-shot source; ytmv has no local index to fuzzy-pick over. The one place a
picker fits is LRCLIB disambiguation, which ytmv lyrics --pick handles inline. Revisit
only if a local library view is ever added.
Troubleshooting¶
--burn-subs needs an ffmpeg built with libass— the plain homebrewffmpegcannot burn subtitles.brew install ffmpeg-full(keg-only, safe), or pointffmpeg = "…"at a build that has it.ytmv doctorshows the probe result.ffmpeg not found— re-run chezmoi init withinstallMediaTools=true, or setffmpegin the config.cp950 cannot encode '涙' (U+6D99) on line 1— the lyrics contain characters the target encoding has no room for (Japanese kana in a Big5 file is the usual case). Use--lrc-encoding utf-8, or--lrc-on-unencodable replaceto accept?. Strict is the default so this never happens silently.Sign in to confirm you're not a bot— runytmv doctorand fix EJS/Node first; then leave VPN/proxy/cloud egress, wait before retrying, and raise--sleep. Cookies are an explicit, account-risking last resort (ytmv doctor --cookies), not the first fix.- Wrong lyrics attached — the artist/track guess was off.
ytmv get --jsonreports which metadata rung fired (yt-dlp-music-metadata,title-split,channel-fallback); fix it withytmv lyrics <file> --artist … --track … --force, or--pickto choose. - No lyrics found — the song may not be on LRCLIB, or it is marked instrumental. Try
ytmv lyrics <file> --pickwith a corrected title.