trackparse 0.3.2__tar.gz → 0.4.0__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.3.2 → trackparse-0.4.0}/PKG-INFO +39 -6
- {trackparse-0.3.2 → trackparse-0.4.0}/README.md +37 -4
- {trackparse-0.3.2 → trackparse-0.4.0}/pyproject.toml +2 -2
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/__init__.py +1 -1
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_data.py +8 -1
- trackparse-0.4.0/src/trackparse/_tracklist_emphasis.py +86 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_kinds.py +20 -1
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_parse.py +19 -9
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_times.py +58 -6
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_types.py +3 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_warnings.py +2 -1
- {trackparse-0.3.2 → trackparse-0.4.0}/.gitignore +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/LICENSE +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_artists.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_assemble.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_classify.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_context.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_credits.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_digits.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_format.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_mode.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_normalize.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_parser.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_prefix.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_scanner.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_separator.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tables.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_title.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_edits.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_options.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_positions.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_tracklist_split.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/_words.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/options.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/py.typed +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/tracklist.py +0 -0
- {trackparse-0.3.2 → trackparse-0.4.0}/src/trackparse/types.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: trackparse
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Spec-driven parser for music track titles and tracklists: artists, feat, remixes, versions, junk, positions, times.
|
|
5
5
|
Project-URL: Homepage, https://4matic.github.io/trackparse/
|
|
6
|
-
Project-URL: Documentation, https://4matic.github.io/trackparse/
|
|
6
|
+
Project-URL: Documentation, https://4matic.github.io/trackparse/start/getting-started/
|
|
7
7
|
Project-URL: Repository, https://github.com/4matic/trackparse
|
|
8
8
|
Project-URL: Issues, https://github.com/4matic/trackparse/issues
|
|
9
9
|
Project-URL: Changelog, https://github.com/4matic/trackparse/blob/main/python/CHANGELOG.md
|
|
@@ -38,7 +38,7 @@ Description-Content-Type: text/markdown
|
|
|
38
38
|
[](https://github.com/4matic/trackparse/actions/workflows/python.yml)
|
|
39
39
|
[](https://github.com/4matic/trackparse/blob/main/LICENSE)
|
|
40
40
|
|
|
41
|
-
**[Documentation](https://4matic.github.io/trackparse/)** · **[
|
|
41
|
+
**[Documentation](https://4matic.github.io/trackparse/)** · **[Getting started](https://4matic.github.io/trackparse/start/getting-started/)** · **[Playground](https://4matic.github.io/trackparse/playground/)**
|
|
42
42
|
|
|
43
43
|
**Turn messy track names into structured data: who made it, who's featured, who remixed it, and
|
|
44
44
|
what's just noise.**
|
|
@@ -54,7 +54,7 @@ doesn't recognise stays in the title, word for word.
|
|
|
54
54
|
|
|
55
55
|
This is the Python port of [trackparse](https://github.com/4matic/trackparse). Its behaviour is
|
|
56
56
|
defined by a [shared spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md) and pinned
|
|
57
|
-
by
|
|
57
|
+
by 13,124 fixtures. It passes all of them, and for every fixture input its output is byte-identical
|
|
58
58
|
to the JavaScript package.
|
|
59
59
|
|
|
60
60
|
- Python 3.9 to 3.14
|
|
@@ -72,6 +72,8 @@ uv add trackparse
|
|
|
72
72
|
|
|
73
73
|
## Quick start
|
|
74
74
|
|
|
75
|
+
**Docs:** [Getting started](https://4matic.github.io/trackparse/start/getting-started/)
|
|
76
|
+
|
|
75
77
|
```python
|
|
76
78
|
from trackparse import format_track, parse
|
|
77
79
|
|
|
@@ -143,6 +145,8 @@ print(json.dumps(track.to_dict(), indent=2))
|
|
|
143
145
|
|
|
144
146
|
## API
|
|
145
147
|
|
|
148
|
+
**Docs:** [Other functions](https://4matic.github.io/trackparse/reference/functions/)
|
|
149
|
+
|
|
146
150
|
```python
|
|
147
151
|
parse(input, *, mode="auto", uploader=None, known_artists=(), split_and="auto", keywords=None) -> ParsedTrack
|
|
148
152
|
parse_artists(input, *, <same options>) -> list[Artist]
|
|
@@ -156,6 +160,8 @@ SPEC_VERSION, __version__
|
|
|
156
160
|
|
|
157
161
|
### `parse(input, **options)`
|
|
158
162
|
|
|
163
|
+
**Docs:** [parse and ParsedTrack](https://4matic.github.io/trackparse/reference/parse/) · [Options](https://4matic.github.io/trackparse/reference/options/)
|
|
164
|
+
|
|
159
165
|
| Option | Default | |
|
|
160
166
|
|---|---|---|
|
|
161
167
|
| `mode` | `"auto"` | `"clean"`, `"youtube"`, `"filename"`, `"tracklist"`, or `"auto"` to detect from the string |
|
|
@@ -196,6 +202,8 @@ the defaults. Passing something that isn't a `str` raises `TypeError`.
|
|
|
196
202
|
|
|
197
203
|
### `ParsedTrack`
|
|
198
204
|
|
|
205
|
+
**Docs:** [ParsedTrack fields](https://4matic.github.io/trackparse/reference/parse/#parsedtrack)
|
|
206
|
+
|
|
199
207
|
| Attribute | Type | |
|
|
200
208
|
|---|---|---|
|
|
201
209
|
| `input` | `str` | The original string, untouched |
|
|
@@ -238,6 +246,8 @@ Version types, junk kinds and warnings are listed in the
|
|
|
238
246
|
|
|
239
247
|
### `to_dict()` and `from_dict()`
|
|
240
248
|
|
|
249
|
+
**Docs:** [JSON and Python objects](https://4matic.github.io/trackparse/reference/parse/#json-and-python-objects)
|
|
250
|
+
|
|
241
251
|
Every result class has `to_dict()`, which returns plain dicts and lists matching
|
|
242
252
|
[`parsed-track.schema.json`](https://github.com/4matic/trackparse/blob/main/spec/schema/parsed-track.schema.json)
|
|
243
253
|
(`fullTitle`, `unknownArtist`, ...). With `json.dumps(track.to_dict(), separators=(",", ":"),
|
|
@@ -250,6 +260,8 @@ ParsedTrack.from_dict(track.to_dict()) == track # True
|
|
|
250
260
|
|
|
251
261
|
### `parse_artists(input, **options)`
|
|
252
262
|
|
|
263
|
+
**Docs:** [parseArtists](https://4matic.github.io/trackparse/reference/functions/#parseartistsinput-options)
|
|
264
|
+
|
|
253
265
|
Splits a bare artist string. Takes the same options as `parse`.
|
|
254
266
|
|
|
255
267
|
```python
|
|
@@ -261,6 +273,8 @@ from trackparse import parse_artists
|
|
|
261
273
|
|
|
262
274
|
### `format_track(track, **options)`
|
|
263
275
|
|
|
276
|
+
**Docs:** [format](https://4matic.github.io/trackparse/reference/functions/#formattrack-options) · [Formatting](https://4matic.github.io/trackparse/guides/formatting/)
|
|
277
|
+
|
|
264
278
|
Renders a track back to a string.
|
|
265
279
|
|
|
266
280
|
| Option | Default | |
|
|
@@ -290,6 +304,8 @@ format_track(track, position=True)
|
|
|
290
304
|
|
|
291
305
|
### `normalize(s)`
|
|
292
306
|
|
|
307
|
+
**Docs:** [normalize](https://4matic.github.io/trackparse/reference/functions/#normalizeinput)
|
|
308
|
+
|
|
293
309
|
The Unicode cleanup every function runs first: NFC, invisible characters, and dash, bracket and
|
|
294
310
|
quote variants.
|
|
295
311
|
|
|
@@ -302,6 +318,8 @@ normalize("Noisia – Stigma (VIP)")
|
|
|
302
318
|
|
|
303
319
|
### `all_artists(track)`
|
|
304
320
|
|
|
321
|
+
**Docs:** [allArtists](https://4matic.github.io/trackparse/reference/functions/#allartiststrack)
|
|
322
|
+
|
|
305
323
|
Credits plus every remixer, deduplicated.
|
|
306
324
|
|
|
307
325
|
```python
|
|
@@ -313,6 +331,8 @@ from trackparse import all_artists
|
|
|
313
331
|
|
|
314
332
|
### `create_parser(**options)`
|
|
315
333
|
|
|
334
|
+
**Docs:** [createParser](https://4matic.github.io/trackparse/reference/functions/#createparseroptions)
|
|
335
|
+
|
|
316
336
|
Compiles the options once, for many calls. Per-call options are merged over the parser's: a value
|
|
317
337
|
passed per call replaces the base one (`known_artists` included), `None` leaves it alone, and
|
|
318
338
|
`keywords` dicts are merged.
|
|
@@ -335,6 +355,8 @@ released together and always share one version number.
|
|
|
335
355
|
|
|
336
356
|
## Modes
|
|
337
357
|
|
|
358
|
+
**Docs:** [Modes](https://4matic.github.io/trackparse/guides/modes/)
|
|
359
|
+
|
|
338
360
|
| Mode | For | Adds |
|
|
339
361
|
|---|---|---|
|
|
340
362
|
| `clean` | Store metadata, tags | Only a spaced dash separates artist and title |
|
|
@@ -358,6 +380,8 @@ parse("Shelter", mode="youtube", uploader="Porter Robinson").artists
|
|
|
358
380
|
|
|
359
381
|
## Tracklists
|
|
360
382
|
|
|
383
|
+
**Docs:** [Tracklists](https://4matic.github.io/trackparse/guides/tracklists/) · [Editing tracklists](https://4matic.github.io/trackparse/guides/editing-tracklists/)
|
|
384
|
+
|
|
361
385
|
`trackparse.tracklist` parses a whole tracklist, one entry per line, and checks it. Each entry
|
|
362
386
|
keeps its line exactly as written plus its own line ending, so `"".join(e.raw + e.eol for e in
|
|
363
387
|
tl.entries)` is always the input, CRLF or not.
|
|
@@ -437,7 +461,7 @@ apply_edits(text, edits, *, <same options>) -> EditResult
|
|
|
437
461
|
| Result | Fields |
|
|
438
462
|
|---|---|
|
|
439
463
|
| `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`) |
|
|
464
|
+
| `TracklistEntry` | `line` (1-based), `kind` (`track`, `marker`, `header`, `link`, `note`, `blank`), `eol`, `raw`, `parts`, `time`, `track` (`ParsedTrack` or `None`), `layered` (the track text after any time starts with `w/`) |
|
|
441
465
|
| `Parts` | `lead`, `time`, `gap`, `rest`, joined they give `raw` |
|
|
442
466
|
| `TracklistWarning` | `code`, `line`, `severity` (`info` or `warning`), `suggestion` |
|
|
443
467
|
| `TimeValue` | `raw`, `seconds` (`entry.time`, or `None` on untimed lines) |
|
|
@@ -445,6 +469,13 @@ apply_edits(text, edits, *, <same options>) -> EditResult
|
|
|
445
469
|
| `Stats` | `lines`, `tracks`, `markers`, `timed`, `first_time`, `last_time` |
|
|
446
470
|
| `EditResult` | `text`, `changed`, `warnings` (`EditWarning(code, edit, line)`) |
|
|
447
471
|
|
|
472
|
+
Lists pasted with list bullets or Markdown emphasis (`* 14:21 Artist - Title`,
|
|
473
|
+
`**[**14:21**] Artist - Title**`) read like plain lines; the decoration stays in `raw` and `parts`.
|
|
474
|
+
A line whose track text (after any time) starts with `w/` is `layered`: a track played over the
|
|
475
|
+
previous one
|
|
476
|
+
([Markdown](https://4matic.github.io/trackparse/guides/tracklists/#markdown),
|
|
477
|
+
[layered tracks](https://4matic.github.io/trackparse/guides/tracklists/#layered-tracks)).
|
|
478
|
+
|
|
448
479
|
Warning codes: `time-missing`, `time-out-of-order`, `time-duplicate`, `no-start-at-zero`,
|
|
449
480
|
`time-after-audio-end` (needs `audio_seconds`), `invalid-time`, `position-gap`,
|
|
450
481
|
`position-duplicate`, `line-unparsed`. Edits: `set-time {line, seconds}`, `shift {seconds, lines? | from?}`
|
|
@@ -467,6 +498,8 @@ parse("Electrosoul System - Liquid Metal", mode="tracklist").title
|
|
|
467
498
|
|
|
468
499
|
## Typing
|
|
469
500
|
|
|
501
|
+
**Docs:** [Types](https://4matic.github.io/trackparse/reference/parse/#types)
|
|
502
|
+
|
|
470
503
|
The package ships `py.typed` and passes `mypy --strict`. `ParsedTrack`, `Artist`, `Version`,
|
|
471
504
|
`Junk`, `Flags`, `Position` and `Timestamp` are frozen dataclasses with snake_case attributes, and
|
|
472
505
|
so are the tracklist results in `trackparse.tracklist`.
|
|
@@ -495,7 +528,7 @@ same spec rule IDs. Behaviour changes start in the spec, not here: see
|
|
|
495
528
|
|
|
496
529
|
## Links
|
|
497
530
|
|
|
498
|
-
- [Documentation](https://4matic.github.io/trackparse/), with
|
|
531
|
+
- [Documentation](https://4matic.github.io/trackparse/), with every example in Python and JavaScript, and a
|
|
499
532
|
[playground](https://4matic.github.io/trackparse/playground/) that runs the parser in your browser
|
|
500
533
|
- [Spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md)
|
|
501
534
|
- [Project README](https://github.com/4matic/trackparse#readme)
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/4matic/trackparse/actions/workflows/python.yml)
|
|
6
6
|
[](https://github.com/4matic/trackparse/blob/main/LICENSE)
|
|
7
7
|
|
|
8
|
-
**[Documentation](https://4matic.github.io/trackparse/)** · **[
|
|
8
|
+
**[Documentation](https://4matic.github.io/trackparse/)** · **[Getting started](https://4matic.github.io/trackparse/start/getting-started/)** · **[Playground](https://4matic.github.io/trackparse/playground/)**
|
|
9
9
|
|
|
10
10
|
**Turn messy track names into structured data: who made it, who's featured, who remixed it, and
|
|
11
11
|
what's just noise.**
|
|
@@ -21,7 +21,7 @@ doesn't recognise stays in the title, word for word.
|
|
|
21
21
|
|
|
22
22
|
This is the Python port of [trackparse](https://github.com/4matic/trackparse). Its behaviour is
|
|
23
23
|
defined by a [shared spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md) and pinned
|
|
24
|
-
by
|
|
24
|
+
by 13,124 fixtures. It passes all of them, and for every fixture input its output is byte-identical
|
|
25
25
|
to the JavaScript package.
|
|
26
26
|
|
|
27
27
|
- Python 3.9 to 3.14
|
|
@@ -39,6 +39,8 @@ uv add trackparse
|
|
|
39
39
|
|
|
40
40
|
## Quick start
|
|
41
41
|
|
|
42
|
+
**Docs:** [Getting started](https://4matic.github.io/trackparse/start/getting-started/)
|
|
43
|
+
|
|
42
44
|
```python
|
|
43
45
|
from trackparse import format_track, parse
|
|
44
46
|
|
|
@@ -110,6 +112,8 @@ print(json.dumps(track.to_dict(), indent=2))
|
|
|
110
112
|
|
|
111
113
|
## API
|
|
112
114
|
|
|
115
|
+
**Docs:** [Other functions](https://4matic.github.io/trackparse/reference/functions/)
|
|
116
|
+
|
|
113
117
|
```python
|
|
114
118
|
parse(input, *, mode="auto", uploader=None, known_artists=(), split_and="auto", keywords=None) -> ParsedTrack
|
|
115
119
|
parse_artists(input, *, <same options>) -> list[Artist]
|
|
@@ -123,6 +127,8 @@ SPEC_VERSION, __version__
|
|
|
123
127
|
|
|
124
128
|
### `parse(input, **options)`
|
|
125
129
|
|
|
130
|
+
**Docs:** [parse and ParsedTrack](https://4matic.github.io/trackparse/reference/parse/) · [Options](https://4matic.github.io/trackparse/reference/options/)
|
|
131
|
+
|
|
126
132
|
| Option | Default | |
|
|
127
133
|
|---|---|---|
|
|
128
134
|
| `mode` | `"auto"` | `"clean"`, `"youtube"`, `"filename"`, `"tracklist"`, or `"auto"` to detect from the string |
|
|
@@ -163,6 +169,8 @@ the defaults. Passing something that isn't a `str` raises `TypeError`.
|
|
|
163
169
|
|
|
164
170
|
### `ParsedTrack`
|
|
165
171
|
|
|
172
|
+
**Docs:** [ParsedTrack fields](https://4matic.github.io/trackparse/reference/parse/#parsedtrack)
|
|
173
|
+
|
|
166
174
|
| Attribute | Type | |
|
|
167
175
|
|---|---|---|
|
|
168
176
|
| `input` | `str` | The original string, untouched |
|
|
@@ -205,6 +213,8 @@ Version types, junk kinds and warnings are listed in the
|
|
|
205
213
|
|
|
206
214
|
### `to_dict()` and `from_dict()`
|
|
207
215
|
|
|
216
|
+
**Docs:** [JSON and Python objects](https://4matic.github.io/trackparse/reference/parse/#json-and-python-objects)
|
|
217
|
+
|
|
208
218
|
Every result class has `to_dict()`, which returns plain dicts and lists matching
|
|
209
219
|
[`parsed-track.schema.json`](https://github.com/4matic/trackparse/blob/main/spec/schema/parsed-track.schema.json)
|
|
210
220
|
(`fullTitle`, `unknownArtist`, ...). With `json.dumps(track.to_dict(), separators=(",", ":"),
|
|
@@ -217,6 +227,8 @@ ParsedTrack.from_dict(track.to_dict()) == track # True
|
|
|
217
227
|
|
|
218
228
|
### `parse_artists(input, **options)`
|
|
219
229
|
|
|
230
|
+
**Docs:** [parseArtists](https://4matic.github.io/trackparse/reference/functions/#parseartistsinput-options)
|
|
231
|
+
|
|
220
232
|
Splits a bare artist string. Takes the same options as `parse`.
|
|
221
233
|
|
|
222
234
|
```python
|
|
@@ -228,6 +240,8 @@ from trackparse import parse_artists
|
|
|
228
240
|
|
|
229
241
|
### `format_track(track, **options)`
|
|
230
242
|
|
|
243
|
+
**Docs:** [format](https://4matic.github.io/trackparse/reference/functions/#formattrack-options) · [Formatting](https://4matic.github.io/trackparse/guides/formatting/)
|
|
244
|
+
|
|
231
245
|
Renders a track back to a string.
|
|
232
246
|
|
|
233
247
|
| Option | Default | |
|
|
@@ -257,6 +271,8 @@ format_track(track, position=True)
|
|
|
257
271
|
|
|
258
272
|
### `normalize(s)`
|
|
259
273
|
|
|
274
|
+
**Docs:** [normalize](https://4matic.github.io/trackparse/reference/functions/#normalizeinput)
|
|
275
|
+
|
|
260
276
|
The Unicode cleanup every function runs first: NFC, invisible characters, and dash, bracket and
|
|
261
277
|
quote variants.
|
|
262
278
|
|
|
@@ -269,6 +285,8 @@ normalize("Noisia – Stigma (VIP)")
|
|
|
269
285
|
|
|
270
286
|
### `all_artists(track)`
|
|
271
287
|
|
|
288
|
+
**Docs:** [allArtists](https://4matic.github.io/trackparse/reference/functions/#allartiststrack)
|
|
289
|
+
|
|
272
290
|
Credits plus every remixer, deduplicated.
|
|
273
291
|
|
|
274
292
|
```python
|
|
@@ -280,6 +298,8 @@ from trackparse import all_artists
|
|
|
280
298
|
|
|
281
299
|
### `create_parser(**options)`
|
|
282
300
|
|
|
301
|
+
**Docs:** [createParser](https://4matic.github.io/trackparse/reference/functions/#createparseroptions)
|
|
302
|
+
|
|
283
303
|
Compiles the options once, for many calls. Per-call options are merged over the parser's: a value
|
|
284
304
|
passed per call replaces the base one (`known_artists` included), `None` leaves it alone, and
|
|
285
305
|
`keywords` dicts are merged.
|
|
@@ -302,6 +322,8 @@ released together and always share one version number.
|
|
|
302
322
|
|
|
303
323
|
## Modes
|
|
304
324
|
|
|
325
|
+
**Docs:** [Modes](https://4matic.github.io/trackparse/guides/modes/)
|
|
326
|
+
|
|
305
327
|
| Mode | For | Adds |
|
|
306
328
|
|---|---|---|
|
|
307
329
|
| `clean` | Store metadata, tags | Only a spaced dash separates artist and title |
|
|
@@ -325,6 +347,8 @@ parse("Shelter", mode="youtube", uploader="Porter Robinson").artists
|
|
|
325
347
|
|
|
326
348
|
## Tracklists
|
|
327
349
|
|
|
350
|
+
**Docs:** [Tracklists](https://4matic.github.io/trackparse/guides/tracklists/) · [Editing tracklists](https://4matic.github.io/trackparse/guides/editing-tracklists/)
|
|
351
|
+
|
|
328
352
|
`trackparse.tracklist` parses a whole tracklist, one entry per line, and checks it. Each entry
|
|
329
353
|
keeps its line exactly as written plus its own line ending, so `"".join(e.raw + e.eol for e in
|
|
330
354
|
tl.entries)` is always the input, CRLF or not.
|
|
@@ -404,7 +428,7 @@ apply_edits(text, edits, *, <same options>) -> EditResult
|
|
|
404
428
|
| Result | Fields |
|
|
405
429
|
|---|---|
|
|
406
430
|
| `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`) |
|
|
431
|
+
| `TracklistEntry` | `line` (1-based), `kind` (`track`, `marker`, `header`, `link`, `note`, `blank`), `eol`, `raw`, `parts`, `time`, `track` (`ParsedTrack` or `None`), `layered` (the track text after any time starts with `w/`) |
|
|
408
432
|
| `Parts` | `lead`, `time`, `gap`, `rest`, joined they give `raw` |
|
|
409
433
|
| `TracklistWarning` | `code`, `line`, `severity` (`info` or `warning`), `suggestion` |
|
|
410
434
|
| `TimeValue` | `raw`, `seconds` (`entry.time`, or `None` on untimed lines) |
|
|
@@ -412,6 +436,13 @@ apply_edits(text, edits, *, <same options>) -> EditResult
|
|
|
412
436
|
| `Stats` | `lines`, `tracks`, `markers`, `timed`, `first_time`, `last_time` |
|
|
413
437
|
| `EditResult` | `text`, `changed`, `warnings` (`EditWarning(code, edit, line)`) |
|
|
414
438
|
|
|
439
|
+
Lists pasted with list bullets or Markdown emphasis (`* 14:21 Artist - Title`,
|
|
440
|
+
`**[**14:21**] Artist - Title**`) read like plain lines; the decoration stays in `raw` and `parts`.
|
|
441
|
+
A line whose track text (after any time) starts with `w/` is `layered`: a track played over the
|
|
442
|
+
previous one
|
|
443
|
+
([Markdown](https://4matic.github.io/trackparse/guides/tracklists/#markdown),
|
|
444
|
+
[layered tracks](https://4matic.github.io/trackparse/guides/tracklists/#layered-tracks)).
|
|
445
|
+
|
|
415
446
|
Warning codes: `time-missing`, `time-out-of-order`, `time-duplicate`, `no-start-at-zero`,
|
|
416
447
|
`time-after-audio-end` (needs `audio_seconds`), `invalid-time`, `position-gap`,
|
|
417
448
|
`position-duplicate`, `line-unparsed`. Edits: `set-time {line, seconds}`, `shift {seconds, lines? | from?}`
|
|
@@ -434,6 +465,8 @@ parse("Electrosoul System - Liquid Metal", mode="tracklist").title
|
|
|
434
465
|
|
|
435
466
|
## Typing
|
|
436
467
|
|
|
468
|
+
**Docs:** [Types](https://4matic.github.io/trackparse/reference/parse/#types)
|
|
469
|
+
|
|
437
470
|
The package ships `py.typed` and passes `mypy --strict`. `ParsedTrack`, `Artist`, `Version`,
|
|
438
471
|
`Junk`, `Flags`, `Position` and `Timestamp` are frozen dataclasses with snake_case attributes, and
|
|
439
472
|
so are the tracklist results in `trackparse.tracklist`.
|
|
@@ -462,7 +495,7 @@ same spec rule IDs. Behaviour changes start in the spec, not here: see
|
|
|
462
495
|
|
|
463
496
|
## Links
|
|
464
497
|
|
|
465
|
-
- [Documentation](https://4matic.github.io/trackparse/), with
|
|
498
|
+
- [Documentation](https://4matic.github.io/trackparse/), with every example in Python and JavaScript, and a
|
|
466
499
|
[playground](https://4matic.github.io/trackparse/playground/) that runs the parser in your browser
|
|
467
500
|
- [Spec](https://github.com/4matic/trackparse/blob/main/spec/SPEC.md)
|
|
468
501
|
- [Project README](https://github.com/4matic/trackparse#readme)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "trackparse"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
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"
|
|
@@ -45,7 +45,7 @@ dependencies = []
|
|
|
45
45
|
|
|
46
46
|
[project.urls]
|
|
47
47
|
Homepage = "https://4matic.github.io/trackparse/"
|
|
48
|
-
Documentation = "https://4matic.github.io/trackparse/
|
|
48
|
+
Documentation = "https://4matic.github.io/trackparse/start/getting-started/"
|
|
49
49
|
Repository = "https://github.com/4matic/trackparse"
|
|
50
50
|
Issues = "https://github.com/4matic/trackparse/issues"
|
|
51
51
|
Changelog = "https://github.com/4matic/trackparse/blob/main/python/CHANGELOG.md"
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
|
-
SPEC_VERSION = "0.
|
|
8
|
+
SPEC_VERSION = "0.4.0" # x-release-please-version
|
|
9
9
|
|
|
10
10
|
# spec/data/descriptors.json
|
|
11
11
|
DESCRIPTORS_DATA: dict[str, Any] = {
|
|
@@ -350,6 +350,13 @@ JUNK_DATA: dict[str, Any] = {
|
|
|
350
350
|
],
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
# spec/data/layered-words.json
|
|
354
|
+
LAYERED_WORDS_DATA: dict[str, Any] = {
|
|
355
|
+
"words": [
|
|
356
|
+
"w/",
|
|
357
|
+
],
|
|
358
|
+
}
|
|
359
|
+
|
|
353
360
|
# spec/data/marker-words.json
|
|
354
361
|
MARKER_WORDS_DATA: dict[str, Any] = {
|
|
355
362
|
"words": [
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""R13.12 Markdown emphasis markers in a tracklist line (js/src/tracklist/emphasis.ts)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ._words import char_at, is_whitespace
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _run_length(s: str, at: int) -> int:
|
|
11
|
+
"""Length of the maximal run of ``*`` or ``_`` at ``at`` (0 when there is none)."""
|
|
12
|
+
ch = char_at(s, at)
|
|
13
|
+
j = at
|
|
14
|
+
if ch == "*" or ch == "_":
|
|
15
|
+
n = len(s)
|
|
16
|
+
while j < n and s[j] == ch:
|
|
17
|
+
j += 1
|
|
18
|
+
return j - at
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def emphasis_run(s: str, at: int) -> int:
|
|
22
|
+
"""R13.2.1 / R13.2.3: the length of an emphasis run at ``at`` (a maximal run of 1 or 2 ``*``
|
|
23
|
+
or ``_``), else 0. Used next to the time, where runs are read by position, not paired."""
|
|
24
|
+
n = _run_length(s, at)
|
|
25
|
+
return n if n <= 2 else 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_outer(ch: Optional[str]) -> bool:
|
|
29
|
+
"""R13.12.2 outer side: line edge, R0.2 whitespace, a bracket, or a ``*`` / ``_``."""
|
|
30
|
+
return ch is None or is_whitespace(ch) or ch in "[]()*_"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_inner(ch: Optional[str]) -> bool:
|
|
34
|
+
"""R13.12.2 inner side: a character that is not R0.2 whitespace."""
|
|
35
|
+
return ch is not None and not is_whitespace(ch)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def without_markers(raw: str, frm: int, open_run: Optional[str] = None) -> str:
|
|
39
|
+
"""R13.12: ``raw[frm:]`` (the line's ``rest``) without its emphasis markers. A run of 1 or 2
|
|
40
|
+
``*`` or ``_`` closes when the character before it is an inner side and the one after it an
|
|
41
|
+
outer side, and opens the other way round. A closer pairs with the nearest opener of the same
|
|
42
|
+
characters; that opener and every opener after it leave the stack. One stack per run keeps
|
|
43
|
+
this linear. Runs before ``frm`` (lead and gap) never pair, except that ``open_run``, a lead
|
|
44
|
+
run the gap left open (R13.12.4), pairs with a closing run of the same character at the end
|
|
45
|
+
of the line."""
|
|
46
|
+
n = len(raw)
|
|
47
|
+
marked = [False] * n
|
|
48
|
+
stacks: dict[str, list[int]] = {"*": [], "**": [], "_": [], "__": []}
|
|
49
|
+
i = frm
|
|
50
|
+
while i < n:
|
|
51
|
+
length = _run_length(raw, i) or 1
|
|
52
|
+
own = stacks.get(raw[i : i + length])
|
|
53
|
+
before = raw[i - 1] if i > 0 else None
|
|
54
|
+
after = char_at(raw, i + length)
|
|
55
|
+
at: Optional[int] = None
|
|
56
|
+
if own and _is_inner(before) and _is_outer(after):
|
|
57
|
+
at = own.pop()
|
|
58
|
+
if at is not None:
|
|
59
|
+
for m in range(length):
|
|
60
|
+
marked[at + m] = True
|
|
61
|
+
marked[i + m] = True
|
|
62
|
+
for st in stacks.values():
|
|
63
|
+
while st and st[-1] > at:
|
|
64
|
+
st.pop()
|
|
65
|
+
elif own is not None and _is_outer(before) and _is_inner(after):
|
|
66
|
+
own.append(i)
|
|
67
|
+
i += length
|
|
68
|
+
# R13.12.4: the last run of the line, after which there is only R0.2 whitespace.
|
|
69
|
+
end = n
|
|
70
|
+
while end > frm and is_whitespace(raw[end - 1]):
|
|
71
|
+
end -= 1
|
|
72
|
+
start = end
|
|
73
|
+
while start > frm and raw[start - 1] == open_run:
|
|
74
|
+
start -= 1
|
|
75
|
+
if (
|
|
76
|
+
open_run is not None
|
|
77
|
+
and end - start <= 2
|
|
78
|
+
and start > frm
|
|
79
|
+
and raw[start - 1] != open_run
|
|
80
|
+
and _is_inner(raw[start - 1])
|
|
81
|
+
):
|
|
82
|
+
m = start
|
|
83
|
+
while m < end and not marked[m]:
|
|
84
|
+
marked[m] = True
|
|
85
|
+
m += 1
|
|
86
|
+
return "".join(raw[k] for k in range(frm, n) if not marked[k])
|
|
@@ -4,12 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
|
-
from ._data import HEADER_WORDS_DATA, MARKER_WORDS_DATA
|
|
7
|
+
from ._data import HEADER_WORDS_DATA, LAYERED_WORDS_DATA, MARKER_WORDS_DATA
|
|
8
8
|
from ._tables import _str_list
|
|
9
9
|
from ._words import PhraseTable, ascii_lower, in_vocab, is_whitespace, split_words
|
|
10
10
|
from .types import ParsedTrack
|
|
11
11
|
|
|
12
12
|
_HEADER_WORDS: frozenset[str] = frozenset(HEADER_WORDS_DATA["words"])
|
|
13
|
+
_LAYERED_WORDS: frozenset[str] = frozenset(LAYERED_WORDS_DATA["words"])
|
|
13
14
|
_default_markers: Optional[PhraseTable[bool]] = None
|
|
14
15
|
|
|
15
16
|
|
|
@@ -63,6 +64,24 @@ def marker_words(text: str) -> list[str]:
|
|
|
63
64
|
return out
|
|
64
65
|
|
|
65
66
|
|
|
67
|
+
def layered_prefix(text: str) -> int:
|
|
68
|
+
"""R13.13: the length of a layered prefix at the start of the line text: R0.2 whitespace, a
|
|
69
|
+
word of ``data/layered-words.json`` (word key, R0.3), then R0.2 whitespace, with more text
|
|
70
|
+
after it. 0 when there is none."""
|
|
71
|
+
n = len(text)
|
|
72
|
+
i = 0
|
|
73
|
+
while i < n and is_whitespace(text[i]):
|
|
74
|
+
i += 1
|
|
75
|
+
j = i
|
|
76
|
+
while j < n and not is_whitespace(text[j]):
|
|
77
|
+
j += 1
|
|
78
|
+
if j in (i, n) or not in_vocab(text[i:j], _LAYERED_WORDS):
|
|
79
|
+
return 0
|
|
80
|
+
while j < n and is_whitespace(text[j]):
|
|
81
|
+
j += 1
|
|
82
|
+
return j if j < n else 0
|
|
83
|
+
|
|
84
|
+
|
|
66
85
|
def is_blank(raw: str) -> bool:
|
|
67
86
|
"""R13.3.1"""
|
|
68
87
|
return all(is_whitespace(c) for c in raw)
|
|
@@ -13,6 +13,7 @@ from ._tracklist_kinds import (
|
|
|
13
13
|
is_blank,
|
|
14
14
|
is_header,
|
|
15
15
|
is_link,
|
|
16
|
+
layered_prefix,
|
|
16
17
|
marker_table,
|
|
17
18
|
)
|
|
18
19
|
from ._tracklist_options import ResolvedOptions
|
|
@@ -30,11 +31,13 @@ class _Row:
|
|
|
30
31
|
"blank",
|
|
31
32
|
"candidate",
|
|
32
33
|
"dash",
|
|
34
|
+
"layered",
|
|
33
35
|
"line",
|
|
34
36
|
"marker",
|
|
35
37
|
"probe",
|
|
36
38
|
"scan",
|
|
37
39
|
"separated",
|
|
40
|
+
"text",
|
|
38
41
|
"track",
|
|
39
42
|
)
|
|
40
43
|
|
|
@@ -42,6 +45,11 @@ class _Row:
|
|
|
42
45
|
self.line = line
|
|
43
46
|
self.scan = scan
|
|
44
47
|
self.blank = blank
|
|
48
|
+
layered = 0 if blank else layered_prefix(scan.text)
|
|
49
|
+
#: R13.4.1 line text: rest without emphasis markers (R13.12) and a layered prefix (R13.13).
|
|
50
|
+
self.text = scan.text[layered:]
|
|
51
|
+
#: R13.13
|
|
52
|
+
self.layered = layered > 0
|
|
45
53
|
#: R13.4.2
|
|
46
54
|
self.candidate = False
|
|
47
55
|
self.dash = False
|
|
@@ -68,11 +76,12 @@ def parse_tracklist_resolved(text: str, opts: ResolvedOptions) -> Tracklist:
|
|
|
68
76
|
rows.append(row)
|
|
69
77
|
if row.blank:
|
|
70
78
|
continue
|
|
71
|
-
probe = lp.probe(
|
|
79
|
+
probe = lp.probe(row.text) # R13.4.1
|
|
72
80
|
row.probe = probe
|
|
73
81
|
row.dash = probe.spaced_dash
|
|
74
82
|
row.candidate = scan.time is not None or row.dash
|
|
75
|
-
|
|
83
|
+
# R13.5 / R13.13.3: a layered line never has a bare position.
|
|
84
|
+
row.bare = None if row.layered else bare_candidate(row.text, probe.position)
|
|
76
85
|
candidates = 0
|
|
77
86
|
dashes = 0
|
|
78
87
|
for r in rows:
|
|
@@ -84,15 +93,16 @@ def parse_tracklist_resolved(text: str, opts: ResolvedOptions) -> Tracklist:
|
|
|
84
93
|
list_context = candidates > 0 and 2 * dashes >= candidates
|
|
85
94
|
|
|
86
95
|
# R13.5: confirmed bare positions are removed before parsing. R13.4.1: one parse per line.
|
|
87
|
-
|
|
96
|
+
# R13.13.3: layered lines are not neighbours for the R13.5 search.
|
|
97
|
+
confirmed = confirm_positions(
|
|
98
|
+
[r.bare for r in rows], [r.candidate and not r.layered for r in rows]
|
|
99
|
+
)
|
|
88
100
|
for i, r in enumerate(rows):
|
|
89
101
|
if r.blank or r.probe is None:
|
|
90
102
|
continue
|
|
91
103
|
bare = r.bare if confirmed[i] else None
|
|
92
104
|
probe = lp.probe(bare.stripped) if bare is not None else r.probe
|
|
93
|
-
r.marker = has_marker_word(
|
|
94
|
-
bare.stripped if bare is not None else r.scan.parts.rest, markers
|
|
95
|
-
)
|
|
105
|
+
r.marker = has_marker_word(bare.stripped if bare is not None else r.text, markers)
|
|
96
106
|
# R13.4.2: the R6.10 tight dash only on timed lines without a marker word.
|
|
97
107
|
track = probe.finish(list_context and r.scan.time is not None and not r.marker)
|
|
98
108
|
if bare is not None:
|
|
@@ -114,6 +124,7 @@ def parse_tracklist_resolved(text: str, opts: ResolvedOptions) -> Tracklist:
|
|
|
114
124
|
parts=r.scan.parts,
|
|
115
125
|
time=r.scan.time,
|
|
116
126
|
track=r.track if keep else None,
|
|
127
|
+
layered=r.layered,
|
|
117
128
|
)
|
|
118
129
|
)
|
|
119
130
|
|
|
@@ -138,10 +149,9 @@ def _kind_of(r: _Row) -> EntryKind:
|
|
|
138
149
|
marker = not r.separated and r.marker
|
|
139
150
|
if r.scan.time is not None:
|
|
140
151
|
return "marker" if marker else "track"
|
|
141
|
-
|
|
142
|
-
if is_link(rest):
|
|
152
|
+
if is_link(r.text):
|
|
143
153
|
return "link"
|
|
144
|
-
if is_header(
|
|
154
|
+
if is_header(r.text):
|
|
145
155
|
return "header"
|
|
146
156
|
if marker:
|
|
147
157
|
return "marker"
|
|
@@ -6,6 +6,7 @@ import math
|
|
|
6
6
|
from typing import NamedTuple, Optional
|
|
7
7
|
|
|
8
8
|
from ._digits import digit_run
|
|
9
|
+
from ._tracklist_emphasis import emphasis_run, without_markers
|
|
9
10
|
from ._tracklist_types import Parts, TimeValue
|
|
10
11
|
from ._words import char_at, is_digit, is_whitespace
|
|
11
12
|
|
|
@@ -33,6 +34,8 @@ class TimeScan(NamedTuple):
|
|
|
33
34
|
style: Optional[TimeStyle]
|
|
34
35
|
#: R13.2.4: a ``digits:digits`` shape after lead that fails R13.2.2.
|
|
35
36
|
invalid: bool
|
|
37
|
+
#: R13.12: ``rest`` without its paired emphasis markers (the line text, before R13.13 / R13.5).
|
|
38
|
+
text: str
|
|
36
39
|
|
|
37
40
|
|
|
38
41
|
def _is_gap_separator(ch: Optional[str]) -> bool:
|
|
@@ -49,7 +52,7 @@ def _skip_whitespace(s: str, frm: int) -> int:
|
|
|
49
52
|
|
|
50
53
|
|
|
51
54
|
def _is_bullet(ch: Optional[str]) -> bool:
|
|
52
|
-
"""R13.2.1 bullets
|
|
55
|
+
"""R13.2.1 bullets (before a time, or alone on a line without one)."""
|
|
53
56
|
return ch == "-" or ch == "–" or ch == "—" or ch == "*" or ch == "•"
|
|
54
57
|
|
|
55
58
|
|
|
@@ -61,7 +64,20 @@ def _untimed(raw: str, invalid: bool) -> TimeScan:
|
|
|
61
64
|
lead_end = 0
|
|
62
65
|
if _is_bullet(char_at(raw, at)) and is_whitespace(char_at(raw, at + 1)):
|
|
63
66
|
lead_end = _skip_whitespace(raw, at + 1)
|
|
64
|
-
return TimeScan(
|
|
67
|
+
return TimeScan(
|
|
68
|
+
Parts(raw[:lead_end], "", "", raw[lead_end:]),
|
|
69
|
+
None,
|
|
70
|
+
None,
|
|
71
|
+
invalid,
|
|
72
|
+
without_markers(raw, lead_end),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _closing_run(raw: str, at: int, open_ch: Optional[str]) -> int:
|
|
77
|
+
"""R13.2.3: a closing run for an opening run of ``open_ch`` (same character, 1 or 2 long)."""
|
|
78
|
+
if open_ch is None or char_at(raw, at) != open_ch:
|
|
79
|
+
return at
|
|
80
|
+
return at + emphasis_run(raw, at)
|
|
65
81
|
|
|
66
82
|
|
|
67
83
|
def seconds_of(whole: int, frac: str) -> float:
|
|
@@ -113,12 +129,28 @@ def shape_seconds(parts: list[str]) -> Optional[int]:
|
|
|
113
129
|
|
|
114
130
|
def scan_time(raw: str) -> TimeScan:
|
|
115
131
|
"""R13.2: lead, time and gap at the start of ``raw``; the rest is everything after."""
|
|
116
|
-
# R13.2.1 lead: whitespace,
|
|
132
|
+
# R13.2.1 lead: whitespace, an optional bullet, an optional emphasis run, an optional `[` /
|
|
133
|
+
# `(`, then (after a bracket) another optional emphasis run. A run counts only when the next
|
|
134
|
+
# character is a bracket (first run) or a digit.
|
|
117
135
|
i = _skip_whitespace(raw, 0)
|
|
136
|
+
if _is_bullet(char_at(raw, i)) and is_whitespace(char_at(raw, i + 1)):
|
|
137
|
+
i = _skip_whitespace(raw, i + 1)
|
|
138
|
+
outer_open: Optional[str] = None
|
|
139
|
+
n = emphasis_run(raw, i)
|
|
140
|
+
if n:
|
|
141
|
+
after = char_at(raw, i + n)
|
|
142
|
+
if after == "[" or after == "(" or is_digit(after):
|
|
143
|
+
outer_open = raw[i]
|
|
144
|
+
i += n
|
|
118
145
|
first_ch = char_at(raw, i)
|
|
119
146
|
closer = "]" if first_ch == "[" else ")" if first_ch == "(" else None
|
|
147
|
+
inner_open: Optional[str] = None
|
|
120
148
|
if closer is not None:
|
|
121
149
|
i += 1
|
|
150
|
+
n = emphasis_run(raw, i)
|
|
151
|
+
if n and is_digit(char_at(raw, i + n)):
|
|
152
|
+
inner_open = raw[i]
|
|
153
|
+
i += n
|
|
122
154
|
lead_end = i
|
|
123
155
|
|
|
124
156
|
# R13.2.2 time
|
|
@@ -141,13 +173,32 @@ def scan_time(raw: str) -> TimeScan:
|
|
|
141
173
|
j += 1 + n
|
|
142
174
|
time_end = j
|
|
143
175
|
|
|
144
|
-
# R13.2.3 gap
|
|
176
|
+
# R13.2.3 gap: closing runs for the lead's runs (same character), around the closing bracket.
|
|
177
|
+
def follows_time(at: int) -> bool:
|
|
178
|
+
return at >= len(raw) or is_whitespace(raw[at]) or _is_gap_separator(raw[at])
|
|
179
|
+
|
|
180
|
+
# R13.12.4: a lead run the gap leaves open may pair with a run at the end of the line.
|
|
181
|
+
open_run: Optional[str] = None
|
|
145
182
|
if closer is not None:
|
|
183
|
+
k = _closing_run(raw, j, inner_open)
|
|
184
|
+
if k == j:
|
|
185
|
+
open_run = inner_open
|
|
186
|
+
j = k
|
|
146
187
|
if char_at(raw, j) != closer:
|
|
147
188
|
return _untimed(raw, False)
|
|
148
189
|
j += 1
|
|
149
|
-
|
|
150
|
-
|
|
190
|
+
k = _closing_run(raw, j, outer_open)
|
|
191
|
+
if k > j and follows_time(k):
|
|
192
|
+
j = k
|
|
193
|
+
elif outer_open is not None:
|
|
194
|
+
open_run = outer_open
|
|
195
|
+
else:
|
|
196
|
+
k = _closing_run(raw, j, outer_open)
|
|
197
|
+
if k == j:
|
|
198
|
+
open_run = outer_open
|
|
199
|
+
j = k
|
|
200
|
+
if not follows_time(j):
|
|
201
|
+
return _untimed(raw, False)
|
|
151
202
|
j = _skip_whitespace(raw, j)
|
|
152
203
|
if _is_gap_separator(char_at(raw, j)):
|
|
153
204
|
j = _skip_whitespace(raw, j + 1)
|
|
@@ -181,6 +232,7 @@ def scan_time(raw: str) -> TimeScan:
|
|
|
181
232
|
frac_digits=len(frac),
|
|
182
233
|
),
|
|
183
234
|
False,
|
|
235
|
+
without_markers(raw, j, open_run),
|
|
184
236
|
)
|
|
185
237
|
|
|
186
238
|
|
|
@@ -77,6 +77,8 @@ class TracklistEntry:
|
|
|
77
77
|
time: Optional[TimeValue]
|
|
78
78
|
#: The line's ParsedTrack (``track`` and ``marker`` entries only).
|
|
79
79
|
track: Optional[ParsedTrack]
|
|
80
|
+
#: R13.13: the line starts with ``w/``: played over the previous track.
|
|
81
|
+
layered: bool
|
|
80
82
|
|
|
81
83
|
def to_dict(self) -> dict[str, Any]:
|
|
82
84
|
return {
|
|
@@ -87,6 +89,7 @@ class TracklistEntry:
|
|
|
87
89
|
"parts": self.parts.to_dict(),
|
|
88
90
|
"time": None if self.time is None else self.time.to_dict(),
|
|
89
91
|
"track": None if self.track is None else self.track.to_dict(),
|
|
92
|
+
"layered": self.layered,
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
|
|
@@ -181,7 +181,8 @@ def tracklist_warnings(
|
|
|
181
181
|
timed = [i for i, e in enumerate(entries) if e.time is not None]
|
|
182
182
|
|
|
183
183
|
for i, e in enumerate(entries):
|
|
184
|
-
|
|
184
|
+
# R13.13.3: an untimed layered line plays over the previous track, at its time.
|
|
185
|
+
if e.kind == "track" and e.time is None and not e.layered and len(timed) > 0:
|
|
185
186
|
add(i, "time-missing")
|
|
186
187
|
if invalid[i]:
|
|
187
188
|
add(i, "invalid-time")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|