ffmeta 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.
ffmeta/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Metadata files: ffmetadata, Vorbis comments and cue sheets, read into one model, written from it.
2
+
3
+ Pure: text in, text out -- no ffmpeg, no file. The formats are ffman's skills'
4
+ (``.agents/skills/ffmetadata``, ``vorbiscomment``, ``cue``); what crosses between them is
5
+ ``metadata-mappings``'s -- all in ffman's repository.
6
+ """
7
+
8
+ from ffmeta._errors import Error
9
+ from ffmeta.convert import Converted, convert
10
+ from ffmeta.fields import Format
11
+ from ffmeta.files import read, write
12
+ from ffmeta.model import Chapter, Disc, File, Index, Metadata, Tag, Track, Written
13
+
14
+ __all__ = [
15
+ "Chapter",
16
+ "Converted",
17
+ "Disc",
18
+ "Error",
19
+ "File",
20
+ "Format",
21
+ "Index",
22
+ "Metadata",
23
+ "Tag",
24
+ "Track",
25
+ "Written",
26
+ "convert",
27
+ "read",
28
+ "write",
29
+ ]
30
+
31
+ Error.__module__ = "ffmeta" # tracebacks name the public ffmeta.Error, not its private module
ffmeta/_errors.py ADDED
@@ -0,0 +1,42 @@
1
+ """The one way ffmeta refuses: an ffmeta.Error, its message the reason; input shown safe."""
2
+
3
+ from typing import Final, Never
4
+
5
+ _SHOWN: Final = 60 # input quoted in a message: its characters, at most
6
+ _ESCAPED: Final = {"\r": "\\r", "\n": "\\n", "\t": "\\t"}
7
+
8
+
9
+ class Error(Exception):
10
+ """ffmeta's refusal: the input cannot be read, converted or written as it stands.
11
+
12
+ The message says why.
13
+ """
14
+
15
+
16
+ def refuse(message: str) -> Never:
17
+ """Refuse with ``message``."""
18
+ raise Error(message)
19
+
20
+
21
+ def shown(text: str) -> str:
22
+ r"""Input quoted in a message: on one line, each character visible, 60 characters at most.
23
+
24
+ A line break or tab as its escape; any other character that prints nothing (a NUL, a BOM, a
25
+ no-break space) as Python writes it, ``\x00``, ``\ufeff``.
26
+ """
27
+ one = "".join(
28
+ _ESCAPED.get(char, char if char.isprintable() else repr(char)[1:-1]) for char in text
29
+ )
30
+ return one if len(one) <= _SHOWN else one[: _SHOWN - 3] + "..."
31
+
32
+
33
+ def refuse_at(source: str, line: int, message: str) -> Never:
34
+ """Refuse at ``source``'s ``line``: ``SOURCE:LINE: message``, lines from 1.
35
+
36
+ The GNU Coding Standards' form for a program reading a file (4.4, "Formatting Error
37
+ Messages").
38
+ """
39
+ if line < 1:
40
+ msg = f"lines count from 1, not {line}"
41
+ raise ValueError(msg)
42
+ refuse(f"{source}:{line}: {message}")
ffmeta/_values.py ADDED
@@ -0,0 +1,8 @@
1
+ """Numbers exactly: the one rounding ffmeta needs."""
2
+
3
+ from fractions import Fraction
4
+
5
+
6
+ def round_half_up(value: Fraction) -> int:
7
+ """The nearest integer, halves up: the floor of ``value`` + 1/2, exactly -- (2n + d) // 2d."""
8
+ return (2 * value.numerator + value.denominator) // (2 * value.denominator)
ffmeta/chapters.py ADDED
@@ -0,0 +1,371 @@
1
+ """Edits of a metadata file's chapters (spec 8) -- in a cue sheet, its tracks.
2
+
3
+ Those that name the input's chapter N (``--drop-chapter``, ``--retitle``, ``--chapter-set``,
4
+ ``--clear-chapters``) apply in the input's words, before converting, so N is the input's;
5
+ ``--chapter`` adds, in time order, in the output's words. Declarative, as the tags' edits.
6
+ """
7
+
8
+ import re
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass, replace
11
+ from fractions import Fraction
12
+ from typing import Final
13
+
14
+ from ffmeta._errors import refuse, shown
15
+ from ffmeta.cue import FLAGS, LAST_FRAME, LAST_TIME, are_flags, is_isrc
16
+ from ffmeta.fields import TRACK, Format, key, rem_field, rem_text, spelled
17
+ from ffmeta.files import NAMES
18
+ from ffmeta.model import (
19
+ FRAMES,
20
+ Chapter,
21
+ Disc,
22
+ File,
23
+ Index,
24
+ Metadata,
25
+ Tag,
26
+ Track,
27
+ exact,
28
+ left_out,
29
+ nearest,
30
+ put,
31
+ set_where,
32
+ )
33
+ from ffmeta.time import parse as parse_time
34
+
35
+ __all__ = ["ChapterEdits", "ChapterText", "NewChapter", "edit_chapters", "parse_chapters"]
36
+
37
+ _NUMBER: Final = re.compile(r"[1-9][0-9]{0,3}", re.ASCII)
38
+ _TRACK: Final = {key(field.ffmetadata): field for field in TRACK.fields} # a track's, by key
39
+ _TRACK_HOLDS: Final = ", ".join(field.ffmetadata for field in TRACK.fields)
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class NewChapter:
44
+ """A chapter ``--chapter`` adds: its start, its end if given, its title if given."""
45
+
46
+ start: Fraction
47
+ end: Fraction | None
48
+ title: str | None
49
+ text: str # as given: the notes name it so
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class ChapterText:
54
+ """The chapter edits as given on the command line, each option's values in order."""
55
+
56
+ new: Sequence[str] = ()
57
+ retitles: Sequence[str] = ()
58
+ sets: Sequence[str] = ()
59
+ drops: Sequence[str] = ()
60
+ flags: Sequence[str] = ()
61
+ pregaps: Sequence[str] = ()
62
+ clear: bool = False
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class ChapterEdits:
67
+ """No chapter (``clear``), these of the input's gone, these tags on the input's, more."""
68
+
69
+ clear: bool = False
70
+ drop: frozenset[int] = frozenset()
71
+ set: tuple[tuple[int, Tag], ...] = () # the input's chapter N, a tag of its
72
+ new: tuple[NewChapter, ...] = ()
73
+ flags: tuple[tuple[int, tuple[str, ...]], ...] = () # a cue's track N: its flags
74
+ pregaps: tuple[
75
+ tuple[int, Fraction | None, str], ...
76
+ ] = () # its INDEX 00 (None: none), as given
77
+
78
+
79
+ def parse_chapters(given: ChapterText) -> ChapterEdits:
80
+ """The chapter edits given; refused: a malformed one, or two that contradict."""
81
+ edits = _tag_edits(given.retitles, given.sets)
82
+ flagged = tuple(_flags(text) for text in given.flags)
83
+ gapped = tuple(_pregap(text) for text in given.pregaps)
84
+ dropped = frozenset(_number(text) for text in given.drops)
85
+ _contradictions(
86
+ [
87
+ *((flag, number) for flag, number, _ in edits),
88
+ *(("--flags", number) for number, _ in flagged),
89
+ *(("--pregap", number) for number, _, _ in gapped),
90
+ ],
91
+ dropped,
92
+ clear=given.clear,
93
+ )
94
+ _once("--flags", [number for number, _ in flagged])
95
+ _once("--pregap", [number for number, _, _ in gapped])
96
+ seen: set[tuple[int, str]] = set()
97
+ for _, number, tag in edits:
98
+ if (number, key(tag.name)) in seen:
99
+ refuse(f"chapter {number}'s {tag.name} set twice")
100
+ seen.add((number, key(tag.name)))
101
+ added = tuple(_new(text) for text in given.new)
102
+ tagged = tuple((number, tag) for _, number, tag in edits)
103
+ return ChapterEdits(given.clear, dropped, tagged, added, flagged, gapped)
104
+
105
+
106
+ def _tag_edits(retitles: Sequence[str], sets: Sequence[str]) -> list[tuple[str, int, Tag]]:
107
+ """``--retitle N=T`` (N's title) and ``--chapter-set N:K=V``: each its flag, N, tag."""
108
+ edits: list[tuple[str, int, Tag]] = []
109
+ for text in retitles:
110
+ number, equals, title = text.partition("=")
111
+ if not equals:
112
+ refuse(f"--retitle needs N=TITLE: {shown(text)}")
113
+ edits.append(("--retitle", _number(number), Tag("title", title)))
114
+ for text in sets:
115
+ number, colon, pair = text.partition(":")
116
+ name, equals, value = pair.partition("=")
117
+ if not colon or not equals or not name:
118
+ refuse(f"--chapter-set needs N:KEY=VALUE: {shown(text)}")
119
+ edits.append(("--chapter-set", _number(number), Tag(name, value)))
120
+ return edits
121
+
122
+
123
+ def _contradictions(
124
+ numbered: list[tuple[str, int]], dropped: frozenset[int], *, clear: bool
125
+ ) -> None:
126
+ """No edit of the input's chapter N with N dropped, or with every chapter cleared."""
127
+ for flag, number in numbered:
128
+ if clear:
129
+ refuse(f"--clear-chapters and {flag} {number} contradict")
130
+ if number in dropped:
131
+ refuse(f"--drop-chapter {number} and {flag} {number} contradict")
132
+ if clear and dropped:
133
+ refuse(f"--clear-chapters and --drop-chapter {min(dropped)} contradict")
134
+
135
+
136
+ def _once(flag: str, numbers: list[int]) -> None:
137
+ """Each N ``flag`` names, once."""
138
+ twice = next((number for number in numbers if numbers.count(number) > 1), None)
139
+ if twice is not None:
140
+ refuse(f"{flag} {twice} twice")
141
+
142
+
143
+ def _flags(text: str) -> tuple[int, tuple[str, ...]]:
144
+ """``N=FLAGS``: comma-separated, any case; empty: none."""
145
+ number, equals, value = text.partition("=")
146
+ if not equals:
147
+ refuse(f"--flags needs N=FLAGS: {shown(text)}")
148
+ names = tuple(name.strip().upper() for name in value.split(",")) if value else ()
149
+ if not are_flags(names):
150
+ refuse(f"FLAGS are {', '.join(FLAGS)}, each once: {shown(value)}")
151
+ return _number(number), names
152
+
153
+
154
+ def _pregap(text: str) -> tuple[int, Fraction | None, str]:
155
+ """``N=TIME``: track N's INDEX 00; empty: none."""
156
+ number, equals, value = text.partition("=")
157
+ if not equals:
158
+ refuse(f"--pregap needs N=TIME: {shown(text)}")
159
+ return _number(number), parse_time(value) if value else None, text
160
+
161
+
162
+ def _number(text: str) -> int:
163
+ if _NUMBER.fullmatch(text) is None:
164
+ refuse(f"a chapter number is 1, 2, 3...: {shown(text)}")
165
+ return int(text)
166
+
167
+
168
+ def _new(text: str) -> NewChapter:
169
+ """``TIME[..END][=TITLE]``."""
170
+ times, equals, title = text.partition("=")
171
+ start_text, dots, end_text = times.partition("..")
172
+ start = parse_time(start_text)
173
+ end = parse_time(end_text) if dots else None
174
+ if end is not None and end < start:
175
+ refuse(f"--chapter {shown(text)}: its end before its start")
176
+ return NewChapter(start, end, title if equals else None, text)
177
+
178
+
179
+ def edit_chapters(
180
+ meta: Metadata, edits: ChapterEdits, fmt: Format, media: str
181
+ ) -> tuple[Metadata, tuple[str, ...]]:
182
+ """``meta``'s chapters (a cue's tracks) edited, in ``fmt``'s words; a note for each loss.
183
+
184
+ ``media`` names a new cue's FILE, should a track be its first.
185
+ """
186
+ if fmt == "cue":
187
+ return _tracks(meta, edits, media)
188
+ for flag, given in (("--flags", edits.flags), ("--pregap", edits.pregaps)):
189
+ if given:
190
+ refuse(f"{flag} edits a cue's tracks: the input is {NAMES[fmt]}")
191
+ _within(edits, len(meta.chapters))
192
+ seen: set[tuple[int, str]] = set() # in this format's words: Vorbis' NAME is the title
193
+ for number, tag in edits.set:
194
+ if (number, _chapter_key(tag.name, fmt)) in seen:
195
+ refuse(f"chapter {number}'s {tag.name} set twice")
196
+ seen.add((number, _chapter_key(tag.name, fmt)))
197
+ chapters = [] if edits.clear else list(meta.chapters)
198
+ for number, tag in edits.set:
199
+ chapter = chapters[number - 1]
200
+ chapters[number - 1] = replace(chapter, tags=_tagged(chapter.tags, tag, fmt))
201
+ chapters = [chapter for i, chapter in enumerate(chapters, 1) if i not in edits.drop]
202
+ for new in edits.new: # after the chapters starting no later
203
+ title = (Tag(_chapter_spelled("title", fmt), new.title),) if new.title is not None else ()
204
+ at = _after([chapter.start for chapter in chapters], new.start)
205
+ chapters.insert(at, Chapter(new.start, new.end, title))
206
+ return replace(meta, chapters=tuple(chapters)), ()
207
+
208
+
209
+ def _after[T: (Fraction, int)](starts: list[T], value: T) -> int:
210
+ """The place after the last of ``starts`` no later than ``value``, in any order.
211
+
212
+ In time order where they are (a cue's tracks); defined where not (Vorbis numbers chapters).
213
+ """
214
+ return max((i + 1 for i, start in enumerate(starts) if start <= value), default=0)
215
+
216
+
217
+ def _pregapped(
218
+ tracks: list[Track], number: int, time: Fraction | None, text: str, notes: list[str]
219
+ ) -> Track:
220
+ """Track N's INDEX 00 at ``time`` (None: none), in its INDEX 01's file.
221
+
222
+ Refused after its INDEX 01, or inside the track before.
223
+ """
224
+ track = tracks[number - 1]
225
+ one, rest = track.start_index, tuple(index for index in track.indexes if index.number)
226
+ if time is None:
227
+ return replace(track, indexes=rest)
228
+ frames = nearest(time, FRAMES)
229
+ if exact(frames, FRAMES) != time:
230
+ notes.append(f"--pregap {text}: between frames (1/75 s): at the nearest, frame {frames}")
231
+ if frames > one.frames:
232
+ refuse(f"--pregap {shown(text)}: after its INDEX 01 (frame {one.frames})")
233
+ before = tracks[number - 2] if number > 1 else None
234
+ later = (
235
+ i for i in (before.indexes if before else ()) if i.file == one.file and i.frames > frames
236
+ )
237
+ if before is not None and (clash := next(later, None)):
238
+ where = f"its INDEX {clash.number:02d} at frame {clash.frames}"
239
+ refuse(f"--pregap {shown(text)}: inside track {before.number} ({where})")
240
+ return replace(track, indexes=(Index(0, frames, one.file), *rest))
241
+
242
+
243
+ def _within(edits: ChapterEdits, count: int) -> None:
244
+ """Each N an edit names is one of the input's ``count`` chapters."""
245
+ named = {
246
+ *edits.drop,
247
+ *(n for n, _ in edits.set),
248
+ *(n for n, _ in edits.flags),
249
+ *(n for n, _, _ in edits.pregaps),
250
+ }
251
+ for number in sorted(named):
252
+ if number > count:
253
+ refuse(f"chapter {number}: the input has {count} chapters")
254
+
255
+
256
+ def _chapter_key(name: str, fmt: Format) -> str:
257
+ """A chapter's tag key, as compared: Vorbis' NAME is its title."""
258
+ generic = key(name)
259
+ return "title" if fmt == "vorbis" and generic == "name" else generic
260
+
261
+
262
+ def _chapter_spelled(name: str, fmt: Format) -> str:
263
+ if _chapter_key(name, fmt) == "title":
264
+ return "NAME" if fmt == "vorbis" else "title"
265
+ return spelled(name, fmt)
266
+
267
+
268
+ def _tagged(tags: tuple[Tag, ...], tag: Tag, fmt: Format) -> tuple[Tag, ...]:
269
+ """``tag`` set on a chapter: where its key stood (its spelling kept), else last."""
270
+ wanted = _chapter_key(tag.name, fmt)
271
+
272
+ def same(held: Tag) -> bool:
273
+ return _chapter_key(held.name, fmt) == wanted
274
+
275
+ return tuple(set_where(list(tags), tag, same, _chapter_spelled(tag.name, fmt)))
276
+
277
+
278
+ def _tracks(meta: Metadata, edits: ChapterEdits, media: str) -> tuple[Metadata, tuple[str, ...]]:
279
+ """A cue's tracks edited: their fields, drops, new tracks at the nearest frame."""
280
+ disc = meta.disc or Disc((), ())
281
+ _within(edits, len(disc.tracks))
282
+ notes: list[str] = []
283
+ tracks = [] if edits.clear else list(disc.tracks)
284
+ for number, tag in edits.set:
285
+ tracks[number - 1] = _track_set(tracks[number - 1], tag, number, notes)
286
+ for number, names in edits.flags:
287
+ tracks[number - 1] = replace(tracks[number - 1], flags=names)
288
+ for number, time, text in edits.pregaps:
289
+ tracks[number - 1] = _pregapped(tracks, number, time, text, notes)
290
+ tracks = [track for i, track in enumerate(tracks, 1) if i not in edits.drop]
291
+ files = list(disc.files) or [File(media, "WAVE")]
292
+ for new in edits.new:
293
+ tracks = _inserted(tracks, new, notes)
294
+ first = disc.tracks[0].number if disc.tracks else 1
295
+ return replace(meta, disc=_renumbered(disc, tracks, files, first)), tuple(notes)
296
+
297
+
298
+ def _track_set(track: Track, tag: Tag, number: int, notes: list[str]) -> Track:
299
+ """``tag`` on a cue's track: its CD-Text, a REM line, or its ISRC."""
300
+ field = _TRACK.get(key(tag.name))
301
+ if field is None:
302
+ holds = f"a cue's track holds no {tag.name} (it holds: {_TRACK_HOLDS})"
303
+ refuse(f"--chapter-set {number}:{tag.name}: {holds}")
304
+ if field.cue == "ISRC":
305
+ if not is_isrc(tag.value):
306
+ form = f"a cue's ISRC is CCOOOYYSSSSS (12): {shown(tag.value)}"
307
+ refuse(f"--chapter-set {number}:{tag.name}: {form}")
308
+ return replace(track, isrc=tag.value)
309
+ if field.cue.startswith("REM "):
310
+ # rem_text is None only for a quoted field (a disc's GENRE): none of a track's is
311
+ text = rem_text(field, tag, notes)
312
+
313
+ def same(rem: str) -> bool:
314
+ return rem_field(rem)[0] == field.cue
315
+
316
+ rems = put(list(track.rems), text, same) if text is not None else list(track.rems)
317
+ return replace(track, rems=tuple(rems))
318
+ cdtext = put(list(track.cdtext), Tag(field.cue, tag.value), lambda old: old.name == field.cue)
319
+ return replace(track, cdtext=tuple(cdtext))
320
+
321
+
322
+ def _inserted(tracks: list[Track], new: NewChapter, notes: list[str]) -> list[Track]:
323
+ """A track for ``new`` among ``tracks``, by time; refused inside one, or over two files."""
324
+ what = f"--chapter {shown(new.text)}"
325
+ files = {index.file for track in tracks for index in track.indexes}
326
+ if len(files) > 1:
327
+ refuse(f"{what}: a cue over {len(files)} files: which one the track is in cannot be told")
328
+ file = min(files, default=0)
329
+ frames = nearest(new.start, FRAMES)
330
+ if frames > LAST_FRAME:
331
+ refuse(f"{what}: past a cue's last time ({LAST_TIME})")
332
+ if exact(frames, FRAMES) != new.start:
333
+ notes.append(
334
+ f"--chapter {new.text}: between frames (1/75 s): at the nearest, frame {frames}"
335
+ )
336
+ if new.end is not None:
337
+ why = "a cue's track ends where the next starts"
338
+ notes.append(left_out(f"--chapter {new.text}'s end", why))
339
+ at = _after([track.start_index.frames for track in tracks], frames)
340
+ if at == 0 and tracks and (zero := tracks[0].index(0)) is not None and zero.frames == 0:
341
+ old = tracks[0] # the time before the first track: now the new first's, at 0 or after
342
+ tracks = [replace(old, indexes=old.indexes[1:]), *tracks[1:]]
343
+ earlier = tracks[at - 1] if at else None # its indexes: none past the new one
344
+ later = tracks[at] if at < len(tracks) else None # its indexes: none before it
345
+ for track, clash in (
346
+ (
347
+ earlier,
348
+ next((i for i in earlier.indexes if i.frames > frames), None) if earlier else None,
349
+ ),
350
+ (later, next((i for i in later.indexes if i.frames < frames), None) if later else None),
351
+ ):
352
+ if track is not None and clash is not None:
353
+ where = f"its INDEX {clash.number:02d} at frame {clash.frames}"
354
+ refuse(f"{what}: inside track {track.number} ({where})")
355
+ one = Index(1, frames, file)
356
+ indexes = (Index(0, 0, file), one) if at == 0 and frames else (one,) # track 1's time before
357
+ cdtext = (Tag("TITLE", new.title),) if new.title is not None else ()
358
+ return [*tracks[:at], Track(1, "AUDIO", indexes, cdtext), *tracks[at:]]
359
+
360
+
361
+ def _renumbered(disc: Disc, tracks: list[Track], files: list[File], first: int) -> Disc:
362
+ """``tracks`` numbered from ``first``, one more each; the files they use alone, in turn."""
363
+ used = sorted({index.file for track in tracks for index in track.indexes})
364
+ moved = {old: new for new, old in enumerate(used)}
365
+
366
+ def renumbered(i: int, track: Track) -> Track:
367
+ indexes = tuple(replace(index, file=moved[index.file]) for index in track.indexes)
368
+ return replace(track, number=first + i, indexes=indexes)
369
+
370
+ numbered = tuple(renumbered(i, track) for i, track in enumerate(tracks))
371
+ return replace(disc, files=tuple(files[old] for old in used), tracks=numbered)