trackparse 0.2.1__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {trackparse-0.2.1 → trackparse-0.3.2}/.gitignore +3 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/PKG-INFO +131 -12
- {trackparse-0.2.1 → trackparse-0.3.2}/README.md +127 -9
- {trackparse-0.2.1 → trackparse-0.3.2}/pyproject.toml +4 -3
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/__init__.py +1 -1
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_data.py +45 -1
- trackparse-0.3.2/src/trackparse/_digits.py +27 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_mode.py +2 -1
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_parser.py +84 -7
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_prefix.py +3 -19
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_separator.py +59 -1
- trackparse-0.3.2/src/trackparse/_tracklist_edits.py +378 -0
- trackparse-0.3.2/src/trackparse/_tracklist_kinds.py +108 -0
- trackparse-0.3.2/src/trackparse/_tracklist_options.py +53 -0
- trackparse-0.3.2/src/trackparse/_tracklist_parse.py +167 -0
- trackparse-0.3.2/src/trackparse/_tracklist_positions.py +60 -0
- trackparse-0.3.2/src/trackparse/_tracklist_split.py +43 -0
- trackparse-0.3.2/src/trackparse/_tracklist_times.py +230 -0
- trackparse-0.3.2/src/trackparse/_tracklist_types.py +186 -0
- trackparse-0.3.2/src/trackparse/_tracklist_warnings.py +224 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_words.py +5 -0
- trackparse-0.3.2/src/trackparse/tracklist.py +99 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/types.py +3 -2
- {trackparse-0.2.1 → trackparse-0.3.2}/LICENSE +0 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_artists.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_assemble.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_classify.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_context.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_credits.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_format.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_normalize.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_scanner.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_tables.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_title.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/options.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/py.typed +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: trackparse
|
|
3
|
-
Version: 0.2
|
|
4
|
-
Summary: Spec-driven parser for music track titles: artists, feat, remixes, versions, junk, positions.
|
|
5
|
-
Project-URL: Homepage, https://github.
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Spec-driven parser for music track titles and tracklists: artists, feat, remixes, versions, junk, positions, times.
|
|
5
|
+
Project-URL: Homepage, https://4matic.github.io/trackparse/
|
|
6
|
+
Project-URL: Documentation, https://4matic.github.io/trackparse/guides/python/
|
|
6
7
|
Project-URL: Repository, https://github.com/4matic/trackparse
|
|
7
8
|
Project-URL: Issues, https://github.com/4matic/trackparse/issues
|
|
8
9
|
Project-URL: Changelog, https://github.com/4matic/trackparse/blob/main/python/CHANGELOG.md
|
|
@@ -37,6 +38,8 @@ Description-Content-Type: text/markdown
|
|
|
37
38
|
[](https://github.com/4matic/trackparse/actions/workflows/python.yml)
|
|
38
39
|
[](https://github.com/4matic/trackparse/blob/main/LICENSE)
|
|
39
40
|
|
|
41
|
+
**[Documentation](https://4matic.github.io/trackparse/)** · **[Python guide](https://4matic.github.io/trackparse/guides/python/)** · **[Playground](https://4matic.github.io/trackparse/playground/)**
|
|
42
|
+
|
|
40
43
|
**Turn messy track names into structured data: who made it, who's featured, who remixed it, and
|
|
41
44
|
what's just noise.**
|
|
42
45
|
|
|
@@ -51,7 +54,7 @@ doesn't recognise stays in the title, word for word.
|
|
|
51
54
|
|
|
52
55
|
This is the Python port of [trackparse](https://github.com/4matic/trackparse). Its behaviour is
|
|
53
56
|
defined by a [shared spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md) and pinned
|
|
54
|
-
by
|
|
57
|
+
by 12,965 fixtures. It passes all of them, and for every fixture input its output is byte-identical
|
|
55
58
|
to the JavaScript package.
|
|
56
59
|
|
|
57
60
|
- Python 3.9 to 3.14
|
|
@@ -155,7 +158,7 @@ SPEC_VERSION, __version__
|
|
|
155
158
|
|
|
156
159
|
| Option | Default | |
|
|
157
160
|
|---|---|---|
|
|
158
|
-
| `mode` | `"auto"` | `"clean"`, `"youtube"`, `"filename"`, or `"auto"` to detect from the string |
|
|
161
|
+
| `mode` | `"auto"` | `"clean"`, `"youtube"`, `"filename"`, `"tracklist"`, or `"auto"` to detect from the string |
|
|
159
162
|
| `uploader` | `None` | YouTube channel name, used as the artist when there's no separator (`- Topic` and `VEVO` are dropped) |
|
|
160
163
|
| `known_artists` | `()` | Names that are never split. There is no built-in list |
|
|
161
164
|
| `split_and` | `"auto"` | `"auto"` splits on `and` only after a comma (`A, B and C`), so `Simon and Garfunkel` stays whole. Also `"always"`, `"never"` |
|
|
@@ -196,7 +199,7 @@ the defaults. Passing something that isn't a `str` raises `TypeError`.
|
|
|
196
199
|
| Attribute | Type | |
|
|
197
200
|
|---|---|---|
|
|
198
201
|
| `input` | `str` | The original string, untouched |
|
|
199
|
-
| `mode` | `"clean" \| "youtube" \| "filename"` | Resolved mode |
|
|
202
|
+
| `mode` | `"clean" \| "youtube" \| "filename" \| "tracklist"` | Resolved mode |
|
|
200
203
|
| `position` | `Position \| None` | Track number: `Position(raw='01', number=1)` |
|
|
201
204
|
| `timestamp` | `Timestamp \| None` | Leading cue time: `[12:34]` gives `Timestamp(raw='12:34', seconds=754)` |
|
|
202
205
|
| `artists` | `tuple[Artist, ...]` | Primary, featured and producer credits. Remixers live in `versions` |
|
|
@@ -229,7 +232,7 @@ parse("Queen - Bohemian Rhapsody (Live at Wembley 1986)").versions[0]
|
|
|
229
232
|
```
|
|
230
233
|
|
|
231
234
|
Version types, junk kinds and warnings are listed in the
|
|
232
|
-
[
|
|
235
|
+
[API reference](https://4matic.github.io/trackparse/reference/parse/) and typed as `Literal` aliases
|
|
233
236
|
(`VersionType`, `JunkKind`, `Warning`, `ArtistRole`, `ArtistSource`, `Mode`, `ModeOption`,
|
|
234
237
|
`VersionDelimiter`).
|
|
235
238
|
|
|
@@ -238,8 +241,8 @@ Version types, junk kinds and warnings are listed in the
|
|
|
238
241
|
Every result class has `to_dict()`, which returns plain dicts and lists matching
|
|
239
242
|
[`parsed-track.schema.json`](https://github.com/4matic/trackparse/blob/main/spec/schema/parsed-track.schema.json)
|
|
240
243
|
(`fullTitle`, `unknownArtist`, ...). With `json.dumps(track.to_dict(), separators=(",", ":"),
|
|
241
|
-
ensure_ascii=False)` the bytes are the same as `JSON.stringify(parse(s))` in JS. The
|
|
242
|
-
classmethod goes the other way:
|
|
244
|
+
ensure_ascii=False)` the bytes are the same as `JSON.stringify(parse(s))` in JS. The single-track classes
|
|
245
|
+
(`ParsedTrack`, `Artist`, ...) also have a `from_dict()` classmethod that goes the other way:
|
|
243
246
|
|
|
244
247
|
```python
|
|
245
248
|
ParsedTrack.from_dict(track.to_dict()) == track # True
|
|
@@ -338,6 +341,7 @@ released together and always share one version number.
|
|
|
338
341
|
| `youtube` | Video titles, scrobbles | Pipe segments, `"Title" by Artist`, `Artist: "Title"`, unspaced dashes, trailing junk, uploader fallback |
|
|
339
342
|
| `filename` | Files | Strips the extension, `_` becomes a space, `01-Track` numbering |
|
|
340
343
|
| `auto` | Anything | `filename` for known extensions, `youtube` when it sees junk, pipes or platform suffixes, `clean` otherwise |
|
|
344
|
+
| `tracklist` | One line of a tracklist | The first spaced dash always separates; `_-_` too. Never picked by `auto` |
|
|
341
345
|
|
|
342
346
|
```python
|
|
343
347
|
t = parse('"Numb" by Linkin Park', mode="youtube")
|
|
@@ -352,12 +356,124 @@ parse("Shelter", mode="youtube", uploader="Porter Robinson").artists
|
|
|
352
356
|
# (Artist(name='Porter Robinson', role='primary', joiner=None, source='artist'),)
|
|
353
357
|
```
|
|
354
358
|
|
|
359
|
+
## Tracklists
|
|
360
|
+
|
|
361
|
+
`trackparse.tracklist` parses a whole tracklist, one entry per line, and checks it. Each entry
|
|
362
|
+
keeps its line exactly as written plus its own line ending, so `"".join(e.raw + e.eol for e in
|
|
363
|
+
tl.entries)` is always the input, CRLF or not.
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
from trackparse.tracklist import apply_edits, parse_tracklist
|
|
367
|
+
|
|
368
|
+
text = "\r\n".join(
|
|
369
|
+
[
|
|
370
|
+
"Hour 2",
|
|
371
|
+
"48:20 Noisia - Stigma",
|
|
372
|
+
"52:47 Mefjus & Kasra - Bandwagon (Phace Remix)",
|
|
373
|
+
"56:05 Black Sun Empire - ID",
|
|
374
|
+
"59:31 Alix Perez - Fade Away",
|
|
375
|
+
"01:03:10 Interview with Hybris",
|
|
376
|
+
"01:07:44 Imanu - Sacrifice",
|
|
377
|
+
"00:11:02 Current Value feat. Teddy Killerz - Payload", # typo for 01:11:02
|
|
378
|
+
"01:15:36 Hybris - Spinning Top",
|
|
379
|
+
]
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
tl = parse_tracklist(text)
|
|
383
|
+
|
|
384
|
+
[e.kind for e in tl.entries]
|
|
385
|
+
# ['header', 'track', 'track', 'track', 'track', 'marker', 'track', 'track', 'track']
|
|
386
|
+
|
|
387
|
+
e = tl.entries[2]
|
|
388
|
+
e.time # TimeValue(raw='52:47', seconds=3167)
|
|
389
|
+
e.parts # Parts(lead='', time='52:47', gap=' ', rest='Mefjus & Kasra - Bandwagon (Phace Remix)')
|
|
390
|
+
[a.name for a in e.track.artists], e.track.title, e.track.versions[0].type
|
|
391
|
+
# (['Mefjus', 'Kasra'], 'Bandwagon', 'remix')
|
|
392
|
+
tl.entries[3].track.flags.unknown_title # True (the ID)
|
|
393
|
+
|
|
394
|
+
tl.warnings
|
|
395
|
+
# (TracklistWarning(code='no-start-at-zero', line=2, severity='info', suggestion=None),
|
|
396
|
+
# TracklistWarning(code='time-out-of-order', line=8, severity='warning',
|
|
397
|
+
# suggestion=Suggestion(line=8, raw='01:11:02', seconds=4262)))
|
|
398
|
+
|
|
399
|
+
tl.stats
|
|
400
|
+
# Stats(lines=9, tracks=7, markers=1, timed=8, first_time=2900, last_time=4536)
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
The suggestion exists because exactly one time, one digit away from `00:11:02`, fits between its
|
|
404
|
+
neighbours. With several candidates or none, `suggestion` is `None`.
|
|
405
|
+
|
|
406
|
+
`apply_edits` takes a list of dicts with the spec's keys (`op`, `line`, `seconds`, `lines`, `from`,
|
|
407
|
+
`after`, `text`, `rest`) and only rewrites the lines they name:
|
|
408
|
+
|
|
409
|
+
```python
|
|
410
|
+
fix = tl.warnings[1].suggestion
|
|
411
|
+
r = apply_edits(
|
|
412
|
+
text,
|
|
413
|
+
[
|
|
414
|
+
{"op": "set-time", "line": fix.line, "seconds": fix.seconds},
|
|
415
|
+
{"op": "shift", "seconds": 45, "from": 5},
|
|
416
|
+
],
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
r.changed # (5, 6, 7, 8, 9)
|
|
420
|
+
r.warnings # (EditWarning(code='style-upgraded', edit=1, line=5),)
|
|
421
|
+
r.text.split("\r\n")[4] # '01:00:16 Alix Perez - Fade Away' (was 59:31)
|
|
422
|
+
|
|
423
|
+
apply_edits(text, [{"op": "shift", "seconds": -3000, "lines": [2, 3]}]).warnings
|
|
424
|
+
# (EditWarning(code='time-clamped', edit=0, line=2),)
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
A rewritten time keeps its line's style: `52:47` stays `MM:SS`, and a time that crosses the hour
|
|
428
|
+
gets hours like the rest of the list, with a `style-upgraded` warning. A bad edit changes nothing
|
|
429
|
+
and warns `edit-invalid`. Neither function raises on `str` input.
|
|
430
|
+
|
|
431
|
+
```python
|
|
432
|
+
parse_tracklist(text, *, mode="tracklist", known_artists=(), keywords=None, split_and="auto",
|
|
433
|
+
audio_seconds=None, marker_words=()) -> Tracklist
|
|
434
|
+
apply_edits(text, edits, *, <same options>) -> EditResult
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
| Result | Fields |
|
|
438
|
+
|---|---|
|
|
439
|
+
| `Tracklist` | `spec_version`, `entries`, `warnings`, `stats` |
|
|
440
|
+
| `TracklistEntry` | `line` (1-based), `kind` (`track`, `marker`, `header`, `link`, `note`, `blank`), `eol`, `raw`, `parts`, `time`, `track` (`ParsedTrack` or `None`) |
|
|
441
|
+
| `Parts` | `lead`, `time`, `gap`, `rest`, joined they give `raw` |
|
|
442
|
+
| `TracklistWarning` | `code`, `line`, `severity` (`info` or `warning`), `suggestion` |
|
|
443
|
+
| `TimeValue` | `raw`, `seconds` (`entry.time`, or `None` on untimed lines) |
|
|
444
|
+
| `Suggestion` | `line`, `raw`, `seconds` (`warning.suggestion`, or `None`) |
|
|
445
|
+
| `Stats` | `lines`, `tracks`, `markers`, `timed`, `first_time`, `last_time` |
|
|
446
|
+
| `EditResult` | `text`, `changed`, `warnings` (`EditWarning(code, edit, line)`) |
|
|
447
|
+
|
|
448
|
+
Warning codes: `time-missing`, `time-out-of-order`, `time-duplicate`, `no-start-at-zero`,
|
|
449
|
+
`time-after-audio-end` (needs `audio_seconds`), `invalid-time`, `position-gap`,
|
|
450
|
+
`position-duplicate`, `line-unparsed`. Edits: `set-time {line, seconds}`, `shift {seconds, lines? | from?}`
|
|
451
|
+
(all timed lines when neither is given), `sort-by-time {}`, `set-rest {line, rest}`,
|
|
452
|
+
`insert {after, text}` (`after: 0` inserts at the top), `delete {line}`. The
|
|
453
|
+
[Tracklist API reference](https://4matic.github.io/trackparse/reference/tracklist/) explains each one.
|
|
454
|
+
`to_dict()` gives the camelCase JSON from
|
|
455
|
+
[`tracklist.schema.json`](https://github.com/4matic/trackparse/blob/main/spec/schema/tracklist.schema.json),
|
|
456
|
+
byte-identical to the JS package's output.
|
|
457
|
+
|
|
458
|
+
Every line is parsed with `mode="tracklist"`, where the first spaced dash always separates artist
|
|
459
|
+
and title. `parse()` accepts it too:
|
|
460
|
+
|
|
461
|
+
```python
|
|
462
|
+
parse("Electrosoul System - Liquid Metal").junk
|
|
463
|
+
# (Junk(raw='Liquid Metal', kind='genre'),)
|
|
464
|
+
parse("Electrosoul System - Liquid Metal", mode="tracklist").title
|
|
465
|
+
# 'Liquid Metal'
|
|
466
|
+
```
|
|
467
|
+
|
|
355
468
|
## Typing
|
|
356
469
|
|
|
357
470
|
The package ships `py.typed` and passes `mypy --strict`. `ParsedTrack`, `Artist`, `Version`,
|
|
358
|
-
`Junk`, `Flags`, `Position` and `Timestamp` are frozen dataclasses with snake_case attributes
|
|
471
|
+
`Junk`, `Flags`, `Position` and `Timestamp` are frozen dataclasses with snake_case attributes, and
|
|
472
|
+
so are the tracklist results in `trackparse.tracklist`.
|
|
359
473
|
Sequences are tuples, so results are immutable and hashable and can go in sets or be used as dict
|
|
360
|
-
keys. Option dicts have `TypedDict` types (`ParseOptions`, `KeywordOptions`, `FormatOptions`)
|
|
474
|
+
keys. Option dicts have `TypedDict` types (`ParseOptions`, `KeywordOptions`, `FormatOptions`);
|
|
475
|
+
tracklist edits are plain dicts. `trackparse.tracklist` also exports the `Literal` aliases
|
|
476
|
+
`EntryKind`, `Eol`, `Severity`, `TracklistWarningCode` and `EditWarningCode`.
|
|
361
477
|
|
|
362
478
|
## Development
|
|
363
479
|
|
|
@@ -379,8 +495,11 @@ same spec rule IDs. Behaviour changes start in the spec, not here: see
|
|
|
379
495
|
|
|
380
496
|
## Links
|
|
381
497
|
|
|
498
|
+
- [Documentation](https://4matic.github.io/trackparse/), with a [Python guide](https://4matic.github.io/trackparse/guides/python/) and a
|
|
499
|
+
[playground](https://4matic.github.io/trackparse/playground/) that runs the parser in your browser
|
|
382
500
|
- [Spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md)
|
|
383
|
-
- [Project README](https://github.com/4matic/trackparse#readme)
|
|
501
|
+
- [Project README](https://github.com/4matic/trackparse#readme)
|
|
502
|
+
- [GitHub repository](https://github.com/4matic/trackparse)
|
|
384
503
|
- [Changelog](https://github.com/4matic/trackparse/blob/main/python/CHANGELOG.md)
|
|
385
504
|
- [npm package](https://www.npmjs.com/package/trackparse)
|
|
386
505
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
[](https://github.com/4matic/trackparse/actions/workflows/python.yml)
|
|
6
6
|
[](https://github.com/4matic/trackparse/blob/main/LICENSE)
|
|
7
7
|
|
|
8
|
+
**[Documentation](https://4matic.github.io/trackparse/)** · **[Python guide](https://4matic.github.io/trackparse/guides/python/)** · **[Playground](https://4matic.github.io/trackparse/playground/)**
|
|
9
|
+
|
|
8
10
|
**Turn messy track names into structured data: who made it, who's featured, who remixed it, and
|
|
9
11
|
what's just noise.**
|
|
10
12
|
|
|
@@ -19,7 +21,7 @@ doesn't recognise stays in the title, word for word.
|
|
|
19
21
|
|
|
20
22
|
This is the Python port of [trackparse](https://github.com/4matic/trackparse). Its behaviour is
|
|
21
23
|
defined by a [shared spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md) and pinned
|
|
22
|
-
by
|
|
24
|
+
by 12,965 fixtures. It passes all of them, and for every fixture input its output is byte-identical
|
|
23
25
|
to the JavaScript package.
|
|
24
26
|
|
|
25
27
|
- Python 3.9 to 3.14
|
|
@@ -123,7 +125,7 @@ SPEC_VERSION, __version__
|
|
|
123
125
|
|
|
124
126
|
| Option | Default | |
|
|
125
127
|
|---|---|---|
|
|
126
|
-
| `mode` | `"auto"` | `"clean"`, `"youtube"`, `"filename"`, or `"auto"` to detect from the string |
|
|
128
|
+
| `mode` | `"auto"` | `"clean"`, `"youtube"`, `"filename"`, `"tracklist"`, or `"auto"` to detect from the string |
|
|
127
129
|
| `uploader` | `None` | YouTube channel name, used as the artist when there's no separator (`- Topic` and `VEVO` are dropped) |
|
|
128
130
|
| `known_artists` | `()` | Names that are never split. There is no built-in list |
|
|
129
131
|
| `split_and` | `"auto"` | `"auto"` splits on `and` only after a comma (`A, B and C`), so `Simon and Garfunkel` stays whole. Also `"always"`, `"never"` |
|
|
@@ -164,7 +166,7 @@ the defaults. Passing something that isn't a `str` raises `TypeError`.
|
|
|
164
166
|
| Attribute | Type | |
|
|
165
167
|
|---|---|---|
|
|
166
168
|
| `input` | `str` | The original string, untouched |
|
|
167
|
-
| `mode` | `"clean" \| "youtube" \| "filename"` | Resolved mode |
|
|
169
|
+
| `mode` | `"clean" \| "youtube" \| "filename" \| "tracklist"` | Resolved mode |
|
|
168
170
|
| `position` | `Position \| None` | Track number: `Position(raw='01', number=1)` |
|
|
169
171
|
| `timestamp` | `Timestamp \| None` | Leading cue time: `[12:34]` gives `Timestamp(raw='12:34', seconds=754)` |
|
|
170
172
|
| `artists` | `tuple[Artist, ...]` | Primary, featured and producer credits. Remixers live in `versions` |
|
|
@@ -197,7 +199,7 @@ parse("Queen - Bohemian Rhapsody (Live at Wembley 1986)").versions[0]
|
|
|
197
199
|
```
|
|
198
200
|
|
|
199
201
|
Version types, junk kinds and warnings are listed in the
|
|
200
|
-
[
|
|
202
|
+
[API reference](https://4matic.github.io/trackparse/reference/parse/) and typed as `Literal` aliases
|
|
201
203
|
(`VersionType`, `JunkKind`, `Warning`, `ArtistRole`, `ArtistSource`, `Mode`, `ModeOption`,
|
|
202
204
|
`VersionDelimiter`).
|
|
203
205
|
|
|
@@ -206,8 +208,8 @@ Version types, junk kinds and warnings are listed in the
|
|
|
206
208
|
Every result class has `to_dict()`, which returns plain dicts and lists matching
|
|
207
209
|
[`parsed-track.schema.json`](https://github.com/4matic/trackparse/blob/main/spec/schema/parsed-track.schema.json)
|
|
208
210
|
(`fullTitle`, `unknownArtist`, ...). With `json.dumps(track.to_dict(), separators=(",", ":"),
|
|
209
|
-
ensure_ascii=False)` the bytes are the same as `JSON.stringify(parse(s))` in JS. The
|
|
210
|
-
classmethod goes the other way:
|
|
211
|
+
ensure_ascii=False)` the bytes are the same as `JSON.stringify(parse(s))` in JS. The single-track classes
|
|
212
|
+
(`ParsedTrack`, `Artist`, ...) also have a `from_dict()` classmethod that goes the other way:
|
|
211
213
|
|
|
212
214
|
```python
|
|
213
215
|
ParsedTrack.from_dict(track.to_dict()) == track # True
|
|
@@ -306,6 +308,7 @@ released together and always share one version number.
|
|
|
306
308
|
| `youtube` | Video titles, scrobbles | Pipe segments, `"Title" by Artist`, `Artist: "Title"`, unspaced dashes, trailing junk, uploader fallback |
|
|
307
309
|
| `filename` | Files | Strips the extension, `_` becomes a space, `01-Track` numbering |
|
|
308
310
|
| `auto` | Anything | `filename` for known extensions, `youtube` when it sees junk, pipes or platform suffixes, `clean` otherwise |
|
|
311
|
+
| `tracklist` | One line of a tracklist | The first spaced dash always separates; `_-_` too. Never picked by `auto` |
|
|
309
312
|
|
|
310
313
|
```python
|
|
311
314
|
t = parse('"Numb" by Linkin Park', mode="youtube")
|
|
@@ -320,12 +323,124 @@ parse("Shelter", mode="youtube", uploader="Porter Robinson").artists
|
|
|
320
323
|
# (Artist(name='Porter Robinson', role='primary', joiner=None, source='artist'),)
|
|
321
324
|
```
|
|
322
325
|
|
|
326
|
+
## Tracklists
|
|
327
|
+
|
|
328
|
+
`trackparse.tracklist` parses a whole tracklist, one entry per line, and checks it. Each entry
|
|
329
|
+
keeps its line exactly as written plus its own line ending, so `"".join(e.raw + e.eol for e in
|
|
330
|
+
tl.entries)` is always the input, CRLF or not.
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
from trackparse.tracklist import apply_edits, parse_tracklist
|
|
334
|
+
|
|
335
|
+
text = "\r\n".join(
|
|
336
|
+
[
|
|
337
|
+
"Hour 2",
|
|
338
|
+
"48:20 Noisia - Stigma",
|
|
339
|
+
"52:47 Mefjus & Kasra - Bandwagon (Phace Remix)",
|
|
340
|
+
"56:05 Black Sun Empire - ID",
|
|
341
|
+
"59:31 Alix Perez - Fade Away",
|
|
342
|
+
"01:03:10 Interview with Hybris",
|
|
343
|
+
"01:07:44 Imanu - Sacrifice",
|
|
344
|
+
"00:11:02 Current Value feat. Teddy Killerz - Payload", # typo for 01:11:02
|
|
345
|
+
"01:15:36 Hybris - Spinning Top",
|
|
346
|
+
]
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
tl = parse_tracklist(text)
|
|
350
|
+
|
|
351
|
+
[e.kind for e in tl.entries]
|
|
352
|
+
# ['header', 'track', 'track', 'track', 'track', 'marker', 'track', 'track', 'track']
|
|
353
|
+
|
|
354
|
+
e = tl.entries[2]
|
|
355
|
+
e.time # TimeValue(raw='52:47', seconds=3167)
|
|
356
|
+
e.parts # Parts(lead='', time='52:47', gap=' ', rest='Mefjus & Kasra - Bandwagon (Phace Remix)')
|
|
357
|
+
[a.name for a in e.track.artists], e.track.title, e.track.versions[0].type
|
|
358
|
+
# (['Mefjus', 'Kasra'], 'Bandwagon', 'remix')
|
|
359
|
+
tl.entries[3].track.flags.unknown_title # True (the ID)
|
|
360
|
+
|
|
361
|
+
tl.warnings
|
|
362
|
+
# (TracklistWarning(code='no-start-at-zero', line=2, severity='info', suggestion=None),
|
|
363
|
+
# TracklistWarning(code='time-out-of-order', line=8, severity='warning',
|
|
364
|
+
# suggestion=Suggestion(line=8, raw='01:11:02', seconds=4262)))
|
|
365
|
+
|
|
366
|
+
tl.stats
|
|
367
|
+
# Stats(lines=9, tracks=7, markers=1, timed=8, first_time=2900, last_time=4536)
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
The suggestion exists because exactly one time, one digit away from `00:11:02`, fits between its
|
|
371
|
+
neighbours. With several candidates or none, `suggestion` is `None`.
|
|
372
|
+
|
|
373
|
+
`apply_edits` takes a list of dicts with the spec's keys (`op`, `line`, `seconds`, `lines`, `from`,
|
|
374
|
+
`after`, `text`, `rest`) and only rewrites the lines they name:
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
fix = tl.warnings[1].suggestion
|
|
378
|
+
r = apply_edits(
|
|
379
|
+
text,
|
|
380
|
+
[
|
|
381
|
+
{"op": "set-time", "line": fix.line, "seconds": fix.seconds},
|
|
382
|
+
{"op": "shift", "seconds": 45, "from": 5},
|
|
383
|
+
],
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
r.changed # (5, 6, 7, 8, 9)
|
|
387
|
+
r.warnings # (EditWarning(code='style-upgraded', edit=1, line=5),)
|
|
388
|
+
r.text.split("\r\n")[4] # '01:00:16 Alix Perez - Fade Away' (was 59:31)
|
|
389
|
+
|
|
390
|
+
apply_edits(text, [{"op": "shift", "seconds": -3000, "lines": [2, 3]}]).warnings
|
|
391
|
+
# (EditWarning(code='time-clamped', edit=0, line=2),)
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
A rewritten time keeps its line's style: `52:47` stays `MM:SS`, and a time that crosses the hour
|
|
395
|
+
gets hours like the rest of the list, with a `style-upgraded` warning. A bad edit changes nothing
|
|
396
|
+
and warns `edit-invalid`. Neither function raises on `str` input.
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
parse_tracklist(text, *, mode="tracklist", known_artists=(), keywords=None, split_and="auto",
|
|
400
|
+
audio_seconds=None, marker_words=()) -> Tracklist
|
|
401
|
+
apply_edits(text, edits, *, <same options>) -> EditResult
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
| Result | Fields |
|
|
405
|
+
|---|---|
|
|
406
|
+
| `Tracklist` | `spec_version`, `entries`, `warnings`, `stats` |
|
|
407
|
+
| `TracklistEntry` | `line` (1-based), `kind` (`track`, `marker`, `header`, `link`, `note`, `blank`), `eol`, `raw`, `parts`, `time`, `track` (`ParsedTrack` or `None`) |
|
|
408
|
+
| `Parts` | `lead`, `time`, `gap`, `rest`, joined they give `raw` |
|
|
409
|
+
| `TracklistWarning` | `code`, `line`, `severity` (`info` or `warning`), `suggestion` |
|
|
410
|
+
| `TimeValue` | `raw`, `seconds` (`entry.time`, or `None` on untimed lines) |
|
|
411
|
+
| `Suggestion` | `line`, `raw`, `seconds` (`warning.suggestion`, or `None`) |
|
|
412
|
+
| `Stats` | `lines`, `tracks`, `markers`, `timed`, `first_time`, `last_time` |
|
|
413
|
+
| `EditResult` | `text`, `changed`, `warnings` (`EditWarning(code, edit, line)`) |
|
|
414
|
+
|
|
415
|
+
Warning codes: `time-missing`, `time-out-of-order`, `time-duplicate`, `no-start-at-zero`,
|
|
416
|
+
`time-after-audio-end` (needs `audio_seconds`), `invalid-time`, `position-gap`,
|
|
417
|
+
`position-duplicate`, `line-unparsed`. Edits: `set-time {line, seconds}`, `shift {seconds, lines? | from?}`
|
|
418
|
+
(all timed lines when neither is given), `sort-by-time {}`, `set-rest {line, rest}`,
|
|
419
|
+
`insert {after, text}` (`after: 0` inserts at the top), `delete {line}`. The
|
|
420
|
+
[Tracklist API reference](https://4matic.github.io/trackparse/reference/tracklist/) explains each one.
|
|
421
|
+
`to_dict()` gives the camelCase JSON from
|
|
422
|
+
[`tracklist.schema.json`](https://github.com/4matic/trackparse/blob/main/spec/schema/tracklist.schema.json),
|
|
423
|
+
byte-identical to the JS package's output.
|
|
424
|
+
|
|
425
|
+
Every line is parsed with `mode="tracklist"`, where the first spaced dash always separates artist
|
|
426
|
+
and title. `parse()` accepts it too:
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
parse("Electrosoul System - Liquid Metal").junk
|
|
430
|
+
# (Junk(raw='Liquid Metal', kind='genre'),)
|
|
431
|
+
parse("Electrosoul System - Liquid Metal", mode="tracklist").title
|
|
432
|
+
# 'Liquid Metal'
|
|
433
|
+
```
|
|
434
|
+
|
|
323
435
|
## Typing
|
|
324
436
|
|
|
325
437
|
The package ships `py.typed` and passes `mypy --strict`. `ParsedTrack`, `Artist`, `Version`,
|
|
326
|
-
`Junk`, `Flags`, `Position` and `Timestamp` are frozen dataclasses with snake_case attributes
|
|
438
|
+
`Junk`, `Flags`, `Position` and `Timestamp` are frozen dataclasses with snake_case attributes, and
|
|
439
|
+
so are the tracklist results in `trackparse.tracklist`.
|
|
327
440
|
Sequences are tuples, so results are immutable and hashable and can go in sets or be used as dict
|
|
328
|
-
keys. Option dicts have `TypedDict` types (`ParseOptions`, `KeywordOptions`, `FormatOptions`)
|
|
441
|
+
keys. Option dicts have `TypedDict` types (`ParseOptions`, `KeywordOptions`, `FormatOptions`);
|
|
442
|
+
tracklist edits are plain dicts. `trackparse.tracklist` also exports the `Literal` aliases
|
|
443
|
+
`EntryKind`, `Eol`, `Severity`, `TracklistWarningCode` and `EditWarningCode`.
|
|
329
444
|
|
|
330
445
|
## Development
|
|
331
446
|
|
|
@@ -347,8 +462,11 @@ same spec rule IDs. Behaviour changes start in the spec, not here: see
|
|
|
347
462
|
|
|
348
463
|
## Links
|
|
349
464
|
|
|
465
|
+
- [Documentation](https://4matic.github.io/trackparse/), with a [Python guide](https://4matic.github.io/trackparse/guides/python/) and a
|
|
466
|
+
[playground](https://4matic.github.io/trackparse/playground/) that runs the parser in your browser
|
|
350
467
|
- [Spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md)
|
|
351
|
-
- [Project README](https://github.com/4matic/trackparse#readme)
|
|
468
|
+
- [Project README](https://github.com/4matic/trackparse#readme)
|
|
469
|
+
- [GitHub repository](https://github.com/4matic/trackparse)
|
|
352
470
|
- [Changelog](https://github.com/4matic/trackparse/blob/main/python/CHANGELOG.md)
|
|
353
471
|
- [npm package](https://www.npmjs.com/package/trackparse)
|
|
354
472
|
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "trackparse"
|
|
7
|
-
version = "0.2
|
|
8
|
-
description = "Spec-driven parser for music track titles: artists, feat, remixes, versions, junk, positions."
|
|
7
|
+
version = "0.3.2"
|
|
8
|
+
description = "Spec-driven parser for music track titles and tracklists: artists, feat, remixes, versions, junk, positions, times."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = "MIT"
|
|
@@ -44,7 +44,8 @@ classifiers = [
|
|
|
44
44
|
dependencies = []
|
|
45
45
|
|
|
46
46
|
[project.urls]
|
|
47
|
-
Homepage = "https://github.
|
|
47
|
+
Homepage = "https://4matic.github.io/trackparse/"
|
|
48
|
+
Documentation = "https://4matic.github.io/trackparse/guides/python/"
|
|
48
49
|
Repository = "https://github.com/4matic/trackparse"
|
|
49
50
|
Issues = "https://github.com/4matic/trackparse/issues"
|
|
50
51
|
Changelog = "https://github.com/4matic/trackparse/blob/main/python/CHANGELOG.md"
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
-
SPEC_VERSION = "0.2
|
|
8
|
+
SPEC_VERSION = "0.3.2" # x-release-please-version
|
|
9
9
|
|
|
10
10
|
# spec/data/descriptors.json
|
|
11
11
|
DESCRIPTORS_DATA: dict[str, Any] = {
|
|
@@ -179,6 +179,23 @@ GENRES_DATA: dict[str, Any] = {
|
|
|
179
179
|
],
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
# spec/data/header-words.json
|
|
183
|
+
HEADER_WORDS_DATA: dict[str, Any] = {
|
|
184
|
+
"words": [
|
|
185
|
+
"tracklist",
|
|
186
|
+
"setlist",
|
|
187
|
+
"playlist",
|
|
188
|
+
"part",
|
|
189
|
+
"hour",
|
|
190
|
+
"cd",
|
|
191
|
+
"disc",
|
|
192
|
+
"side",
|
|
193
|
+
"chapter",
|
|
194
|
+
"segment",
|
|
195
|
+
"set",
|
|
196
|
+
],
|
|
197
|
+
}
|
|
198
|
+
|
|
182
199
|
# spec/data/joiners.json
|
|
183
200
|
JOINERS_DATA: dict[str, Any] = {
|
|
184
201
|
"spaced": [
|
|
@@ -333,6 +350,33 @@ JUNK_DATA: dict[str, Any] = {
|
|
|
333
350
|
],
|
|
334
351
|
}
|
|
335
352
|
|
|
353
|
+
# spec/data/marker-words.json
|
|
354
|
+
MARKER_WORDS_DATA: dict[str, Any] = {
|
|
355
|
+
"words": [
|
|
356
|
+
"intro",
|
|
357
|
+
"outro",
|
|
358
|
+
"break",
|
|
359
|
+
"interlude",
|
|
360
|
+
"intermission",
|
|
361
|
+
"jingle",
|
|
362
|
+
"ad",
|
|
363
|
+
"advert",
|
|
364
|
+
"advertisement",
|
|
365
|
+
"talk",
|
|
366
|
+
"interview",
|
|
367
|
+
"speech",
|
|
368
|
+
"announcement",
|
|
369
|
+
"shoutout",
|
|
370
|
+
"shout-out",
|
|
371
|
+
"pause",
|
|
372
|
+
"start",
|
|
373
|
+
"end",
|
|
374
|
+
"silence",
|
|
375
|
+
"applause",
|
|
376
|
+
"skit",
|
|
377
|
+
],
|
|
378
|
+
}
|
|
379
|
+
|
|
336
380
|
# spec/data/no-split-before.json
|
|
337
381
|
NO_SPLIT_BEFORE_DATA: dict[str, Any] = {
|
|
338
382
|
"words": [
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""ASCII digit scanning shared by R4.1 (_prefix.py) and the tracklist time scanner (R13.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ._words import char_at, is_digit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def digit_run(s: str, frm: int) -> int:
|
|
11
|
+
"""Length of the run of ASCII digits starting at ``frm``."""
|
|
12
|
+
i = frm
|
|
13
|
+
while is_digit(char_at(s, i)):
|
|
14
|
+
i += 1
|
|
15
|
+
return i - frm
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def two_digits_up_to_59(s: str, at: int) -> Optional[int]:
|
|
19
|
+
"""Exactly two digits (not followed by a third) with a value of at most 59, else None."""
|
|
20
|
+
if (
|
|
21
|
+
not is_digit(char_at(s, at))
|
|
22
|
+
or not is_digit(char_at(s, at + 1))
|
|
23
|
+
or is_digit(char_at(s, at + 2))
|
|
24
|
+
):
|
|
25
|
+
return None
|
|
26
|
+
n = int(s[at : at + 2])
|
|
27
|
+
return n if n <= 59 else None
|
|
@@ -128,7 +128,8 @@ def prelude(normalized: str, requested: object, ctx: Ctx) -> Prelude:
|
|
|
128
128
|
s = _strip_platform_suffixes(normalized, ctx, junk)
|
|
129
129
|
had_platform = len(junk) > 0
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
# R13.6: tracklist is explicit only; R2.3/R2.4 do not run.
|
|
132
|
+
if requested in ("clean", "youtube", "filename", "tracklist"):
|
|
132
133
|
mode = str(requested)
|
|
133
134
|
elif _extension_of(s, ctx):
|
|
134
135
|
mode = "filename"
|