trackparse 0.3.3__tar.gz → 0.5.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.
Files changed (37) hide show
  1. {trackparse-0.3.3 → trackparse-0.5.0}/.gitignore +0 -1
  2. {trackparse-0.3.3 → trackparse-0.5.0}/PKG-INFO +10 -3
  3. {trackparse-0.3.3 → trackparse-0.5.0}/README.md +9 -2
  4. {trackparse-0.3.3 → trackparse-0.5.0}/pyproject.toml +1 -1
  5. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/__init__.py +1 -1
  6. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_data.py +8 -1
  7. trackparse-0.5.0/src/trackparse/_tracklist_emphasis.py +86 -0
  8. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_tracklist_kinds.py +20 -1
  9. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_tracklist_parse.py +19 -9
  10. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_tracklist_times.py +58 -6
  11. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_tracklist_types.py +3 -0
  12. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_tracklist_warnings.py +2 -1
  13. {trackparse-0.3.3 → trackparse-0.5.0}/LICENSE +0 -0
  14. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_artists.py +0 -0
  15. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_assemble.py +0 -0
  16. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_classify.py +0 -0
  17. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_context.py +0 -0
  18. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_credits.py +0 -0
  19. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_digits.py +0 -0
  20. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_format.py +0 -0
  21. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_mode.py +0 -0
  22. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_normalize.py +0 -0
  23. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_parser.py +0 -0
  24. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_prefix.py +0 -0
  25. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_scanner.py +0 -0
  26. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_separator.py +0 -0
  27. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_tables.py +0 -0
  28. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_title.py +0 -0
  29. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_tracklist_edits.py +0 -0
  30. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_tracklist_options.py +0 -0
  31. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_tracklist_positions.py +0 -0
  32. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_tracklist_split.py +0 -0
  33. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/_words.py +0 -0
  34. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/options.py +0 -0
  35. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/py.typed +0 -0
  36. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/tracklist.py +0 -0
  37. {trackparse-0.3.3 → trackparse-0.5.0}/src/trackparse/types.py +0 -0
@@ -18,7 +18,6 @@ python/dist/
18
18
 
19
19
  # Rust
20
20
  target/
21
- rust/Cargo.lock
22
21
 
23
22
  # Editors / OS
24
23
  .DS_Store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: trackparse
3
- Version: 0.3.3
3
+ Version: 0.5.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
6
  Project-URL: Documentation, https://4matic.github.io/trackparse/start/getting-started/
@@ -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 12,965 fixtures. It passes all of them, and for every fixture input its output is byte-identical
57
+ by 13,130 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
@@ -461,7 +461,7 @@ apply_edits(text, edits, *, <same options>) -> EditResult
461
461
  | Result | Fields |
462
462
  |---|---|
463
463
  | `Tracklist` | `spec_version`, `entries`, `warnings`, `stats` |
464
- | `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/`) |
465
465
  | `Parts` | `lead`, `time`, `gap`, `rest`, joined they give `raw` |
466
466
  | `TracklistWarning` | `code`, `line`, `severity` (`info` or `warning`), `suggestion` |
467
467
  | `TimeValue` | `raw`, `seconds` (`entry.time`, or `None` on untimed lines) |
@@ -469,6 +469,13 @@ apply_edits(text, edits, *, <same options>) -> EditResult
469
469
  | `Stats` | `lines`, `tracks`, `markers`, `timed`, `first_time`, `last_time` |
470
470
  | `EditResult` | `text`, `changed`, `warnings` (`EditWarning(code, edit, line)`) |
471
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
+
472
479
  Warning codes: `time-missing`, `time-out-of-order`, `time-duplicate`, `no-start-at-zero`,
473
480
  `time-after-audio-end` (needs `audio_seconds`), `invalid-time`, `position-gap`,
474
481
  `position-duplicate`, `line-unparsed`. Edits: `set-time {line, seconds}`, `shift {seconds, lines? | from?}`
@@ -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 12,965 fixtures. It passes all of them, and for every fixture input its output is byte-identical
24
+ by 13,130 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
@@ -428,7 +428,7 @@ apply_edits(text, edits, *, <same options>) -> EditResult
428
428
  | Result | Fields |
429
429
  |---|---|
430
430
  | `Tracklist` | `spec_version`, `entries`, `warnings`, `stats` |
431
- | `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/`) |
432
432
  | `Parts` | `lead`, `time`, `gap`, `rest`, joined they give `raw` |
433
433
  | `TracklistWarning` | `code`, `line`, `severity` (`info` or `warning`), `suggestion` |
434
434
  | `TimeValue` | `raw`, `seconds` (`entry.time`, or `None` on untimed lines) |
@@ -436,6 +436,13 @@ apply_edits(text, edits, *, <same options>) -> EditResult
436
436
  | `Stats` | `lines`, `tracks`, `markers`, `timed`, `first_time`, `last_time` |
437
437
  | `EditResult` | `text`, `changed`, `warnings` (`EditWarning(code, edit, line)`) |
438
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
+
439
446
  Warning codes: `time-missing`, `time-out-of-order`, `time-duplicate`, `no-start-at-zero`,
440
447
  `time-after-audio-end` (needs `audio_seconds`), `invalid-time`, `position-gap`,
441
448
  `position-duplicate`, `line-unparsed`. Edits: `set-time {line, seconds}`, `shift {seconds, lines? | from?}`
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "trackparse"
7
- version = "0.3.3"
7
+ version = "0.5.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"
@@ -33,7 +33,7 @@ from .types import (
33
33
  Warning,
34
34
  )
35
35
 
36
- __version__ = "0.3.3" # x-release-please-version
36
+ __version__ = "0.5.0" # 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.3.3" # x-release-please-version
8
+ SPEC_VERSION = "0.5.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(scan.parts.rest)
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
- row.bare = bare_candidate(scan.parts.rest, probe.position) # R13.5
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
- confirmed = confirm_positions([r.bare for r in rows], [r.candidate for r in rows])
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
- rest = r.scan.parts.rest
142
- if is_link(rest):
152
+ if is_link(r.text):
143
153
  return "link"
144
- if is_header(rest):
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 of a line without a time."""
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(Parts(raw[:lead_end], "", "", raw[lead_end:]), None, None, invalid)
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, then an optional `[` / `(`.
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
- elif j < len(raw) and not is_whitespace(raw[j]) and not _is_gap_separator(raw[j]):
150
- return _untimed(raw, False)
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
- if e.kind == "track" and e.time is None and len(timed) > 0:
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