Skip to main content

trackparse

PyPI Python versions python license

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

Install

pip install trackparse
# or
uv add trackparse

Quick start

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

API

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__

parse(input, **options)

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.

ParsedTrack

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

  • role is "primary", "featured", "remixer" or "producer".
  • joiner is the text before the name as written ("&", ",", "ft.", "prod. by"), or None for the first name in a list.
  • source is 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).

to_dict() and from_dict()

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  # True

parse_artists(input, **options)

Docs: 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']

format_track(track, **options)

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

normalize(s)

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

all_artists(track)

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')]

create_parser(**options)

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'

SPEC_VERSION

The spec version this build implements. The PyPI package, the npm package and the spec are released together and always share one version number.

Modes

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'),)

Tracklists

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'

Typing

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.

Development

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 rule

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

License

MIT

Metadata

Release files for trackparse 0.3.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for trackparse 0.3.3
File Size Uploaded
trackparse-0.3.3.tar.gz 59.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for trackparse 0.3.3
File Interpreter ABI Platform
trackparse-0.3.3-py3-none-any.whl Python 3 none any Details

Total release size: 132.5 kB

Release files / trackparse-0.3.3.tar.gz

Download URL trackparse-0.3.3.tar.gz
Size 59.1 kB
Tags Source
SHA-256 checksum
How to use checksums
27ec4902a44ca6865066afd4b95126fcbc1e2cd10c10c0cfaf671f18f1bec5ef
BLAKE2b-256 checksum
How to use checksums
6e065468f1333d2dfd14af0a2b30989d29e31c5c6a6701cb9208e17c91669ca4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.

Transparency log

Release files / trackparse-0.3.3-py3-none-any.whl

Download URL trackparse-0.3.3-py3-none-any.whl
Size 73.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
65c0d36d8789655d13396d8c2c5cc4ed03c745901c033cf7ed9784f6ef058c29
BLAKE2b-256 checksum
How to use checksums
213703137aafe20058075e257072ed00b0a30df82bf0e7f95abcc73d827fa9bb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.3 This release

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.2.1

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page