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/fftools.py ADDED
@@ -0,0 +1,278 @@
1
+ """Subprocess access to FFmpeg and FFprobe. No shell invocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import shutil
8
+ import subprocess
9
+ from collections.abc import Sequence
10
+ from importlib.metadata import PackageNotFoundError
11
+ from importlib.metadata import version as distribution_version
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .errors import EncodingError, ToolNotFoundError, VerificationError
16
+ from .formats import FORMATS, normalize_format
17
+
18
+
19
+ def executable(name: str, provided: str | Path | None = None) -> str:
20
+ if provided is None:
21
+ found = shutil.which(name)
22
+ else:
23
+ found = shutil.which(str(provided))
24
+ if not found:
25
+ raise ToolNotFoundError(
26
+ f"{name} executable not found: {provided or name}",
27
+ code=f"audioexport.{name}_missing",
28
+ )
29
+ return found
30
+
31
+
32
+ def run(
33
+ args: list[str], *, error_type: type[EncodingError | VerificationError] = EncodingError
34
+ ) -> subprocess.CompletedProcess[str]:
35
+ try:
36
+ result = subprocess.run(args, capture_output=True, text=True, check=False)
37
+ except OSError as exc:
38
+ raise error_type(f"failed to run {args[0]}: {exc}", code="audioexport.tool_failed") from exc
39
+ if result.returncode:
40
+ tail = "\n".join(result.stderr.splitlines()[-6:]).strip()
41
+ raise error_type(
42
+ f"{Path(args[0]).name} exited {result.returncode}: {tail}",
43
+ code="audioexport.tool_failed",
44
+ )
45
+ return result
46
+
47
+
48
+ def version(exe: str) -> str:
49
+ return run([exe, "-version"]).stdout.splitlines()[0].strip()
50
+
51
+
52
+ def _probe_number(value: Any, field: str, *, allow_zero: bool) -> float:
53
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
54
+ raise VerificationError(
55
+ f"ffprobe returned invalid {field}", code="audioexport.probe_invalid"
56
+ )
57
+ try:
58
+ number = float(value)
59
+ except (OverflowError, ValueError) as exc:
60
+ raise VerificationError(
61
+ f"ffprobe returned invalid {field}", code="audioexport.probe_invalid"
62
+ ) from exc
63
+ if not math.isfinite(number) or number < 0 or (not allow_zero and number == 0):
64
+ raise VerificationError(
65
+ f"ffprobe returned invalid {field}", code="audioexport.probe_invalid"
66
+ )
67
+ return number
68
+
69
+
70
+ def _probe_positive_int(value: Any, field: str) -> int:
71
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
72
+ raise VerificationError(
73
+ f"ffprobe returned invalid {field}", code="audioexport.probe_invalid"
74
+ )
75
+ try:
76
+ number = int(value)
77
+ except ValueError as exc:
78
+ raise VerificationError(
79
+ f"ffprobe returned invalid {field}", code="audioexport.probe_invalid"
80
+ ) from exc
81
+ if number <= 0 or str(number) != str(value).strip():
82
+ raise VerificationError(
83
+ f"ffprobe returned invalid {field}", code="audioexport.probe_invalid"
84
+ )
85
+ return number
86
+
87
+
88
+ def _validate_tags(value: Any, field: str) -> None:
89
+ if not isinstance(value, dict) or any(
90
+ not isinstance(key, str) or not isinstance(tag, str) for key, tag in value.items()
91
+ ):
92
+ raise VerificationError(
93
+ f"ffprobe returned invalid {field}", code="audioexport.probe_invalid"
94
+ )
95
+
96
+
97
+ def _validate_probe_payload(payload: Any) -> dict[str, Any]:
98
+ if not isinstance(payload, dict):
99
+ raise VerificationError(
100
+ "ffprobe returned invalid JSON structure", code="audioexport.probe_invalid"
101
+ )
102
+ streams = payload.get("streams")
103
+ container = payload.get("format")
104
+ chapters = payload.get("chapters", [])
105
+ if (
106
+ not isinstance(streams, list)
107
+ or not isinstance(container, dict)
108
+ or not isinstance(chapters, list)
109
+ ):
110
+ raise VerificationError(
111
+ "ffprobe returned invalid JSON structure", code="audioexport.probe_invalid"
112
+ )
113
+ if "tags" in container:
114
+ _validate_tags(container["tags"], "format tags")
115
+ if container.get("duration") is not None:
116
+ _probe_number(container["duration"], "format duration", allow_zero=False)
117
+ has_audio = False
118
+ for index, stream in enumerate(streams):
119
+ if not isinstance(stream, dict):
120
+ raise VerificationError(
121
+ "ffprobe returned invalid stream", code="audioexport.probe_invalid"
122
+ )
123
+ if "tags" in stream:
124
+ _validate_tags(stream["tags"], f"stream {index} tags")
125
+ if stream.get("codec_type") == "audio":
126
+ has_audio = True
127
+ for field in ("channels", "sample_rate"):
128
+ if field in stream:
129
+ _probe_positive_int(stream[field], f"audio {field}")
130
+ if stream.get("duration") is not None:
131
+ _probe_number(stream["duration"], "audio duration", allow_zero=False)
132
+ for index, chapter in enumerate(chapters):
133
+ if not isinstance(chapter, dict):
134
+ raise VerificationError(
135
+ "ffprobe returned invalid chapter", code="audioexport.probe_invalid"
136
+ )
137
+ if "tags" in chapter:
138
+ _validate_tags(chapter["tags"], f"chapter {index} tags")
139
+ for field in ("start_time", "end_time"):
140
+ if field in chapter:
141
+ _probe_number(chapter[field], f"chapter {field}", allow_zero=True)
142
+ if not has_audio:
143
+ raise VerificationError("input has no audio stream", code="audioexport.no_audio")
144
+ return payload
145
+
146
+
147
+ def probe(path: Path | str, ffprobe: str | Path | None = None) -> dict[str, Any]:
148
+ exe = executable("ffprobe", ffprobe)
149
+ text = run(
150
+ [
151
+ exe,
152
+ "-v",
153
+ "error",
154
+ "-show_format",
155
+ "-show_streams",
156
+ "-show_chapters",
157
+ "-of",
158
+ "json",
159
+ str(path),
160
+ ],
161
+ error_type=VerificationError,
162
+ ).stdout
163
+ try:
164
+ payload = json.loads(text)
165
+ except (ValueError, TypeError) as exc:
166
+ raise VerificationError(
167
+ "ffprobe returned invalid JSON", code="audioexport.probe_invalid"
168
+ ) from exc
169
+ return _validate_probe_payload(payload)
170
+
171
+
172
+ def encoder_for_format(fmt: str, ffmpeg: str | Path | None = None) -> str:
173
+ """Return the preferred available encoder for a format."""
174
+ normalized = normalize_format(fmt)
175
+ exe = executable("ffmpeg", ffmpeg)
176
+ result = run([exe, "-hide_banner", "-encoders"])
177
+ encoder_names = {
178
+ fields[1]
179
+ for line in (result.stdout + "\n" + result.stderr).splitlines()
180
+ if len(fields := line.split()) >= 2
181
+ }
182
+ spec = FORMATS[normalized]
183
+ selected = next(
184
+ (
185
+ candidate
186
+ for candidate in (spec.codec, *spec.fallback_codecs)
187
+ if candidate in encoder_names
188
+ ),
189
+ None,
190
+ )
191
+ if selected is None:
192
+ candidates = ", ".join(repr(candidate) for candidate in (spec.codec, *spec.fallback_codecs))
193
+ raise EncodingError(
194
+ f"required FFmpeg encoders unavailable: {normalized}: encoders {candidates} are not available in this FFmpeg build",
195
+ code="audioexport.encoder_unavailable",
196
+ )
197
+ return selected
198
+
199
+
200
+ def doctor(
201
+ *,
202
+ ffmpeg: str | Path | None = None,
203
+ ffprobe: str | Path | None = None,
204
+ requested_formats: Sequence[str] | None = None,
205
+ ) -> dict[str, Any]:
206
+ """Report tool availability and encoder capability without exporting media."""
207
+ requested = (
208
+ {normalize_format(item) for item in requested_formats}
209
+ if requested_formats is not None
210
+ else None
211
+ )
212
+ tools: dict[str, Any] = {}
213
+ for name, override in (("ffmpeg", ffmpeg), ("ffprobe", ffprobe)):
214
+ try:
215
+ exe = executable(name, override)
216
+ tools[name] = {"available": True, "path": exe, "version": version(exe)}
217
+ except (ToolNotFoundError, EncodingError) as exc:
218
+ tools[name] = {"available": False, "error": str(exc)}
219
+ ff = tools["ffmpeg"]
220
+ supported: dict[str, Any] = {}
221
+ encoder_text = ""
222
+ encoder_error: str | None = None
223
+ if ff["available"]:
224
+ try:
225
+ result = run([ff["path"], "-hide_banner", "-encoders"])
226
+ encoder_text = result.stdout + "\n" + result.stderr
227
+ except EncodingError as exc:
228
+ encoder_error = str(exc)
229
+ else:
230
+ encoder_error = "FFmpeg is unavailable"
231
+ for name, spec in FORMATS.items():
232
+ available: bool | None = None
233
+ reason: str | None = None
234
+ if encoder_error is None:
235
+ encoder_names = {
236
+ fields[1] for line in encoder_text.splitlines() if len(fields := line.split()) >= 2
237
+ }
238
+ selected = next(
239
+ (
240
+ candidate
241
+ for candidate in (spec.codec, *spec.fallback_codecs)
242
+ if candidate in encoder_names
243
+ ),
244
+ None,
245
+ )
246
+ available = selected is not None
247
+ if not available:
248
+ candidates = ", ".join(
249
+ repr(candidate) for candidate in (spec.codec, *spec.fallback_codecs)
250
+ )
251
+ reason = f"encoders {candidates} are not available in this FFmpeg build"
252
+ else:
253
+ selected = None
254
+ reason = encoder_error
255
+ supported[name] = {
256
+ "encoder": selected or spec.codec,
257
+ "available": available,
258
+ }
259
+ if reason is not None:
260
+ supported[name]["reason"] = reason
261
+ all_formats_ready = all(item["available"] is True for item in supported.values())
262
+ requested_formats_ready = (
263
+ all(supported[name]["available"] is True for name in requested)
264
+ if requested is not None
265
+ else None
266
+ )
267
+ try:
268
+ package_version = distribution_version("audioexport")
269
+ except PackageNotFoundError:
270
+ package_version = "0+uninstalled"
271
+ return {
272
+ "package": {"name": "audioexport", "version": package_version},
273
+ "tools": tools,
274
+ "formats": supported,
275
+ "ready": all(x["available"] for x in tools.values()),
276
+ "all_formats_ready": all_formats_ready,
277
+ "requested_formats_ready": requested_formats_ready,
278
+ }
audioexport/formats.py ADDED
@@ -0,0 +1,83 @@
1
+ """Explicit FFmpeg encoder/container policy, not Readio-dependent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from .errors import InvalidExportError
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class FormatSpec:
13
+ extension: str
14
+ codec: str
15
+ muxer: str
16
+ default_bitrate: str | None = None
17
+ extra_args: tuple[str, ...] = ()
18
+ fallback_codecs: tuple[str, ...] = ()
19
+
20
+
21
+ FORMATS: dict[str, FormatSpec] = {
22
+ "wav": FormatSpec(".wav", "pcm_s16le", "wav"),
23
+ "flac": FormatSpec(".flac", "flac", "flac"),
24
+ "mp3": FormatSpec(".mp3", "libmp3lame", "mp3", "192k", ("-id3v2_version", "3")),
25
+ "m4a": FormatSpec(".m4a", "aac", "ipod", "192k", ("-movflags", "+faststart")),
26
+ "m4b": FormatSpec(".m4b", "aac", "ipod", "192k", ("-movflags", "+faststart")),
27
+ # Some packaged FFmpeg builds omit the optional libvorbis encoder but
28
+ # retain FFmpeg's native Vorbis encoder.
29
+ "ogg": FormatSpec(".ogg", "libvorbis", "ogg", fallback_codecs=("vorbis",)),
30
+ "opus": FormatSpec(".opus", "libopus", "opus", "96k"),
31
+ }
32
+
33
+ EXPECTED_CODECS = {
34
+ "wav": "pcm_s16le",
35
+ "flac": "flac",
36
+ "mp3": "mp3",
37
+ "m4a": "aac",
38
+ "m4b": "aac",
39
+ "ogg": "vorbis",
40
+ "opus": "opus",
41
+ }
42
+
43
+
44
+ def normalize_format(name: str) -> str:
45
+ name = name.lower().strip().removeprefix(".")
46
+ if name not in FORMATS:
47
+ raise InvalidExportError(
48
+ f"unsupported format {name!r}; expected {', '.join(FORMATS)}",
49
+ code="audioexport.format_unsupported",
50
+ )
51
+ return name
52
+
53
+
54
+ def encoder_options(codec: str) -> tuple[str, ...]:
55
+ """Return FFmpeg options required by a selected encoder."""
56
+ if codec == "vorbis":
57
+ # FFmpeg's native Vorbis encoder is experimental and only accepts stereo.
58
+ return ("-strict", "-2", "-ac", "2")
59
+ return ()
60
+
61
+
62
+ def normalize_bitrate(value: str | int | None, fmt: str) -> str | None:
63
+ fmt = normalize_format(fmt)
64
+ if value is None:
65
+ return FORMATS[fmt].default_bitrate
66
+ if FORMATS[fmt].default_bitrate is None and fmt != "ogg":
67
+ raise InvalidExportError(
68
+ f"bitrate is not supported for {fmt}", code="audioexport.bitrate_unsupported"
69
+ )
70
+ # Adapted from Readio's normalize_bitrate, with a strict integer bits/s form.
71
+ text = str(value).strip()
72
+ match = re.fullmatch(r"(\d+)([kKmM]?)", text)
73
+ if match is None or int(match[1]) <= 0:
74
+ raise InvalidExportError(f"invalid bitrate {value!r}", code="audioexport.bitrate_invalid")
75
+ quantity, unit = int(match[1]), match[2].lower()
76
+ kbps = (
77
+ quantity * 1000
78
+ if unit == "m"
79
+ else quantity
80
+ if unit == "k"
81
+ else max(1, round(quantity / 1000))
82
+ )
83
+ return f"{kbps}k"