trackparse 0.2.1__tar.gz → 0.3.1__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.1}/.gitignore +3 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/PKG-INFO +122 -9
- {trackparse-0.2.1 → trackparse-0.3.1}/README.md +120 -7
- {trackparse-0.2.1 → trackparse-0.3.1}/pyproject.toml +2 -2
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/__init__.py +1 -1
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_data.py +45 -1
- trackparse-0.3.1/src/trackparse/_digits.py +27 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_mode.py +2 -1
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_parser.py +84 -7
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_prefix.py +3 -19
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_separator.py +59 -1
- trackparse-0.3.1/src/trackparse/_tracklist_edits.py +378 -0
- trackparse-0.3.1/src/trackparse/_tracklist_kinds.py +108 -0
- trackparse-0.3.1/src/trackparse/_tracklist_options.py +53 -0
- trackparse-0.3.1/src/trackparse/_tracklist_parse.py +167 -0
- trackparse-0.3.1/src/trackparse/_tracklist_positions.py +60 -0
- trackparse-0.3.1/src/trackparse/_tracklist_split.py +43 -0
- trackparse-0.3.1/src/trackparse/_tracklist_times.py +230 -0
- trackparse-0.3.1/src/trackparse/_tracklist_types.py +186 -0
- trackparse-0.3.1/src/trackparse/_tracklist_warnings.py +224 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_words.py +5 -0
- trackparse-0.3.1/src/trackparse/tracklist.py +99 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/types.py +3 -2
- {trackparse-0.2.1 → trackparse-0.3.1}/LICENSE +0 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_artists.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_assemble.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_classify.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_context.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_credits.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_format.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_normalize.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_scanner.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_tables.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/_title.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/options.py +0 -0
- {trackparse-0.2.1 → trackparse-0.3.1}/src/trackparse/py.typed +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: trackparse
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Spec-driven parser for music track titles: artists, feat, remixes, versions, junk, positions.
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Spec-driven parser for music track titles and tracklists: artists, feat, remixes, versions, junk, positions, times.
|
|
5
5
|
Project-URL: Homepage, https://github.com/4matic/trackparse
|
|
6
6
|
Project-URL: Repository, https://github.com/4matic/trackparse
|
|
7
7
|
Project-URL: Issues, https://github.com/4matic/trackparse/issues
|
|
@@ -51,7 +51,7 @@ doesn't recognise stays in the title, word for word.
|
|
|
51
51
|
|
|
52
52
|
This is the Python port of [trackparse](https://github.com/4matic/trackparse). Its behaviour is
|
|
53
53
|
defined by a [shared spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md) and pinned
|
|
54
|
-
by
|
|
54
|
+
by 12,965 fixtures. It passes all of them, and for every fixture input its output is byte-identical
|
|
55
55
|
to the JavaScript package.
|
|
56
56
|
|
|
57
57
|
- Python 3.9 to 3.14
|
|
@@ -155,7 +155,7 @@ SPEC_VERSION, __version__
|
|
|
155
155
|
|
|
156
156
|
| Option | Default | |
|
|
157
157
|
|---|---|---|
|
|
158
|
-
| `mode` | `"auto"` | `"clean"`, `"youtube"`, `"filename"`, or `"auto"` to detect from the string |
|
|
158
|
+
| `mode` | `"auto"` | `"clean"`, `"youtube"`, `"filename"`, `"tracklist"`, or `"auto"` to detect from the string |
|
|
159
159
|
| `uploader` | `None` | YouTube channel name, used as the artist when there's no separator (`- Topic` and `VEVO` are dropped) |
|
|
160
160
|
| `known_artists` | `()` | Names that are never split. There is no built-in list |
|
|
161
161
|
| `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 +196,7 @@ the defaults. Passing something that isn't a `str` raises `TypeError`.
|
|
|
196
196
|
| Attribute | Type | |
|
|
197
197
|
|---|---|---|
|
|
198
198
|
| `input` | `str` | The original string, untouched |
|
|
199
|
-
| `mode` | `"clean" \| "youtube" \| "filename"` | Resolved mode |
|
|
199
|
+
| `mode` | `"clean" \| "youtube" \| "filename" \| "tracklist"` | Resolved mode |
|
|
200
200
|
| `position` | `Position \| None` | Track number: `Position(raw='01', number=1)` |
|
|
201
201
|
| `timestamp` | `Timestamp \| None` | Leading cue time: `[12:34]` gives `Timestamp(raw='12:34', seconds=754)` |
|
|
202
202
|
| `artists` | `tuple[Artist, ...]` | Primary, featured and producer credits. Remixers live in `versions` |
|
|
@@ -238,8 +238,8 @@ Version types, junk kinds and warnings are listed in the
|
|
|
238
238
|
Every result class has `to_dict()`, which returns plain dicts and lists matching
|
|
239
239
|
[`parsed-track.schema.json`](https://github.com/4matic/trackparse/blob/main/spec/schema/parsed-track.schema.json)
|
|
240
240
|
(`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:
|
|
241
|
+
ensure_ascii=False)` the bytes are the same as `JSON.stringify(parse(s))` in JS. The single-track classes
|
|
242
|
+
(`ParsedTrack`, `Artist`, ...) also have a `from_dict()` classmethod that goes the other way:
|
|
243
243
|
|
|
244
244
|
```python
|
|
245
245
|
ParsedTrack.from_dict(track.to_dict()) == track # True
|
|
@@ -338,6 +338,7 @@ released together and always share one version number.
|
|
|
338
338
|
| `youtube` | Video titles, scrobbles | Pipe segments, `"Title" by Artist`, `Artist: "Title"`, unspaced dashes, trailing junk, uploader fallback |
|
|
339
339
|
| `filename` | Files | Strips the extension, `_` becomes a space, `01-Track` numbering |
|
|
340
340
|
| `auto` | Anything | `filename` for known extensions, `youtube` when it sees junk, pipes or platform suffixes, `clean` otherwise |
|
|
341
|
+
| `tracklist` | One line of a tracklist | The first spaced dash always separates; `_-_` too. Never picked by `auto` |
|
|
341
342
|
|
|
342
343
|
```python
|
|
343
344
|
t = parse('"Numb" by Linkin Park', mode="youtube")
|
|
@@ -352,12 +353,124 @@ parse("Shelter", mode="youtube", uploader="Porter Robinson").artists
|
|
|
352
353
|
# (Artist(name='Porter Robinson', role='primary', joiner=None, source='artist'),)
|
|
353
354
|
```
|
|
354
355
|
|
|
356
|
+
## Tracklists
|
|
357
|
+
|
|
358
|
+
`trackparse.tracklist` parses a whole tracklist, one entry per line, and checks it. Each entry
|
|
359
|
+
keeps its line exactly as written plus its own line ending, so `"".join(e.raw + e.eol for e in
|
|
360
|
+
tl.entries)` is always the input, CRLF or not.
|
|
361
|
+
|
|
362
|
+
```python
|
|
363
|
+
from trackparse.tracklist import apply_edits, parse_tracklist
|
|
364
|
+
|
|
365
|
+
text = "\r\n".join(
|
|
366
|
+
[
|
|
367
|
+
"Hour 2",
|
|
368
|
+
"48:20 Noisia - Stigma",
|
|
369
|
+
"52:47 Mefjus & Kasra - Bandwagon (Phace Remix)",
|
|
370
|
+
"56:05 Black Sun Empire - ID",
|
|
371
|
+
"59:31 Alix Perez - Fade Away",
|
|
372
|
+
"01:03:10 Interview with Hybris",
|
|
373
|
+
"01:07:44 Imanu - Sacrifice",
|
|
374
|
+
"00:11:02 Current Value feat. Teddy Killerz - Payload", # typo for 01:11:02
|
|
375
|
+
"01:15:36 Hybris - Spinning Top",
|
|
376
|
+
]
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
tl = parse_tracklist(text)
|
|
380
|
+
|
|
381
|
+
[e.kind for e in tl.entries]
|
|
382
|
+
# ['header', 'track', 'track', 'track', 'track', 'marker', 'track', 'track', 'track']
|
|
383
|
+
|
|
384
|
+
e = tl.entries[2]
|
|
385
|
+
e.time # TimeValue(raw='52:47', seconds=3167)
|
|
386
|
+
e.parts # Parts(lead='', time='52:47', gap=' ', rest='Mefjus & Kasra - Bandwagon (Phace Remix)')
|
|
387
|
+
[a.name for a in e.track.artists], e.track.title, e.track.versions[0].type
|
|
388
|
+
# (['Mefjus', 'Kasra'], 'Bandwagon', 'remix')
|
|
389
|
+
tl.entries[3].track.flags.unknown_title # True (the ID)
|
|
390
|
+
|
|
391
|
+
tl.warnings
|
|
392
|
+
# (TracklistWarning(code='no-start-at-zero', line=2, severity='info', suggestion=None),
|
|
393
|
+
# TracklistWarning(code='time-out-of-order', line=8, severity='warning',
|
|
394
|
+
# suggestion=Suggestion(line=8, raw='01:11:02', seconds=4262)))
|
|
395
|
+
|
|
396
|
+
tl.stats
|
|
397
|
+
# Stats(lines=9, tracks=7, markers=1, timed=8, first_time=2900, last_time=4536)
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
The suggestion exists because exactly one time, one digit away from `00:11:02`, fits between its
|
|
401
|
+
neighbours. With several candidates or none, `suggestion` is `None`.
|
|
402
|
+
|
|
403
|
+
`apply_edits` takes a list of dicts with the spec's keys (`op`, `line`, `seconds`, `lines`, `from`,
|
|
404
|
+
`after`, `text`, `rest`) and only rewrites the lines they name:
|
|
405
|
+
|
|
406
|
+
```python
|
|
407
|
+
fix = tl.warnings[1].suggestion
|
|
408
|
+
r = apply_edits(
|
|
409
|
+
text,
|
|
410
|
+
[
|
|
411
|
+
{"op": "set-time", "line": fix.line, "seconds": fix.seconds},
|
|
412
|
+
{"op": "shift", "seconds": 45, "from": 5},
|
|
413
|
+
],
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
r.changed # (5, 6, 7, 8, 9)
|
|
417
|
+
r.warnings # (EditWarning(code='style-upgraded', edit=1, line=5),)
|
|
418
|
+
r.text.split("\r\n")[4] # '01:00:16 Alix Perez - Fade Away' (was 59:31)
|
|
419
|
+
|
|
420
|
+
apply_edits(text, [{"op": "shift", "seconds": -3000, "lines": [2, 3]}]).warnings
|
|
421
|
+
# (EditWarning(code='time-clamped', edit=0, line=2),)
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
A rewritten time keeps its line's style: `52:47` stays `MM:SS`, and a time that crosses the hour
|
|
425
|
+
gets hours like the rest of the list, with a `style-upgraded` warning. A bad edit changes nothing
|
|
426
|
+
and warns `edit-invalid`. Neither function raises on `str` input.
|
|
427
|
+
|
|
428
|
+
```python
|
|
429
|
+
parse_tracklist(text, *, mode="tracklist", known_artists=(), keywords=None, split_and="auto",
|
|
430
|
+
audio_seconds=None, marker_words=()) -> Tracklist
|
|
431
|
+
apply_edits(text, edits, *, <same options>) -> EditResult
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
| Result | Fields |
|
|
435
|
+
|---|---|
|
|
436
|
+
| `Tracklist` | `spec_version`, `entries`, `warnings`, `stats` |
|
|
437
|
+
| `TracklistEntry` | `line` (1-based), `kind` (`track`, `marker`, `header`, `link`, `note`, `blank`), `eol`, `raw`, `parts`, `time`, `track` (`ParsedTrack` or `None`) |
|
|
438
|
+
| `Parts` | `lead`, `time`, `gap`, `rest`, joined they give `raw` |
|
|
439
|
+
| `TracklistWarning` | `code`, `line`, `severity` (`info` or `warning`), `suggestion` |
|
|
440
|
+
| `TimeValue` | `raw`, `seconds` (`entry.time`, or `None` on untimed lines) |
|
|
441
|
+
| `Suggestion` | `line`, `raw`, `seconds` (`warning.suggestion`, or `None`) |
|
|
442
|
+
| `Stats` | `lines`, `tracks`, `markers`, `timed`, `first_time`, `last_time` |
|
|
443
|
+
| `EditResult` | `text`, `changed`, `warnings` (`EditWarning(code, edit, line)`) |
|
|
444
|
+
|
|
445
|
+
Warning codes: `time-missing`, `time-out-of-order`, `time-duplicate`, `no-start-at-zero`,
|
|
446
|
+
`time-after-audio-end` (needs `audio_seconds`), `invalid-time`, `position-gap`,
|
|
447
|
+
`position-duplicate`, `line-unparsed`. Edits: `set-time {line, seconds}`, `shift {seconds, lines? | from?}`
|
|
448
|
+
(all timed lines when neither is given), `sort-by-time {}`, `set-rest {line, rest}`,
|
|
449
|
+
`insert {after, text}` (`after: 0` inserts at the top), `delete {line}`. The
|
|
450
|
+
[main README](https://github.com/4matic/trackparse#tracklist-api) explains each one.
|
|
451
|
+
`to_dict()` gives the camelCase JSON from
|
|
452
|
+
[`tracklist.schema.json`](https://github.com/4matic/trackparse/blob/main/spec/schema/tracklist.schema.json),
|
|
453
|
+
byte-identical to the JS package's output.
|
|
454
|
+
|
|
455
|
+
Every line is parsed with `mode="tracklist"`, where the first spaced dash always separates artist
|
|
456
|
+
and title. `parse()` accepts it too:
|
|
457
|
+
|
|
458
|
+
```python
|
|
459
|
+
parse("Electrosoul System - Liquid Metal").junk
|
|
460
|
+
# (Junk(raw='Liquid Metal', kind='genre'),)
|
|
461
|
+
parse("Electrosoul System - Liquid Metal", mode="tracklist").title
|
|
462
|
+
# 'Liquid Metal'
|
|
463
|
+
```
|
|
464
|
+
|
|
355
465
|
## Typing
|
|
356
466
|
|
|
357
467
|
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
|
|
468
|
+
`Junk`, `Flags`, `Position` and `Timestamp` are frozen dataclasses with snake_case attributes, and
|
|
469
|
+
so are the tracklist results in `trackparse.tracklist`.
|
|
359
470
|
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`)
|
|
471
|
+
keys. Option dicts have `TypedDict` types (`ParseOptions`, `KeywordOptions`, `FormatOptions`);
|
|
472
|
+
tracklist edits are plain dicts. `trackparse.tracklist` also exports the `Literal` aliases
|
|
473
|
+
`EntryKind`, `Eol`, `Severity`, `TracklistWarningCode` and `EditWarningCode`.
|
|
361
474
|
|
|
362
475
|
## Development
|
|
363
476
|
|
|
@@ -19,7 +19,7 @@ doesn't recognise stays in the title, word for word.
|
|
|
19
19
|
|
|
20
20
|
This is the Python port of [trackparse](https://github.com/4matic/trackparse). Its behaviour is
|
|
21
21
|
defined by a [shared spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md) and pinned
|
|
22
|
-
by
|
|
22
|
+
by 12,965 fixtures. It passes all of them, and for every fixture input its output is byte-identical
|
|
23
23
|
to the JavaScript package.
|
|
24
24
|
|
|
25
25
|
- Python 3.9 to 3.14
|
|
@@ -123,7 +123,7 @@ SPEC_VERSION, __version__
|
|
|
123
123
|
|
|
124
124
|
| Option | Default | |
|
|
125
125
|
|---|---|---|
|
|
126
|
-
| `mode` | `"auto"` | `"clean"`, `"youtube"`, `"filename"`, or `"auto"` to detect from the string |
|
|
126
|
+
| `mode` | `"auto"` | `"clean"`, `"youtube"`, `"filename"`, `"tracklist"`, or `"auto"` to detect from the string |
|
|
127
127
|
| `uploader` | `None` | YouTube channel name, used as the artist when there's no separator (`- Topic` and `VEVO` are dropped) |
|
|
128
128
|
| `known_artists` | `()` | Names that are never split. There is no built-in list |
|
|
129
129
|
| `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 +164,7 @@ the defaults. Passing something that isn't a `str` raises `TypeError`.
|
|
|
164
164
|
| Attribute | Type | |
|
|
165
165
|
|---|---|---|
|
|
166
166
|
| `input` | `str` | The original string, untouched |
|
|
167
|
-
| `mode` | `"clean" \| "youtube" \| "filename"` | Resolved mode |
|
|
167
|
+
| `mode` | `"clean" \| "youtube" \| "filename" \| "tracklist"` | Resolved mode |
|
|
168
168
|
| `position` | `Position \| None` | Track number: `Position(raw='01', number=1)` |
|
|
169
169
|
| `timestamp` | `Timestamp \| None` | Leading cue time: `[12:34]` gives `Timestamp(raw='12:34', seconds=754)` |
|
|
170
170
|
| `artists` | `tuple[Artist, ...]` | Primary, featured and producer credits. Remixers live in `versions` |
|
|
@@ -206,8 +206,8 @@ Version types, junk kinds and warnings are listed in the
|
|
|
206
206
|
Every result class has `to_dict()`, which returns plain dicts and lists matching
|
|
207
207
|
[`parsed-track.schema.json`](https://github.com/4matic/trackparse/blob/main/spec/schema/parsed-track.schema.json)
|
|
208
208
|
(`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:
|
|
209
|
+
ensure_ascii=False)` the bytes are the same as `JSON.stringify(parse(s))` in JS. The single-track classes
|
|
210
|
+
(`ParsedTrack`, `Artist`, ...) also have a `from_dict()` classmethod that goes the other way:
|
|
211
211
|
|
|
212
212
|
```python
|
|
213
213
|
ParsedTrack.from_dict(track.to_dict()) == track # True
|
|
@@ -306,6 +306,7 @@ released together and always share one version number.
|
|
|
306
306
|
| `youtube` | Video titles, scrobbles | Pipe segments, `"Title" by Artist`, `Artist: "Title"`, unspaced dashes, trailing junk, uploader fallback |
|
|
307
307
|
| `filename` | Files | Strips the extension, `_` becomes a space, `01-Track` numbering |
|
|
308
308
|
| `auto` | Anything | `filename` for known extensions, `youtube` when it sees junk, pipes or platform suffixes, `clean` otherwise |
|
|
309
|
+
| `tracklist` | One line of a tracklist | The first spaced dash always separates; `_-_` too. Never picked by `auto` |
|
|
309
310
|
|
|
310
311
|
```python
|
|
311
312
|
t = parse('"Numb" by Linkin Park', mode="youtube")
|
|
@@ -320,12 +321,124 @@ parse("Shelter", mode="youtube", uploader="Porter Robinson").artists
|
|
|
320
321
|
# (Artist(name='Porter Robinson', role='primary', joiner=None, source='artist'),)
|
|
321
322
|
```
|
|
322
323
|
|
|
324
|
+
## Tracklists
|
|
325
|
+
|
|
326
|
+
`trackparse.tracklist` parses a whole tracklist, one entry per line, and checks it. Each entry
|
|
327
|
+
keeps its line exactly as written plus its own line ending, so `"".join(e.raw + e.eol for e in
|
|
328
|
+
tl.entries)` is always the input, CRLF or not.
|
|
329
|
+
|
|
330
|
+
```python
|
|
331
|
+
from trackparse.tracklist import apply_edits, parse_tracklist
|
|
332
|
+
|
|
333
|
+
text = "\r\n".join(
|
|
334
|
+
[
|
|
335
|
+
"Hour 2",
|
|
336
|
+
"48:20 Noisia - Stigma",
|
|
337
|
+
"52:47 Mefjus & Kasra - Bandwagon (Phace Remix)",
|
|
338
|
+
"56:05 Black Sun Empire - ID",
|
|
339
|
+
"59:31 Alix Perez - Fade Away",
|
|
340
|
+
"01:03:10 Interview with Hybris",
|
|
341
|
+
"01:07:44 Imanu - Sacrifice",
|
|
342
|
+
"00:11:02 Current Value feat. Teddy Killerz - Payload", # typo for 01:11:02
|
|
343
|
+
"01:15:36 Hybris - Spinning Top",
|
|
344
|
+
]
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
tl = parse_tracklist(text)
|
|
348
|
+
|
|
349
|
+
[e.kind for e in tl.entries]
|
|
350
|
+
# ['header', 'track', 'track', 'track', 'track', 'marker', 'track', 'track', 'track']
|
|
351
|
+
|
|
352
|
+
e = tl.entries[2]
|
|
353
|
+
e.time # TimeValue(raw='52:47', seconds=3167)
|
|
354
|
+
e.parts # Parts(lead='', time='52:47', gap=' ', rest='Mefjus & Kasra - Bandwagon (Phace Remix)')
|
|
355
|
+
[a.name for a in e.track.artists], e.track.title, e.track.versions[0].type
|
|
356
|
+
# (['Mefjus', 'Kasra'], 'Bandwagon', 'remix')
|
|
357
|
+
tl.entries[3].track.flags.unknown_title # True (the ID)
|
|
358
|
+
|
|
359
|
+
tl.warnings
|
|
360
|
+
# (TracklistWarning(code='no-start-at-zero', line=2, severity='info', suggestion=None),
|
|
361
|
+
# TracklistWarning(code='time-out-of-order', line=8, severity='warning',
|
|
362
|
+
# suggestion=Suggestion(line=8, raw='01:11:02', seconds=4262)))
|
|
363
|
+
|
|
364
|
+
tl.stats
|
|
365
|
+
# Stats(lines=9, tracks=7, markers=1, timed=8, first_time=2900, last_time=4536)
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
The suggestion exists because exactly one time, one digit away from `00:11:02`, fits between its
|
|
369
|
+
neighbours. With several candidates or none, `suggestion` is `None`.
|
|
370
|
+
|
|
371
|
+
`apply_edits` takes a list of dicts with the spec's keys (`op`, `line`, `seconds`, `lines`, `from`,
|
|
372
|
+
`after`, `text`, `rest`) and only rewrites the lines they name:
|
|
373
|
+
|
|
374
|
+
```python
|
|
375
|
+
fix = tl.warnings[1].suggestion
|
|
376
|
+
r = apply_edits(
|
|
377
|
+
text,
|
|
378
|
+
[
|
|
379
|
+
{"op": "set-time", "line": fix.line, "seconds": fix.seconds},
|
|
380
|
+
{"op": "shift", "seconds": 45, "from": 5},
|
|
381
|
+
],
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
r.changed # (5, 6, 7, 8, 9)
|
|
385
|
+
r.warnings # (EditWarning(code='style-upgraded', edit=1, line=5),)
|
|
386
|
+
r.text.split("\r\n")[4] # '01:00:16 Alix Perez - Fade Away' (was 59:31)
|
|
387
|
+
|
|
388
|
+
apply_edits(text, [{"op": "shift", "seconds": -3000, "lines": [2, 3]}]).warnings
|
|
389
|
+
# (EditWarning(code='time-clamped', edit=0, line=2),)
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
A rewritten time keeps its line's style: `52:47` stays `MM:SS`, and a time that crosses the hour
|
|
393
|
+
gets hours like the rest of the list, with a `style-upgraded` warning. A bad edit changes nothing
|
|
394
|
+
and warns `edit-invalid`. Neither function raises on `str` input.
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
parse_tracklist(text, *, mode="tracklist", known_artists=(), keywords=None, split_and="auto",
|
|
398
|
+
audio_seconds=None, marker_words=()) -> Tracklist
|
|
399
|
+
apply_edits(text, edits, *, <same options>) -> EditResult
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
| Result | Fields |
|
|
403
|
+
|---|---|
|
|
404
|
+
| `Tracklist` | `spec_version`, `entries`, `warnings`, `stats` |
|
|
405
|
+
| `TracklistEntry` | `line` (1-based), `kind` (`track`, `marker`, `header`, `link`, `note`, `blank`), `eol`, `raw`, `parts`, `time`, `track` (`ParsedTrack` or `None`) |
|
|
406
|
+
| `Parts` | `lead`, `time`, `gap`, `rest`, joined they give `raw` |
|
|
407
|
+
| `TracklistWarning` | `code`, `line`, `severity` (`info` or `warning`), `suggestion` |
|
|
408
|
+
| `TimeValue` | `raw`, `seconds` (`entry.time`, or `None` on untimed lines) |
|
|
409
|
+
| `Suggestion` | `line`, `raw`, `seconds` (`warning.suggestion`, or `None`) |
|
|
410
|
+
| `Stats` | `lines`, `tracks`, `markers`, `timed`, `first_time`, `last_time` |
|
|
411
|
+
| `EditResult` | `text`, `changed`, `warnings` (`EditWarning(code, edit, line)`) |
|
|
412
|
+
|
|
413
|
+
Warning codes: `time-missing`, `time-out-of-order`, `time-duplicate`, `no-start-at-zero`,
|
|
414
|
+
`time-after-audio-end` (needs `audio_seconds`), `invalid-time`, `position-gap`,
|
|
415
|
+
`position-duplicate`, `line-unparsed`. Edits: `set-time {line, seconds}`, `shift {seconds, lines? | from?}`
|
|
416
|
+
(all timed lines when neither is given), `sort-by-time {}`, `set-rest {line, rest}`,
|
|
417
|
+
`insert {after, text}` (`after: 0` inserts at the top), `delete {line}`. The
|
|
418
|
+
[main README](https://github.com/4matic/trackparse#tracklist-api) explains each one.
|
|
419
|
+
`to_dict()` gives the camelCase JSON from
|
|
420
|
+
[`tracklist.schema.json`](https://github.com/4matic/trackparse/blob/main/spec/schema/tracklist.schema.json),
|
|
421
|
+
byte-identical to the JS package's output.
|
|
422
|
+
|
|
423
|
+
Every line is parsed with `mode="tracklist"`, where the first spaced dash always separates artist
|
|
424
|
+
and title. `parse()` accepts it too:
|
|
425
|
+
|
|
426
|
+
```python
|
|
427
|
+
parse("Electrosoul System - Liquid Metal").junk
|
|
428
|
+
# (Junk(raw='Liquid Metal', kind='genre'),)
|
|
429
|
+
parse("Electrosoul System - Liquid Metal", mode="tracklist").title
|
|
430
|
+
# 'Liquid Metal'
|
|
431
|
+
```
|
|
432
|
+
|
|
323
433
|
## Typing
|
|
324
434
|
|
|
325
435
|
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
|
|
436
|
+
`Junk`, `Flags`, `Position` and `Timestamp` are frozen dataclasses with snake_case attributes, and
|
|
437
|
+
so are the tracklist results in `trackparse.tracklist`.
|
|
327
438
|
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`)
|
|
439
|
+
keys. Option dicts have `TypedDict` types (`ParseOptions`, `KeywordOptions`, `FormatOptions`);
|
|
440
|
+
tracklist edits are plain dicts. `trackparse.tracklist` also exports the `Literal` aliases
|
|
441
|
+
`EntryKind`, `Eol`, `Severity`, `TracklistWarningCode` and `EditWarningCode`.
|
|
329
442
|
|
|
330
443
|
## Development
|
|
331
444
|
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "trackparse"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Spec-driven parser for music track titles: artists, feat, remixes, versions, junk, positions."
|
|
7
|
+
version = "0.3.1"
|
|
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"
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
-
SPEC_VERSION = "0.
|
|
8
|
+
SPEC_VERSION = "0.3.1" # 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"
|
|
@@ -4,15 +4,15 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from collections.abc import Mapping, Sequence
|
|
6
6
|
from dataclasses import replace
|
|
7
|
-
from typing import Any, Optional, cast
|
|
7
|
+
from typing import Any, NamedTuple, Optional, cast
|
|
8
8
|
|
|
9
9
|
from ._artists import parse_artist_side
|
|
10
10
|
from ._assemble import by_pos, dedup_artists, full_title_of, to_version
|
|
11
11
|
from ._context import Ctx, warn
|
|
12
|
-
from ._mode import Piece, prelude
|
|
12
|
+
from ._mode import Piece, Prelude, prelude
|
|
13
13
|
from ._normalize import normalize
|
|
14
|
-
from ._prefix import strip_prefixes
|
|
15
|
-
from ._scanner import Skel, scan, trim_skel
|
|
14
|
+
from ._prefix import PrefixResult, strip_prefixes
|
|
15
|
+
from ._scanner import Skel, scan, split_at_spaced_dashes, trim_skel
|
|
16
16
|
from ._separator import find_separator
|
|
17
17
|
from ._tables import Tables, js_entries, tables_for
|
|
18
18
|
from ._title import Extracted, add_peeled, parse_title_side
|
|
@@ -74,7 +74,20 @@ def _empty_track(inp: str, mode: str) -> ParsedTrack:
|
|
|
74
74
|
)
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
class _Staged(NamedTuple):
|
|
78
|
+
"""The R1, R2 and R4 stage of ``_run``, kept so the tracklist probe's work is not redone."""
|
|
79
|
+
|
|
80
|
+
inp: str
|
|
81
|
+
options: Mapping[str, Any]
|
|
82
|
+
ctx: Ctx
|
|
83
|
+
pre: Prelude
|
|
84
|
+
#: None when the input is empty after R1 (R9.5).
|
|
85
|
+
prefix: Optional[PrefixResult]
|
|
86
|
+
#: The skeleton after R4; None when the input is empty after R1.
|
|
87
|
+
working: Optional[Skel]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _stage(inp: str, options: Mapping[str, Any], t: Tables) -> _Staged:
|
|
78
91
|
ctx = Ctx(t, options)
|
|
79
92
|
norm = normalize(inp)
|
|
80
93
|
requested = options.get("mode")
|
|
@@ -82,11 +95,25 @@ def _run(inp: str, options: Mapping[str, Any], t: Tables) -> ParsedTrack:
|
|
|
82
95
|
requested = "auto"
|
|
83
96
|
pre = prelude(norm, requested, ctx)
|
|
84
97
|
if len(norm) == 0:
|
|
85
|
-
return
|
|
98
|
+
return _Staged(inp, options, ctx, pre, None, None) # R9.5
|
|
86
99
|
|
|
87
100
|
# R4
|
|
88
101
|
prefix = strip_prefixes(pre.working.text, pre.working.base, pre.mode, ctx)
|
|
89
102
|
working = _skel_of(Piece(prefix.rest, prefix.base), ctx)
|
|
103
|
+
return _Staged(inp, options, ctx, pre, prefix, working)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _run(
|
|
107
|
+
inp: str, options: Mapping[str, Any], t: Tables, list_context: bool = False
|
|
108
|
+
) -> ParsedTrack:
|
|
109
|
+
return _finish(_stage(inp, options, t), list_context)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _finish(st: _Staged, list_context: bool) -> ParsedTrack:
|
|
113
|
+
"""R6–R9 on a staged input. Each staged value is finished at most once."""
|
|
114
|
+
inp, options, ctx, pre, prefix, working = st
|
|
115
|
+
if prefix is None or working is None:
|
|
116
|
+
return _empty_track(inp, pre.mode) # R9.5
|
|
90
117
|
|
|
91
118
|
out = Extracted()
|
|
92
119
|
artist_skel: Optional[Skel]
|
|
@@ -96,7 +123,9 @@ def _run(inp: str, options: Mapping[str, Any], t: Tables) -> ParsedTrack:
|
|
|
96
123
|
title_skel = _skel_of(pre.pipe_title, ctx)
|
|
97
124
|
relaxed = artist_skel is not None
|
|
98
125
|
else:
|
|
99
|
-
sep = find_separator(
|
|
126
|
+
sep = find_separator(
|
|
127
|
+
working, pre.mode, _uploader_skel(options.get("uploader")), ctx, list_context
|
|
128
|
+
)
|
|
100
129
|
add_peeled(out, sep.peeled)
|
|
101
130
|
artist_skel = sep.artist
|
|
102
131
|
title_skel = sep.title
|
|
@@ -141,6 +170,54 @@ def _run(inp: str, options: Mapping[str, Any], t: Tables) -> ParsedTrack:
|
|
|
141
170
|
)
|
|
142
171
|
|
|
143
172
|
|
|
173
|
+
class LineProbe:
|
|
174
|
+
"""Facts about a line's prefix and separator search, for the tracklist module (R13.4.2,
|
|
175
|
+
R13.5). :meth:`finish` completes the parse from the same stage, so a probed line is not
|
|
176
|
+
normalized twice."""
|
|
177
|
+
|
|
178
|
+
__slots__ = ("_staged", "empty", "position", "spaced_dash")
|
|
179
|
+
|
|
180
|
+
def __init__(self, staged: _Staged) -> None:
|
|
181
|
+
self._staged = staged
|
|
182
|
+
prefix = staged.prefix
|
|
183
|
+
working = staged.working
|
|
184
|
+
#: R4.2 took a position.
|
|
185
|
+
self.position = prefix is not None and prefix.position is not None
|
|
186
|
+
#: The skeleton R6 searches (after R2 and R4) has a spaced dash (R6.1).
|
|
187
|
+
self.spaced_dash = (
|
|
188
|
+
working is not None
|
|
189
|
+
and staged.pre.pipe_title is None
|
|
190
|
+
and len(split_at_spaced_dashes(working, True)) >= 2
|
|
191
|
+
)
|
|
192
|
+
#: The input is empty after R1.
|
|
193
|
+
self.empty = working is None
|
|
194
|
+
|
|
195
|
+
def finish(self, list_context: bool) -> ParsedTrack:
|
|
196
|
+
"""The parse of the probed input (R6–R9 only; call at most once)."""
|
|
197
|
+
return _finish(self._staged, list_context)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _probe(inp: str, options: Mapping[str, Any], t: Tables) -> LineProbe:
|
|
201
|
+
"""R1, R2 and R4 only, as ``_run`` does them: no artist/title parsing."""
|
|
202
|
+
return LineProbe(_stage(inp, options, t))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class ListParser:
|
|
206
|
+
"""Internal (not exported from the package root): parsing hooks for the tracklist module,
|
|
207
|
+
with the base options compiled once."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, options: Mapping[str, Any]) -> None:
|
|
210
|
+
self._options = _safe_options(options)
|
|
211
|
+
self._tables = tables_for(self._options.get("keywords"))
|
|
212
|
+
|
|
213
|
+
def parse(self, inp: str, list_context: bool) -> ParsedTrack:
|
|
214
|
+
"""``parse`` with the R13.4.2 list-context flag (it only matters in tracklist mode)."""
|
|
215
|
+
return _run(inp, self._options, self._tables, list_context)
|
|
216
|
+
|
|
217
|
+
def probe(self, inp: str) -> LineProbe:
|
|
218
|
+
return _probe(inp, self._options, self._tables)
|
|
219
|
+
|
|
220
|
+
|
|
144
221
|
def _run_artists(inp: str, options: Mapping[str, Any], t: Tables) -> list[Artist]:
|
|
145
222
|
ctx = Ctx(t, options)
|
|
146
223
|
norm = normalize(inp)
|
|
@@ -5,8 +5,10 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import NamedTuple, Optional
|
|
6
6
|
|
|
7
7
|
from ._context import Ctx, match_known_at
|
|
8
|
+
from ._digits import digit_run as _digit_run
|
|
9
|
+
from ._digits import two_digits_up_to_59 as _two_digits_up_to_59
|
|
8
10
|
from ._scanner import has_spaced_dash, scan
|
|
9
|
-
from ._words import char_at
|
|
11
|
+
from ._words import char_at
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
class PrefixResult(NamedTuple):
|
|
@@ -17,24 +19,6 @@ class PrefixResult(NamedTuple):
|
|
|
17
19
|
position: Optional[tuple[str, int]]
|
|
18
20
|
|
|
19
21
|
|
|
20
|
-
def _digit_run(s: str, frm: int) -> int:
|
|
21
|
-
i = frm
|
|
22
|
-
while is_digit(char_at(s, i)):
|
|
23
|
-
i += 1
|
|
24
|
-
return i - frm
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _two_digits_up_to_59(s: str, at: int) -> Optional[int]:
|
|
28
|
-
if (
|
|
29
|
-
not is_digit(char_at(s, at))
|
|
30
|
-
or not is_digit(char_at(s, at + 1))
|
|
31
|
-
or is_digit(char_at(s, at + 2))
|
|
32
|
-
):
|
|
33
|
-
return None
|
|
34
|
-
n = int(s[at : at + 2])
|
|
35
|
-
return n if n <= 59 else None
|
|
36
|
-
|
|
37
|
-
|
|
38
22
|
class _TimeMatch(NamedTuple):
|
|
39
23
|
raw: str
|
|
40
24
|
seconds: int
|