audioexport 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.
- audioexport/__init__.py +44 -0
- audioexport/__main__.py +4 -0
- audioexport/_version.py +24 -0
- audioexport/api.py +36 -0
- audioexport/chapters.py +159 -0
- audioexport/cli.py +141 -0
- audioexport/errors.py +25 -0
- audioexport/fftools.py +278 -0
- audioexport/formats.py +83 -0
- audioexport/pipeline.py +728 -0
- audioexport/profile.py +212 -0
- audioexport/py.typed +0 -0
- audioexport-0.1.0.dist-info/METADATA +226 -0
- audioexport-0.1.0.dist-info/RECORD +18 -0
- audioexport-0.1.0.dist-info/WHEEL +5 -0
- audioexport-0.1.0.dist-info/entry_points.txt +2 -0
- audioexport-0.1.0.dist-info/licenses/LICENSE +201 -0
- audioexport-0.1.0.dist-info/top_level.txt +1 -0
audioexport/pipeline.py
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
"""Standalone encoding, identity, atomic writing and exported-artifact cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
from importlib.metadata import PackageNotFoundError
|
|
17
|
+
from importlib.metadata import version as distribution_version
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .chapters import Chapter, ffmetadata_text, load_chapters, normalize_chapters
|
|
22
|
+
from .errors import EncodingError, InvalidExportError, VerificationError
|
|
23
|
+
from .fftools import doctor as fftool_doctor
|
|
24
|
+
from .fftools import encoder_for_format, executable, probe, run, version
|
|
25
|
+
from .formats import EXPECTED_CODECS, FORMATS, encoder_options, normalize_bitrate, normalize_format
|
|
26
|
+
from .profile import ExportProfile, ResolvedOutput, load_profile, resolve_output
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class ExportResult:
|
|
31
|
+
path: Path
|
|
32
|
+
format: str
|
|
33
|
+
codec: str
|
|
34
|
+
export_id: str
|
|
35
|
+
output_sha256: str
|
|
36
|
+
manifest_path: Path
|
|
37
|
+
reused: bool
|
|
38
|
+
chapter_count: int
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, Any]:
|
|
41
|
+
data = asdict(self)
|
|
42
|
+
data["path"] = str(self.path)
|
|
43
|
+
data["manifest_path"] = str(self.manifest_path)
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _producer_version() -> str:
|
|
48
|
+
try:
|
|
49
|
+
return distribution_version("audioexport")
|
|
50
|
+
except PackageNotFoundError:
|
|
51
|
+
return "0+uninstalled"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sha256_file(path: Path) -> str:
|
|
55
|
+
digest = hashlib.sha256()
|
|
56
|
+
with path.open("rb") as stream:
|
|
57
|
+
while chunk := stream.read(1024 * 1024):
|
|
58
|
+
digest.update(chunk)
|
|
59
|
+
return digest.hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _canonical_hash(payload: Mapping[str, Any]) -> str:
|
|
63
|
+
canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
64
|
+
return f"sha256:{hashlib.sha256(canonical.encode('utf-8')).hexdigest()}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _duration_ms(info: Mapping[str, Any]) -> int:
|
|
68
|
+
try:
|
|
69
|
+
streams = info["streams"]
|
|
70
|
+
stream = next(row for row in streams if row.get("codec_type") == "audio")
|
|
71
|
+
raw = stream.get("duration") or info["format"].get("duration")
|
|
72
|
+
seconds = float(raw)
|
|
73
|
+
if not math.isfinite(seconds) or seconds <= 0:
|
|
74
|
+
raise ValueError("non-positive duration")
|
|
75
|
+
return round(seconds * 1000)
|
|
76
|
+
except (ValueError, TypeError, KeyError, StopIteration) as exc:
|
|
77
|
+
raise InvalidExportError(
|
|
78
|
+
"source has no valid positive duration", code="audioexport.duration_invalid"
|
|
79
|
+
) from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _validate_metadata(metadata: Mapping[str, str] | None) -> dict[str, str]:
|
|
83
|
+
result: dict[str, str] = {}
|
|
84
|
+
for key, value in (metadata or {}).items():
|
|
85
|
+
if (
|
|
86
|
+
not isinstance(key, str)
|
|
87
|
+
or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", key)
|
|
88
|
+
or not isinstance(value, str)
|
|
89
|
+
):
|
|
90
|
+
raise InvalidExportError(
|
|
91
|
+
"metadata must be string key/value pairs with simple keys",
|
|
92
|
+
code="audioexport.metadata_invalid",
|
|
93
|
+
)
|
|
94
|
+
result[key.lower()] = value
|
|
95
|
+
return dict(sorted(result.items()))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cover_kind(path: Path) -> str:
|
|
99
|
+
try:
|
|
100
|
+
with path.open("rb") as stream:
|
|
101
|
+
signature = stream.read(8)
|
|
102
|
+
except OSError as exc:
|
|
103
|
+
raise InvalidExportError(
|
|
104
|
+
f"cover not readable: {path}", code="audioexport.cover_invalid"
|
|
105
|
+
) from exc
|
|
106
|
+
if signature.startswith(b"\xff\xd8\xff"):
|
|
107
|
+
return "mjpeg"
|
|
108
|
+
if signature == b"\x89PNG\r\n\x1a\n":
|
|
109
|
+
return "png"
|
|
110
|
+
raise InvalidExportError("cover must be JPEG or PNG", code="audioexport.cover_invalid")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _manifest_path(output: Path) -> Path:
|
|
114
|
+
return output.with_name(output.name + ".audioexport.json")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _output_lock_path(output: Path) -> Path:
|
|
118
|
+
owner = str(os.getuid()) if hasattr(os, "getuid") else getpass.getuser()
|
|
119
|
+
owner_key = hashlib.sha256(owner.encode("utf-8")).hexdigest()[:16]
|
|
120
|
+
root = Path(tempfile.gettempdir()) / f"audioexport-locks-{owner_key}"
|
|
121
|
+
if root.is_symlink():
|
|
122
|
+
raise EncodingError(
|
|
123
|
+
"output lock directory must not be a symlink", code="audioexport.output_lock_failed"
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
raise EncodingError(
|
|
129
|
+
"could not create output lock directory", code="audioexport.output_lock_failed"
|
|
130
|
+
) from exc
|
|
131
|
+
if root.is_symlink() or not root.is_dir():
|
|
132
|
+
raise EncodingError(
|
|
133
|
+
"output lock directory is invalid", code="audioexport.output_lock_failed"
|
|
134
|
+
)
|
|
135
|
+
key = os.path.normcase(str(output.resolve()))
|
|
136
|
+
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
137
|
+
return root / f"{digest}.lock"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@contextmanager
|
|
141
|
+
def _output_lock(output: Path) -> Iterator[None]:
|
|
142
|
+
lock_path = _output_lock_path(output)
|
|
143
|
+
try:
|
|
144
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
145
|
+
except FileExistsError as exc:
|
|
146
|
+
raise EncodingError(
|
|
147
|
+
f"another audioexport operation holds {lock_path}; verify no writer is active "
|
|
148
|
+
"before removing a stale lock",
|
|
149
|
+
code="audioexport.output_busy",
|
|
150
|
+
) from exc
|
|
151
|
+
except OSError as exc:
|
|
152
|
+
raise EncodingError(
|
|
153
|
+
"could not acquire output lock", code="audioexport.output_lock_failed"
|
|
154
|
+
) from exc
|
|
155
|
+
try:
|
|
156
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
157
|
+
stream.write(json.dumps({"pid": os.getpid(), "output": str(output.absolute())}) + "\n")
|
|
158
|
+
stream.flush()
|
|
159
|
+
os.fsync(stream.fileno())
|
|
160
|
+
yield
|
|
161
|
+
finally:
|
|
162
|
+
lock_path.unlink(missing_ok=True)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _write_json_temp(path: Path, payload: Mapping[str, Any]) -> Path:
|
|
166
|
+
data = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
167
|
+
fd, name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
168
|
+
temporary = Path(name)
|
|
169
|
+
try:
|
|
170
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
171
|
+
stream.write(data)
|
|
172
|
+
stream.flush()
|
|
173
|
+
os.fsync(stream.fileno())
|
|
174
|
+
except BaseException:
|
|
175
|
+
temporary.unlink(missing_ok=True)
|
|
176
|
+
raise
|
|
177
|
+
return temporary
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _backup_existing(path: Path) -> Path | None:
|
|
181
|
+
if path.is_symlink():
|
|
182
|
+
raise InvalidExportError(
|
|
183
|
+
f"refusing to replace symlink artifact: {path}", code="audioexport.output_invalid"
|
|
184
|
+
)
|
|
185
|
+
if not path.exists():
|
|
186
|
+
return None
|
|
187
|
+
if not path.is_file():
|
|
188
|
+
raise InvalidExportError(
|
|
189
|
+
f"artifact must be a regular file: {path}", code="audioexport.output_invalid"
|
|
190
|
+
)
|
|
191
|
+
fd, name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".backup", dir=path.parent)
|
|
192
|
+
os.close(fd)
|
|
193
|
+
backup = Path(name)
|
|
194
|
+
backup.unlink()
|
|
195
|
+
try:
|
|
196
|
+
link_file = getattr(os, "link", None)
|
|
197
|
+
if link_file is None:
|
|
198
|
+
shutil.copy2(path, backup)
|
|
199
|
+
else:
|
|
200
|
+
try:
|
|
201
|
+
link_file(path, backup)
|
|
202
|
+
except (OSError, NotImplementedError):
|
|
203
|
+
shutil.copy2(path, backup)
|
|
204
|
+
except BaseException:
|
|
205
|
+
backup.unlink(missing_ok=True)
|
|
206
|
+
raise
|
|
207
|
+
return backup
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _commit_export(
|
|
211
|
+
audio_temp: Path,
|
|
212
|
+
output: Path,
|
|
213
|
+
sidecar: Path,
|
|
214
|
+
manifest: Mapping[str, Any],
|
|
215
|
+
) -> None:
|
|
216
|
+
try:
|
|
217
|
+
manifest_temp = _write_json_temp(sidecar, manifest)
|
|
218
|
+
except OSError as exc:
|
|
219
|
+
raise EncodingError(
|
|
220
|
+
"could not stage export manifest", code="audioexport.manifest_write_failed"
|
|
221
|
+
) from exc
|
|
222
|
+
audio_backup: Path | None = None
|
|
223
|
+
manifest_backup: Path | None = None
|
|
224
|
+
preserved_backups: list[Path] = []
|
|
225
|
+
try:
|
|
226
|
+
audio_backup = _backup_existing(output)
|
|
227
|
+
manifest_backup = _backup_existing(sidecar)
|
|
228
|
+
try:
|
|
229
|
+
os.replace(audio_temp, output)
|
|
230
|
+
except OSError as exc:
|
|
231
|
+
raise EncodingError(
|
|
232
|
+
"could not commit encoded audio", code="audioexport.output_commit_failed"
|
|
233
|
+
) from exc
|
|
234
|
+
try:
|
|
235
|
+
os.replace(manifest_temp, sidecar)
|
|
236
|
+
except OSError as exc:
|
|
237
|
+
rollback_errors = []
|
|
238
|
+
for target, backup in ((output, audio_backup), (sidecar, manifest_backup)):
|
|
239
|
+
try:
|
|
240
|
+
if backup is None:
|
|
241
|
+
target.unlink(missing_ok=True)
|
|
242
|
+
else:
|
|
243
|
+
os.replace(backup, target)
|
|
244
|
+
except OSError as rollback_exc:
|
|
245
|
+
rollback_errors.append(rollback_exc)
|
|
246
|
+
if backup is not None and backup.exists():
|
|
247
|
+
preserved_backups.append(backup)
|
|
248
|
+
if rollback_errors:
|
|
249
|
+
raise EncodingError(
|
|
250
|
+
"manifest commit failed and automatic recovery was incomplete; "
|
|
251
|
+
f"preserved backups: {', '.join(map(str, preserved_backups)) or 'none'}. "
|
|
252
|
+
f"Inspect {output} and {sidecar} before retrying",
|
|
253
|
+
code="audioexport.commit_recovery_failed",
|
|
254
|
+
) from exc
|
|
255
|
+
raise EncodingError(
|
|
256
|
+
"manifest commit failed; the previous export was restored",
|
|
257
|
+
code="audioexport.manifest_commit_failed",
|
|
258
|
+
) from exc
|
|
259
|
+
finally:
|
|
260
|
+
manifest_temp.unlink(missing_ok=True)
|
|
261
|
+
if audio_backup is not None and audio_backup not in preserved_backups:
|
|
262
|
+
audio_backup.unlink(missing_ok=True)
|
|
263
|
+
if manifest_backup is not None and manifest_backup not in preserved_backups:
|
|
264
|
+
manifest_backup.unlink(missing_ok=True)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _load_manifest(path: Path) -> dict[str, Any] | None:
|
|
268
|
+
if path.is_symlink():
|
|
269
|
+
return None
|
|
270
|
+
try:
|
|
271
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
272
|
+
return (
|
|
273
|
+
data
|
|
274
|
+
if isinstance(data, dict) and data.get("schema") == "audioexport.manifest.v1"
|
|
275
|
+
else None
|
|
276
|
+
)
|
|
277
|
+
except (OSError, ValueError):
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _verify_output(
|
|
282
|
+
path: Path,
|
|
283
|
+
*,
|
|
284
|
+
fmt: str,
|
|
285
|
+
chapters: Sequence[Chapter],
|
|
286
|
+
cover: Path | None,
|
|
287
|
+
metadata: Mapping[str, str],
|
|
288
|
+
ffprobe: str,
|
|
289
|
+
source_duration_ms: int,
|
|
290
|
+
) -> dict[str, Any]:
|
|
291
|
+
info = probe(path, ffprobe)
|
|
292
|
+
streams = info["streams"]
|
|
293
|
+
audio_streams = [item for item in streams if item.get("codec_type") == "audio"]
|
|
294
|
+
if len(audio_streams) != 1:
|
|
295
|
+
raise VerificationError(
|
|
296
|
+
"encoded output must contain exactly one audio stream",
|
|
297
|
+
code="audioexport.stream_mismatch",
|
|
298
|
+
)
|
|
299
|
+
audio = audio_streams[0]
|
|
300
|
+
if audio.get("codec_name") != EXPECTED_CODECS[fmt]:
|
|
301
|
+
raise VerificationError(
|
|
302
|
+
f"expected {EXPECTED_CODECS[fmt]} output, found {audio.get('codec_name')}",
|
|
303
|
+
code="audioexport.codec_mismatch",
|
|
304
|
+
)
|
|
305
|
+
try:
|
|
306
|
+
channels = int(audio["channels"])
|
|
307
|
+
sample_rate = int(audio["sample_rate"])
|
|
308
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
309
|
+
raise VerificationError(
|
|
310
|
+
"encoded output lacks valid channels/sample rate", code="audioexport.probe_invalid"
|
|
311
|
+
) from exc
|
|
312
|
+
if channels <= 0 or sample_rate <= 0:
|
|
313
|
+
raise VerificationError(
|
|
314
|
+
"encoded output lacks valid channels/sample rate", code="audioexport.probe_invalid"
|
|
315
|
+
)
|
|
316
|
+
output_duration = _duration_ms(info)
|
|
317
|
+
if abs(output_duration - source_duration_ms) > max(200, round(source_duration_ms * 0.02)):
|
|
318
|
+
raise VerificationError(
|
|
319
|
+
"encoded duration differs substantially from source",
|
|
320
|
+
code="audioexport.duration_mismatch",
|
|
321
|
+
)
|
|
322
|
+
format_tags = info["format"].get("tags", {})
|
|
323
|
+
normalized_tags = {key.lower(): value for key, value in format_tags.items()}
|
|
324
|
+
for stream in audio_streams:
|
|
325
|
+
normalized_tags.update(
|
|
326
|
+
{key.lower(): value for key, value in stream.get("tags", {}).items()}
|
|
327
|
+
)
|
|
328
|
+
for key, expected_value in metadata.items():
|
|
329
|
+
candidates = ("artist", "author") if key.lower() in {"artist", "author"} else (key.lower(),)
|
|
330
|
+
if not any(normalized_tags.get(candidate) == expected_value for candidate in candidates):
|
|
331
|
+
raise VerificationError(
|
|
332
|
+
f"encoded metadata tag {key!r} does not match requested value",
|
|
333
|
+
code="audioexport.metadata_mismatch",
|
|
334
|
+
)
|
|
335
|
+
emitted = info.get("chapters", [])
|
|
336
|
+
if chapters:
|
|
337
|
+
if len(emitted) != len(chapters):
|
|
338
|
+
raise VerificationError(
|
|
339
|
+
"encoded chapter count mismatch", code="audioexport.chapter_mismatch"
|
|
340
|
+
)
|
|
341
|
+
for actual, expected_chapter in zip(emitted, chapters):
|
|
342
|
+
try:
|
|
343
|
+
start_ms = float(actual["start_time"]) * 1000
|
|
344
|
+
end_ms = float(actual["end_time"]) * 1000
|
|
345
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
346
|
+
raise VerificationError(
|
|
347
|
+
"encoded chapter timing is invalid", code="audioexport.chapter_mismatch"
|
|
348
|
+
) from exc
|
|
349
|
+
if abs(start_ms - expected_chapter.start_ms) > 30:
|
|
350
|
+
raise VerificationError(
|
|
351
|
+
"encoded chapter start mismatch", code="audioexport.chapter_mismatch"
|
|
352
|
+
)
|
|
353
|
+
if expected_chapter.end_ms is None or abs(end_ms - expected_chapter.end_ms) > 35:
|
|
354
|
+
raise VerificationError(
|
|
355
|
+
"encoded chapter end mismatch", code="audioexport.chapter_mismatch"
|
|
356
|
+
)
|
|
357
|
+
actual_tags = {key.lower(): value for key, value in actual.get("tags", {}).items()}
|
|
358
|
+
if actual_tags.get("title") != expected_chapter.title:
|
|
359
|
+
raise VerificationError(
|
|
360
|
+
"encoded chapter title mismatch", code="audioexport.chapter_mismatch"
|
|
361
|
+
)
|
|
362
|
+
pictures = [
|
|
363
|
+
item
|
|
364
|
+
for item in streams
|
|
365
|
+
if item.get("codec_type") == "video"
|
|
366
|
+
and item.get("disposition", {}).get("attached_pic") == 1
|
|
367
|
+
]
|
|
368
|
+
if cover is not None:
|
|
369
|
+
if len(pictures) != 1 or pictures[0].get("codec_name") != _cover_kind(cover):
|
|
370
|
+
raise VerificationError(
|
|
371
|
+
"encoded cover art is missing or has the wrong codec",
|
|
372
|
+
code="audioexport.cover_mismatch",
|
|
373
|
+
)
|
|
374
|
+
elif pictures:
|
|
375
|
+
raise VerificationError(
|
|
376
|
+
"encoded output contains unexpected cover art", code="audioexport.cover_mismatch"
|
|
377
|
+
)
|
|
378
|
+
return info
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _encode_unlocked(
|
|
382
|
+
source: Path | str,
|
|
383
|
+
output: Path | str,
|
|
384
|
+
*,
|
|
385
|
+
format: str | None = None,
|
|
386
|
+
bitrate: str | int | None = None,
|
|
387
|
+
metadata: Mapping[str, str] | None = None,
|
|
388
|
+
cover: Path | str | None = None,
|
|
389
|
+
timeline: Path | str | None = None,
|
|
390
|
+
chapters: Sequence[Chapter] | None = None,
|
|
391
|
+
force: bool = False,
|
|
392
|
+
ffmpeg: Path | str | None = None,
|
|
393
|
+
ffprobe: Path | str | None = None,
|
|
394
|
+
) -> ExportResult:
|
|
395
|
+
"""Encode media to an audio format; verify using FFprobe and save sidecar manifest.
|
|
396
|
+
|
|
397
|
+
Reuses a previously produced unmodified output with matching source/options/tools.
|
|
398
|
+
Refuses to overwrite an unknown or manually changed output unless force=True.
|
|
399
|
+
"""
|
|
400
|
+
src = Path(source).expanduser().absolute()
|
|
401
|
+
dest = Path(output).expanduser().absolute()
|
|
402
|
+
if not src.is_file():
|
|
403
|
+
raise InvalidExportError(f"source audio missing: {src}", code="audioexport.source_missing")
|
|
404
|
+
if src.resolve() == dest.resolve():
|
|
405
|
+
raise InvalidExportError(
|
|
406
|
+
"input and output must be different files", code="audioexport.same_path"
|
|
407
|
+
)
|
|
408
|
+
inferred = dest.suffix.lower().removeprefix(".")
|
|
409
|
+
fmt = normalize_format(format or inferred)
|
|
410
|
+
if inferred != fmt:
|
|
411
|
+
raise InvalidExportError(
|
|
412
|
+
f"format {fmt!r} conflicts with output extension {dest.suffix!r}",
|
|
413
|
+
code="audioexport.format_mismatch",
|
|
414
|
+
)
|
|
415
|
+
rate = normalize_bitrate(bitrate, fmt)
|
|
416
|
+
tags = _validate_metadata(metadata)
|
|
417
|
+
if timeline is not None and chapters is not None:
|
|
418
|
+
raise InvalidExportError(
|
|
419
|
+
"use timeline or chapters, not both", code="audioexport.chapters_invalid"
|
|
420
|
+
)
|
|
421
|
+
if timeline is not None:
|
|
422
|
+
chapters = load_chapters(timeline)
|
|
423
|
+
chapters = tuple(chapters or ())
|
|
424
|
+
if chapters and fmt not in {"m4b", "m4a"}:
|
|
425
|
+
raise InvalidExportError(
|
|
426
|
+
"chapter marks require M4B or M4A", code="audioexport.chapters_unsupported"
|
|
427
|
+
)
|
|
428
|
+
artwork = Path(cover).expanduser().absolute() if cover is not None else None
|
|
429
|
+
cover_codec = None
|
|
430
|
+
if artwork is not None:
|
|
431
|
+
if fmt not in {"mp3", "m4a", "m4b"}:
|
|
432
|
+
raise InvalidExportError(
|
|
433
|
+
"cover art requires MP3/M4A/M4B", code="audioexport.cover_unsupported"
|
|
434
|
+
)
|
|
435
|
+
cover_codec = _cover_kind(artwork)
|
|
436
|
+
ffmpeg_exe = executable("ffmpeg", ffmpeg)
|
|
437
|
+
ffprobe_exe = executable("ffprobe", ffprobe)
|
|
438
|
+
ffmpeg_version = version(ffmpeg_exe)
|
|
439
|
+
ffprobe_version = version(ffprobe_exe)
|
|
440
|
+
source_info = probe(src, ffprobe_exe)
|
|
441
|
+
duration_ms = _duration_ms(source_info)
|
|
442
|
+
resolved_chapters = normalize_chapters(chapters, duration_ms)
|
|
443
|
+
source_sha = sha256_file(src)
|
|
444
|
+
cover_sha = sha256_file(artwork) if artwork is not None else None
|
|
445
|
+
timeline_sha = sha256_file(Path(timeline)) if timeline is not None else None
|
|
446
|
+
spec = FORMATS[fmt]
|
|
447
|
+
codec = encoder_for_format(fmt, ffmpeg_exe)
|
|
448
|
+
codec_args = encoder_options(codec)
|
|
449
|
+
options = {
|
|
450
|
+
"format": fmt,
|
|
451
|
+
"codec": codec,
|
|
452
|
+
"codec_args": codec_args,
|
|
453
|
+
"bitrate": rate,
|
|
454
|
+
"metadata": tags,
|
|
455
|
+
"cover_sha256": cover_sha,
|
|
456
|
+
"timeline_sha256": timeline_sha,
|
|
457
|
+
"chapters": [asdict(row) for row in resolved_chapters],
|
|
458
|
+
}
|
|
459
|
+
producer_version = _producer_version()
|
|
460
|
+
identity = _canonical_hash(
|
|
461
|
+
{
|
|
462
|
+
"schema": "audioexport.identity.v1",
|
|
463
|
+
"source_sha256": source_sha,
|
|
464
|
+
"options": options,
|
|
465
|
+
"ffmpeg": ffmpeg_version,
|
|
466
|
+
"ffprobe": ffprobe_version,
|
|
467
|
+
"producer_version": producer_version,
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
sidecar = _manifest_path(dest)
|
|
471
|
+
if dest.is_symlink():
|
|
472
|
+
raise InvalidExportError(
|
|
473
|
+
"refusing to replace symlink output", code="audioexport.output_invalid"
|
|
474
|
+
)
|
|
475
|
+
if sidecar.is_symlink() or (sidecar.exists() and not sidecar.is_file()):
|
|
476
|
+
raise InvalidExportError(
|
|
477
|
+
"manifest sidecar must be a regular non-symlink file",
|
|
478
|
+
code="audioexport.output_invalid",
|
|
479
|
+
)
|
|
480
|
+
if dest.exists() and not dest.is_file():
|
|
481
|
+
raise InvalidExportError("output must be a regular file", code="audioexport.output_invalid")
|
|
482
|
+
old = _load_manifest(sidecar)
|
|
483
|
+
intact = dest.is_file() and old is not None and old.get("output_sha256") == sha256_file(dest)
|
|
484
|
+
if dest.exists() and not force and not intact:
|
|
485
|
+
raise FileExistsError(
|
|
486
|
+
f"existing output is not an intact audioexport artifact: {dest}; use --force"
|
|
487
|
+
)
|
|
488
|
+
if not force and intact and old is not None and old.get("export_id") == identity:
|
|
489
|
+
return ExportResult(
|
|
490
|
+
dest,
|
|
491
|
+
fmt,
|
|
492
|
+
EXPECTED_CODECS[fmt],
|
|
493
|
+
identity,
|
|
494
|
+
old["output_sha256"],
|
|
495
|
+
sidecar,
|
|
496
|
+
True,
|
|
497
|
+
len(resolved_chapters),
|
|
498
|
+
)
|
|
499
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
500
|
+
fd, temporary_name = tempfile.mkstemp(
|
|
501
|
+
prefix=f".{dest.stem}.", suffix=spec.extension, dir=dest.parent
|
|
502
|
+
)
|
|
503
|
+
os.close(fd)
|
|
504
|
+
temporary = Path(temporary_name)
|
|
505
|
+
temporary.unlink() # let ffmpeg create the file; avoids a preexisting input/output.
|
|
506
|
+
meta_path: Path | None = None
|
|
507
|
+
try:
|
|
508
|
+
command = [
|
|
509
|
+
ffmpeg_exe,
|
|
510
|
+
"-hide_banner",
|
|
511
|
+
"-loglevel",
|
|
512
|
+
"error",
|
|
513
|
+
"-nostdin",
|
|
514
|
+
"-y",
|
|
515
|
+
"-i",
|
|
516
|
+
str(src),
|
|
517
|
+
]
|
|
518
|
+
if resolved_chapters:
|
|
519
|
+
with tempfile.NamedTemporaryFile(
|
|
520
|
+
"w",
|
|
521
|
+
encoding="utf-8",
|
|
522
|
+
suffix=".ffmeta",
|
|
523
|
+
prefix=".audioexport-",
|
|
524
|
+
dir=dest.parent,
|
|
525
|
+
delete=False,
|
|
526
|
+
) as stream:
|
|
527
|
+
meta_path = Path(stream.name)
|
|
528
|
+
stream.write(ffmetadata_text(resolved_chapters))
|
|
529
|
+
command.extend(["-f", "ffmetadata", "-i", str(meta_path)])
|
|
530
|
+
cover_index = 2 if resolved_chapters else 1
|
|
531
|
+
if artwork is not None:
|
|
532
|
+
command.extend(["-i", str(artwork)])
|
|
533
|
+
command.extend(["-map", "0:a:0", "-map_metadata", "0"])
|
|
534
|
+
if resolved_chapters:
|
|
535
|
+
command.extend(["-map_chapters", "1"])
|
|
536
|
+
else:
|
|
537
|
+
command.extend(["-map_chapters", "-1"])
|
|
538
|
+
if artwork is not None:
|
|
539
|
+
command.extend(
|
|
540
|
+
[
|
|
541
|
+
"-map",
|
|
542
|
+
f"{cover_index}:v:0",
|
|
543
|
+
"-c:v",
|
|
544
|
+
cover_codec or "mjpeg",
|
|
545
|
+
"-disposition:v:0",
|
|
546
|
+
"attached_pic",
|
|
547
|
+
]
|
|
548
|
+
)
|
|
549
|
+
if fmt == "mp3":
|
|
550
|
+
command.extend(
|
|
551
|
+
["-metadata:s:v", "title=Album cover", "-metadata:s:v", "comment=Cover (front)"]
|
|
552
|
+
)
|
|
553
|
+
command.extend(["-c:a", codec, *codec_args])
|
|
554
|
+
if rate is not None:
|
|
555
|
+
command.extend(["-b:a", rate])
|
|
556
|
+
elif fmt == "ogg":
|
|
557
|
+
command.extend(["-qscale:a", "4"])
|
|
558
|
+
for key, value in tags.items():
|
|
559
|
+
command.extend(["-metadata", f"{key}={value}"])
|
|
560
|
+
command.extend([*spec.extra_args, "-f", spec.muxer, str(temporary)])
|
|
561
|
+
run(command)
|
|
562
|
+
info = _verify_output(
|
|
563
|
+
temporary,
|
|
564
|
+
fmt=fmt,
|
|
565
|
+
chapters=resolved_chapters,
|
|
566
|
+
cover=artwork,
|
|
567
|
+
metadata=tags,
|
|
568
|
+
ffprobe=ffprobe_exe,
|
|
569
|
+
source_duration_ms=duration_ms,
|
|
570
|
+
)
|
|
571
|
+
output_sha = sha256_file(temporary)
|
|
572
|
+
payload = {
|
|
573
|
+
"schema": "audioexport.manifest.v1",
|
|
574
|
+
"schema_version": 1,
|
|
575
|
+
"producer": "audioexport",
|
|
576
|
+
"producer_version": producer_version,
|
|
577
|
+
"export_id": identity,
|
|
578
|
+
"source_sha256": source_sha,
|
|
579
|
+
"output": dest.name,
|
|
580
|
+
"output_sha256": output_sha,
|
|
581
|
+
"options": options,
|
|
582
|
+
"tools": {"ffmpeg": ffmpeg_version, "ffprobe": ffprobe_version},
|
|
583
|
+
"verified": {
|
|
584
|
+
"codec": EXPECTED_CODECS[fmt],
|
|
585
|
+
"duration_ms": _duration_ms(info),
|
|
586
|
+
"channels": next(
|
|
587
|
+
x["channels"] for x in info["streams"] if x.get("codec_type") == "audio"
|
|
588
|
+
),
|
|
589
|
+
"chapters": len(info.get("chapters", [])),
|
|
590
|
+
},
|
|
591
|
+
}
|
|
592
|
+
_commit_export(temporary, dest, sidecar, payload)
|
|
593
|
+
return ExportResult(
|
|
594
|
+
dest,
|
|
595
|
+
fmt,
|
|
596
|
+
EXPECTED_CODECS[fmt],
|
|
597
|
+
identity,
|
|
598
|
+
output_sha,
|
|
599
|
+
sidecar,
|
|
600
|
+
False,
|
|
601
|
+
len(resolved_chapters),
|
|
602
|
+
)
|
|
603
|
+
finally:
|
|
604
|
+
temporary.unlink(missing_ok=True)
|
|
605
|
+
if meta_path is not None:
|
|
606
|
+
meta_path.unlink(missing_ok=True)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def encode(
|
|
610
|
+
source: Path | str,
|
|
611
|
+
output: Path | str,
|
|
612
|
+
*,
|
|
613
|
+
format: str | None = None,
|
|
614
|
+
bitrate: str | int | None = None,
|
|
615
|
+
metadata: Mapping[str, str] | None = None,
|
|
616
|
+
cover: Path | str | None = None,
|
|
617
|
+
timeline: Path | str | None = None,
|
|
618
|
+
chapters: Sequence[Chapter] | None = None,
|
|
619
|
+
force: bool = False,
|
|
620
|
+
ffmpeg: Path | str | None = None,
|
|
621
|
+
ffprobe: Path | str | None = None,
|
|
622
|
+
) -> ExportResult:
|
|
623
|
+
"""Encode under a per-output inter-process lock and verify the final artifacts."""
|
|
624
|
+
destination = Path(output).expanduser().absolute()
|
|
625
|
+
with _output_lock(destination):
|
|
626
|
+
return _encode_unlocked(
|
|
627
|
+
source,
|
|
628
|
+
output,
|
|
629
|
+
format=format,
|
|
630
|
+
bitrate=bitrate,
|
|
631
|
+
metadata=metadata,
|
|
632
|
+
cover=cover,
|
|
633
|
+
timeline=timeline,
|
|
634
|
+
chapters=chapters,
|
|
635
|
+
force=force,
|
|
636
|
+
ffmpeg=ffmpeg,
|
|
637
|
+
ffprobe=ffprobe,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _preflight_profile(
|
|
642
|
+
profile: ExportProfile,
|
|
643
|
+
source: Path | str,
|
|
644
|
+
out_dir: Path,
|
|
645
|
+
*,
|
|
646
|
+
ffprobe: Path | str | None,
|
|
647
|
+
ffmpeg: Path | str | None,
|
|
648
|
+
) -> tuple[tuple[ResolvedOutput, Path], ...]:
|
|
649
|
+
"""Resolve and validate every profile output before any encoder can write."""
|
|
650
|
+
src = Path(source).expanduser().absolute()
|
|
651
|
+
if not src.is_file():
|
|
652
|
+
raise InvalidExportError(f"source audio missing: {src}", code="audioexport.source_missing")
|
|
653
|
+
source_real = src.resolve()
|
|
654
|
+
seen_names: set[str] = set()
|
|
655
|
+
planned: list[tuple[ResolvedOutput, Path]] = []
|
|
656
|
+
checked_timeline: Path | None = None
|
|
657
|
+
for spec in profile.outputs:
|
|
658
|
+
resolved = resolve_output(profile, spec, src.stem)
|
|
659
|
+
key = resolved.filename.casefold()
|
|
660
|
+
if key in seen_names:
|
|
661
|
+
raise InvalidExportError(
|
|
662
|
+
"duplicate profile output filenames", code="audioexport.profile_invalid"
|
|
663
|
+
)
|
|
664
|
+
seen_names.add(key)
|
|
665
|
+
destination = out_dir / resolved.filename
|
|
666
|
+
if destination.resolve() == source_real:
|
|
667
|
+
raise InvalidExportError(
|
|
668
|
+
"input and output must be different files", code="audioexport.same_path"
|
|
669
|
+
)
|
|
670
|
+
if resolved.cover is not None:
|
|
671
|
+
_cover_kind(resolved.cover)
|
|
672
|
+
if resolved.timeline is not None and resolved.timeline != checked_timeline:
|
|
673
|
+
chapters = load_chapters(resolved.timeline)
|
|
674
|
+
source_info = probe(src, ffprobe)
|
|
675
|
+
normalize_chapters(chapters, _duration_ms(source_info))
|
|
676
|
+
checked_timeline = resolved.timeline
|
|
677
|
+
planned.append((resolved, destination))
|
|
678
|
+
formats = tuple(spec.format for spec in profile.outputs)
|
|
679
|
+
capabilities = fftool_doctor(ffmpeg=ffmpeg, ffprobe=ffprobe, requested_formats=formats)
|
|
680
|
+
for name, override in (("ffmpeg", ffmpeg), ("ffprobe", ffprobe)):
|
|
681
|
+
if not capabilities["tools"][name]["available"]:
|
|
682
|
+
executable(name, override)
|
|
683
|
+
raise EncodingError(
|
|
684
|
+
f"{name} is not ready: {capabilities['tools'][name].get('error', 'unavailable')}",
|
|
685
|
+
code="audioexport.tool_failed",
|
|
686
|
+
)
|
|
687
|
+
if not capabilities["requested_formats_ready"]:
|
|
688
|
+
unavailable = [
|
|
689
|
+
f"{fmt}: {capabilities['formats'][fmt].get('reason', 'encoder unavailable')}"
|
|
690
|
+
for fmt in formats
|
|
691
|
+
if capabilities["formats"][fmt]["available"] is not True
|
|
692
|
+
]
|
|
693
|
+
raise EncodingError(
|
|
694
|
+
"required FFmpeg encoders unavailable: " + "; ".join(unavailable),
|
|
695
|
+
code="audioexport.encoder_unavailable",
|
|
696
|
+
)
|
|
697
|
+
return tuple(planned)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def run_profile(
|
|
701
|
+
source: Path | str,
|
|
702
|
+
profile: Path | str,
|
|
703
|
+
out_dir: Path | str,
|
|
704
|
+
*,
|
|
705
|
+
force: bool = False,
|
|
706
|
+
ffmpeg: Path | str | None = None,
|
|
707
|
+
ffprobe: Path | str | None = None,
|
|
708
|
+
) -> tuple[ExportResult, ...]:
|
|
709
|
+
"""Batch-export an ordinary audio file using a portable TOML export profile."""
|
|
710
|
+
configuration = load_profile(profile)
|
|
711
|
+
root = Path(out_dir).expanduser().absolute()
|
|
712
|
+
planned = _preflight_profile(configuration, source, root, ffprobe=ffprobe, ffmpeg=ffmpeg)
|
|
713
|
+
outputs = []
|
|
714
|
+
for item, destination in planned:
|
|
715
|
+
result = encode(
|
|
716
|
+
source,
|
|
717
|
+
destination,
|
|
718
|
+
format=item.format,
|
|
719
|
+
bitrate=item.bitrate,
|
|
720
|
+
metadata=configuration.metadata,
|
|
721
|
+
cover=item.cover,
|
|
722
|
+
timeline=item.timeline,
|
|
723
|
+
force=force,
|
|
724
|
+
ffmpeg=ffmpeg,
|
|
725
|
+
ffprobe=ffprobe,
|
|
726
|
+
)
|
|
727
|
+
outputs.append(result)
|
|
728
|
+
return tuple(outputs)
|