trackparse 0.2.0__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.
Files changed (36) hide show
  1. {trackparse-0.2.0 → trackparse-0.3.1}/.gitignore +3 -0
  2. {trackparse-0.2.0 → trackparse-0.3.1}/PKG-INFO +122 -9
  3. {trackparse-0.2.0 → trackparse-0.3.1}/README.md +120 -7
  4. {trackparse-0.2.0 → trackparse-0.3.1}/pyproject.toml +2 -2
  5. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/__init__.py +1 -1
  6. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_data.py +45 -1
  7. trackparse-0.3.1/src/trackparse/_digits.py +27 -0
  8. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_mode.py +2 -1
  9. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_parser.py +84 -7
  10. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_prefix.py +3 -19
  11. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_separator.py +59 -1
  12. trackparse-0.3.1/src/trackparse/_tracklist_edits.py +378 -0
  13. trackparse-0.3.1/src/trackparse/_tracklist_kinds.py +108 -0
  14. trackparse-0.3.1/src/trackparse/_tracklist_options.py +53 -0
  15. trackparse-0.3.1/src/trackparse/_tracklist_parse.py +167 -0
  16. trackparse-0.3.1/src/trackparse/_tracklist_positions.py +60 -0
  17. trackparse-0.3.1/src/trackparse/_tracklist_split.py +43 -0
  18. trackparse-0.3.1/src/trackparse/_tracklist_times.py +230 -0
  19. trackparse-0.3.1/src/trackparse/_tracklist_types.py +186 -0
  20. trackparse-0.3.1/src/trackparse/_tracklist_warnings.py +224 -0
  21. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_words.py +5 -0
  22. trackparse-0.3.1/src/trackparse/tracklist.py +99 -0
  23. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/types.py +3 -2
  24. {trackparse-0.2.0 → trackparse-0.3.1}/LICENSE +0 -0
  25. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_artists.py +0 -0
  26. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_assemble.py +0 -0
  27. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_classify.py +0 -0
  28. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_context.py +0 -0
  29. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_credits.py +0 -0
  30. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_format.py +0 -0
  31. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_normalize.py +0 -0
  32. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_scanner.py +0 -0
  33. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_tables.py +0 -0
  34. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/_title.py +0 -0
  35. {trackparse-0.2.0 → trackparse-0.3.1}/src/trackparse/options.py +0 -0
  36. {trackparse-0.2.0 → trackparse-0.3.1}/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,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: trackparse
3
- Version: 0.2.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 11,891 fixtures. It passes all of them, and for every fixture input its output is byte-identical
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 `from_dict()`
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 11,891 fixtures. It passes all of them, and for every fixture input its output is byte-identical
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 `from_dict()`
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.2.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"
@@ -33,7 +33,7 @@ from .types import (
33
33
  Warning,
34
34
  )
35
35
 
36
- __version__ = "0.2.0" # x-release-please-version
36
+ __version__ = "0.3.1" # 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.0" # x-release-please-version
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
- 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"
@@ -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
- def _run(inp: str, options: Mapping[str, Any], t: Tables) -> ParsedTrack:
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 _empty_track(inp, pre.mode) # R9.5
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(working, pre.mode, _uploader_skel(options.get("uploader")), ctx)
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, is_digit
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