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.
@@ -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)