saveapi 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.
- saveapi/__init__.py +11 -0
- saveapi/_client.py +255 -0
- saveapi/errors.py +36 -0
- saveapi/py.typed +0 -0
- saveapi/types.py +133 -0
- saveapi-0.1.0.dist-info/METADATA +126 -0
- saveapi-0.1.0.dist-info/RECORD +9 -0
- saveapi-0.1.0.dist-info/WHEEL +4 -0
- saveapi-0.1.0.dist-info/licenses/LICENSE +21 -0
saveapi/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Python client for saveapi.org — one REST API to download media from
|
|
2
|
+
Instagram, TikTok, YouTube, Facebook, X, Pinterest, Likee, Snapchat,
|
|
3
|
+
Threads, VK, Rutube, 9GAG and Twitch.
|
|
4
|
+
|
|
5
|
+
Docs: https://saveapi.org/en/docs/
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._client import AsyncSaveAPI, SaveAPI, __version__
|
|
9
|
+
from .errors import RETRYABLE_CODES, SaveAPIError
|
|
10
|
+
|
|
11
|
+
__all__ = ["SaveAPI", "AsyncSaveAPI", "SaveAPIError", "RETRYABLE_CODES", "__version__"]
|
saveapi/_client.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .errors import SaveAPIError
|
|
11
|
+
from .types import Detection, DownloadResult, YouTubeInfo, YouTubeLink, YouTubeQuality
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.saveapi.org"
|
|
14
|
+
#: Social links resolve in 1–10 s; a slow source can take up to ~30 s on our side.
|
|
15
|
+
DEFAULT_TIMEOUT = 45.0
|
|
16
|
+
#: Longest ``Retry-After`` the client is willing to sleep through on 429.
|
|
17
|
+
MAX_RATE_LIMIT_WAIT = 60.0
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from importlib.metadata import version as _version
|
|
21
|
+
|
|
22
|
+
__version__ = _version("saveapi")
|
|
23
|
+
except Exception: # pragma: no cover - running from a source tree
|
|
24
|
+
__version__ = "0.0.0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _key(api_key: Optional[str]) -> str:
|
|
28
|
+
key = api_key or os.environ.get("SAVEAPI_KEY")
|
|
29
|
+
if not key:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
"No API key. Pass api_key=... or set SAVEAPI_KEY. "
|
|
32
|
+
"Get one free at https://saveapi.org/en/signup/"
|
|
33
|
+
)
|
|
34
|
+
return key
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _clean(params: dict[str, Any]) -> dict[str, Any]:
|
|
38
|
+
out = {}
|
|
39
|
+
for name, value in params.items():
|
|
40
|
+
if value is None:
|
|
41
|
+
continue
|
|
42
|
+
out[name] = ("true" if value else "false") if isinstance(value, bool) else value
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _error(response: httpx.Response) -> SaveAPIError:
|
|
47
|
+
code, message = "HTTP_ERROR", response.reason_phrase or "Request failed"
|
|
48
|
+
try:
|
|
49
|
+
body = response.json()
|
|
50
|
+
error = body.get("error") or {}
|
|
51
|
+
code = error.get("code") or code
|
|
52
|
+
message = error.get("message") or message
|
|
53
|
+
except ValueError:
|
|
54
|
+
pass
|
|
55
|
+
retry_after = response.headers.get("Retry-After")
|
|
56
|
+
return SaveAPIError(
|
|
57
|
+
code, message, response.status_code,
|
|
58
|
+
float(retry_after) if retry_after and retry_after.replace(".", "", 1).isdigit() else None,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _delay(error: SaveAPIError, attempt: int) -> Optional[float]:
|
|
63
|
+
"""Seconds to wait before retrying, or ``None`` to give up."""
|
|
64
|
+
if error.code == "RATE_LIMITED":
|
|
65
|
+
wait = error.retry_after if error.retry_after is not None else 5.0
|
|
66
|
+
return wait if wait <= MAX_RATE_LIMIT_WAIT else None
|
|
67
|
+
if error.retryable:
|
|
68
|
+
return min(1.0 * (2 ** attempt), 8.0)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _Base:
|
|
73
|
+
def __init__(self, api_key: Optional[str], base_url: str, max_retries: int):
|
|
74
|
+
self._headers = {
|
|
75
|
+
"Authorization": f"Bearer {_key(api_key)}",
|
|
76
|
+
"User-Agent": f"saveapi-python/{__version__}",
|
|
77
|
+
"Accept": "application/json",
|
|
78
|
+
}
|
|
79
|
+
self._base_url = base_url.rstrip("/")
|
|
80
|
+
self.max_retries = max_retries
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SaveAPI(_Base):
|
|
84
|
+
"""Synchronous client for https://api.saveapi.org.
|
|
85
|
+
|
|
86
|
+
>>> from saveapi import SaveAPI
|
|
87
|
+
>>> api = SaveAPI() # reads SAVEAPI_KEY
|
|
88
|
+
>>> post = api.download("https://www.tiktok.com/@zachking/video/6768504823336815877")
|
|
89
|
+
>>> post["medias"][0]["url"]
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
api_key: Your ``sk_live_...`` key. Defaults to ``$SAVEAPI_KEY``.
|
|
93
|
+
max_retries: How many times to retry ``UPSTREAM_TIMEOUT`` /
|
|
94
|
+
``UPSTREAM_ERROR`` / ``RATE_LIMITED``. Final errors (private,
|
|
95
|
+
deleted, unsupported) are never retried.
|
|
96
|
+
timeout: Per-request timeout in seconds.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
api_key: Optional[str] = None,
|
|
102
|
+
*,
|
|
103
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
104
|
+
max_retries: int = 1,
|
|
105
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
106
|
+
http_client: Optional[httpx.Client] = None,
|
|
107
|
+
):
|
|
108
|
+
super().__init__(api_key, base_url, max_retries)
|
|
109
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
self._http.close()
|
|
113
|
+
|
|
114
|
+
def __enter__(self) -> "SaveAPI":
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __exit__(self, *exc: object) -> None:
|
|
118
|
+
self.close()
|
|
119
|
+
|
|
120
|
+
def _get(self, path: str, **params: Any) -> Any:
|
|
121
|
+
attempt = 0
|
|
122
|
+
while True:
|
|
123
|
+
response = self._http.get(self._base_url + path, params=_clean(params), headers=self._headers)
|
|
124
|
+
if response.is_success:
|
|
125
|
+
return response.json()
|
|
126
|
+
error = _error(response)
|
|
127
|
+
wait = _delay(error, attempt) if attempt < self.max_retries else None
|
|
128
|
+
if wait is None:
|
|
129
|
+
raise error
|
|
130
|
+
time.sleep(wait)
|
|
131
|
+
attempt += 1
|
|
132
|
+
|
|
133
|
+
# ── Media ──────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
def download(self, url: str, *, full_meta: bool = False, limit_mb: Optional[float] = None) -> DownloadResult:
|
|
136
|
+
"""Resolve any supported post link (platform detected from the URL).
|
|
137
|
+
|
|
138
|
+
``full_meta=True`` adds likes, location and music on Instagram (~+0.3 s,
|
|
139
|
+
same price). ``limit_mb`` skips renditions above that size where the
|
|
140
|
+
platform reports sizes.
|
|
141
|
+
"""
|
|
142
|
+
return self._get("/v1/download", url=url, full_meta=full_meta or None, limit_mb=limit_mb)
|
|
143
|
+
|
|
144
|
+
def platform(self, platform: str, url: str, *, full_meta: bool = False,
|
|
145
|
+
limit_mb: Optional[float] = None) -> DownloadResult:
|
|
146
|
+
"""Resolve through one platform's endpoint, e.g. ``platform("tiktok", url)``."""
|
|
147
|
+
return self._get(f"/v1/{platform}", url=url, full_meta=full_meta or None, limit_mb=limit_mb)
|
|
148
|
+
|
|
149
|
+
def youtube_info(self, url: str) -> YouTubeInfo:
|
|
150
|
+
"""Title, duration and the qualities a YouTube video offers (2 credits)."""
|
|
151
|
+
return self._get("/v1/youtube/info", url=url)
|
|
152
|
+
|
|
153
|
+
def youtube_create(self, url: str, quality: YouTubeQuality = "720p") -> YouTubeLink:
|
|
154
|
+
"""A short-lived download link for one quality (10 credits).
|
|
155
|
+
|
|
156
|
+
Video qualities are H.264 MP4 144p–1080p; ``mp3`` and ``m4a`` are audio.
|
|
157
|
+
"""
|
|
158
|
+
return self._get("/v1/youtube/create", url=url, quality=quality)
|
|
159
|
+
|
|
160
|
+
def instagram_profile(self, username: Optional[str] = None, *, url: Optional[str] = None) -> dict:
|
|
161
|
+
"""Public profile by username (``natgeo`` or ``@natgeo``) or profile link (3 credits)."""
|
|
162
|
+
if not username and not url:
|
|
163
|
+
raise ValueError("Pass a username or a profile url")
|
|
164
|
+
return self._get("/v1/instagram/profile", username=username, url=url)
|
|
165
|
+
|
|
166
|
+
# ── Free calls ─────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def detect(self, url: str) -> Detection:
|
|
169
|
+
"""Which platform a link belongs to. Free."""
|
|
170
|
+
return self._get("/v1/detect", url=url)
|
|
171
|
+
|
|
172
|
+
def platforms(self) -> dict:
|
|
173
|
+
"""Supported platforms and their endpoints. Free."""
|
|
174
|
+
return self._get("/v1/platforms")
|
|
175
|
+
|
|
176
|
+
def me(self) -> dict:
|
|
177
|
+
"""Key, plan and credit balance. Free."""
|
|
178
|
+
return self._get("/v1/me")
|
|
179
|
+
|
|
180
|
+
def usage(self, days: int = 30) -> dict:
|
|
181
|
+
"""Daily usage for the last ``days`` (1–180). Free."""
|
|
182
|
+
return self._get("/v1/usage", days=days)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class AsyncSaveAPI(_Base):
|
|
186
|
+
"""``asyncio`` twin of :class:`SaveAPI` — same methods, awaitable.
|
|
187
|
+
|
|
188
|
+
>>> async with AsyncSaveAPI() as api:
|
|
189
|
+
... post = await api.download(url)
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
api_key: Optional[str] = None,
|
|
195
|
+
*,
|
|
196
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
197
|
+
max_retries: int = 1,
|
|
198
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
199
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
200
|
+
):
|
|
201
|
+
super().__init__(api_key, base_url, max_retries)
|
|
202
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
203
|
+
|
|
204
|
+
async def aclose(self) -> None:
|
|
205
|
+
await self._http.aclose()
|
|
206
|
+
|
|
207
|
+
async def __aenter__(self) -> "AsyncSaveAPI":
|
|
208
|
+
return self
|
|
209
|
+
|
|
210
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
211
|
+
await self.aclose()
|
|
212
|
+
|
|
213
|
+
async def _get(self, path: str, **params: Any) -> Any:
|
|
214
|
+
attempt = 0
|
|
215
|
+
while True:
|
|
216
|
+
response = await self._http.get(self._base_url + path, params=_clean(params), headers=self._headers)
|
|
217
|
+
if response.is_success:
|
|
218
|
+
return response.json()
|
|
219
|
+
error = _error(response)
|
|
220
|
+
wait = _delay(error, attempt) if attempt < self.max_retries else None
|
|
221
|
+
if wait is None:
|
|
222
|
+
raise error
|
|
223
|
+
await asyncio.sleep(wait)
|
|
224
|
+
attempt += 1
|
|
225
|
+
|
|
226
|
+
async def download(self, url: str, *, full_meta: bool = False,
|
|
227
|
+
limit_mb: Optional[float] = None) -> DownloadResult:
|
|
228
|
+
return await self._get("/v1/download", url=url, full_meta=full_meta or None, limit_mb=limit_mb)
|
|
229
|
+
|
|
230
|
+
async def platform(self, platform: str, url: str, *, full_meta: bool = False,
|
|
231
|
+
limit_mb: Optional[float] = None) -> DownloadResult:
|
|
232
|
+
return await self._get(f"/v1/{platform}", url=url, full_meta=full_meta or None, limit_mb=limit_mb)
|
|
233
|
+
|
|
234
|
+
async def youtube_info(self, url: str) -> YouTubeInfo:
|
|
235
|
+
return await self._get("/v1/youtube/info", url=url)
|
|
236
|
+
|
|
237
|
+
async def youtube_create(self, url: str, quality: YouTubeQuality = "720p") -> YouTubeLink:
|
|
238
|
+
return await self._get("/v1/youtube/create", url=url, quality=quality)
|
|
239
|
+
|
|
240
|
+
async def instagram_profile(self, username: Optional[str] = None, *, url: Optional[str] = None) -> dict:
|
|
241
|
+
if not username and not url:
|
|
242
|
+
raise ValueError("Pass a username or a profile url")
|
|
243
|
+
return await self._get("/v1/instagram/profile", username=username, url=url)
|
|
244
|
+
|
|
245
|
+
async def detect(self, url: str) -> Detection:
|
|
246
|
+
return await self._get("/v1/detect", url=url)
|
|
247
|
+
|
|
248
|
+
async def platforms(self) -> dict:
|
|
249
|
+
return await self._get("/v1/platforms")
|
|
250
|
+
|
|
251
|
+
async def me(self) -> dict:
|
|
252
|
+
return await self._get("/v1/me")
|
|
253
|
+
|
|
254
|
+
async def usage(self, days: int = 30) -> dict:
|
|
255
|
+
return await self._get("/v1/usage", days=days)
|
saveapi/errors.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Errors raised by the client.
|
|
2
|
+
|
|
3
|
+
Every failed call raises :class:`SaveAPIError`. Match on ``error.code`` —
|
|
4
|
+
the codes are stable, the messages are not.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
#: Codes a retry can fix. Everything else is a final answer: retrying a
|
|
10
|
+
#: private or deleted post does not change it, and where the error is billed
|
|
11
|
+
#: (0.1 credit for a link the source refuses) it costs another credit.
|
|
12
|
+
RETRYABLE_CODES = frozenset({"UPSTREAM_TIMEOUT", "UPSTREAM_ERROR", "RATE_LIMITED"})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SaveAPIError(Exception):
|
|
16
|
+
"""A non-2xx answer from api.saveapi.org.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
code: Stable error code, e.g. ``PRIVATE_CONTENT`` or ``QUOTA_EXCEEDED``.
|
|
20
|
+
Full list: https://saveapi.org/en/docs/#errors
|
|
21
|
+
message: Human-readable explanation (may change between releases).
|
|
22
|
+
status: HTTP status code.
|
|
23
|
+
retry_after: Seconds to wait, set on ``RATE_LIMITED``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, code: str, message: str, status: int, retry_after: float | None = None):
|
|
27
|
+
super().__init__(f"{code}: {message}")
|
|
28
|
+
self.code = code
|
|
29
|
+
self.message = message
|
|
30
|
+
self.status = status
|
|
31
|
+
self.retry_after = retry_after
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def retryable(self) -> bool:
|
|
35
|
+
"""True when sending the same request again can succeed."""
|
|
36
|
+
return self.code in RETRYABLE_CODES
|
saveapi/py.typed
ADDED
|
File without changes
|
saveapi/types.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Response shapes (``TypedDict``) — the client returns plain dicts.
|
|
2
|
+
|
|
3
|
+
Field names match https://saveapi.org/en/docs/. Fields the source does not
|
|
4
|
+
provide are ``None`` rather than missing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Literal, Optional, TypedDict
|
|
10
|
+
|
|
11
|
+
from typing_extensions import NotRequired
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Credits(TypedDict):
|
|
15
|
+
spent: float
|
|
16
|
+
remaining: Optional[float]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Media(TypedDict):
|
|
20
|
+
type: Literal["video", "image", "audio"]
|
|
21
|
+
url: str
|
|
22
|
+
ext: Optional[str]
|
|
23
|
+
quality: Optional[str]
|
|
24
|
+
width: Optional[int]
|
|
25
|
+
height: Optional[int]
|
|
26
|
+
size_mb: Optional[float]
|
|
27
|
+
watermark: Optional[bool]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Format(TypedDict):
|
|
31
|
+
"""A ready rendition the source itself offers (TikTok, VK, Likee)."""
|
|
32
|
+
|
|
33
|
+
format: str
|
|
34
|
+
label: str
|
|
35
|
+
type: Literal["video", "audio"]
|
|
36
|
+
size_mb: Optional[float]
|
|
37
|
+
exact: bool
|
|
38
|
+
url: str
|
|
39
|
+
watermark: NotRequired[Optional[bool]]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Meta(TypedDict, total=False):
|
|
43
|
+
title: Optional[str]
|
|
44
|
+
text: Optional[str]
|
|
45
|
+
author: Optional[str]
|
|
46
|
+
author_url: Optional[str]
|
|
47
|
+
thumbnail: Optional[str]
|
|
48
|
+
duration: Optional[float]
|
|
49
|
+
views: Optional[int]
|
|
50
|
+
published_at: Optional[int]
|
|
51
|
+
username: Optional[str]
|
|
52
|
+
author_avatar: Optional[str]
|
|
53
|
+
author_verified: Optional[bool]
|
|
54
|
+
likes: Optional[int]
|
|
55
|
+
comments: Optional[int]
|
|
56
|
+
shares: Optional[int]
|
|
57
|
+
saves: Optional[int]
|
|
58
|
+
location: Optional[str]
|
|
59
|
+
region: Optional[str]
|
|
60
|
+
music: Optional[dict]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DownloadResult(TypedDict):
|
|
64
|
+
"""``/v1/download`` and ``/v1/{platform}``."""
|
|
65
|
+
|
|
66
|
+
success: Literal[True]
|
|
67
|
+
platform: str
|
|
68
|
+
type: str
|
|
69
|
+
source_url: str
|
|
70
|
+
video_id: Optional[str]
|
|
71
|
+
medias: list[Media]
|
|
72
|
+
music: Optional[Media]
|
|
73
|
+
meta: Meta
|
|
74
|
+
formats: NotRequired[list[Format]]
|
|
75
|
+
recommended_format: NotRequired[Optional[str]]
|
|
76
|
+
credits: Credits
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class YouTubeFormat(TypedDict):
|
|
80
|
+
quality: str
|
|
81
|
+
file_size: Optional[int]
|
|
82
|
+
file_size_str: Optional[str]
|
|
83
|
+
exact: bool
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class YouTubeInfo(TypedDict):
|
|
87
|
+
success: Literal[True]
|
|
88
|
+
platform: Literal["youtube"]
|
|
89
|
+
source_url: str
|
|
90
|
+
video_id: str
|
|
91
|
+
title: str
|
|
92
|
+
author: Optional[str]
|
|
93
|
+
duration_seconds: Optional[int]
|
|
94
|
+
duration_str: Optional[str]
|
|
95
|
+
thumbnail: Optional[str]
|
|
96
|
+
is_short: bool
|
|
97
|
+
formats: list[YouTubeFormat]
|
|
98
|
+
audio_formats: list[YouTubeFormat]
|
|
99
|
+
credits: Credits
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class YouTubeLink(TypedDict):
|
|
103
|
+
success: Literal[True]
|
|
104
|
+
platform: Literal["youtube"]
|
|
105
|
+
source_url: str
|
|
106
|
+
video_id: str
|
|
107
|
+
url: str
|
|
108
|
+
filename: str
|
|
109
|
+
quality: str
|
|
110
|
+
mode: Literal["video", "audio"]
|
|
111
|
+
ext: str
|
|
112
|
+
file_size: Optional[int]
|
|
113
|
+
file_size_str: Optional[str]
|
|
114
|
+
exact: bool
|
|
115
|
+
seekable: bool
|
|
116
|
+
expires: int
|
|
117
|
+
title: str
|
|
118
|
+
duration_seconds: Optional[int]
|
|
119
|
+
duration_str: Optional[str]
|
|
120
|
+
thumbnail: Optional[str]
|
|
121
|
+
credits: Credits
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Detection(TypedDict):
|
|
125
|
+
success: Literal[True]
|
|
126
|
+
supported: bool
|
|
127
|
+
platform: Optional[str]
|
|
128
|
+
name: NotRequired[Optional[str]]
|
|
129
|
+
host: NotRequired[Optional[str]]
|
|
130
|
+
formats: NotRequired[bool]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
YouTubeQuality = Literal["144p", "240p", "360p", "480p", "720p", "1080p", "mp3", "m4a"]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: saveapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for saveapi.org — download media from Instagram, TikTok, YouTube, Facebook, X, Pinterest and 7 more platforms with one API.
|
|
5
|
+
Project-URL: Homepage, https://saveapi.org/
|
|
6
|
+
Project-URL: Documentation, https://saveapi.org/en/docs/
|
|
7
|
+
Project-URL: Pricing, https://saveapi.org/en/pricing/
|
|
8
|
+
Project-URL: Source, https://github.com/akhroruz/saveapi-sdk
|
|
9
|
+
Project-URL: Changelog, https://saveapi.org/en/changelog/
|
|
10
|
+
Author-email: "saveapi.org" <hello@saveapi.org>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: instagram api,instagram downloader api,reels downloader,saveapi,social media downloader,tiktok api,tiktok downloader api,video downloader,youtube downloader api
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Classifier: Topic :: Multimedia :: Video
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: httpx>=0.24
|
|
23
|
+
Requires-Dist: typing-extensions>=4.5
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# saveapi — Python client for saveapi.org
|
|
27
|
+
|
|
28
|
+
Download videos, photos and audio from **Instagram, TikTok, YouTube, Facebook, X (Twitter), Pinterest, Likee, Snapchat, Threads, VK, Rutube, 9GAG and Twitch** with one API call. Send a public post link, get back direct media URLs and metadata in one JSON shape.
|
|
29
|
+
|
|
30
|
+
- Docs: <https://saveapi.org/en/docs/>
|
|
31
|
+
- Free key (1,000 credits, no card): <https://saveapi.org/en/signup/>
|
|
32
|
+
- Pricing: <https://saveapi.org/en/pricing/>
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install saveapi
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from saveapi import SaveAPI
|
|
42
|
+
|
|
43
|
+
api = SaveAPI("sk_live_...") # or set SAVEAPI_KEY and call SaveAPI()
|
|
44
|
+
|
|
45
|
+
post = api.download("https://www.tiktok.com/@zachking/video/6768504823336815877")
|
|
46
|
+
print(post["platform"], post["meta"]["title"])
|
|
47
|
+
for media in post["medias"]:
|
|
48
|
+
print(media["type"], media["ext"], media["url"])
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`download()` detects the platform from the link. To call one platform's endpoint directly use `api.platform("instagram", url)`.
|
|
52
|
+
|
|
53
|
+
## Async
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import asyncio
|
|
57
|
+
from saveapi import AsyncSaveAPI
|
|
58
|
+
|
|
59
|
+
async def main():
|
|
60
|
+
async with AsyncSaveAPI() as api:
|
|
61
|
+
post = await api.download("https://www.instagram.com/reel/DdwXSSkMvJz/")
|
|
62
|
+
print(post["medias"][0]["url"])
|
|
63
|
+
|
|
64
|
+
asyncio.run(main())
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## YouTube
|
|
68
|
+
|
|
69
|
+
YouTube is two steps: read the available qualities, then create a link for one of them.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
info = api.youtube_info("https://youtu.be/dQw4w9WgXcQ") # 2 credits
|
|
73
|
+
print([f["quality"] for f in info["formats"]])
|
|
74
|
+
|
|
75
|
+
link = api.youtube_create("https://youtu.be/dQw4w9WgXcQ", "mp3") # 10 credits
|
|
76
|
+
print(link["url"], link["filename"])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Video qualities are H.264 MP4 `144p`–`1080p`; `mp3` and `m4a` are audio. The link is short-lived — download it right away.
|
|
80
|
+
|
|
81
|
+
## Errors
|
|
82
|
+
|
|
83
|
+
Every failed call raises `SaveAPIError`. Match on `error.code`, not on the message:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from saveapi import SaveAPI, SaveAPIError
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
post = api.download(url)
|
|
90
|
+
except SaveAPIError as error:
|
|
91
|
+
if error.code in ("PRIVATE_CONTENT", "MEDIA_NOT_FOUND"):
|
|
92
|
+
print("The post is private or gone") # final answer, costs 0.1 credit
|
|
93
|
+
elif error.code == "QUOTA_EXCEEDED":
|
|
94
|
+
print("Top up at https://saveapi.org/en/dashboard/billing/")
|
|
95
|
+
else:
|
|
96
|
+
raise
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The client retries only what a retry can fix: `UPSTREAM_TIMEOUT` and `UPSTREAM_ERROR` (once by default, `max_retries=`), and `RATE_LIMITED` after the `Retry-After` delay. Private, deleted and unsupported links are never retried. Full list of codes: <https://saveapi.org/en/docs/#errors>.
|
|
100
|
+
|
|
101
|
+
## All methods
|
|
102
|
+
|
|
103
|
+
| Method | Endpoint | Cost |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `download(url, full_meta=False, limit_mb=None)` | `/v1/download` | 1.5 credits |
|
|
106
|
+
| `platform(name, url, ...)` | `/v1/{platform}` | 1.5 credits |
|
|
107
|
+
| `youtube_info(url)` | `/v1/youtube/info` | 2 credits |
|
|
108
|
+
| `youtube_create(url, quality="720p")` | `/v1/youtube/create` | 10 credits |
|
|
109
|
+
| `instagram_profile(username=None, url=None)` | `/v1/instagram/profile` | 3 credits |
|
|
110
|
+
| `detect(url)` | `/v1/detect` | free |
|
|
111
|
+
| `platforms()` | `/v1/platforms` | free |
|
|
112
|
+
| `me()` | `/v1/me` | free |
|
|
113
|
+
| `usage(days=30)` | `/v1/usage` | free |
|
|
114
|
+
|
|
115
|
+
Instagram stories cost 5 credits, Rutube 3, and a link the source refuses 0.1. Current prices: <https://saveapi.org/en/pricing/>.
|
|
116
|
+
|
|
117
|
+
## Guides
|
|
118
|
+
|
|
119
|
+
- [Telegram downloader bot](https://saveapi.org/en/guides/telegram-bot/)
|
|
120
|
+
- [Download social media videos in Python](https://saveapi.org/en/guides/python/)
|
|
121
|
+
- [TikTok without watermark](https://saveapi.org/en/guides/no-watermark/)
|
|
122
|
+
- [YouTube to MP3 API](https://saveapi.org/en/guides/youtube-mp3-api/)
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
saveapi/__init__.py,sha256=-kD6hnrZA_RbpHGOA11CgLMwzYiX2_W6_8gsztDUYb4,416
|
|
2
|
+
saveapi/_client.py,sha256=yGmN6Zhyf1Vw3u5dvUYCzRs97zSvkhqUUK0XaVpFG8c,9789
|
|
3
|
+
saveapi/errors.py,sha256=rSz5w5cZ_rVATd3OEWRYubTWWA9BVbpJhMQcrV4gplY,1342
|
|
4
|
+
saveapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
saveapi/types.py,sha256=2_e2BTsPmlAB_7TxsFYm1KXvLu8GCY3D7AbpGNqFEso,3105
|
|
6
|
+
saveapi-0.1.0.dist-info/METADATA,sha256=-xt1pfPpMU9MeXccHJSXrlEQgq4FA2iTFx2eGC5Qe3g,4729
|
|
7
|
+
saveapi-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
saveapi-0.1.0.dist-info/licenses/LICENSE,sha256=0zEE0_GdMg3sMgU9CMCgbg02qEutYVEGCxeJOBvrNgA,1068
|
|
9
|
+
saveapi-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 saveapi.org
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|