Documentation · Getting started · Playground
Turn messy track names into structured data: who made it, who's featured, who remixed it, and what's just noise.
01. Wilkinson ft. Becky Hill & Tom Cane - Afterglow (Sub Focus VIP Remix) [Official Video]
Most title parsers split that into "Wilkinson ft. Becky Hill & Tom Cane" and
"Afterglow (Sub Focus VIP Remix)" and stop. Others "clean" it and throw the feat and the remix
away. trackparse keeps all of it. Everything it recognises goes into a field, and anything it
doesn't recognise stays in the title, word for word.
This is the Python port of trackparse. Its behaviour is defined by a shared spec and pinned by 12,965 fixtures. It passes all of them, and for every fixture input its output is byte-identical to the JavaScript package.
- Python 3.9 to 3.14
- No dependencies
- Typed (
py.typed), immutable results - Never raises on string input
pip install trackparse
# or
uv add trackparseDocs: Getting started
from trackparse import format_track, parse
track = parse(
"01. Wilkinson ft. Becky Hill & Tom Cane - Afterglow (Sub Focus VIP Remix) [Official Video]"
)
[f"{a.name} ({a.role})" for a in track.artists]
# ['Wilkinson (primary)', 'Becky Hill (featured)', 'Tom Cane (featured)']
track.title # 'Afterglow'
track.full_title # 'Afterglow (Sub Focus VIP Remix)'
track.versions[0].type # 'remix'
track.versions[0].artists[0].name # 'Sub Focus'
track.versions[0].modifiers # ('vip',)
track.junk # (Junk(raw='Official Video', kind='video'),)
track.position # Position(raw='01', number=1)
track.mode # 'youtube'
format_track(track, joiners="canonical")
# 'Wilkinson feat. Becky Hill & Tom Cane - Afterglow (Sub Focus VIP Remix)'to_dict() returns the spec's JSON form, with the same camelCase keys the JS package uses:
import json
print(json.dumps(track.to_dict(), indent=2))Output
{
"input": "01. Wilkinson ft. Becky Hill & Tom Cane - Afterglow (Sub Focus VIP Remix) [Official Video]",
"mode": "youtube",
"position": { "raw": "01", "number": 1 },
"timestamp": null,
"artists": [
{ "name": "Wilkinson", "role": "primary", "joiner": null, "source": "artist" },
{ "name": "Becky Hill", "role": "featured", "joiner": "ft.", "source": "artist" },
{ "name": "Tom Cane", "role": "featured", "joiner": "&", "source": "artist" }
],
"title": "Afterglow",
"fullTitle": "Afterglow (Sub Focus VIP Remix)",
"versions": [
{
"type": "remix",
"raw": "Sub Focus VIP Remix",
"artists": [{ "name": "Sub Focus", "role": "remixer", "joiner": null, "source": "version" }],
"modifiers": ["vip"],
"descriptor": null,
"year": null,
"unknownArtist": false,
"delimiter": "("
}
],
"year": null,
"flags": { "explicit": false, "clean": false, "unknownArtist": false, "unknownTitle": false },
"junk": [{ "raw": "Official Video", "kind": "video" }],
"warnings": []
}(Reformatted for width; the keys and values are exactly what to_dict() returns.)
Docs: Other functions
parse(input, *, mode="auto", uploader=None, known_artists=(), split_and="auto", keywords=None) -> ParsedTrack
parse_artists(input, *, <same options>) -> list[Artist]
format_track(track, *, feat="source", feat_marker=None, joiners="original",
versions="all", producers=True, position=False) -> str
normalize(s) -> str
all_artists(track) -> list[Artist]
create_parser(**options) -> Parser # Parser.parse(...), Parser.parse_artists(...)
SPEC_VERSION, __version__Docs: parse and ParsedTrack · Options
| Option | Default | |
|---|---|---|
mode |
"auto" |
"clean", "youtube", "filename", "tracklist", or "auto" to detect from the string |
uploader |
None |
YouTube channel name, used as the artist when there's no separator (- Topic and VEVO are dropped) |
known_artists |
() |
Names that are never split. There is no built-in list |
split_and |
"auto" |
"auto" splits on and only after a comma (A, B and C), so Simon and Garfunkel stays whole. Also "always", "never" |
keywords |
None |
Extra vocabulary, added to the built-in lists (see below) |
[a.name for a in parse("Chase & Status - Baddadan").artists]
# ['Chase', 'Status']
[a.name for a in parse("Chase & Status - Baddadan", known_artists=["Chase & Status"]).artists]
# ['Chase & Status']
[a.name for a in parse("Simon and Garfunkel - Mrs. Robinson", split_and="always").artists]
# ['Simon', 'Garfunkel']keywords is a dict with any of these keys:
| Key | Type | |
|---|---|---|
version_heads |
dict[str, VersionType] |
Word that sets a version type ({"rerub": "rework"}) |
descriptors |
list[str] |
Words kept as a version's descriptor |
genres |
list[str] |
Genre words |
junk |
dict[str, JunkKind] |
Phrase to junk kind ({"some label rip": "label"}) |
feat_markers |
list[str] |
Extra feat markers |
parse("Artist - Song [Some Label Rip]").title
# 'Song [Some Label Rip]'
parse("Artist - Song [Some Label Rip]", keywords={"junk": {"some label rip": "label"}}).junk
# (Junk(raw='Some Label Rip', kind='label'),)Parsing is total: any str gives a ParsedTrack, including "". Bad option values fall back to
the defaults. Passing something that isn't a str raises TypeError.
Docs: ParsedTrack fields
| Attribute | Type | |
|---|---|---|
input |
str |
The original string, untouched |
mode |
"clean" | "youtube" | "filename" | "tracklist" |
Resolved mode |
position |
Position | None |
Track number: Position(raw='01', number=1) |
timestamp |
Timestamp | None |
Leading cue time: [12:34] gives Timestamp(raw='12:34', seconds=754) |
artists |
tuple[Artist, ...] |
Primary, featured and producer credits. Remixers live in versions |
title |
str |
Clean title. Brackets it doesn't recognise stay in |
full_title |
str |
title with the versions re-attached |
versions |
tuple[Version, ...] |
Remixes and other versions, left to right |
year |
int | None |
From (2015) or - 2015 |
flags |
Flags |
explicit, clean, unknown_artist, unknown_title |
junk |
tuple[Junk, ...] |
What was taken out, each with a kind |
warnings |
tuple[Warning, ...] |
Heuristics that fired |
Artist(name, role, joiner, source):
roleis"primary","featured","remixer"or"producer".joineris the text before the name as written ("&",",","ft.","prod. by"), orNonefor the first name in a list.sourceis where the credit came from:"artist"(left of the dash),"title"(right of it) or"version".
Version(type, raw, artists, modifiers, descriptor, year, unknown_artist, delimiter):
parse("Pendulum - Watercolour (Extended VIP Mix)").versions[0]
# Version(type='vip', raw='Extended VIP Mix', artists=(), modifiers=('extended',),
# descriptor=None, year=None, unknown_artist=False, delimiter='(')
parse("Queen - Bohemian Rhapsody (Live at Wembley 1986)").versions[0]
# Version(type='live', raw='Live at Wembley 1986', artists=(), modifiers=(),
# descriptor='at Wembley', year=1986, unknown_artist=False, delimiter='(')Version types, junk kinds and warnings are listed in the
API reference and typed as Literal aliases
(VersionType, JunkKind, Warning, ArtistRole, ArtistSource, Mode, ModeOption,
VersionDelimiter).
Docs: JSON and Python objects
Every result class has to_dict(), which returns plain dicts and lists matching
parsed-track.schema.json
(fullTitle, unknownArtist, ...). With json.dumps(track.to_dict(), separators=(",", ":"), ensure_ascii=False) the bytes are the same as JSON.stringify(parse(s)) in JS. The single-track classes
(ParsedTrack, Artist, ...) also have a from_dict() classmethod that goes the other way:
ParsedTrack.from_dict(track.to_dict()) == track # TrueDocs: parseArtists
Splits a bare artist string. Takes the same options as parse.
from trackparse import parse_artists
[f"{a.name}:{a.role}" for a in parse_artists("Sub Focus, Wilkinson & Dimension feat. Kojo")]
# ['Sub Focus:primary', 'Wilkinson:primary', 'Dimension:primary', 'Kojo:featured']Docs: format · Formatting
Renders a track back to a string.
| Option | Default | |
|---|---|---|
feat |
"source" |
Where featured artists go: "source" (where they were), "artist", "title" or "omit" |
feat_marker |
None |
Marker for featured artists, such as "feat." |
joiners |
"original" |
"original" keeps the joiners as written, "canonical" uses the standard ones |
versions |
"all" |
"all" or "none" |
producers |
True |
Include (prod. ...) credits |
position |
False |
Include the track number |
from trackparse import format_track
format_track(
parse("Wilkinson ft. Becky Hill - Afterglow (Sub Focus Remix)"),
feat="title",
joiners="canonical",
)
# 'Wilkinson - Afterglow (feat. Becky Hill) (Sub Focus Remix)'
format_track(track, feat="omit", versions="none")
# 'Wilkinson - Afterglow'
format_track(track, position=True)
# '01. Wilkinson ft. Becky Hill & Tom Cane - Afterglow (Sub Focus VIP Remix)'Docs: normalize
The Unicode cleanup every function runs first: NFC, invisible characters, and dash, bracket and quote variants.
from trackparse import normalize
normalize("Noisia – Stigma (VIP)")
# 'Noisia - Stigma (VIP)'Docs: allArtists
Credits plus every remixer, deduplicated.
from trackparse import all_artists
[(a.name, a.role) for a in all_artists(track)]
# [('Wilkinson', 'primary'), ('Becky Hill', 'featured'), ('Tom Cane', 'featured'), ('Sub Focus', 'remixer')]Docs: createParser
Compiles the options once, for many calls. Per-call options are merged over the parser's: a value
passed per call replaces the base one (known_artists included), None leaves it alone, and
keywords dicts are merged.
from trackparse import create_parser
parser = create_parser(
known_artists=["Chase & Status", "Camo & Krooked"],
keywords={"version_heads": {"rerub": "rework"}},
)
parser.parse("Chase & Status x Camo & Krooked - Track (Alix Perez Rerub)").versions[0].type
# 'rework'The spec version this build implements. The PyPI package, the npm package and the spec are released together and always share one version number.
Docs: Modes
| Mode | For | Adds |
|---|---|---|
clean |
Store metadata, tags | Only a spaced dash separates artist and title |
youtube |
Video titles, scrobbles | Pipe segments, "Title" by Artist, Artist: "Title", unspaced dashes, trailing junk, uploader fallback |
filename |
Files | Strips the extension, _ becomes a space, 01-Track numbering |
auto |
Anything | filename for known extensions, youtube when it sees junk, pipes or platform suffixes, clean otherwise |
tracklist |
One line of a tracklist | The first spaced dash always separates; _-_ too. Never picked by auto |
t = parse('"Numb" by Linkin Park', mode="youtube")
[a.name for a in t.artists], t.title, t.warnings
# (['Linkin Park'], 'Numb', ('bySplit',))
t = parse("07_Burial_-_Archangel.mp3")
t.mode, [a.name for a in t.artists], t.title, t.position
# ('filename', ['Burial'], 'Archangel', Position(raw='07', number=7))
parse("Shelter", mode="youtube", uploader="Porter Robinson").artists
# (Artist(name='Porter Robinson', role='primary', joiner=None, source='artist'),)Docs: Tracklists · Editing tracklists
trackparse.tracklist parses a whole tracklist, one entry per line, and checks it. Each entry
keeps its line exactly as written plus its own line ending, so "".join(e.raw + e.eol for e in tl.entries) is always the input, CRLF or not.
from trackparse.tracklist import apply_edits, parse_tracklist
text = "\r\n".join(
[
"Hour 2",
"48:20 Noisia - Stigma",
"52:47 Mefjus & Kasra - Bandwagon (Phace Remix)",
"56:05 Black Sun Empire - ID",
"59:31 Alix Perez - Fade Away",
"01:03:10 Interview with Hybris",
"01:07:44 Imanu - Sacrifice",
"00:11:02 Current Value feat. Teddy Killerz - Payload", # typo for 01:11:02
"01:15:36 Hybris - Spinning Top",
]
)
tl = parse_tracklist(text)
[e.kind for e in tl.entries]
# ['header', 'track', 'track', 'track', 'track', 'marker', 'track', 'track', 'track']
e = tl.entries[2]
e.time # TimeValue(raw='52:47', seconds=3167)
e.parts # Parts(lead='', time='52:47', gap=' ', rest='Mefjus & Kasra - Bandwagon (Phace Remix)')
[a.name for a in e.track.artists], e.track.title, e.track.versions[0].type
# (['Mefjus', 'Kasra'], 'Bandwagon', 'remix')
tl.entries[3].track.flags.unknown_title # True (the ID)
tl.warnings
# (TracklistWarning(code='no-start-at-zero', line=2, severity='info', suggestion=None),
# TracklistWarning(code='time-out-of-order', line=8, severity='warning',
# suggestion=Suggestion(line=8, raw='01:11:02', seconds=4262)))
tl.stats
# Stats(lines=9, tracks=7, markers=1, timed=8, first_time=2900, last_time=4536)The suggestion exists because exactly one time, one digit away from 00:11:02, fits between its
neighbours. With several candidates or none, suggestion is None.
apply_edits takes a list of dicts with the spec's keys (op, line, seconds, lines, from,
after, text, rest) and only rewrites the lines they name:
fix = tl.warnings[1].suggestion
r = apply_edits(
text,
[
{"op": "set-time", "line": fix.line, "seconds": fix.seconds},
{"op": "shift", "seconds": 45, "from": 5},
],
)
r.changed # (5, 6, 7, 8, 9)
r.warnings # (EditWarning(code='style-upgraded', edit=1, line=5),)
r.text.split("\r\n")[4] # '01:00:16 Alix Perez - Fade Away' (was 59:31)
apply_edits(text, [{"op": "shift", "seconds": -3000, "lines": [2, 3]}]).warnings
# (EditWarning(code='time-clamped', edit=0, line=2),)A rewritten time keeps its line's style: 52:47 stays MM:SS, and a time that crosses the hour
gets hours like the rest of the list, with a style-upgraded warning. A bad edit changes nothing
and warns edit-invalid. Neither function raises on str input.
parse_tracklist(text, *, mode="tracklist", known_artists=(), keywords=None, split_and="auto",
audio_seconds=None, marker_words=()) -> Tracklist
apply_edits(text, edits, *, <same options>) -> EditResult| Result | Fields |
|---|---|
Tracklist |
spec_version, entries, warnings, stats |
TracklistEntry |
line (1-based), kind (track, marker, header, link, note, blank), eol, raw, parts, time, track (ParsedTrack or None) |
Parts |
lead, time, gap, rest, joined they give raw |
TracklistWarning |
code, line, severity (info or warning), suggestion |
TimeValue |
raw, seconds (entry.time, or None on untimed lines) |
Suggestion |
line, raw, seconds (warning.suggestion, or None) |
Stats |
lines, tracks, markers, timed, first_time, last_time |
EditResult |
text, changed, warnings (EditWarning(code, edit, line)) |
Warning codes: time-missing, time-out-of-order, time-duplicate, no-start-at-zero,
time-after-audio-end (needs audio_seconds), invalid-time, position-gap,
position-duplicate, line-unparsed. Edits: set-time {line, seconds}, shift {seconds, lines? | from?}
(all timed lines when neither is given), sort-by-time {}, set-rest {line, rest},
insert {after, text} (after: 0 inserts at the top), delete {line}. The
Tracklist API reference explains each one.
to_dict() gives the camelCase JSON from
tracklist.schema.json,
byte-identical to the JS package's output.
Every line is parsed with mode="tracklist", where the first spaced dash always separates artist
and title. parse() accepts it too:
parse("Electrosoul System - Liquid Metal").junk
# (Junk(raw='Liquid Metal', kind='genre'),)
parse("Electrosoul System - Liquid Metal", mode="tracklist").title
# 'Liquid Metal'Docs: Types
The package ships py.typed and passes mypy --strict. ParsedTrack, Artist, Version,
Junk, Flags, Position and Timestamp are frozen dataclasses with snake_case attributes, and
so are the tracklist results in trackparse.tracklist.
Sequences are tuples, so results are immutable and hashable and can go in sets or be used as dict
keys. Option dicts have TypedDict types (ParseOptions, KeywordOptions, FormatOptions);
tracklist edits are plain dicts. trackparse.tracklist also exports the Literal aliases
EntryKind, Eol, Severity, TracklistWarningCode and EditWarningCode.
From python/ in the repository, with
uv:
uv sync
uv run python scripts/gen_data.py --check # spec/data codegen is up to date
uv run ruff check . && uv run ruff format --check .
uv run mypy --strict src
uv run pytest # every shared fixture, properties, API
uv run python scripts/conformance.py # pass rate per fixture file and per ruleModules mirror the JS reference one to one (_scanner.py is scanner.ts, and so on) and cite the
same spec rule IDs. Behaviour changes start in the spec, not here: see
AGENTS.md.
- Documentation, with every example in Python and JavaScript, and a playground that runs the parser in your browser
- Spec
- Project README
- GitHub repository
- Changelog
- npm package