tocparser 0.1.0__py3-none-any.whl
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.
- tocparser/__init__.py +88 -0
- tocparser/errors.py +80 -0
- tocparser/grammar.lark +89 -0
- tocparser/models.py +739 -0
- tocparser/parser.py +695 -0
- tocparser/py.typed +0 -0
- tocparser/serializer.py +243 -0
- tocparser/times.py +92 -0
- tocparser-0.1.0.dist-info/METADATA +205 -0
- tocparser-0.1.0.dist-info/RECORD +12 -0
- tocparser-0.1.0.dist-info/WHEEL +4 -0
- tocparser-0.1.0.dist-info/licenses/LICENSE.txt +201 -0
tocparser/models.py
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
"""Pydantic models describing the contents of a TOC file.
|
|
2
|
+
|
|
3
|
+
The hierarchy mirrors cdrdao's own grammar: a :class:`Toc` carries disc level
|
|
4
|
+
metadata and a list of :class:`Track`, and each track carries flags, optional
|
|
5
|
+
CD-TEXT and an ordered list of statements describing where its data comes
|
|
6
|
+
from.
|
|
7
|
+
|
|
8
|
+
Every rule cdrdao enforces on the text is enforced when a model is built, so a
|
|
9
|
+
model can only describe a file cdrdao would accept. A broken rule raises
|
|
10
|
+
:class:`~tocparser.errors.TocValidationError`, whose ``loc`` says where the
|
|
11
|
+
problem is; a value of the wrong type raises pydantic's ``ValidationError``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Annotated, ClassVar, Literal, NoReturn, TypeAlias, TypeVar
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, model_validator
|
|
22
|
+
|
|
23
|
+
from tocparser.errors import TocValidationError
|
|
24
|
+
from tocparser.times import Msf, Time
|
|
25
|
+
|
|
26
|
+
# cdrdao's syntax has no negative numbers, so neither do the models: a bare
|
|
27
|
+
# integer time or a byte offset below zero could not be written back.
|
|
28
|
+
_TimeField: TypeAlias = Msf | NonNegativeInt
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CdText",
|
|
32
|
+
"CdTextBlock",
|
|
33
|
+
"CdTextEncoding",
|
|
34
|
+
"CdTextItemName",
|
|
35
|
+
"CdTextValue",
|
|
36
|
+
"ContentItem",
|
|
37
|
+
"DataFile",
|
|
38
|
+
"DataMode",
|
|
39
|
+
"DiscType",
|
|
40
|
+
"End",
|
|
41
|
+
"Fifo",
|
|
42
|
+
"File",
|
|
43
|
+
"Silence",
|
|
44
|
+
"Start",
|
|
45
|
+
"SubChannelMode",
|
|
46
|
+
"Toc",
|
|
47
|
+
"Track",
|
|
48
|
+
"TrackMode",
|
|
49
|
+
"TrackStatement",
|
|
50
|
+
"Zero",
|
|
51
|
+
"cd_text_codec",
|
|
52
|
+
"validate_binary_value",
|
|
53
|
+
"validate_block_number",
|
|
54
|
+
"validate_catalog",
|
|
55
|
+
"validate_first_track_number",
|
|
56
|
+
"validate_isrc",
|
|
57
|
+
"validate_language_code",
|
|
58
|
+
"validate_language_number",
|
|
59
|
+
"validate_non_zero_length",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
_T = TypeVar("_T")
|
|
63
|
+
|
|
64
|
+
_CATALOG_RE = re.compile(r"\A[0-9]{13}\Z")
|
|
65
|
+
_ISRC_RE = re.compile(r"\A[A-Z0-9]{5}[0-9]{7}\Z")
|
|
66
|
+
|
|
67
|
+
MAX_INDEX_INCREMENTS = 98
|
|
68
|
+
#: cdrdao's ``MAX_CD_TEXT_DATA_LEN``.
|
|
69
|
+
MAX_BINARY_LENGTH = 256 * 12
|
|
70
|
+
|
|
71
|
+
# cdrdao's messages, shared with the parser so both report the same text.
|
|
72
|
+
PREGAP_IS_ZERO = "Length of pregap is zero."
|
|
73
|
+
SILENCE_IS_ZERO = "Length of silence is 0."
|
|
74
|
+
ZERO_DATA_IS_ZERO = "Length of zero data is 0."
|
|
75
|
+
MIXED_STATEMENTS = (
|
|
76
|
+
"Mixing of FILE/AUDIOFILE/SILENCE and DATAFILE/ZERO statements not allowed."
|
|
77
|
+
)
|
|
78
|
+
BINARY_DATA_TOO_LONG = f"Binary data exceeds maximum length ({MAX_BINARY_LENGTH})."
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DiscType(str, Enum):
|
|
82
|
+
"""The session type declared by the leading disc flag."""
|
|
83
|
+
|
|
84
|
+
CD_DA = "CD_DA"
|
|
85
|
+
CD_ROM = "CD_ROM"
|
|
86
|
+
CD_ROM_XA = "CD_ROM_XA"
|
|
87
|
+
CD_I = "CD_I"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TrackMode(str, Enum):
|
|
91
|
+
"""The sector layout of a track's data."""
|
|
92
|
+
|
|
93
|
+
AUDIO = "AUDIO"
|
|
94
|
+
MODE1 = "MODE1"
|
|
95
|
+
MODE1_RAW = "MODE1_RAW"
|
|
96
|
+
MODE2 = "MODE2"
|
|
97
|
+
MODE2_RAW = "MODE2_RAW"
|
|
98
|
+
MODE2_FORM1 = "MODE2_FORM1"
|
|
99
|
+
MODE2_FORM2 = "MODE2_FORM2"
|
|
100
|
+
MODE2_FORM_MIX = "MODE2_FORM_MIX"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class DataMode(str, Enum):
|
|
104
|
+
"""Sector layouts accepted by a ``ZERO`` statement.
|
|
105
|
+
|
|
106
|
+
Identical to :class:`TrackMode` plus ``MODE0``, which cdrdao's grammar
|
|
107
|
+
only reaches through ``ZERO``.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
AUDIO = "AUDIO"
|
|
111
|
+
MODE0 = "MODE0"
|
|
112
|
+
MODE1 = "MODE1"
|
|
113
|
+
MODE1_RAW = "MODE1_RAW"
|
|
114
|
+
MODE2 = "MODE2"
|
|
115
|
+
MODE2_RAW = "MODE2_RAW"
|
|
116
|
+
MODE2_FORM1 = "MODE2_FORM1"
|
|
117
|
+
MODE2_FORM2 = "MODE2_FORM2"
|
|
118
|
+
MODE2_FORM_MIX = "MODE2_FORM_MIX"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SubChannelMode(str, Enum):
|
|
122
|
+
"""The type of R-W sub-channel data carried by each sector."""
|
|
123
|
+
|
|
124
|
+
RW = "RW"
|
|
125
|
+
RW_RAW = "RW_RAW"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
#: Bytes of user data per sector, per sector layout.
|
|
129
|
+
BLOCK_SIZES: dict[str, int] = {
|
|
130
|
+
"AUDIO": 2352,
|
|
131
|
+
"MODE0": 2336,
|
|
132
|
+
"MODE1": 2048,
|
|
133
|
+
"MODE1_RAW": 2352,
|
|
134
|
+
"MODE2": 2336,
|
|
135
|
+
"MODE2_RAW": 2352,
|
|
136
|
+
"MODE2_FORM1": 2048,
|
|
137
|
+
"MODE2_FORM2": 2324,
|
|
138
|
+
"MODE2_FORM_MIX": 2336,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#: Extra bytes per sector when a sub-channel mode is in use.
|
|
142
|
+
SUB_CHANNEL_SIZE = 96
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def block_size(
|
|
146
|
+
mode: TrackMode | DataMode, sub_channel_mode: SubChannelMode | None
|
|
147
|
+
) -> int:
|
|
148
|
+
"""Return the sector size for ``mode``, including sub-channel data."""
|
|
149
|
+
size = BLOCK_SIZES[mode.value]
|
|
150
|
+
if sub_channel_mode is not None:
|
|
151
|
+
size += SUB_CHANNEL_SIZE
|
|
152
|
+
return size
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class CdTextItemName(str, Enum):
|
|
156
|
+
"""The CD-TEXT pack types cdrdao's grammar accepts."""
|
|
157
|
+
|
|
158
|
+
TITLE = "TITLE"
|
|
159
|
+
PERFORMER = "PERFORMER"
|
|
160
|
+
SONGWRITER = "SONGWRITER"
|
|
161
|
+
COMPOSER = "COMPOSER"
|
|
162
|
+
ARRANGER = "ARRANGER"
|
|
163
|
+
MESSAGE = "MESSAGE"
|
|
164
|
+
DISC_ID = "DISC_ID"
|
|
165
|
+
GENRE = "GENRE"
|
|
166
|
+
TOC_INFO1 = "TOC_INFO1"
|
|
167
|
+
TOC_INFO2 = "TOC_INFO2"
|
|
168
|
+
RESERVED1 = "RESERVED1"
|
|
169
|
+
RESERVED2 = "RESERVED2"
|
|
170
|
+
RESERVED3 = "RESERVED3"
|
|
171
|
+
RESERVED4 = "RESERVED4"
|
|
172
|
+
CLOSED = "CLOSED"
|
|
173
|
+
UPC_EAN = "UPC_EAN"
|
|
174
|
+
ISRC = "ISRC"
|
|
175
|
+
SIZE_INFO = "SIZE_INFO"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
#: Pairs of names cdrdao reads as the same pack: ``UPC_EAN`` is what it writes
|
|
179
|
+
#: on the disc and ``ISRC`` what it writes on a track, and ``RESERVED4`` is an
|
|
180
|
+
#: old spelling of ``CLOSED``.
|
|
181
|
+
CD_TEXT_ALIASES: dict[CdTextItemName, CdTextItemName] = {
|
|
182
|
+
CdTextItemName.UPC_EAN: CdTextItemName.ISRC,
|
|
183
|
+
CdTextItemName.ISRC: CdTextItemName.UPC_EAN,
|
|
184
|
+
CdTextItemName.RESERVED4: CdTextItemName.CLOSED,
|
|
185
|
+
CdTextItemName.CLOSED: CdTextItemName.RESERVED4,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#: CD-TEXT items cdrdao rejects inside a track's ``CD_TEXT`` block.
|
|
189
|
+
DISC_ONLY_CD_TEXT_ITEMS = frozenset(
|
|
190
|
+
{
|
|
191
|
+
CdTextItemName.GENRE,
|
|
192
|
+
CdTextItemName.TOC_INFO1,
|
|
193
|
+
CdTextItemName.TOC_INFO2,
|
|
194
|
+
CdTextItemName.SIZE_INFO,
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class CdTextEncoding(str, Enum):
|
|
200
|
+
"""The character set of a CD-TEXT language block (cdrdao 1.2.5 and later)."""
|
|
201
|
+
|
|
202
|
+
ISO_8859_1 = "ENCODING_ISO_8859_1"
|
|
203
|
+
ASCII = "ENCODING_ASCII"
|
|
204
|
+
MS_JIS = "ENCODING_MS_JIS"
|
|
205
|
+
KOREAN = "ENCODING_KOREAN"
|
|
206
|
+
MANDARIN = "ENCODING_MANDARIN"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cd_text_codec(encoding: CdTextEncoding) -> str:
|
|
210
|
+
"""The Python codec matching the conversion cdrdao applies for ``encoding``.
|
|
211
|
+
|
|
212
|
+
cdrdao has no converter for Korean or Mandarin and treats both as
|
|
213
|
+
ISO-8859-1, so they share its codec.
|
|
214
|
+
"""
|
|
215
|
+
if encoding is CdTextEncoding.ASCII:
|
|
216
|
+
return "ascii"
|
|
217
|
+
if encoding is CdTextEncoding.MS_JIS:
|
|
218
|
+
return "cp932"
|
|
219
|
+
return "latin-1"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
#: A CD-TEXT value is either a string or a run of raw bytes.
|
|
223
|
+
CdTextValue: TypeAlias = str | list[int]
|
|
224
|
+
|
|
225
|
+
MAX_LANGUAGE_NUMBER = 7
|
|
226
|
+
MAX_LANGUAGE_CODE = 255
|
|
227
|
+
MAX_BINARY_BYTE = 255
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
#: The only symbolic country code cdrdao's lexer knows.
|
|
231
|
+
LANGUAGE_CODE_EN = 9
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _fail(message: str, *loc: str | int) -> NoReturn:
|
|
235
|
+
raise TocValidationError(message, loc=loc)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _check_at(check: Callable[[_T], object], value: _T, *loc: str | int) -> None:
|
|
239
|
+
"""Run one of the ``validate_*`` checks, locating any failure at ``loc``."""
|
|
240
|
+
try:
|
|
241
|
+
check(value)
|
|
242
|
+
except TocValidationError as exc:
|
|
243
|
+
_fail(exc.message, *loc)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _is_zero_length(length: Time) -> bool:
|
|
247
|
+
return length.total_frames == 0 if isinstance(length, Msf) else length == 0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def validate_catalog(catalog: str) -> str:
|
|
251
|
+
"""Check a ``CATALOG`` value: cdrdao requires exactly 13 digits."""
|
|
252
|
+
if not _CATALOG_RE.match(catalog):
|
|
253
|
+
raise TocValidationError(f"Illegal catalog number: {catalog}.")
|
|
254
|
+
return catalog
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def validate_isrc(isrc: str) -> str:
|
|
258
|
+
"""Check a track ``ISRC`` value against cdrdao's ``CCOOOYYSSSSS`` format."""
|
|
259
|
+
if not _ISRC_RE.match(isrc):
|
|
260
|
+
raise TocValidationError(f"Illegal ISRC code: {isrc}.")
|
|
261
|
+
return isrc
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def validate_first_track_number(number: int) -> int:
|
|
265
|
+
"""Check a ``FIRST_TRACK_NO`` value."""
|
|
266
|
+
if not 1 <= number <= 99:
|
|
267
|
+
raise TocValidationError(f"Illegal track number: {number}")
|
|
268
|
+
return number
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def validate_language_number(number: int) -> int:
|
|
272
|
+
"""Check the language number of a ``LANGUAGE_MAP`` entry."""
|
|
273
|
+
if not 0 <= number <= MAX_LANGUAGE_NUMBER:
|
|
274
|
+
raise TocValidationError(
|
|
275
|
+
f"Invalid language number, allowed range: [0..{MAX_LANGUAGE_NUMBER}]."
|
|
276
|
+
)
|
|
277
|
+
return number
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def validate_block_number(number: int) -> int:
|
|
281
|
+
"""Check the number of a ``LANGUAGE`` block."""
|
|
282
|
+
if not 0 <= number <= MAX_LANGUAGE_NUMBER:
|
|
283
|
+
raise TocValidationError(
|
|
284
|
+
f"Invalid block number, allowed range: [0..{MAX_LANGUAGE_NUMBER}]."
|
|
285
|
+
)
|
|
286
|
+
return number
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def validate_language_code(code: int) -> int:
|
|
290
|
+
"""Check a country code from a ``LANGUAGE_MAP`` entry."""
|
|
291
|
+
if not 0 <= code <= MAX_LANGUAGE_CODE:
|
|
292
|
+
raise TocValidationError(
|
|
293
|
+
f"Invalid language code, allowed range: [0..{MAX_LANGUAGE_CODE}]."
|
|
294
|
+
)
|
|
295
|
+
return code
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def validate_non_zero_length(length: Time, message: str) -> Time:
|
|
299
|
+
"""Check a ``PREGAP``/``SILENCE``/``ZERO`` length, which may not be zero."""
|
|
300
|
+
if _is_zero_length(length):
|
|
301
|
+
raise TocValidationError(message)
|
|
302
|
+
return length
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def validate_binary_value(value: int) -> int:
|
|
306
|
+
"""Check one byte of a binary CD-TEXT value."""
|
|
307
|
+
if not 0 <= value <= MAX_BINARY_BYTE:
|
|
308
|
+
raise TocValidationError(f"Illegal binary data: {value}")
|
|
309
|
+
return value
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class _Model(BaseModel):
|
|
313
|
+
"""Base for every tocparser model: unknown fields are rejected."""
|
|
314
|
+
|
|
315
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class CdTextBlock(_Model):
|
|
319
|
+
"""The CD-TEXT items defined for one language.
|
|
320
|
+
|
|
321
|
+
``encoding`` is the ``ENCODING_*`` keyword written at the top of the block,
|
|
322
|
+
if any. cdrdao only honors it on the disc's ``CD_TEXT``; track blocks
|
|
323
|
+
follow the disc block with the same number, see
|
|
324
|
+
:meth:`Toc.cd_text_encoding`.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
encoding: CdTextEncoding | None = None
|
|
328
|
+
items: dict[CdTextItemName, CdTextValue] = Field(default_factory=dict)
|
|
329
|
+
|
|
330
|
+
@model_validator(mode="after")
|
|
331
|
+
def _check(self) -> CdTextBlock:
|
|
332
|
+
for name, value in self.items.items():
|
|
333
|
+
loc = ("items", name.value)
|
|
334
|
+
alias = CD_TEXT_ALIASES.get(name)
|
|
335
|
+
if alias is not None and alias in self.items:
|
|
336
|
+
_fail(
|
|
337
|
+
f"{name.value} and {alias.value} are the same CD-TEXT item.", *loc
|
|
338
|
+
)
|
|
339
|
+
if isinstance(value, list):
|
|
340
|
+
for byte in value:
|
|
341
|
+
_check_at(validate_binary_value, byte, *loc)
|
|
342
|
+
if len(value) > MAX_BINARY_LENGTH:
|
|
343
|
+
_fail(BINARY_DATA_TOO_LONG, *loc)
|
|
344
|
+
return self
|
|
345
|
+
|
|
346
|
+
def get(self, name: CdTextItemName) -> CdTextValue | None:
|
|
347
|
+
"""Return the value of ``name``, or ``None`` when it is not defined.
|
|
348
|
+
|
|
349
|
+
A value stored under the other spelling of the same pack (see
|
|
350
|
+
:data:`CD_TEXT_ALIASES`) is found too.
|
|
351
|
+
"""
|
|
352
|
+
value = self.items.get(name)
|
|
353
|
+
alias = CD_TEXT_ALIASES.get(name)
|
|
354
|
+
if value is None and alias is not None:
|
|
355
|
+
value = self.items.get(alias)
|
|
356
|
+
return value
|
|
357
|
+
|
|
358
|
+
def text(self, name: CdTextItemName) -> str | None:
|
|
359
|
+
"""Return ``name`` when it holds a string, otherwise ``None``."""
|
|
360
|
+
value = self.get(name)
|
|
361
|
+
return value if isinstance(value, str) else None
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def title(self) -> str | None:
|
|
365
|
+
return self.text(CdTextItemName.TITLE)
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def performer(self) -> str | None:
|
|
369
|
+
return self.text(CdTextItemName.PERFORMER)
|
|
370
|
+
|
|
371
|
+
@property
|
|
372
|
+
def songwriter(self) -> str | None:
|
|
373
|
+
return self.text(CdTextItemName.SONGWRITER)
|
|
374
|
+
|
|
375
|
+
@property
|
|
376
|
+
def composer(self) -> str | None:
|
|
377
|
+
return self.text(CdTextItemName.COMPOSER)
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def arranger(self) -> str | None:
|
|
381
|
+
return self.text(CdTextItemName.ARRANGER)
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def message(self) -> str | None:
|
|
385
|
+
return self.text(CdTextItemName.MESSAGE)
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def disc_id(self) -> str | None:
|
|
389
|
+
return self.text(CdTextItemName.DISC_ID)
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def upc_ean(self) -> str | None:
|
|
393
|
+
"""The UPC/EAN code; the same pack as :attr:`isrc`."""
|
|
394
|
+
return self.text(CdTextItemName.UPC_EAN)
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def isrc(self) -> str | None:
|
|
398
|
+
"""The ISRC code; the same pack as :attr:`upc_ean`."""
|
|
399
|
+
return self.text(CdTextItemName.ISRC)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class CdText(_Model):
|
|
403
|
+
"""A ``CD_TEXT`` block: an optional language map plus per-language items.
|
|
404
|
+
|
|
405
|
+
``cd_text[n]`` returns block ``n`` and ``n in cd_text`` tests for it.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
language_map: dict[int, int] = Field(default_factory=dict)
|
|
409
|
+
blocks: dict[int, CdTextBlock] = Field(default_factory=dict)
|
|
410
|
+
|
|
411
|
+
@model_validator(mode="after")
|
|
412
|
+
def _check(self) -> CdText:
|
|
413
|
+
for number, code in self.language_map.items():
|
|
414
|
+
_check_at(validate_language_number, number, "language_map", number)
|
|
415
|
+
_check_at(validate_language_code, code, "language_map", number)
|
|
416
|
+
for number in self.blocks:
|
|
417
|
+
_check_at(validate_block_number, number, "blocks", number)
|
|
418
|
+
return self
|
|
419
|
+
|
|
420
|
+
def __getitem__(self, language: int) -> CdTextBlock:
|
|
421
|
+
return self.blocks[language]
|
|
422
|
+
|
|
423
|
+
def __contains__(self, language: int) -> bool:
|
|
424
|
+
return language in self.blocks
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class File(_Model):
|
|
428
|
+
"""``FILE`` or ``AUDIOFILE``: audio data taken from a file."""
|
|
429
|
+
|
|
430
|
+
kind: Literal["file"] = "file"
|
|
431
|
+
filename: str
|
|
432
|
+
start: _TimeField
|
|
433
|
+
length: _TimeField | None = None
|
|
434
|
+
swap: bool = False
|
|
435
|
+
#: Byte offset given as ``#N``, which also forces raw (headerless) reading.
|
|
436
|
+
offset: NonNegativeInt | None = None
|
|
437
|
+
#: ``True`` when the statement was spelled ``AUDIOFILE``.
|
|
438
|
+
audiofile: bool = False
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class DataFile(_Model):
|
|
442
|
+
"""``DATAFILE``: sector data taken from a file."""
|
|
443
|
+
|
|
444
|
+
kind: Literal["datafile"] = "datafile"
|
|
445
|
+
filename: str
|
|
446
|
+
length: _TimeField | None = None
|
|
447
|
+
offset: NonNegativeInt | None = None
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class Fifo(_Model):
|
|
451
|
+
"""``FIFO``: data read from a named pipe."""
|
|
452
|
+
|
|
453
|
+
kind: Literal["fifo"] = "fifo"
|
|
454
|
+
filename: str
|
|
455
|
+
length: _TimeField
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class Silence(_Model):
|
|
459
|
+
"""``SILENCE``: zeroed audio data."""
|
|
460
|
+
|
|
461
|
+
kind: Literal["silence"] = "silence"
|
|
462
|
+
length: _TimeField
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class Zero(_Model):
|
|
466
|
+
"""``ZERO``: zeroed sector data."""
|
|
467
|
+
|
|
468
|
+
kind: Literal["zero"] = "zero"
|
|
469
|
+
length: _TimeField
|
|
470
|
+
data_mode: DataMode | None = None
|
|
471
|
+
sub_channel_mode: SubChannelMode | None = None
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class Start(_Model):
|
|
475
|
+
"""``START``: marks the end of the pre-gap.
|
|
476
|
+
|
|
477
|
+
``position`` is ``None`` for a bare ``START``, which means "here", at
|
|
478
|
+
whatever length the track has accumulated so far.
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
kind: Literal["start"] = "start"
|
|
482
|
+
position: _TimeField | None = None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class End(_Model):
|
|
486
|
+
"""``END``: marks the start of the post-gap."""
|
|
487
|
+
|
|
488
|
+
kind: Literal["end"] = "end"
|
|
489
|
+
position: _TimeField | None = None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
#: A statement inside a track, in the order it was written.
|
|
493
|
+
TrackStatement = Annotated[
|
|
494
|
+
File | DataFile | Fifo | Silence | Zero | Start | End,
|
|
495
|
+
Field(discriminator="kind"),
|
|
496
|
+
]
|
|
497
|
+
|
|
498
|
+
#: The subset of statements that contribute data to a track.
|
|
499
|
+
ContentItem: TypeAlias = File | DataFile | Fifo | Silence | Zero
|
|
500
|
+
|
|
501
|
+
_CONTENT_TYPES = (File, DataFile, Fifo, Silence, Zero)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class Track(_Model):
|
|
505
|
+
"""A single ``TRACK`` specification."""
|
|
506
|
+
|
|
507
|
+
mode: TrackMode
|
|
508
|
+
sub_channel_mode: SubChannelMode | None = None
|
|
509
|
+
#: ``None`` when no ``COPY`` flag was written.
|
|
510
|
+
copy_permitted: bool | None = None
|
|
511
|
+
#: ``None`` when no ``PRE_EMPHASIS`` flag was written.
|
|
512
|
+
pre_emphasis: bool | None = None
|
|
513
|
+
#: ``None`` when neither channel flag was written.
|
|
514
|
+
channels: Literal[2, 4] | None = None
|
|
515
|
+
isrc: str | None = None
|
|
516
|
+
cd_text: CdText | None = None
|
|
517
|
+
pregap: _TimeField | None = None
|
|
518
|
+
statements: list[TrackStatement] = Field(default_factory=list)
|
|
519
|
+
indexes: list[_TimeField] = Field(default_factory=list)
|
|
520
|
+
|
|
521
|
+
@property
|
|
522
|
+
def is_copy_permitted(self) -> bool:
|
|
523
|
+
"""The effective copy flag; cdrdao defaults to not permitted."""
|
|
524
|
+
return bool(self.copy_permitted)
|
|
525
|
+
|
|
526
|
+
@property
|
|
527
|
+
def has_pre_emphasis(self) -> bool:
|
|
528
|
+
"""The effective pre-emphasis flag; cdrdao defaults to off."""
|
|
529
|
+
return bool(self.pre_emphasis)
|
|
530
|
+
|
|
531
|
+
@property
|
|
532
|
+
def channel_count(self) -> int:
|
|
533
|
+
"""The effective channel count; cdrdao defaults to two."""
|
|
534
|
+
return self.channels if self.channels is not None else 2
|
|
535
|
+
|
|
536
|
+
@property
|
|
537
|
+
def block_size(self) -> int:
|
|
538
|
+
"""Bytes per sector for this track, including sub-channel data."""
|
|
539
|
+
return block_size(self.mode, self.sub_channel_mode)
|
|
540
|
+
|
|
541
|
+
@property
|
|
542
|
+
def content(self) -> list[ContentItem]:
|
|
543
|
+
"""The statements that contribute data, in order."""
|
|
544
|
+
return [s for s in self.statements if isinstance(s, _CONTENT_TYPES)]
|
|
545
|
+
|
|
546
|
+
@property
|
|
547
|
+
def start(self) -> Start | None:
|
|
548
|
+
"""The track's ``START`` statement, if any."""
|
|
549
|
+
return next((s for s in self.statements if isinstance(s, Start)), None)
|
|
550
|
+
|
|
551
|
+
@property
|
|
552
|
+
def end(self) -> End | None:
|
|
553
|
+
"""The track's ``END`` statement, if any."""
|
|
554
|
+
return next((s for s in self.statements if isinstance(s, End)), None)
|
|
555
|
+
|
|
556
|
+
@property
|
|
557
|
+
def has_pregap(self) -> bool:
|
|
558
|
+
"""Whether the track defines a pre-gap, by ``PREGAP`` or ``START``."""
|
|
559
|
+
if self.pregap is not None:
|
|
560
|
+
return True
|
|
561
|
+
start = self.start
|
|
562
|
+
if start is None:
|
|
563
|
+
return False
|
|
564
|
+
return start.position is None or not _is_zero_length(start.position)
|
|
565
|
+
|
|
566
|
+
@model_validator(mode="after")
|
|
567
|
+
def _check(self) -> Track:
|
|
568
|
+
# The checks run in the order cdrdao meets the text, so the first
|
|
569
|
+
# problem reported is the one cdrdao reports first.
|
|
570
|
+
if self.isrc is not None:
|
|
571
|
+
_check_at(validate_isrc, self.isrc, "isrc")
|
|
572
|
+
|
|
573
|
+
if self.cd_text is not None:
|
|
574
|
+
if self.cd_text.language_map:
|
|
575
|
+
_fail(
|
|
576
|
+
"LANGUAGE_MAP is only allowed in the global CD_TEXT block.",
|
|
577
|
+
"cd_text",
|
|
578
|
+
"language_map",
|
|
579
|
+
)
|
|
580
|
+
for number, block in self.cd_text.blocks.items():
|
|
581
|
+
for name, value in block.items.items():
|
|
582
|
+
# cdrdao 1.2.6 drops an empty string before checking it
|
|
583
|
+
# (cdTextItem in trackdb/TocParser.g). Its master branch
|
|
584
|
+
# keeps the string, so there this item is rejected.
|
|
585
|
+
if name in DISC_ONLY_CD_TEXT_ITEMS and value != "":
|
|
586
|
+
_fail(
|
|
587
|
+
"Invalid CD-TEXT item for a track.",
|
|
588
|
+
"cd_text",
|
|
589
|
+
"blocks",
|
|
590
|
+
number,
|
|
591
|
+
"items",
|
|
592
|
+
name.value,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
if self.pregap is not None and _is_zero_length(self.pregap):
|
|
596
|
+
_fail(PREGAP_IS_ZERO, "pregap")
|
|
597
|
+
|
|
598
|
+
if not self.statements:
|
|
599
|
+
_fail("Track has no data statement.", "statements")
|
|
600
|
+
|
|
601
|
+
self._check_statements()
|
|
602
|
+
self._check_indexes()
|
|
603
|
+
return self
|
|
604
|
+
|
|
605
|
+
def _check_statements(self) -> None:
|
|
606
|
+
is_audio = self.mode is TrackMode.AUDIO
|
|
607
|
+
# cdrdao files each piece of data as audio (FILE, SILENCE, and a
|
|
608
|
+
# PREGAP on an audio track, which it inserts as silence) or as data
|
|
609
|
+
# (everything else), and rejects a track holding both.
|
|
610
|
+
audio_data: bool | None = None
|
|
611
|
+
if self.pregap is not None:
|
|
612
|
+
audio_data = is_audio
|
|
613
|
+
start_defined = self.pregap is not None
|
|
614
|
+
end_defined = False
|
|
615
|
+
|
|
616
|
+
for index, statement in enumerate(self.statements):
|
|
617
|
+
loc = ("statements", index)
|
|
618
|
+
if isinstance(statement, Start):
|
|
619
|
+
if start_defined:
|
|
620
|
+
_fail("Track start (end of pre-gap) already defined.", *loc)
|
|
621
|
+
start_defined = True
|
|
622
|
+
continue
|
|
623
|
+
if isinstance(statement, End):
|
|
624
|
+
if end_defined:
|
|
625
|
+
_fail("Track end (start of post-gap) already defined.", *loc)
|
|
626
|
+
end_defined = True
|
|
627
|
+
continue
|
|
628
|
+
|
|
629
|
+
if isinstance(statement, Silence) and _is_zero_length(statement.length):
|
|
630
|
+
_fail(SILENCE_IS_ZERO, *loc)
|
|
631
|
+
if isinstance(statement, Zero) and _is_zero_length(statement.length):
|
|
632
|
+
_fail(ZERO_DATA_IS_ZERO, *loc)
|
|
633
|
+
|
|
634
|
+
is_audio_statement = isinstance(statement, (File, Silence))
|
|
635
|
+
if is_audio_statement:
|
|
636
|
+
name = "FILE/AUDIOFILE" if isinstance(statement, File) else "SILENCE"
|
|
637
|
+
if not is_audio:
|
|
638
|
+
_fail(f"{name} statements are only allowed for audio tracks.", *loc)
|
|
639
|
+
if self.sub_channel_mode is not None:
|
|
640
|
+
_fail(
|
|
641
|
+
f"{name} statements are only allowed for audio tracks "
|
|
642
|
+
"without sub-channel mode.",
|
|
643
|
+
*loc,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
if audio_data is None:
|
|
647
|
+
audio_data = is_audio_statement
|
|
648
|
+
elif audio_data != is_audio_statement:
|
|
649
|
+
_fail(MIXED_STATEMENTS, *loc)
|
|
650
|
+
|
|
651
|
+
def _check_indexes(self) -> None:
|
|
652
|
+
for index, position in enumerate(self.indexes):
|
|
653
|
+
loc = ("indexes", index)
|
|
654
|
+
if index == MAX_INDEX_INCREMENTS:
|
|
655
|
+
_fail(f"More than {MAX_INDEX_INCREMENTS} index increments.", *loc)
|
|
656
|
+
if _is_zero_length(position):
|
|
657
|
+
_fail("Index at start of track.", *loc)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
class Toc(_Model):
|
|
661
|
+
"""A parsed TOC file."""
|
|
662
|
+
|
|
663
|
+
catalog: str | None = None
|
|
664
|
+
disc_type: DiscType = DiscType.CD_DA
|
|
665
|
+
#: The disc type flags written before ``disc_type``, in file order. The last
|
|
666
|
+
#: flag takes effect, so these have none, but they are written back.
|
|
667
|
+
superseded_disc_types: list[DiscType] = Field(default_factory=list)
|
|
668
|
+
first_track_number: int | None = None
|
|
669
|
+
cd_text: CdText | None = None
|
|
670
|
+
tracks: list[Track] = Field(default_factory=list)
|
|
671
|
+
|
|
672
|
+
def cd_text_encoding(self, language: int) -> CdTextEncoding:
|
|
673
|
+
"""The encoding cdrdao applies to CD-TEXT language block ``language``.
|
|
674
|
+
|
|
675
|
+
It is the ``ENCODING_*`` of that block in the disc's ``CD_TEXT``, and
|
|
676
|
+
ISO-8859-1 when there is none. Track blocks follow the disc: an
|
|
677
|
+
encoding written on a track block is ignored.
|
|
678
|
+
|
|
679
|
+
This is ``CdTextContainer::enforceEncoding`` in cdrdao 1.2.6's
|
|
680
|
+
``trackdb/CdTextContainer.cc``. cdrdao's master branch differs: for a
|
|
681
|
+
block with no ``ENCODING_*`` it first tries the character code in the
|
|
682
|
+
first byte of the block's ``SIZE_INFO``.
|
|
683
|
+
"""
|
|
684
|
+
if self.cd_text is not None:
|
|
685
|
+
block = self.cd_text.blocks.get(language)
|
|
686
|
+
if block is not None and block.encoding is not None:
|
|
687
|
+
return block.encoding
|
|
688
|
+
return CdTextEncoding.ISO_8859_1
|
|
689
|
+
|
|
690
|
+
@model_validator(mode="after")
|
|
691
|
+
def _check(self) -> Toc:
|
|
692
|
+
if self.catalog is not None:
|
|
693
|
+
_check_at(validate_catalog, self.catalog, "catalog")
|
|
694
|
+
|
|
695
|
+
if self.first_track_number is not None:
|
|
696
|
+
_check_at(
|
|
697
|
+
validate_first_track_number,
|
|
698
|
+
self.first_track_number,
|
|
699
|
+
"first_track_number",
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
if not self.tracks:
|
|
703
|
+
_fail("A TOC file must define at least one track.", "tracks")
|
|
704
|
+
|
|
705
|
+
# cdrdao converts CD-TEXT to its block's encoding once the whole file
|
|
706
|
+
# is read, so this is the last check it makes.
|
|
707
|
+
located: list[tuple[tuple[str | int, ...], CdText | None]] = [
|
|
708
|
+
(("cd_text",), self.cd_text)
|
|
709
|
+
]
|
|
710
|
+
located += [
|
|
711
|
+
(("tracks", number, "cd_text"), track.cd_text)
|
|
712
|
+
for number, track in enumerate(self.tracks)
|
|
713
|
+
]
|
|
714
|
+
for prefix, cd_text in located:
|
|
715
|
+
if cd_text is None:
|
|
716
|
+
continue
|
|
717
|
+
for number, block in cd_text.blocks.items():
|
|
718
|
+
codec = cd_text_codec(self.cd_text_encoding(number))
|
|
719
|
+
for name, value in block.items.items():
|
|
720
|
+
if isinstance(value, str) and not _encodes(value, codec):
|
|
721
|
+
_fail(
|
|
722
|
+
f'CD-TEXT: Unable to encode "{value}" '
|
|
723
|
+
"into compatible format",
|
|
724
|
+
*prefix,
|
|
725
|
+
"blocks",
|
|
726
|
+
number,
|
|
727
|
+
"items",
|
|
728
|
+
name.value,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
return self
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _encodes(text: str, codec: str) -> bool:
|
|
735
|
+
try:
|
|
736
|
+
text.encode(codec)
|
|
737
|
+
except UnicodeEncodeError:
|
|
738
|
+
return False
|
|
739
|
+
return True
|