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/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