sonilo-video-kit 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.
- sonilo_video_kit/__init__.py +20 -0
- sonilo_video_kit/_ducking_api.py +496 -0
- sonilo_video_kit/_ffmpeg.py +465 -0
- sonilo_video_kit/duck.py +344 -0
- sonilo_video_kit/errors.py +44 -0
- sonilo_video_kit/generate.py +27 -0
- sonilo_video_kit/loudness.py +28 -0
- sonilo_video_kit/mix.py +166 -0
- sonilo_video_kit-0.1.0.dist-info/METADATA +144 -0
- sonilo_video_kit-0.1.0.dist-info/RECORD +12 -0
- sonilo_video_kit-0.1.0.dist-info/WHEEL +4 -0
- sonilo_video_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Video helpers for the Sonilo API (ffmpeg-based)."""
|
|
2
|
+
from .duck import (
|
|
3
|
+
MAX_DUCKED_MIX_BYTES, MAX_DUCKING_DURATION_SECONDS, duck_music_under_speech,
|
|
4
|
+
)
|
|
5
|
+
from .errors import (
|
|
6
|
+
DuckingFailedError, FfmpegError, FfmpegNotFoundError, VideoKitError,
|
|
7
|
+
)
|
|
8
|
+
from .generate import VideoMusicClient, generate_music_for_video
|
|
9
|
+
from .loudness import (
|
|
10
|
+
DELIVERY_TARGET_LUFS, FALLBACK_MUSIC_LUFS, GAP_BELOW_VOICE_LU, OUTPUT_CEILING_DBFS,
|
|
11
|
+
)
|
|
12
|
+
from .mix import mix_with_video
|
|
13
|
+
|
|
14
|
+
__all__ = sorted([
|
|
15
|
+
"DELIVERY_TARGET_LUFS", "DuckingFailedError", "FALLBACK_MUSIC_LUFS", "FfmpegError",
|
|
16
|
+
"FfmpegNotFoundError", "GAP_BELOW_VOICE_LU", "MAX_DUCKED_MIX_BYTES",
|
|
17
|
+
"MAX_DUCKING_DURATION_SECONDS", "OUTPUT_CEILING_DBFS", "VideoKitError",
|
|
18
|
+
"VideoMusicClient", "duck_music_under_speech", "generate_music_for_video",
|
|
19
|
+
"mix_with_video",
|
|
20
|
+
])
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Ducking API transport: submit / poll / download (ported from ducking-api.ts).
|
|
2
|
+
|
|
3
|
+
Self-contained HTTP layer for the async `/v1/audio-ducking` endpoint. Three
|
|
4
|
+
concerns live here, matching the JS reference:
|
|
5
|
+
|
|
6
|
+
- submit_ducking_job: POSTs the voice+music tracks. This is what CHARGES
|
|
7
|
+
the account, so it is deliberately NEVER retried (see its docstring).
|
|
8
|
+
- await_ducking_result: polls GET /v1/tasks/{id} until the task leaves
|
|
9
|
+
"processing", retrying transient (5xx / network) failures — the task
|
|
10
|
+
keeps running server-side no matter what happens to this client.
|
|
11
|
+
- download_ducked_mix: fetches the finished mix from its presigned URL
|
|
12
|
+
(a DIFFERENT, unauthenticated host — never routed through client._http,
|
|
13
|
+
so the customer's API key never reaches it), guarded against SSRF and
|
|
14
|
+
bounded by a byte cap / exact-size check.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ipaddress
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable, Optional, Protocol
|
|
25
|
+
from urllib.parse import quote, urlsplit
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
from sonilo.errors import error_from_response
|
|
29
|
+
|
|
30
|
+
from ._ffmpeg import StrPath
|
|
31
|
+
from .errors import DuckingFailedError, VideoKitError
|
|
32
|
+
|
|
33
|
+
Sleep = Callable[[float], None]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DuckingClient(Protocol):
|
|
37
|
+
"""The minimal client surface the ducking calls need — satisfied by
|
|
38
|
+
`sonilo.Sonilo`, whose `_http` is a configured httpx.Client (base_url
|
|
39
|
+
https://api.sonilo.com, Authorization header already set)."""
|
|
40
|
+
|
|
41
|
+
_http: httpx.Client
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class DuckingResult:
|
|
46
|
+
output_url: str
|
|
47
|
+
output_type: str
|
|
48
|
+
output_bytes: Optional[int] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# One initial try plus three retries.
|
|
52
|
+
_MAX_ATTEMPTS = 4
|
|
53
|
+
_RETRY_BASE_SECONDS = 0.5
|
|
54
|
+
_RETRY_MAX_SECONDS = 4.0
|
|
55
|
+
|
|
56
|
+
# Task/submit JSON envelopes are a few hundred bytes; 1 MB is orders of
|
|
57
|
+
# magnitude of headroom while still capping a compromised API trying to OOM
|
|
58
|
+
# the client.
|
|
59
|
+
MAX_JSON_BYTES = 1024 * 1024
|
|
60
|
+
|
|
61
|
+
# Per-attempt wall-clock cap for the mix download, mirroring ducking-api.ts's
|
|
62
|
+
# DEFAULT_DOWNLOAD_TIMEOUT_MS.
|
|
63
|
+
DEFAULT_DOWNLOAD_TIMEOUT_SECONDS = 120.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _HttpStatusError(VideoKitError):
|
|
67
|
+
"""A non-2xx from the (unauthed) download host. Carries `status_code` so
|
|
68
|
+
`_is_transient` classifies it exactly like the sonilo SDK's own
|
|
69
|
+
APIError (which also carries `status_code`)."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, status_code: int, what: str) -> None:
|
|
72
|
+
super().__init__(f"{what} (HTTP {status_code})")
|
|
73
|
+
self.status_code = status_code
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _DownloadTimeoutError(VideoKitError):
|
|
77
|
+
"""A download attempt that blew its per-attempt wall-clock deadline.
|
|
78
|
+
Transient on purpose (see `_is_transient`): a stalled/slow attempt is
|
|
79
|
+
retried with a FRESH deadline, mirroring ducking-api.ts's
|
|
80
|
+
DownloadTimeoutError. httpx's own `timeout` only bounds the gap BETWEEN
|
|
81
|
+
chunks (it resets on every byte received), so a server dribbling bytes
|
|
82
|
+
just under that gap would otherwise stream forever; this bounds the
|
|
83
|
+
whole attempt's wall-clock lifetime instead."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, timeout_seconds: float) -> None:
|
|
86
|
+
super().__init__(
|
|
87
|
+
f"The ducked-mix download did not complete within its {timeout_seconds}s "
|
|
88
|
+
"deadline and was aborted."
|
|
89
|
+
)
|
|
90
|
+
self.timeout_seconds = timeout_seconds
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _retry_delay_seconds(attempt: int) -> float:
|
|
94
|
+
return min(_RETRY_BASE_SECONDS * (2 ** (attempt - 1)), _RETRY_MAX_SECONDS)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_transient(err: BaseException) -> bool:
|
|
98
|
+
"""Worth another go? Mirrors ducking-api.ts's isTransient: a blown
|
|
99
|
+
per-attempt download deadline is retried with a fresh one; a numeric
|
|
100
|
+
status >= 500 is transient (covers both the sonilo SDK's APIError and our
|
|
101
|
+
own _HttpStatusError); any other VideoKitError is terminal; anything else
|
|
102
|
+
(a network-level failure — connection reset, DNS, TLS) is transient."""
|
|
103
|
+
if isinstance(err, _DownloadTimeoutError):
|
|
104
|
+
return True
|
|
105
|
+
status = getattr(err, "status_code", None)
|
|
106
|
+
if isinstance(status, int):
|
|
107
|
+
return status >= 500
|
|
108
|
+
if isinstance(err, VideoKitError):
|
|
109
|
+
return False
|
|
110
|
+
return isinstance(err, Exception)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _with_retry(op: "Callable[[], Any]", *, sleep: Sleep) -> Any:
|
|
114
|
+
attempt = 1
|
|
115
|
+
while True:
|
|
116
|
+
try:
|
|
117
|
+
return op()
|
|
118
|
+
except Exception as err: # noqa: BLE001 - reclassified below
|
|
119
|
+
if attempt >= _MAX_ATTEMPTS or not _is_transient(err):
|
|
120
|
+
raise
|
|
121
|
+
sleep(_retry_delay_seconds(attempt))
|
|
122
|
+
attempt += 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_json_capped(response: httpx.Response, what: str) -> Any:
|
|
126
|
+
"""`response.json()`, but refusing to trust a body over MAX_JSON_BYTES."""
|
|
127
|
+
if len(response.content) > MAX_JSON_BYTES:
|
|
128
|
+
raise VideoKitError(
|
|
129
|
+
f"The ducking API's {what} response exceeded {MAX_JSON_BYTES} bytes; "
|
|
130
|
+
"refusing to buffer it."
|
|
131
|
+
)
|
|
132
|
+
return response.json()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# submit_ducking_job
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def submit_ducking_job(client: DuckingClient, voice_path: StrPath, music_path: StrPath) -> str:
|
|
141
|
+
"""POST the voice and music tracks to /v1/audio-ducking. Returns the task
|
|
142
|
+
id; the endpoint is async (submit + poll).
|
|
143
|
+
|
|
144
|
+
Deliberately NOT retried, unlike the poll and the download below: the
|
|
145
|
+
POST is what CHARGES the account (the backend charges in the request
|
|
146
|
+
handler, before the background job is even spawned), and it carries no
|
|
147
|
+
idempotency key. A retry after a response we failed to read would risk
|
|
148
|
+
submitting — and paying for — the same job twice. Failing here is safe;
|
|
149
|
+
failing after here is what has to be recovered (see duck.py's rescue
|
|
150
|
+
path)."""
|
|
151
|
+
voice = Path(voice_path)
|
|
152
|
+
music = Path(music_path)
|
|
153
|
+
files = {
|
|
154
|
+
"voice_file": (voice.name, voice.read_bytes()),
|
|
155
|
+
"music_file": (music.name, music.read_bytes()),
|
|
156
|
+
}
|
|
157
|
+
response = client._http.post("/v1/audio-ducking", files=files)
|
|
158
|
+
if response.status_code >= 400:
|
|
159
|
+
raise error_from_response(response)
|
|
160
|
+
body = _parse_json_capped(response, "submit")
|
|
161
|
+
task_id = body.get("task_id") if isinstance(body, dict) else None
|
|
162
|
+
if not task_id:
|
|
163
|
+
raise VideoKitError("The ducking API accepted the request but returned no task_id")
|
|
164
|
+
return task_id
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# await_ducking_result
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _extract_error_fields(body: "dict[str, Any]") -> "tuple[str, str]":
|
|
173
|
+
"""The task's failure code/message. The backend's documented envelope
|
|
174
|
+
nests them under `error: {code, message}` (matching the sonilo SDK's
|
|
175
|
+
other task endpoints and ducking-api.ts's TaskBody); tolerate a flat
|
|
176
|
+
top-level `code`/`message` too, in case an older or alternate envelope
|
|
177
|
+
omits the wrapper."""
|
|
178
|
+
error = body.get("error")
|
|
179
|
+
if isinstance(error, dict):
|
|
180
|
+
code = error.get("code") or "DUCKING_FAILED"
|
|
181
|
+
message = error.get("message") or "the ducking task failed"
|
|
182
|
+
else:
|
|
183
|
+
code = body.get("code") or "DUCKING_FAILED"
|
|
184
|
+
message = body.get("message") or "the ducking task failed"
|
|
185
|
+
return str(code), str(message)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _dedup_failed_message(message: str, code: str, refunded: bool) -> str:
|
|
189
|
+
"""Compose the final DuckingFailedError message, ported from errors.ts's
|
|
190
|
+
DuckingFailedError constructor (kept here rather than in errors.py: the
|
|
191
|
+
Python DuckingFailedError, ported earlier, stores its message as given —
|
|
192
|
+
this is the one call site that needs the composed text).
|
|
193
|
+
|
|
194
|
+
The server derives `code` by splitting its own error_message on ":"
|
|
195
|
+
(error_message = "DUCKING_FAILED: audio processing failed"), so both
|
|
196
|
+
fields carry the code and a naive compose renders it twice. Strip the
|
|
197
|
+
"{code}:" prefix from `message` first."""
|
|
198
|
+
prefix = f"{code}:"
|
|
199
|
+
detail = message[len(prefix):].strip() if message.startswith(prefix) else message
|
|
200
|
+
detail = detail or message
|
|
201
|
+
note = (
|
|
202
|
+
" — the charge was refunded"
|
|
203
|
+
if refunded
|
|
204
|
+
else (
|
|
205
|
+
" — the charge had not been reversed yet when the task was polled; the server "
|
|
206
|
+
"reverses it after marking the task failed, and retries a reversal that fails, "
|
|
207
|
+
"so it may still land. Check your usage before assuming you were billed for this."
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
return f"Ducking failed [{code}]: {detail}{note}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _poll_task(client: DuckingClient, task_id: str) -> "dict[str, Any]":
|
|
214
|
+
response = client._http.get(f"/v1/tasks/{quote(task_id, safe='')}")
|
|
215
|
+
if response.status_code >= 400:
|
|
216
|
+
raise error_from_response(response)
|
|
217
|
+
body = _parse_json_capped(response, "task")
|
|
218
|
+
if not isinstance(body, dict):
|
|
219
|
+
raise VideoKitError(f"Ducking task {task_id} returned a malformed response")
|
|
220
|
+
return body
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _sanitize_output_bytes(value: Any) -> Optional[int]:
|
|
224
|
+
"""Only ever a POSITIVE integer or None. A real ducking artifact is never
|
|
225
|
+
0 bytes, and a fractional/negative/wrong-typed value is not a byte
|
|
226
|
+
count — see the extensive comment on this exact check in ducking-api.ts."""
|
|
227
|
+
if isinstance(value, bool):
|
|
228
|
+
return None
|
|
229
|
+
if isinstance(value, int) and value > 0:
|
|
230
|
+
return value
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def await_ducking_result(
|
|
235
|
+
client: DuckingClient,
|
|
236
|
+
task_id: str,
|
|
237
|
+
*,
|
|
238
|
+
poll_interval: float,
|
|
239
|
+
timeout: float,
|
|
240
|
+
deadline: Optional[float] = None,
|
|
241
|
+
sleep: Sleep = time.sleep,
|
|
242
|
+
) -> DuckingResult:
|
|
243
|
+
"""Poll GET /v1/tasks/{id} until the task leaves `processing`.
|
|
244
|
+
|
|
245
|
+
`deadline` is an absolute `time.monotonic()` ceiling; when omitted it is
|
|
246
|
+
computed from `timeout`. duck.py passes the same deadline into both this
|
|
247
|
+
call and download_ducked_mix, so one budget governs polling AND the
|
|
248
|
+
download together (see duck.ts's "ONE deadline governs the WHOLE
|
|
249
|
+
post-submit collection")."""
|
|
250
|
+
effective_deadline = deadline if deadline is not None else time.monotonic() + timeout
|
|
251
|
+
|
|
252
|
+
while True:
|
|
253
|
+
body = _with_retry(lambda: _poll_task(client, task_id), sleep=sleep)
|
|
254
|
+
status = body.get("status")
|
|
255
|
+
|
|
256
|
+
if status == "succeeded":
|
|
257
|
+
output_url = body.get("output_url")
|
|
258
|
+
if not output_url:
|
|
259
|
+
raise VideoKitError(f"Ducking task {task_id} succeeded but carried no output_url")
|
|
260
|
+
return DuckingResult(
|
|
261
|
+
output_url=output_url,
|
|
262
|
+
output_type=body.get("output_type") or "audio",
|
|
263
|
+
output_bytes=_sanitize_output_bytes(body.get("output_bytes")),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if status == "failed":
|
|
267
|
+
code, message = _extract_error_fields(body)
|
|
268
|
+
refunded = bool(body.get("refunded", False))
|
|
269
|
+
raise DuckingFailedError(
|
|
270
|
+
_dedup_failed_message(message, code, refunded), code=code, refunded=refunded
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if time.monotonic() + poll_interval >= effective_deadline:
|
|
274
|
+
raise VideoKitError(f"Ducking task {task_id} did not finish within {timeout}s")
|
|
275
|
+
sleep(poll_interval)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# assert_safe_download_url / download_ducked_mix
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
_DECIMAL_DIGITS = set("0123456789")
|
|
283
|
+
_OCTAL_DIGITS = set("01234567")
|
|
284
|
+
_HEX_DIGITS = set("0123456789abcdefABCDEF")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _parse_ipv4_number(label: str) -> Optional[int]:
|
|
288
|
+
"""Parse one dot-separated label (or a whole no-dot host) as an IPv4
|
|
289
|
+
"number", per the WHATWG URL standard's IPv4 number parser: a decimal,
|
|
290
|
+
`0x`/`0X`-prefixed hex, or legacy leading-zero octal integer. Returns
|
|
291
|
+
None if `label` is empty or is not such a number — i.e. an ordinary
|
|
292
|
+
domain label, which the WHATWG parser also leaves untouched.
|
|
293
|
+
|
|
294
|
+
This is what a browser's URL parser runs BEFORE the IP-literal check
|
|
295
|
+
ever sees the host — see ducking-api.ts's isIpLiteralHost comment.
|
|
296
|
+
httpx/urlsplit + ipaddress.ip_address do none of this canonicalization,
|
|
297
|
+
so this Python port has to do it itself."""
|
|
298
|
+
if not label:
|
|
299
|
+
return None
|
|
300
|
+
if len(label) >= 2 and label[0] == "0" and label[1] in ("x", "X"):
|
|
301
|
+
digits, base, allowed = label[2:], 16, _HEX_DIGITS
|
|
302
|
+
elif len(label) >= 2 and label[0] == "0":
|
|
303
|
+
digits, base, allowed = label[1:], 8, _OCTAL_DIGITS
|
|
304
|
+
else:
|
|
305
|
+
digits, base, allowed = label, 10, _DECIMAL_DIGITS
|
|
306
|
+
if digits == "":
|
|
307
|
+
return 0
|
|
308
|
+
if not all(ch in allowed for ch in digits):
|
|
309
|
+
return None
|
|
310
|
+
return int(digits, base)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _is_ip_literal_host(host: str) -> bool:
|
|
314
|
+
"""True if `host` (already lowercased) denotes an IPv4/IPv6 literal in
|
|
315
|
+
ANY form a WHATWG-compliant URL parser (what JS's `new URL()` uses)
|
|
316
|
+
would canonicalize into one — not just the strict textual forms
|
|
317
|
+
`ipaddress.ip_address` accepts on its own. Mirrors ducking-api.ts's
|
|
318
|
+
`isIpLiteralHost`, whose intent relies on the browser URL parser having
|
|
319
|
+
already canonicalized the host before that check runs; this port has no
|
|
320
|
+
such parser in front of it, so it reimplements the canonicalization.
|
|
321
|
+
|
|
322
|
+
Catches, in addition to the textbook dotted-quad / bracketed-v6 forms:
|
|
323
|
+
- a bare integer host in decimal, hex (`0x...`), or octal (leading
|
|
324
|
+
`0`) that fits in 32 bits, e.g. "2130706433" / "0x7f000001" /
|
|
325
|
+
"017700000001" — all 127.0.0.1;
|
|
326
|
+
- a dotted host whose every label is itself such a number, e.g.
|
|
327
|
+
"0x7f.0.0.1" or "0177.0.0.1";
|
|
328
|
+
- a trailing dot on a dotted-quad, e.g. "127.0.0.1." (the DNS-root
|
|
329
|
+
dot; "127.0.0.1." resolves identically to "127.0.0.1", but
|
|
330
|
+
`ipaddress.ip_address` rejects the trailing-dot form outright).
|
|
331
|
+
|
|
332
|
+
A host that is NOT an IP literal in any of these forms (an ordinary
|
|
333
|
+
domain) returns False and is left alone — a numeric-only label is not
|
|
334
|
+
a legal DNS TLD, so this cannot misclassify a real hostname."""
|
|
335
|
+
stripped = host.rstrip(".")
|
|
336
|
+
if not stripped:
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
ipaddress.ip_address(stripped)
|
|
341
|
+
return True
|
|
342
|
+
except ValueError:
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
if "." not in stripped:
|
|
346
|
+
# A bare integer host is the WHATWG IPv4 number parser applied to a
|
|
347
|
+
# single label: "2130706433", "0x7f000001", "017700000001" all take
|
|
348
|
+
# this path (and all denote 127.0.0.1).
|
|
349
|
+
number = _parse_ipv4_number(stripped)
|
|
350
|
+
return number is not None and 0 <= number <= 0xFFFFFFFF
|
|
351
|
+
|
|
352
|
+
labels = stripped.split(".")
|
|
353
|
+
return all(_parse_ipv4_number(label) is not None for label in labels)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def assert_safe_download_url(url: str) -> None:
|
|
357
|
+
"""Validate the presigned download URL before fetching it.
|
|
358
|
+
|
|
359
|
+
`output_url` arrives in the task body, i.e. from the API — which a
|
|
360
|
+
compromise could turn hostile. Unchecked, it is an SSRF primitive. This
|
|
361
|
+
raises the bar against the obvious payloads:
|
|
362
|
+
- only `https` is allowed (a presigned R2 GET always is);
|
|
363
|
+
- IP-literal hosts (v4/v6) — including decimal/hex/octal and
|
|
364
|
+
trailing-dot encodings a browser's URL parser would canonicalize
|
|
365
|
+
into one, see `_is_ip_literal_host` — plus `localhost` and
|
|
366
|
+
`*.local` / `*.internal`, are refused: the cloud-metadata
|
|
367
|
+
(169.254.169.254), loopback, and internal-DNS targets an SSRF aims
|
|
368
|
+
at, never a real presigned host.
|
|
369
|
+
|
|
370
|
+
What it does NOT stop: a public hostname that resolves to a private
|
|
371
|
+
address (DNS rebinding) — out of scope for a zero-dependency kit; the
|
|
372
|
+
download additionally disables redirects so a 200-looking URL cannot
|
|
373
|
+
302 into internal infrastructure.
|
|
374
|
+
|
|
375
|
+
On rejection: name only the scheme or host, never the full URL — its
|
|
376
|
+
query string carries the signing signature (a capability), and errors
|
|
377
|
+
get logged."""
|
|
378
|
+
try:
|
|
379
|
+
parsed = urlsplit(url)
|
|
380
|
+
except ValueError as exc:
|
|
381
|
+
raise VideoKitError(
|
|
382
|
+
"The ducking API returned an output_url that is not a valid URL."
|
|
383
|
+
) from exc
|
|
384
|
+
if parsed.scheme != "https":
|
|
385
|
+
raise VideoKitError(
|
|
386
|
+
f'The ducking API\'s output_url uses an unsupported scheme "{parsed.scheme}"; '
|
|
387
|
+
"only https is allowed for the presigned download."
|
|
388
|
+
)
|
|
389
|
+
host = (parsed.hostname or "").lower()
|
|
390
|
+
if not host:
|
|
391
|
+
raise VideoKitError("The ducking API returned an output_url with no host.")
|
|
392
|
+
|
|
393
|
+
# Strip the DNS-root trailing dot(s) before the named-host blocklist, so
|
|
394
|
+
# "localhost." (which resolves identically to "localhost") cannot sneak
|
|
395
|
+
# through. Comparison-only: the fetch below still uses the original URL.
|
|
396
|
+
named_host = host.rstrip(".")
|
|
397
|
+
if (
|
|
398
|
+
_is_ip_literal_host(host)
|
|
399
|
+
or named_host == "localhost"
|
|
400
|
+
or named_host.endswith(".local")
|
|
401
|
+
or named_host.endswith(".internal")
|
|
402
|
+
):
|
|
403
|
+
raise VideoKitError(
|
|
404
|
+
f'The ducking API\'s output_url points at a non-public host "{host}", which is '
|
|
405
|
+
"never a legitimate presigned download location; refusing to fetch it."
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def download_ducked_mix(
|
|
410
|
+
url: str,
|
|
411
|
+
dest_path: StrPath,
|
|
412
|
+
*,
|
|
413
|
+
max_bytes: int,
|
|
414
|
+
expected_bytes: Optional[int] = None,
|
|
415
|
+
timeout: Optional[float] = None,
|
|
416
|
+
deadline: Optional[float] = None,
|
|
417
|
+
sleep: Sleep = time.sleep,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Download the finished mix, streamed to `dest_path` without ever
|
|
420
|
+
leaving partial bytes there.
|
|
421
|
+
|
|
422
|
+
Deliberately uses a FRESH, unauthenticated httpx.Client, never
|
|
423
|
+
`client._http`: `url` is a presigned link on a different host, and
|
|
424
|
+
routing it through the authenticated client would put the customer's
|
|
425
|
+
API key on a request to that host. Redirects are disabled (a presigned
|
|
426
|
+
GET never legitimately 302s). Retried through transient failures — by
|
|
427
|
+
the time there is something to download, the task has succeeded and the
|
|
428
|
+
account has been charged, so losing the mix to one 503 would mean
|
|
429
|
+
paying twice for it."""
|
|
430
|
+
assert_safe_download_url(url)
|
|
431
|
+
per_attempt = timeout if timeout is not None else DEFAULT_DOWNLOAD_TIMEOUT_SECONDS
|
|
432
|
+
dest = Path(dest_path)
|
|
433
|
+
|
|
434
|
+
def _attempt() -> None:
|
|
435
|
+
attempt_timeout = per_attempt
|
|
436
|
+
if deadline is not None:
|
|
437
|
+
remaining = deadline - time.monotonic()
|
|
438
|
+
if remaining <= 0:
|
|
439
|
+
raise VideoKitError(
|
|
440
|
+
"The ducked-mix download did not complete within the overall time "
|
|
441
|
+
"budget for this operation (shared with polling) and was stopped "
|
|
442
|
+
"before starting a fresh attempt past it."
|
|
443
|
+
)
|
|
444
|
+
attempt_timeout = min(per_attempt, remaining)
|
|
445
|
+
|
|
446
|
+
# Wall-clock ceiling for this WHOLE attempt (connect + stream), not
|
|
447
|
+
# just the inter-chunk gap httpx's own `timeout` bounds — see
|
|
448
|
+
# _DownloadTimeoutError. Read with time.monotonic() only, never
|
|
449
|
+
# wall-clock time.time(), so an NTP step can't shorten or extend it.
|
|
450
|
+
attempt_deadline = time.monotonic() + attempt_timeout
|
|
451
|
+
|
|
452
|
+
tmp_path = dest.parent / f".{dest.name}.{uuid.uuid4().hex}.part"
|
|
453
|
+
with httpx.Client(follow_redirects=False, timeout=attempt_timeout) as http:
|
|
454
|
+
try:
|
|
455
|
+
with http.stream("GET", url) as response:
|
|
456
|
+
# >= 300, not just >= 400: `follow_redirects=False` means
|
|
457
|
+
# a 3xx comes back as an ordinary response object here
|
|
458
|
+
# (unlike JS's `redirect:"error"` fetch, which throws),
|
|
459
|
+
# so it must be rejected explicitly or a redirect body
|
|
460
|
+
# would be written out as the "mix".
|
|
461
|
+
if response.status_code >= 300:
|
|
462
|
+
raise _HttpStatusError(
|
|
463
|
+
response.status_code, "Could not download the ducked mix"
|
|
464
|
+
)
|
|
465
|
+
total = 0
|
|
466
|
+
with open(tmp_path, "wb") as handle:
|
|
467
|
+
for chunk in response.iter_bytes():
|
|
468
|
+
# A server dribbling chunks just under httpx's
|
|
469
|
+
# inter-chunk read timeout never trips it (that
|
|
470
|
+
# timeout resets on every byte received); this
|
|
471
|
+
# wall-clock check bounds the attempt regardless
|
|
472
|
+
# of how the bytes are paced.
|
|
473
|
+
if time.monotonic() >= attempt_deadline:
|
|
474
|
+
raise _DownloadTimeoutError(attempt_timeout)
|
|
475
|
+
total += len(chunk)
|
|
476
|
+
if total > max_bytes:
|
|
477
|
+
raise VideoKitError(
|
|
478
|
+
"The ducked mix exceeded the maximum allowed size "
|
|
479
|
+
f"({max_bytes} bytes) and was refused."
|
|
480
|
+
)
|
|
481
|
+
handle.write(chunk)
|
|
482
|
+
if expected_bytes is not None and total != expected_bytes:
|
|
483
|
+
raise VideoKitError(
|
|
484
|
+
f"The ducked mix download was {total} bytes but the ducking "
|
|
485
|
+
f"API declared exactly {expected_bytes} bytes; refusing this "
|
|
486
|
+
"truncated or altered download."
|
|
487
|
+
)
|
|
488
|
+
os.replace(tmp_path, dest)
|
|
489
|
+
except Exception:
|
|
490
|
+
try:
|
|
491
|
+
os.remove(tmp_path)
|
|
492
|
+
except OSError:
|
|
493
|
+
pass
|
|
494
|
+
raise
|
|
495
|
+
|
|
496
|
+
_with_retry(_attempt, sleep=sleep)
|