When you record or archive Twitch streams using yt-dlp, you end up with MP3 files whose names encode everything worth knowing — the streamer, the date, the time, the stream ID — but whose ID3 tags are completely blank. Blank tags mean your music player, media server, or NAS cannot sort, browse, or display the recordings correctly.
twitch-tag-media fixes that. It reads the structured filenames that yt-dlp produces and writes proper ID3v1/v2 tags directly into the MP3 files:
| Tag | Value |
|---|---|
| Artist | The streamer's display name (normalised from their Twitch handle) |
| Album | <Artist> on Twitch |
| Track | The stream timestamp, used as a unique title |
| Year | The year the stream took place |
| Comment | A provenance note pointing back to this tool |
ArtistHandle (type) YYYY-MM-DD HH_MM-StreamID.mp3
YYYY-MM-DD-HH-MM-SS-artisthandle.mp3
Artist handles are normalised automatically: underscores become spaces, noise words like
Official, Music, and dj are stripped, and a growing table of known Twitch handles maps
to the DJ or artist's real display name.
Files are processed concurrently — one child process per file, up to the limit set by
--jobs — so large collections tag quickly. Synology NAS index directories (@eaDir) are
skipped automatically when recursing.
Any feedback on this is welcome. The author is happy to make reasonable adjustments.
twitch-tag-media [--atime <S>] [--ctime <S>] [--force] [--help] [--jobs <N>] [--json] [--mtime <S>] [--noop] [--random] [--recursive] [--verbose] [--version] PATH [PATH...]
twitch-tag-media [-f] [-h] [-j <N>] [-J] [-n] [-R] [-r] [-v] [-V] PATH [PATH...]
Add MP3/MP4 tags to media files downloaded from Twitch using yt-dlp. Each
PATH may be a file or a directory; directories are walked one level deep
unless --recursive is also given.
| Option | Short | Description |
|---|---|---|
--delay <S> |
-d <S> |
Pause for S seconds between files (fractional, e.g. 0.001 for 1 ms) |
--force |
-f |
Rewrite tags even when already up to date |
--help |
-h |
Display this usage information and exit |
--jobs <N> |
-j <N> |
Allow parallel I/O (default 1) |
--json |
-J |
Emit newline-delimited JSON events instead of human-readable text (see below) |
--atime <S> |
Skip files whose atime is too recent (same threshold semantics as --mtime). Not overridden by --force |
|
--ctime <S> |
Skip files whose ctime is too recent (same threshold semantics as --mtime). Not overridden by --force |
|
--mtime <S> |
Skip files whose mtime is too recent: values ≤ 604800 are a maximum file age in seconds; larger values are an absolute Unix timestamp cutoff. Not overridden by --force |
|
--noop |
-n |
Preview the tags which would be written without modifying any files |
--recursive |
-r |
Descend into subdirectories |
--verbose |
-v |
See verbose progress (with elapsed time and ETA), tag information, and a run summary |
--version |
-V |
Print the version number and exit |
--json is intended for front-end applications and scripts that want to consume tagging
progress programmatically — a progress bar UI, a web dashboard, a log aggregator, or any
wrapper that needs structured data rather than human-readable text.
With --json and --verbose active together, twitch-tag-media writes one JSON object per line
to stdout (newline-delimited JSON / JSON Lines format). Each object has a process envelope
and a type-specific payload.
Emitted by the parent process once per file, just before the child is forked. Reports the current percentage, elapsed wall-clock time, and (from the second file onwards) an estimated time to completion based on the file-dispatch rate.
{
"process": { "type": "progress", "pct": 42 },
"file": "/media/streams/artist (live) 2024-06-01 20_30-123456789.mp3",
"elapsed_s": 5.2,
"eta_s": 7.1
}eta_s is absent on the first file because no rate data is available yet.
Emitted when processing begins on a file. Reports the resolved tag fields before any write occurs.
{
"process": { "type": "tag", "pct": 42, "pid": 12345 },
"fields": {
"artist": "DJ Example",
"album": "DJ Example on Twitch",
"track": "2024-06-01 20_30-123456789",
"year": "2024",
"comment": "Generated by github.com/daybologic/twitch-tag-media"
}
}Emitted after comparing the file's existing tags against the values that will be written.
Contains a changes array listing every field that differs. If no fields differ but
--force is set, a message key is added instead.
{
"process": { "type": "changelog", "pct": 42, "pid": 12345 },
"changes": [
{ "field": "artist", "old": "djexample", "new": "DJ Example" },
{ "field": "year", "old": "", "new": "2024" }
]
}pct is the progress percentage (0–100) across the whole run; pid identifies which child
process emitted the event, which is useful when --jobs is greater than 1 and events from
multiple workers are interleaved on stdout.
Emitted once at the end of a run (requires --verbose). Summarises the entire run.
{
"process": { "type": "stats" },
"stats": {
"total_files": 42,
"modified_files": 38,
"skipped_files": 4,
"total_bytes": 1234567890,
"modified_bytes": 1100000000,
"tags_altered": 380,
"elapsed_s": 45.3,
"avg_time_per_file_s": 1.079,
"avg_time_per_mib_s": 0.038
}
}skipped_files counts files whose tags were already correct (no write needed).
tags_altered is the total number of individual tag fields that differed from the existing
values across all modified files. Under --noop, modified_files is always 0 but
tags_altered still reflects what would have changed.
EXPERIMENTAL_PROGRESS=1 twitch-tag-media -d <DIR> ...Setting the EXPERIMENTAL_PROGRESS environment variable enables size-weighted progress
percentages. Instead of advancing by an equal step per file, each file contributes weight
proportional to its size on disk, so large files move the percentage more than small ones.
Feedback welcome.
When contributing to the project, please fork from the GitHub repository and make all contributions based on the master branch, unless you are specifically patching a bug within an historical release, in which case, branch from the relevant rel/ branch.
Please name your branch using this scheme:
| branch | description | FF allowed | rebase allowed |
|---|---|---|---|
| bugfix/<ticket>-<description> | A user bug report, with the ticket number | NO | NO |
| docs/<description> | Documentation changes only | NO | NO |
| feature/<description> | New functionality | NO | NO |
| f/YYYYMM-<description> | Legacy features, please don't create new ones | NO | NO |
| hotfix/<description> | Emergency fixes only | NO | YES |
| maint | Maintainer branches (features for developers) | NO | NO |
| master | Mainline merge point for all features | NO | NO |
| platform/<uname>/base | Specific changes which can't be merged to master | NO | NO |
| private/<user-defined> | Undocumented hierarchy, maintainer-use only | YES | YES |
| rel/X.Y | released 1.0, 2.0, 2.1 etc, which contain specific tags vX.Y.Z | NO | NO |
| refactor/<description> | Not features, design changes | NO | NO |
| tests/<description> | Unit tests, functional tests, sanity improvements | NO | NO |
| translation/<identifier> | Translation work | NO | NO |
| <user>/<hierarchy> | Your GitHub username, followed by recognized hierarchies above | NO | YES |