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