videofetch-sdk 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.
- videofetch/__init__.py +41 -0
- videofetch/asyncio.py +7 -0
- videofetch/client.py +181 -0
- videofetch/errors.py +99 -0
- videofetch/models.py +173 -0
- videofetch/resources.py +261 -0
- videofetch/webhooks.py +56 -0
- videofetch_sdk-0.1.0.dist-info/METADATA +133 -0
- videofetch_sdk-0.1.0.dist-info/RECORD +10 -0
- videofetch_sdk-0.1.0.dist-info/WHEEL +4 -0
videofetch/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""VideoFetch — official Python SDK.
|
|
2
|
+
|
|
3
|
+
import videofetch
|
|
4
|
+
client = videofetch.VideoFetch(api_key="vf_live_sk_...")
|
|
5
|
+
job = client.downloads.create(url="https://youtu.be/...", format="1080p")
|
|
6
|
+
result = job.wait() # polls until completed/failed
|
|
7
|
+
print(result.download_url) # presigned 7-day link (url destination)
|
|
8
|
+
|
|
9
|
+
Async:
|
|
10
|
+
from videofetch.asyncio import AsyncVideoFetch
|
|
11
|
+
async with AsyncVideoFetch(api_key="...") as client:
|
|
12
|
+
result = await (await client.downloads.create(url=..., format="1080p")).wait()
|
|
13
|
+
|
|
14
|
+
Webhooks (FastAPI example):
|
|
15
|
+
from videofetch.webhooks import construct_event
|
|
16
|
+
payload = await request.body()
|
|
17
|
+
event = construct_event(payload, request.headers.get("X-VideoFetch-Signature"), endpoint_secret)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .client import AsyncVideoFetch, VideoFetch # noqa: F401
|
|
21
|
+
from .errors import ( # noqa: F401
|
|
22
|
+
ApiError,
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
JobFailedError,
|
|
25
|
+
NotFoundError,
|
|
26
|
+
QuotaExceededError,
|
|
27
|
+
RateLimitError,
|
|
28
|
+
ValidationError,
|
|
29
|
+
VideoFetchError,
|
|
30
|
+
)
|
|
31
|
+
from .models import Download, DownloadAttempt, DownloadList, FormatInfo, TrimSpec, VideoInfo # noqa: F401
|
|
32
|
+
from .resources import DownloadJob # noqa: F401
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
__all__ = [
|
|
36
|
+
"VideoFetch", "AsyncVideoFetch", "DownloadJob",
|
|
37
|
+
"VideoFetchError", "AuthenticationError", "QuotaExceededError", "ValidationError",
|
|
38
|
+
"NotFoundError", "RateLimitError", "ApiError", "JobFailedError",
|
|
39
|
+
"Download", "DownloadList", "DownloadAttempt", "TrimSpec", "FormatInfo", "VideoInfo",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
videofetch/asyncio.py
ADDED
videofetch/client.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Sync + Async HTTP clients with retry, error mapping and typed resources.
|
|
2
|
+
|
|
3
|
+
Design mirrors the OpenAI/Stripe SDKs:
|
|
4
|
+
VideoFetch(api_key) → sync client: client.downloads.create(...)
|
|
5
|
+
AsyncVideoFetch(api_key) → async client: await client.downloads.create(...)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .errors import VideoFetchError, map_error
|
|
19
|
+
from .models import Download, DownloadList, TrimSpec, VideoInfo
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = os.getenv("VIDEOFETCH_BASE_URL", "https://api.vidfetch.dev")
|
|
22
|
+
DEFAULT_MAX_RETRIES = 2
|
|
23
|
+
DEFAULT_TIMEOUT = 30.0
|
|
24
|
+
DEFAULT_JOB_TIMEOUT = 120.0
|
|
25
|
+
DEFAULT_POLL_INTERVAL = 2.0
|
|
26
|
+
|
|
27
|
+
_TERMINAL = ("completed", "failed", "deleted")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _headers(api_key: str) -> dict:
|
|
31
|
+
return {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _retry_delay(attempt: int) -> float:
|
|
35
|
+
"""2s → 3s → 5s → 8s → 13s (Fibonacci) + 20% jitter."""
|
|
36
|
+
a, b = 2.0, 3.0
|
|
37
|
+
for _ in range(attempt):
|
|
38
|
+
a, b = b, a + b
|
|
39
|
+
return min(a, 30.0) * random.uniform(0.8, 1.2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _poll_delay(attempt: int) -> float:
|
|
43
|
+
"""Poll backoff for job.wait: 2s → 3s → 5s → 8s → 13s → cap 30s."""
|
|
44
|
+
return min(_retry_delay(attempt), 30.0)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ────────────────────────── Sync client ──────────────────────────
|
|
48
|
+
class VideoFetch:
|
|
49
|
+
"""Sync client. Usage:
|
|
50
|
+
client = VideoFetch(api_key="vf_live_sk_...")
|
|
51
|
+
job = client.downloads.create(url=..., format="1080p")
|
|
52
|
+
result = job.wait(timeout=120)
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
downloads: "DownloadsResource"
|
|
56
|
+
info: "InfoResource"
|
|
57
|
+
|
|
58
|
+
def __init__(self, api_key: Optional[str] = None, *, base_url: str = DEFAULT_BASE_URL,
|
|
59
|
+
timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES,
|
|
60
|
+
http_client: Optional[httpx.Client] = None):
|
|
61
|
+
self.api_key = api_key or os.getenv("VIDEOFETCH_API_KEY") or ""
|
|
62
|
+
if not self.api_key:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"No API key provided. Pass api_key=... or set VIDEOFETCH_API_KEY."
|
|
65
|
+
)
|
|
66
|
+
self.base_url = base_url.rstrip("/")
|
|
67
|
+
self.max_retries = max_retries
|
|
68
|
+
self._client = http_client or httpx.Client(
|
|
69
|
+
base_url=self.base_url, headers=_headers(self.api_key),
|
|
70
|
+
timeout=timeout, follow_redirects=True,
|
|
71
|
+
# do not inherit system proxy env (can hijack localhost/dev traffic);
|
|
72
|
+
# advanced users can pass their own http_client to override
|
|
73
|
+
trust_env=False,
|
|
74
|
+
)
|
|
75
|
+
# lazy import avoids circular dependency (resources imports client)
|
|
76
|
+
from .resources import DownloadsResource, InfoResource
|
|
77
|
+
self.downloads = DownloadsResource(self)
|
|
78
|
+
self.info = InfoResource(self)
|
|
79
|
+
|
|
80
|
+
def request(self, method: str, path: str, *, json_body: Optional[dict] = None,
|
|
81
|
+
params: Optional[dict] = None) -> Any:
|
|
82
|
+
"""Send request with automatic retry on 429/5xx. Returns parsed JSON."""
|
|
83
|
+
for attempt in range(self.max_retries + 1):
|
|
84
|
+
try:
|
|
85
|
+
resp = self._client.request(method, path, json=json_body, params=params)
|
|
86
|
+
except httpx.HTTPError as e:
|
|
87
|
+
if attempt < self.max_retries:
|
|
88
|
+
time.sleep(_retry_delay(attempt))
|
|
89
|
+
continue
|
|
90
|
+
raise VideoFetchError(f"Network error calling {path}: {e}", status_code=None) from e
|
|
91
|
+
if resp.status_code in (429,) or resp.status_code >= 500:
|
|
92
|
+
if attempt < self.max_retries:
|
|
93
|
+
retry_after = resp.headers.get("Retry-After")
|
|
94
|
+
delay = float(retry_after) if retry_after and retry_after.isdigit() else _retry_delay(attempt)
|
|
95
|
+
time.sleep(min(delay, 30.0))
|
|
96
|
+
continue
|
|
97
|
+
if resp.status_code >= 400:
|
|
98
|
+
body = _parse_body(resp)
|
|
99
|
+
raise map_error(resp.status_code, body)
|
|
100
|
+
if resp.status_code == 204:
|
|
101
|
+
return None
|
|
102
|
+
return _parse_body(resp)
|
|
103
|
+
raise VideoFetchError("request failed") # pragma: no cover
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
self._client.close()
|
|
107
|
+
|
|
108
|
+
def __enter__(self) -> "VideoFetch":
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def __exit__(self, *exc) -> None:
|
|
112
|
+
self.close()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ────────────────────────── Async client ─────────────────────────
|
|
116
|
+
class AsyncVideoFetch:
|
|
117
|
+
"""Async client for asyncio/Next.js backends. Same shape as VideoFetch, but
|
|
118
|
+
every resource method is a coroutine (await client.downloads.create(...)).
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
downloads: "AsyncDownloadsResource"
|
|
122
|
+
info: "AsyncInfoResource"
|
|
123
|
+
|
|
124
|
+
def __init__(self, api_key: Optional[str] = None, *, base_url: str = DEFAULT_BASE_URL,
|
|
125
|
+
timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES,
|
|
126
|
+
http_client: Optional[httpx.AsyncClient] = None):
|
|
127
|
+
self.api_key = api_key or os.getenv("VIDEOFETCH_API_KEY") or ""
|
|
128
|
+
if not self.api_key:
|
|
129
|
+
raise ValueError("No API key provided. Pass api_key=... or set VIDEOFETCH_API_KEY.")
|
|
130
|
+
self.base_url = base_url.rstrip("/")
|
|
131
|
+
self.max_retries = max_retries
|
|
132
|
+
self._client = http_client or httpx.AsyncClient(
|
|
133
|
+
base_url=self.base_url, headers=_headers(self.api_key),
|
|
134
|
+
timeout=timeout, follow_redirects=True,
|
|
135
|
+
trust_env=False,
|
|
136
|
+
)
|
|
137
|
+
from .resources import AsyncDownloadsResource, AsyncInfoResource
|
|
138
|
+
self.downloads = AsyncDownloadsResource(self)
|
|
139
|
+
self.info = AsyncInfoResource(self)
|
|
140
|
+
|
|
141
|
+
async def request(self, method: str, path: str, *, json_body: Optional[dict] = None,
|
|
142
|
+
params: Optional[dict] = None) -> Any:
|
|
143
|
+
import asyncio
|
|
144
|
+
|
|
145
|
+
for attempt in range(self.max_retries + 1):
|
|
146
|
+
try:
|
|
147
|
+
resp = await self._client.request(method, path, json=json_body, params=params)
|
|
148
|
+
except httpx.HTTPError as e:
|
|
149
|
+
if attempt < self.max_retries:
|
|
150
|
+
await asyncio.sleep(_retry_delay(attempt))
|
|
151
|
+
continue
|
|
152
|
+
raise VideoFetchError(f"Network error calling {path}: {e}", status_code=None) from e
|
|
153
|
+
if resp.status_code in (429,) or resp.status_code >= 500:
|
|
154
|
+
if attempt < self.max_retries:
|
|
155
|
+
retry_after = resp.headers.get("Retry-After")
|
|
156
|
+
delay = float(retry_after) if retry_after and retry_after.isdigit() else _retry_delay(attempt)
|
|
157
|
+
await asyncio.sleep(min(delay, 30.0))
|
|
158
|
+
continue
|
|
159
|
+
if resp.status_code >= 400:
|
|
160
|
+
body = _parse_body(resp)
|
|
161
|
+
raise map_error(resp.status_code, body)
|
|
162
|
+
if resp.status_code == 204:
|
|
163
|
+
return None
|
|
164
|
+
return _parse_body(resp)
|
|
165
|
+
raise VideoFetchError("request failed") # pragma: no cover
|
|
166
|
+
|
|
167
|
+
async def close(self) -> None:
|
|
168
|
+
await self._client.aclose()
|
|
169
|
+
|
|
170
|
+
async def __aenter__(self) -> "AsyncVideoFetch":
|
|
171
|
+
return self
|
|
172
|
+
|
|
173
|
+
async def __aexit__(self, *exc) -> None:
|
|
174
|
+
await self.close()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_body(resp: httpx.Response) -> Any:
|
|
178
|
+
try:
|
|
179
|
+
return resp.json()
|
|
180
|
+
except Exception:
|
|
181
|
+
return resp.text
|
videofetch/errors.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Type hierarchy for VideoFetch SDK errors.
|
|
2
|
+
|
|
3
|
+
Mirrors the server error contract:
|
|
4
|
+
HTTP 4xx/5xx → {"detail": {"code": ..., "message": ..., "param": ...}}
|
|
5
|
+
Terminal job failure → DownloadOut {status: "failed", error_code, error_message}
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class VideoFetchError(Exception):
|
|
10
|
+
"""Base class for all SDK errors."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, *, code: str | None = None,
|
|
13
|
+
status_code: int | None = None, param: str | None = None,
|
|
14
|
+
response_body: object | None = None):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.message = message
|
|
17
|
+
self.code = code
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.param = param
|
|
20
|
+
self.response_body = response_body
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthenticationError(VideoFetchError):
|
|
24
|
+
"""Invalid/expired API key or JWT (401)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PermissionDeniedError(VideoFetchError):
|
|
28
|
+
"""Authenticated but not allowed (403)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class QuotaExceededError(VideoFetchError):
|
|
32
|
+
"""Monthly quota exhausted (402)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ValidationError(VideoFetchError):
|
|
36
|
+
"""Request rejected (400/422): bad url/format/trim/destination/webhook."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NotFoundError(VideoFetchError):
|
|
40
|
+
"""Resource does not exist or does not belong to this key (404)."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RateLimitError(VideoFetchError):
|
|
44
|
+
"""Too many requests (429). Respect Retry-After when present."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ApiError(VideoFetchError):
|
|
48
|
+
"""Server-side failure (5xx) or unknown non-2xx."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class JobFailedError(VideoFetchError):
|
|
52
|
+
"""The download job reached a terminal failed state.
|
|
53
|
+
|
|
54
|
+
A failed download is never charged — see job.failed_not_charged.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, *, job_id: str, error_code: str | None, error_message: str | None):
|
|
58
|
+
super().__init__(
|
|
59
|
+
f"Download job {job_id} failed"
|
|
60
|
+
+ (f" [{error_code}]: {error_message}" if error_code or error_message else ""),
|
|
61
|
+
code=error_code or "job_failed",
|
|
62
|
+
)
|
|
63
|
+
self.job_id = job_id
|
|
64
|
+
self.error_code = error_code
|
|
65
|
+
self.error_message = error_message
|
|
66
|
+
self.failed_not_charged = True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def map_error(status_code: int, body: object, *, message: str | None = None) -> VideoFetchError:
|
|
70
|
+
"""Translate an HTTP response into the right SDK error."""
|
|
71
|
+
detail = None
|
|
72
|
+
if isinstance(body, dict):
|
|
73
|
+
detail = body.get("detail")
|
|
74
|
+
if isinstance(detail, dict):
|
|
75
|
+
code = detail.get("code") or detail.get("type")
|
|
76
|
+
msg = detail.get("message") or detail.get("msg") or message or "Request failed"
|
|
77
|
+
param = detail.get("param")
|
|
78
|
+
elif isinstance(detail, str):
|
|
79
|
+
code = None
|
|
80
|
+
msg = detail
|
|
81
|
+
param = None
|
|
82
|
+
else:
|
|
83
|
+
code = None
|
|
84
|
+
msg = message or "Request failed"
|
|
85
|
+
param = None
|
|
86
|
+
|
|
87
|
+
if status_code == 401:
|
|
88
|
+
return AuthenticationError(msg, code=code, status_code=status_code, param=param, response_body=body)
|
|
89
|
+
if status_code == 403:
|
|
90
|
+
return PermissionDeniedError(msg, code=code, status_code=status_code, param=param, response_body=body)
|
|
91
|
+
if status_code == 402:
|
|
92
|
+
return QuotaExceededError(msg, code=code, status_code=status_code, param=param, response_body=body)
|
|
93
|
+
if status_code in (400, 422):
|
|
94
|
+
return ValidationError(msg, code=code, status_code=status_code, param=param, response_body=body)
|
|
95
|
+
if status_code == 404:
|
|
96
|
+
return NotFoundError(msg, code=code, status_code=status_code, param=param, response_body=body)
|
|
97
|
+
if status_code == 429:
|
|
98
|
+
return RateLimitError(msg, code=code, status_code=status_code, param=param, response_body=body)
|
|
99
|
+
return ApiError(msg, code=code, status_code=status_code, param=param, response_body=body)
|
videofetch/models.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Typed models mirroring the VideoFetch API contract (openapi/openapi.json).
|
|
2
|
+
|
|
3
|
+
Implemented as lightweight dataclasses with from_dict() so the SDK has zero
|
|
4
|
+
runtime deps beyond httpx (no pydantic required by consumers).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _as_float(v: Any) -> Optional[float]:
|
|
14
|
+
return float(v) if v is not None else None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _as_int(v: Any) -> Optional[int]:
|
|
18
|
+
return int(v) if v is not None else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TrimSpec:
|
|
23
|
+
"""Requested clip window in seconds (float). end must be > start."""
|
|
24
|
+
start: Optional[float] = None
|
|
25
|
+
end: Optional[float] = None
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, d: Optional[dict]) -> Optional["TrimSpec"]:
|
|
29
|
+
if not d:
|
|
30
|
+
return None
|
|
31
|
+
return cls(start=_as_float(d.get("start")), end=_as_float(d.get("end")))
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> Optional[dict]:
|
|
34
|
+
if self.start is None and self.end is None:
|
|
35
|
+
return None
|
|
36
|
+
return {"start": self.start, "end": self.end}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class DownloadAttempt:
|
|
41
|
+
attempt_no: Optional[int] = None
|
|
42
|
+
strategy: Optional[str] = None # direct | decodo_isp | decodo_dc
|
|
43
|
+
proxy_host: Optional[str] = None
|
|
44
|
+
result: Optional[str] = None
|
|
45
|
+
error_code: Optional[str] = None
|
|
46
|
+
error_message: Optional[str] = None
|
|
47
|
+
latency_ms: Optional[int] = None
|
|
48
|
+
started_at: Optional[str] = None
|
|
49
|
+
finished_at: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, d: Optional[dict]) -> Optional["DownloadAttempt"]:
|
|
53
|
+
if not d:
|
|
54
|
+
return None
|
|
55
|
+
return cls(
|
|
56
|
+
attempt_no=_as_int(d.get("attempt_no")), strategy=d.get("strategy"),
|
|
57
|
+
proxy_host=d.get("proxy_host"), result=d.get("result"),
|
|
58
|
+
error_code=d.get("error_code"), error_message=d.get("error_message"),
|
|
59
|
+
latency_ms=_as_int(d.get("latency_ms")), started_at=d.get("started_at"),
|
|
60
|
+
finished_at=d.get("finished_at"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Download:
|
|
66
|
+
"""A download job (dl_xxx). Mirrors GET /v1/downloads/{id}."""
|
|
67
|
+
id: str
|
|
68
|
+
status: str # queued|processing|completed|failed|deleted
|
|
69
|
+
url: str
|
|
70
|
+
format: str # 144p..2160p|mp3
|
|
71
|
+
progress: int = 0
|
|
72
|
+
title: Optional[str] = None
|
|
73
|
+
video_id: Optional[str] = None
|
|
74
|
+
channel: Optional[str] = None
|
|
75
|
+
upload_date: Optional[str] = None
|
|
76
|
+
thumbnail: Optional[str] = None
|
|
77
|
+
duration_seconds: Optional[float] = None
|
|
78
|
+
size_bytes: Optional[int] = None
|
|
79
|
+
trim: Optional[TrimSpec] = None
|
|
80
|
+
destination_type: Optional[str] = None # url|s3|r2|gcs|s3_compatible
|
|
81
|
+
download_url: Optional[str] = None # presigned link (url destination only)
|
|
82
|
+
download_url_expires_at: Optional[str] = None
|
|
83
|
+
storage_key: Optional[str] = None # user://<bucket>/<key> when destination used
|
|
84
|
+
processing_time_ms: Optional[int] = None
|
|
85
|
+
cost_usd: float = 0.0
|
|
86
|
+
attempts: int = 0
|
|
87
|
+
strategy: Optional[str] = None
|
|
88
|
+
error_code: Optional[str] = None
|
|
89
|
+
error_message: Optional[str] = None
|
|
90
|
+
created_at: Optional[str] = None
|
|
91
|
+
completed_at: Optional[str] = None
|
|
92
|
+
estimated_bytes: Optional[int] = None
|
|
93
|
+
attempt_details: list[DownloadAttempt] = field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def is_terminal(self) -> bool:
|
|
97
|
+
return self.status in ("completed", "failed", "deleted")
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def failed_not_charged(self) -> bool:
|
|
101
|
+
return self.status == "failed"
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def from_dict(cls, d: dict) -> "Download":
|
|
105
|
+
return cls(
|
|
106
|
+
id=d.get("id", ""), status=d.get("status", "queued"), url=d.get("url", ""),
|
|
107
|
+
format=d.get("format", "mp4"), progress=int(d.get("progress") or 0),
|
|
108
|
+
title=d.get("title"), video_id=d.get("video_id"), channel=d.get("channel"),
|
|
109
|
+
upload_date=d.get("upload_date"), thumbnail=d.get("thumbnail"),
|
|
110
|
+
duration_seconds=_as_float(d.get("duration_seconds")),
|
|
111
|
+
size_bytes=_as_int(d.get("size_bytes")),
|
|
112
|
+
trim=TrimSpec.from_dict(d.get("trim")),
|
|
113
|
+
destination_type=d.get("destination_type"), download_url=d.get("download_url"),
|
|
114
|
+
download_url_expires_at=d.get("download_url_expires_at"),
|
|
115
|
+
storage_key=d.get("storage_key"),
|
|
116
|
+
processing_time_ms=_as_int(d.get("processing_time_ms")),
|
|
117
|
+
cost_usd=float(d.get("cost_usd") or 0.0),
|
|
118
|
+
attempts=int(d.get("attempts") or 0), strategy=d.get("strategy"),
|
|
119
|
+
error_code=d.get("error_code"), error_message=d.get("error_message"),
|
|
120
|
+
created_at=d.get("created_at"), completed_at=d.get("completed_at"),
|
|
121
|
+
estimated_bytes=_as_int(d.get("estimated_bytes")),
|
|
122
|
+
attempt_details=[DownloadAttempt.from_dict(a) or DownloadAttempt()
|
|
123
|
+
for a in (d.get("attempt_details") or [])],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class DownloadList:
|
|
129
|
+
items: list[Download] = field(default_factory=list)
|
|
130
|
+
total: int = 0
|
|
131
|
+
has_more: bool = False
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_dict(cls, d: dict) -> "DownloadList":
|
|
135
|
+
return cls(
|
|
136
|
+
items=[Download.from_dict(i) for i in (d.get("items") or [])],
|
|
137
|
+
total=int(d.get("total") or 0), has_more=bool(d.get("has_more")),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class FormatInfo:
|
|
143
|
+
quality: str
|
|
144
|
+
size: Optional[int] = None
|
|
145
|
+
container: str = "MP4"
|
|
146
|
+
note: Optional[str] = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class VideoInfo:
|
|
151
|
+
"""POST /v1/info result (free metadata lookup)."""
|
|
152
|
+
id: Optional[str] = None
|
|
153
|
+
url: str = ""
|
|
154
|
+
title: Optional[str] = None
|
|
155
|
+
duration: Optional[float] = None
|
|
156
|
+
thumbnail: Optional[str] = None
|
|
157
|
+
channel: Optional[str] = None
|
|
158
|
+
upload_date: Optional[str] = None
|
|
159
|
+
view_count: Optional[int] = None
|
|
160
|
+
formats: list[FormatInfo] = field(default_factory=list)
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def from_dict(cls, d: dict) -> "VideoInfo":
|
|
164
|
+
return cls(
|
|
165
|
+
id=d.get("id"), url=d.get("url", ""), title=d.get("title"),
|
|
166
|
+
duration=_as_float(d.get("duration")), thumbnail=d.get("thumbnail"),
|
|
167
|
+
channel=d.get("channel"), upload_date=d.get("upload_date"),
|
|
168
|
+
view_count=_as_int(d.get("view_count")),
|
|
169
|
+
formats=[FormatInfo(
|
|
170
|
+
quality=f.get("quality", ""), size=_as_int(f.get("size")),
|
|
171
|
+
container=f.get("container", "MP4"), note=f.get("note"),
|
|
172
|
+
) for f in (d.get("formats") or [])],
|
|
173
|
+
)
|
videofetch/resources.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Downloads resource: create / retrieve / list / cancel + Job.wait() polling.
|
|
2
|
+
|
|
3
|
+
Three-layer design (see docs/SDK_RELEASE_GUIDE.md):
|
|
4
|
+
L1 job = client.downloads.create(...) # POST only, returns immediately
|
|
5
|
+
L2 result = job.wait(timeout=120) # poll GET with backoff
|
|
6
|
+
L3 result = client.downloads.create_and_wait(...)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .client import DEFAULT_JOB_TIMEOUT, _poll_delay
|
|
15
|
+
from .errors import JobFailedError, VideoFetchError
|
|
16
|
+
from .models import Download, DownloadList, TrimSpec, VideoInfo
|
|
17
|
+
|
|
18
|
+
TERMINAL = ("completed", "failed", "deleted")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ────────────────────────── Sync resources ───────────────────────
|
|
22
|
+
class DownloadsResource:
|
|
23
|
+
def __init__(self, client):
|
|
24
|
+
self._client = client
|
|
25
|
+
|
|
26
|
+
def create(self, url: str, format: str = "720p", *,
|
|
27
|
+
trim: Optional[TrimSpec] = None,
|
|
28
|
+
destination: Optional[dict] = None,
|
|
29
|
+
webhook_url: Optional[str] = None,
|
|
30
|
+
**extra: dict) -> DownloadJob:
|
|
31
|
+
"""Create a download job. Returns immediately with status 'queued'."""
|
|
32
|
+
body: dict = {"url": url, "format": format, **extra}
|
|
33
|
+
t = trim.to_dict() if trim else None
|
|
34
|
+
if t:
|
|
35
|
+
body["trim"] = t
|
|
36
|
+
if destination:
|
|
37
|
+
body["destination"] = destination
|
|
38
|
+
if webhook_url:
|
|
39
|
+
body["webhook_url"] = webhook_url
|
|
40
|
+
data = self._client.request("POST", "/v1/downloads", json_body=body)
|
|
41
|
+
return DownloadJob(self._client, Download.from_dict(data))
|
|
42
|
+
|
|
43
|
+
def retrieve(self, download_id: str) -> Download:
|
|
44
|
+
data = self._client.request("GET", f"/v1/downloads/{download_id}")
|
|
45
|
+
return Download.from_dict(data)
|
|
46
|
+
|
|
47
|
+
def list(self, *, status: Optional[str] = None, q: Optional[str] = None,
|
|
48
|
+
limit: int = 20, offset: int = 0) -> DownloadList:
|
|
49
|
+
params = {"limit": limit, "offset": offset}
|
|
50
|
+
if status:
|
|
51
|
+
params["status"] = status
|
|
52
|
+
if q:
|
|
53
|
+
params["q"] = q
|
|
54
|
+
data = self._client.request("GET", "/v1/downloads", params=params)
|
|
55
|
+
return DownloadList.from_dict(data)
|
|
56
|
+
|
|
57
|
+
def cancel(self, download_id: str) -> None:
|
|
58
|
+
"""Cancel/delete a job (queued/processing). Safe to call on any state."""
|
|
59
|
+
self._client.request("DELETE", f"/v1/downloads/{download_id}")
|
|
60
|
+
|
|
61
|
+
def create_and_wait(self, url: str, format: str = "720p", *, timeout: float = DEFAULT_JOB_TIMEOUT,
|
|
62
|
+
**kwargs) -> Download:
|
|
63
|
+
job = self.create(url, format, **kwargs)
|
|
64
|
+
return job.wait(timeout=timeout)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DownloadJob:
|
|
68
|
+
"""Polling handle around a queued/processing download (sync)."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, client, initial: Download):
|
|
71
|
+
self._client = client
|
|
72
|
+
self._download = initial
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def id(self) -> str:
|
|
76
|
+
return self._download.id
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def status(self) -> str:
|
|
80
|
+
return self._download.status
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def download(self) -> Download:
|
|
84
|
+
return self._download
|
|
85
|
+
|
|
86
|
+
def refresh(self) -> Download:
|
|
87
|
+
self._download = self._client.downloads.retrieve(self.id)
|
|
88
|
+
return self._download
|
|
89
|
+
|
|
90
|
+
def wait(self, *, timeout: float = DEFAULT_JOB_TIMEOUT, poll_interval: float = None) -> Download:
|
|
91
|
+
"""Poll until terminal (completed/failed/deleted).
|
|
92
|
+
|
|
93
|
+
- Network errors do not abort; we keep polling until timeout.
|
|
94
|
+
- A failed job raises JobFailedError (failed downloads are never charged).
|
|
95
|
+
- timeout=0 / None means wait forever (CLI use). Cancel only stops local
|
|
96
|
+
waiting — the server-side job keeps running unless you call cancel().
|
|
97
|
+
"""
|
|
98
|
+
deadline = None if (timeout is None or timeout <= 0) else time.monotonic() + timeout
|
|
99
|
+
attempt = 0
|
|
100
|
+
# If the create response already reached a terminal state, return immediately.
|
|
101
|
+
if self._download.is_terminal:
|
|
102
|
+
return self._raise_if_failed(self._download)
|
|
103
|
+
interval = poll_interval or 2.0
|
|
104
|
+
while True:
|
|
105
|
+
if deadline is not None:
|
|
106
|
+
remaining = deadline - time.monotonic()
|
|
107
|
+
if remaining <= 0:
|
|
108
|
+
raise VideoFetchError(
|
|
109
|
+
f"Timed out after {timeout}s waiting for job {self.id}. "
|
|
110
|
+
"The job is still running server-side — retrieve() it later.",
|
|
111
|
+
code="job_timeout",
|
|
112
|
+
)
|
|
113
|
+
time.sleep(min(interval, remaining))
|
|
114
|
+
else:
|
|
115
|
+
time.sleep(interval)
|
|
116
|
+
try:
|
|
117
|
+
self._download = self._client.downloads.retrieve(self.id)
|
|
118
|
+
except VideoFetchError:
|
|
119
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
120
|
+
raise
|
|
121
|
+
attempt += 1
|
|
122
|
+
interval = _poll_delay(attempt)
|
|
123
|
+
continue
|
|
124
|
+
if self._download.is_terminal:
|
|
125
|
+
return self._raise_if_failed(self._download)
|
|
126
|
+
attempt += 1
|
|
127
|
+
interval = _poll_delay(attempt)
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _raise_if_failed(dl: Download) -> Download:
|
|
131
|
+
if dl.status == "failed":
|
|
132
|
+
raise JobFailedError(
|
|
133
|
+
job_id=dl.id, error_code=dl.error_code, error_message=dl.error_message)
|
|
134
|
+
return dl
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class InfoResource:
|
|
138
|
+
def __init__(self, client):
|
|
139
|
+
self._client = client
|
|
140
|
+
|
|
141
|
+
def lookup(self, url: str) -> VideoInfo:
|
|
142
|
+
data = self._client.request("POST", "/v1/info", json_body={"url": url})
|
|
143
|
+
return VideoInfo.from_dict(data)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ────────────────────────── Async resources ──────────────────────
|
|
147
|
+
class AsyncDownloadsResource:
|
|
148
|
+
def __init__(self, client):
|
|
149
|
+
self._client = client
|
|
150
|
+
|
|
151
|
+
async def create(self, url: str, format: str = "720p", *,
|
|
152
|
+
trim: Optional[TrimSpec] = None,
|
|
153
|
+
destination: Optional[dict] = None,
|
|
154
|
+
webhook_url: Optional[str] = None,
|
|
155
|
+
**extra: dict) -> AsyncDownloadJob:
|
|
156
|
+
body: dict = {"url": url, "format": format, **extra}
|
|
157
|
+
t = trim.to_dict() if trim else None
|
|
158
|
+
if t:
|
|
159
|
+
body["trim"] = t
|
|
160
|
+
if destination:
|
|
161
|
+
body["destination"] = destination
|
|
162
|
+
if webhook_url:
|
|
163
|
+
body["webhook_url"] = webhook_url
|
|
164
|
+
data = await self._client.request("POST", "/v1/downloads", json_body=body)
|
|
165
|
+
return AsyncDownloadJob(self._client, Download.from_dict(data))
|
|
166
|
+
|
|
167
|
+
async def retrieve(self, download_id: str) -> Download:
|
|
168
|
+
data = await self._client.request("GET", f"/v1/downloads/{download_id}")
|
|
169
|
+
return Download.from_dict(data)
|
|
170
|
+
|
|
171
|
+
async def list(self, *, status: Optional[str] = None, q: Optional[str] = None,
|
|
172
|
+
limit: int = 20, offset: int = 0) -> DownloadList:
|
|
173
|
+
params = {"limit": limit, "offset": offset}
|
|
174
|
+
if status:
|
|
175
|
+
params["status"] = status
|
|
176
|
+
if q:
|
|
177
|
+
params["q"] = q
|
|
178
|
+
data = await self._client.request("GET", "/v1/downloads", params=params)
|
|
179
|
+
return DownloadList.from_dict(data)
|
|
180
|
+
|
|
181
|
+
async def cancel(self, download_id: str) -> None:
|
|
182
|
+
await self._client.request("DELETE", f"/v1/downloads/{download_id}")
|
|
183
|
+
|
|
184
|
+
async def create_and_wait(self, url: str, format: str = "720p", *,
|
|
185
|
+
timeout: float = DEFAULT_JOB_TIMEOUT, **kwargs) -> Download:
|
|
186
|
+
job = await self.create(url, format, **kwargs)
|
|
187
|
+
return await job.wait(timeout=timeout)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class AsyncDownloadJob:
|
|
191
|
+
"""Polling handle around a queued/processing download (async)."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, client, initial: Download):
|
|
194
|
+
self._client = client
|
|
195
|
+
self._download = initial
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def id(self) -> str:
|
|
199
|
+
return self._download.id
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def status(self) -> str:
|
|
203
|
+
return self._download.status
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def download(self) -> Download:
|
|
207
|
+
return self._download
|
|
208
|
+
|
|
209
|
+
async def refresh(self) -> Download:
|
|
210
|
+
self._download = await self._client.downloads.retrieve(self.id)
|
|
211
|
+
return self._download
|
|
212
|
+
|
|
213
|
+
async def wait(self, *, timeout: float = DEFAULT_JOB_TIMEOUT,
|
|
214
|
+
poll_interval: float = None) -> Download:
|
|
215
|
+
import asyncio
|
|
216
|
+
|
|
217
|
+
deadline = None if (timeout is None or timeout <= 0) else asyncio.get_event_loop().time() + timeout
|
|
218
|
+
if self._download.is_terminal:
|
|
219
|
+
return self._raise_if_failed(self._download)
|
|
220
|
+
interval = poll_interval or 2.0
|
|
221
|
+
attempt = 0
|
|
222
|
+
while True:
|
|
223
|
+
if deadline is not None:
|
|
224
|
+
remaining = deadline - asyncio.get_event_loop().time()
|
|
225
|
+
if remaining <= 0:
|
|
226
|
+
raise VideoFetchError(
|
|
227
|
+
f"Timed out after {timeout}s waiting for job {self.id}. "
|
|
228
|
+
"The job is still running server-side — retrieve() it later.",
|
|
229
|
+
code="job_timeout",
|
|
230
|
+
)
|
|
231
|
+
await asyncio.sleep(min(interval, remaining))
|
|
232
|
+
else:
|
|
233
|
+
await asyncio.sleep(interval)
|
|
234
|
+
try:
|
|
235
|
+
self._download = await self._client.downloads.retrieve(self.id)
|
|
236
|
+
except VideoFetchError:
|
|
237
|
+
if deadline is not None and asyncio.get_event_loop().time() > deadline:
|
|
238
|
+
raise
|
|
239
|
+
attempt += 1
|
|
240
|
+
interval = _poll_delay(attempt)
|
|
241
|
+
continue
|
|
242
|
+
if self._download.is_terminal:
|
|
243
|
+
return self._raise_if_failed(self._download)
|
|
244
|
+
attempt += 1
|
|
245
|
+
interval = _poll_delay(attempt)
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _raise_if_failed(dl: Download) -> Download:
|
|
249
|
+
if dl.status == "failed":
|
|
250
|
+
raise JobFailedError(
|
|
251
|
+
job_id=dl.id, error_code=dl.error_code, error_message=dl.error_message)
|
|
252
|
+
return dl
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class AsyncInfoResource:
|
|
256
|
+
def __init__(self, client):
|
|
257
|
+
self._client = client
|
|
258
|
+
|
|
259
|
+
async def lookup(self, url: str) -> VideoInfo:
|
|
260
|
+
data = await self._client.request("POST", "/v1/info", json_body={"url": url})
|
|
261
|
+
return VideoInfo.from_dict(data)
|
videofetch/webhooks.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Webhook signature verification.
|
|
2
|
+
|
|
3
|
+
The server signs the raw request body with the endpoint secret (HMAC-SHA256)
|
|
4
|
+
and sends the digest in the `X-VideoFetch-Signature` header:
|
|
5
|
+
|
|
6
|
+
X-VideoFetch-Signature: sha256=<hex digest of raw body>
|
|
7
|
+
|
|
8
|
+
Verify with the secret shown when you created the webhook endpoint:
|
|
9
|
+
|
|
10
|
+
from videofetch.webhooks import construct_event
|
|
11
|
+
event = construct_event(await request.body(), request.headers.get("X-VideoFetch-Signature"), secret)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import json
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from .errors import VideoFetchError
|
|
22
|
+
|
|
23
|
+
# Event names (docs/Design contract)
|
|
24
|
+
WEBHOOK_EVENTS = (
|
|
25
|
+
"download.queued", "download.processing", "download.completed", "download.failed",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SignatureVerificationError(VideoFetchError):
|
|
30
|
+
"""The signature header is missing, malformed, or does not match."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def compute_signature(payload: bytes, secret: str) -> str:
|
|
34
|
+
"""sha256=<hex> — mirrors the server implementation."""
|
|
35
|
+
digest = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
|
|
36
|
+
return f"sha256={digest}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def construct_event(payload: bytes, sig_header: Optional[str], secret: str) -> dict:
|
|
40
|
+
"""Verify the signature header against the raw body and return the parsed event.
|
|
41
|
+
|
|
42
|
+
Raises SignatureVerificationError if the header is missing or does not match.
|
|
43
|
+
"""
|
|
44
|
+
if not sig_header:
|
|
45
|
+
raise SignatureVerificationError("No signature header was present.")
|
|
46
|
+
if not sig_header.startswith("sha256="):
|
|
47
|
+
raise SignatureVerificationError("Signature header is malformed (expected sha256=<hex>).")
|
|
48
|
+
|
|
49
|
+
expected = compute_signature(payload, secret)
|
|
50
|
+
if not hmac.compare_digest(expected, sig_header):
|
|
51
|
+
raise SignatureVerificationError("Signature does not match the payload and secret.")
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(payload.decode("utf-8"))
|
|
55
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
56
|
+
raise SignatureVerificationError("Payload is not valid JSON.") from None
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: videofetch-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official VideoFetch SDK for Python — video ingestion API (YouTube URL → MP4/MP3 to your storage)
|
|
5
|
+
Project-URL: Homepage, https://github.com/heavenlxj/videofetch-sdk/tree/main/python
|
|
6
|
+
Project-URL: Documentation, https://videofetch.dev/docs
|
|
7
|
+
Project-URL: Source, https://github.com/heavenlxj/videofetch-sdk/tree/main/python
|
|
8
|
+
Author-email: VideoFetch <dev@videofetch.dev>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: api,download,sdk,video,videofetch,youtube
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: httpx<1,>=0.24
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# videofetch (Python SDK)
|
|
28
|
+
|
|
29
|
+
Official [VideoFetch](https://vidfetch.dev) SDK — video ingestion API:
|
|
30
|
+
give us a video URL, we deliver MP4/MP3 to your storage.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install videofetch-sdk
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import videofetch
|
|
40
|
+
|
|
41
|
+
client = videofetch.VideoFetch(api_key="vf_live_sk_...")
|
|
42
|
+
|
|
43
|
+
# L1: create a job (returns immediately, status "queued")
|
|
44
|
+
job = client.downloads.create(
|
|
45
|
+
url="https://www.youtube.com/watch?v=...",
|
|
46
|
+
format="1080p", # 144p..2160p | mp3
|
|
47
|
+
trim=videofetch.TrimSpec(start=120, end=420), # optional clip window
|
|
48
|
+
# destination={"type": "r2", "id": "..."}, # optional direct-to-bucket
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# L2: wait for the terminal state (polls with backoff)
|
|
52
|
+
result = job.wait(timeout=120)
|
|
53
|
+
print(result.status) # "completed"
|
|
54
|
+
print(result.download_url) # presigned link, valid 7 days (url destination)
|
|
55
|
+
print(result.storage_key) # "user://<bucket>/<key>" (bucket destination)
|
|
56
|
+
print(result.size_bytes, result.cost_usd)
|
|
57
|
+
|
|
58
|
+
# L3: one-shot
|
|
59
|
+
result = client.downloads.create_and_wait(url=..., format="1080p")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Async (FastAPI / Next.js backends):
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from videofetch.asyncio import AsyncVideoFetch
|
|
66
|
+
|
|
67
|
+
async with AsyncVideoFetch(api_key="...") as client:
|
|
68
|
+
job = await client.downloads.create(url=..., format="1080p")
|
|
69
|
+
result = await job.wait(timeout=120)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Free metadata lookup:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
info = client.info.lookup("https://www.youtube.com/watch?v=...")
|
|
76
|
+
print(info.title, info.duration, info.formats) # available quality tiers
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Webhooks (FastAPI):
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from fastapi import Request, HTTPException
|
|
83
|
+
from videofetch.webhooks import construct_event, SignatureVerificationError
|
|
84
|
+
|
|
85
|
+
@app.post("/webhooks/videofetch")
|
|
86
|
+
async def on_event(request: Request):
|
|
87
|
+
body = await request.body()
|
|
88
|
+
try:
|
|
89
|
+
event = construct_event(body, request.headers.get("X-VideoFetch-Signature"), WEBHOOK_SECRET)
|
|
90
|
+
except SignatureVerificationError:
|
|
91
|
+
raise HTTPException(status_code=400, detail="bad signature")
|
|
92
|
+
if event["event"] == "download.completed":
|
|
93
|
+
... # fetch the result via client.downloads.retrieve(event["id"])
|
|
94
|
+
return {"ok": True}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
- `api_key` — required. Create keys in the Dashboard (`vf_live_sk_...`).
|
|
100
|
+
Falls back to the `VIDEOFETCH_API_KEY` env var.
|
|
101
|
+
- `base_url` — defaults to the hosted API; override for local/single-server dev
|
|
102
|
+
(`http://localhost:8301`). Env: `VIDEOFETCH_BASE_URL`.
|
|
103
|
+
- `timeout` (s), `max_retries` (default 2, automatic on 429/5xx).
|
|
104
|
+
|
|
105
|
+
## Errors
|
|
106
|
+
|
|
107
|
+
All SDK errors derive from `videofetch.VideoFetchError`:
|
|
108
|
+
|
|
109
|
+
| error | meaning |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `AuthenticationError` | bad/expired key (401) |
|
|
112
|
+
| `QuotaExceededError` | monthly quota exhausted (402) |
|
|
113
|
+
| `ValidationError` | bad url/format/trim/destination (400/422) |
|
|
114
|
+
| `NotFoundError` | job/connection not found (404) |
|
|
115
|
+
| `RateLimitError` | slow down (429) |
|
|
116
|
+
| `JobFailedError` | job reached `failed` — **never charged** |
|
|
117
|
+
|
|
118
|
+
## Serverless warning
|
|
119
|
+
|
|
120
|
+
Do **not** call `job.wait()` in a Vercel/Cloudflare/Lambda function — it burns
|
|
121
|
+
billed execution time. Create the job, persist its id, then respond to the
|
|
122
|
+
`download.completed` webhook and `retrieve(id)` the final result.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pip install -e ".[dev]"
|
|
128
|
+
pytest
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
videofetch/__init__.py,sha256=XTpu-QUH8bjLiywHgPNlALsMFPBq2dBhUCvYK9VO3I8,1538
|
|
2
|
+
videofetch/asyncio.py,sha256=hS5T04uPBMKakVgmqDHDGyrl0vFfagcj9dsa_2-c_Pc,201
|
|
3
|
+
videofetch/client.py,sha256=qJFjPfWffMiUJ4F8HpbJbBctI2f-qPWn5Bvu-qJnhTA,7412
|
|
4
|
+
videofetch/errors.py,sha256=6XyD0DziJpLiQbxi1tv5wwFzqD1pFRKhSifVby1Elvo,3612
|
|
5
|
+
videofetch/models.py,sha256=-38cEIRw31aFYAsvYrqvQTjs25tDSyX5odq5f9Ep40I,6421
|
|
6
|
+
videofetch/resources.py,sha256=rAc7PT_HVc6Ox0o-kmVoTCWmnuPINZKlc1ZBBfkUlj8,10224
|
|
7
|
+
videofetch/webhooks.py,sha256=b39cpKTlwuRB9FG76O-JcpzQjYLuqFKyBXofBdrM10w,2017
|
|
8
|
+
videofetch_sdk-0.1.0.dist-info/METADATA,sha256=oT6xzFatc1ePG2THDD9YtTFFw31ZKjnTk5ykUx7Hnlo,4373
|
|
9
|
+
videofetch_sdk-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
videofetch_sdk-0.1.0.dist-info/RECORD,,
|