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 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
@@ -0,0 +1,7 @@
1
+ """Async entrypoint mirror (import-friendly for asyncio users).
2
+
3
+ from videofetch.asyncio import AsyncVideoFetch
4
+ """
5
+ from .client import AsyncVideoFetch # noqa: F401
6
+
7
+ __all__ = ["AsyncVideoFetch"]
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
+ )
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any