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.
Files changed (36) hide show
  1. {trackparse-0.2.1 → trackparse-0.3.2}/.gitignore +3 -0
  2. {trackparse-0.2.1 → trackparse-0.3.2}/PKG-INFO +131 -12
  3. {trackparse-0.2.1 → trackparse-0.3.2}/README.md +127 -9
  4. {trackparse-0.2.1 → trackparse-0.3.2}/pyproject.toml +4 -3
  5. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/__init__.py +1 -1
  6. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_data.py +45 -1
  7. trackparse-0.3.2/src/trackparse/_digits.py +27 -0
  8. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_mode.py +2 -1
  9. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_parser.py +84 -7
  10. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_prefix.py +3 -19
  11. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_separator.py +59 -1
  12. trackparse-0.3.2/src/trackparse/_tracklist_edits.py +378 -0
  13. trackparse-0.3.2/src/trackparse/_tracklist_kinds.py +108 -0
  14. trackparse-0.3.2/src/trackparse/_tracklist_options.py +53 -0
  15. trackparse-0.3.2/src/trackparse/_tracklist_parse.py +167 -0
  16. trackparse-0.3.2/src/trackparse/_tracklist_positions.py +60 -0
  17. trackparse-0.3.2/src/trackparse/_tracklist_split.py +43 -0
  18. trackparse-0.3.2/src/trackparse/_tracklist_times.py +230 -0
  19. trackparse-0.3.2/src/trackparse/_tracklist_types.py +186 -0
  20. trackparse-0.3.2/src/trackparse/_tracklist_warnings.py +224 -0
  21. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_words.py +5 -0
  22. trackparse-0.3.2/src/trackparse/tracklist.py +99 -0
  23. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/types.py +3 -2
  24. {trackparse-0.2.1 → trackparse-0.3.2}/LICENSE +0 -0
  25. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_artists.py +0 -0
  26. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_assemble.py +0 -0
  27. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_classify.py +0 -0
  28. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_context.py +0 -0
  29. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_credits.py +0 -0
  30. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_format.py +0 -0
  31. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_normalize.py +0 -0
  32. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_scanner.py +0 -0
  33. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_tables.py +0 -0
  34. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/_title.py +0 -0
  35. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/options.py +0 -0
  36. {trackparse-0.2.1 → trackparse-0.3.2}/src/trackparse/py.typed +0 -0
@@ -25,3 +25,6 @@ rust/Cargo.lock
25
25
  .idea/
26
26
  .vscode/
27
27
  *.swp
28
+
29
+ # Real-world corpora (platform data, not redistributable)
30
+ spec/corpus/
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: trackparse
3
- Version: 0.2.1
4
- Summary: Spec-driven parser for music track titles: artists, feat, remixes, versions, junk, positions.
5
- Project-URL: Homepage, https://github.com/4matic/trackparse
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
  [![python](https://img.shields.io/github/actions/workflow/status/4matic/trackparse/python.yml?branch=main&label=tests&logo=github)](https://github.com/4matic/trackparse/actions/workflows/python.yml)
38
39
  [![license](https://img.shields.io/badge/license-MIT-blue)](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 11,891 fixtures. It passes all of them, and for every fixture input its output is byte-identical
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
- [main README](https://github.com/4matic/trackparse#api) and typed as `Literal` aliases
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 `from_dict()`
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), with a table of real outputs
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
  [![python](https://img.shields.io/github/actions/workflow/status/4matic/trackparse/python.yml?branch=main&label=tests&logo=github)](https://github.com/4matic/trackparse/actions/workflows/python.yml)
6
6
  [![license](https://img.shields.io/badge/license-MIT-blue)](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 11,891 fixtures. It passes all of them, and for every fixture input its output is byte-identical
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
- [main README](https://github.com/4matic/trackparse#api) and typed as `Literal` aliases
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 `from_dict()`
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), with a table of real outputs
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.1"
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.com/4matic/trackparse"
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"
@@ -33,7 +33,7 @@ from .types import (
33
33
  Warning,
34
34
  )
35
35
 
36
- __version__ = "0.2.1" # x-release-please-version
36
+ __version__ = "0.3.2" # x-release-please-version
37
37
 
38
38
  __all__ = [
39
39
  "SPEC_VERSION",
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  from typing import Any
7
7
 
8
- SPEC_VERSION = "0.2.1" # x-release-please-version
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
- if requested in ("clean", "youtube", "filename"):
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"