saveapi 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- saveapi-0.1.0/PKG-INFO +126 -0
- saveapi-0.1.0/README.md +101 -0
- saveapi-0.1.0/pyproject.toml +41 -0
- saveapi-0.1.0/src/saveapi/__init__.py +11 -0
- saveapi-0.1.0/src/saveapi/_client.py +255 -0
- saveapi-0.1.0/src/saveapi/errors.py +36 -0
- saveapi-0.1.0/src/saveapi/py.typed +0 -0
- saveapi-0.1.0/src/saveapi/types.py +133 -0
- saveapi-0.1.0/tests/test_client.py +163 -0
saveapi-0.1.0/LICENSE
ADDED
|
@@ -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.
|
saveapi-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
saveapi-0.1.0/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# saveapi — Python client for saveapi.org
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
- Docs: <https://saveapi.org/en/docs/>
|
|
6
|
+
- Free key (1,000 credits, no card): <https://saveapi.org/en/signup/>
|
|
7
|
+
- Pricing: <https://saveapi.org/en/pricing/>
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install saveapi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from saveapi import SaveAPI
|
|
17
|
+
|
|
18
|
+
api = SaveAPI("sk_live_...") # or set SAVEAPI_KEY and call SaveAPI()
|
|
19
|
+
|
|
20
|
+
post = api.download("https://www.tiktok.com/@zachking/video/6768504823336815877")
|
|
21
|
+
print(post["platform"], post["meta"]["title"])
|
|
22
|
+
for media in post["medias"]:
|
|
23
|
+
print(media["type"], media["ext"], media["url"])
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`download()` detects the platform from the link. To call one platform's endpoint directly use `api.platform("instagram", url)`.
|
|
27
|
+
|
|
28
|
+
## Async
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
from saveapi import AsyncSaveAPI
|
|
33
|
+
|
|
34
|
+
async def main():
|
|
35
|
+
async with AsyncSaveAPI() as api:
|
|
36
|
+
post = await api.download("https://www.instagram.com/reel/DdwXSSkMvJz/")
|
|
37
|
+
print(post["medias"][0]["url"])
|
|
38
|
+
|
|
39
|
+
asyncio.run(main())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## YouTube
|
|
43
|
+
|
|
44
|
+
YouTube is two steps: read the available qualities, then create a link for one of them.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
info = api.youtube_info("https://youtu.be/dQw4w9WgXcQ") # 2 credits
|
|
48
|
+
print([f["quality"] for f in info["formats"]])
|
|
49
|
+
|
|
50
|
+
link = api.youtube_create("https://youtu.be/dQw4w9WgXcQ", "mp3") # 10 credits
|
|
51
|
+
print(link["url"], link["filename"])
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Video qualities are H.264 MP4 `144p`–`1080p`; `mp3` and `m4a` are audio. The link is short-lived — download it right away.
|
|
55
|
+
|
|
56
|
+
## Errors
|
|
57
|
+
|
|
58
|
+
Every failed call raises `SaveAPIError`. Match on `error.code`, not on the message:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from saveapi import SaveAPI, SaveAPIError
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
post = api.download(url)
|
|
65
|
+
except SaveAPIError as error:
|
|
66
|
+
if error.code in ("PRIVATE_CONTENT", "MEDIA_NOT_FOUND"):
|
|
67
|
+
print("The post is private or gone") # final answer, costs 0.1 credit
|
|
68
|
+
elif error.code == "QUOTA_EXCEEDED":
|
|
69
|
+
print("Top up at https://saveapi.org/en/dashboard/billing/")
|
|
70
|
+
else:
|
|
71
|
+
raise
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
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>.
|
|
75
|
+
|
|
76
|
+
## All methods
|
|
77
|
+
|
|
78
|
+
| Method | Endpoint | Cost |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `download(url, full_meta=False, limit_mb=None)` | `/v1/download` | 1.5 credits |
|
|
81
|
+
| `platform(name, url, ...)` | `/v1/{platform}` | 1.5 credits |
|
|
82
|
+
| `youtube_info(url)` | `/v1/youtube/info` | 2 credits |
|
|
83
|
+
| `youtube_create(url, quality="720p")` | `/v1/youtube/create` | 10 credits |
|
|
84
|
+
| `instagram_profile(username=None, url=None)` | `/v1/instagram/profile` | 3 credits |
|
|
85
|
+
| `detect(url)` | `/v1/detect` | free |
|
|
86
|
+
| `platforms()` | `/v1/platforms` | free |
|
|
87
|
+
| `me()` | `/v1/me` | free |
|
|
88
|
+
| `usage(days=30)` | `/v1/usage` | free |
|
|
89
|
+
|
|
90
|
+
Instagram stories cost 5 credits, Rutube 3, and a link the source refuses 0.1. Current prices: <https://saveapi.org/en/pricing/>.
|
|
91
|
+
|
|
92
|
+
## Guides
|
|
93
|
+
|
|
94
|
+
- [Telegram downloader bot](https://saveapi.org/en/guides/telegram-bot/)
|
|
95
|
+
- [Download social media videos in Python](https://saveapi.org/en/guides/python/)
|
|
96
|
+
- [TikTok without watermark](https://saveapi.org/en/guides/no-watermark/)
|
|
97
|
+
- [YouTube to MP3 API](https://saveapi.org/en/guides/youtube-mp3-api/)
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "saveapi"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for saveapi.org — download media from Instagram, TikTok, YouTube, Facebook, X, Pinterest and 7 more platforms with one API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "saveapi.org", email = "hello@saveapi.org" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"instagram downloader api", "tiktok downloader api", "youtube downloader api",
|
|
16
|
+
"social media downloader", "video downloader", "instagram api", "tiktok api",
|
|
17
|
+
"reels downloader", "saveapi",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
25
|
+
"Topic :: Multimedia :: Video",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = ["httpx>=0.24", "typing_extensions>=4.5"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://saveapi.org/"
|
|
32
|
+
Documentation = "https://saveapi.org/en/docs/"
|
|
33
|
+
Pricing = "https://saveapi.org/en/pricing/"
|
|
34
|
+
Source = "https://github.com/akhroruz/saveapi-sdk"
|
|
35
|
+
Changelog = "https://saveapi.org/en/changelog/"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/saveapi"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
asyncio_mode = "auto"
|
|
@@ -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__"]
|
|
@@ -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)
|
|
@@ -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
|
|
File without changes
|
|
@@ -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,163 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from saveapi import AsyncSaveAPI, SaveAPI, SaveAPIError
|
|
5
|
+
from saveapi import _client
|
|
6
|
+
|
|
7
|
+
OK = {
|
|
8
|
+
"success": True, "platform": "tiktok", "type": "video",
|
|
9
|
+
"source_url": "https://www.tiktok.com/@a/video/1", "video_id": "1",
|
|
10
|
+
"medias": [{"type": "video", "url": "https://cdn/x.mp4", "ext": "mp4", "quality": None,
|
|
11
|
+
"width": 0, "height": 0, "size_mb": 1.0, "watermark": False}],
|
|
12
|
+
"music": None, "meta": {}, "credits": {"spent": 1.5, "remaining": 998.5},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _error(status, code, headers=None):
|
|
17
|
+
return httpx.Response(status, json={"success": False, "error": {"code": code, "message": code.lower()}},
|
|
18
|
+
headers=headers or {})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _client_with(handler, **kwargs):
|
|
22
|
+
return SaveAPI("sk_test", http_client=httpx.Client(transport=httpx.MockTransport(handler)), **kwargs)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(autouse=True)
|
|
26
|
+
def no_sleep(monkeypatch):
|
|
27
|
+
slept = []
|
|
28
|
+
monkeypatch.setattr(_client.time, "sleep", slept.append)
|
|
29
|
+
|
|
30
|
+
async def fake_async_sleep(seconds):
|
|
31
|
+
slept.append(seconds)
|
|
32
|
+
|
|
33
|
+
monkeypatch.setattr(_client.asyncio, "sleep", fake_async_sleep)
|
|
34
|
+
return slept
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_download_sends_bearer_key_and_url():
|
|
38
|
+
seen = {}
|
|
39
|
+
|
|
40
|
+
def handler(request):
|
|
41
|
+
seen["auth"] = request.headers["Authorization"]
|
|
42
|
+
seen["path"] = request.url.path
|
|
43
|
+
seen["params"] = dict(request.url.params)
|
|
44
|
+
seen["ua"] = request.headers["User-Agent"]
|
|
45
|
+
return httpx.Response(200, json=OK)
|
|
46
|
+
|
|
47
|
+
result = _client_with(handler).download("https://www.tiktok.com/@a/video/1", full_meta=True)
|
|
48
|
+
|
|
49
|
+
assert result["medias"][0]["url"] == "https://cdn/x.mp4"
|
|
50
|
+
assert seen["auth"] == "Bearer sk_test"
|
|
51
|
+
assert seen["path"] == "/v1/download"
|
|
52
|
+
assert seen["params"] == {"url": "https://www.tiktok.com/@a/video/1", "full_meta": "true"}
|
|
53
|
+
assert seen["ua"].startswith("saveapi-python/")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_false_flags_and_none_are_not_sent():
|
|
57
|
+
seen = {}
|
|
58
|
+
|
|
59
|
+
def handler(request):
|
|
60
|
+
seen["params"] = dict(request.url.params)
|
|
61
|
+
return httpx.Response(200, json=OK)
|
|
62
|
+
|
|
63
|
+
_client_with(handler).platform("tiktok", "https://t/1")
|
|
64
|
+
assert seen["params"] == {"url": "https://t/1"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_final_error_is_raised_without_retry(no_sleep):
|
|
68
|
+
calls = []
|
|
69
|
+
|
|
70
|
+
def handler(request):
|
|
71
|
+
calls.append(1)
|
|
72
|
+
return _error(403, "PRIVATE_CONTENT")
|
|
73
|
+
|
|
74
|
+
with pytest.raises(SaveAPIError) as exc:
|
|
75
|
+
_client_with(handler, max_retries=3).download("https://t/1")
|
|
76
|
+
|
|
77
|
+
assert exc.value.code == "PRIVATE_CONTENT"
|
|
78
|
+
assert exc.value.status == 403
|
|
79
|
+
assert exc.value.retryable is False
|
|
80
|
+
assert len(calls) == 1
|
|
81
|
+
assert no_sleep == []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_upstream_timeout_is_retried_once_by_default(no_sleep):
|
|
85
|
+
answers = iter([_error(504, "UPSTREAM_TIMEOUT"), httpx.Response(200, json=OK)])
|
|
86
|
+
result = _client_with(lambda request: next(answers)).download("https://t/1")
|
|
87
|
+
assert result["success"] is True
|
|
88
|
+
assert no_sleep == [1.0]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_retries_are_bounded():
|
|
92
|
+
calls = []
|
|
93
|
+
|
|
94
|
+
def handler(request):
|
|
95
|
+
calls.append(1)
|
|
96
|
+
return _error(502, "UPSTREAM_ERROR")
|
|
97
|
+
|
|
98
|
+
with pytest.raises(SaveAPIError):
|
|
99
|
+
_client_with(handler, max_retries=2).download("https://t/1")
|
|
100
|
+
assert len(calls) == 3
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_rate_limit_waits_for_retry_after(no_sleep):
|
|
104
|
+
answers = iter([_error(429, "RATE_LIMITED", {"Retry-After": "7"}), httpx.Response(200, json=OK)])
|
|
105
|
+
_client_with(lambda request: next(answers)).download("https://t/1")
|
|
106
|
+
assert no_sleep == [7.0]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_quota_exceeded_is_final():
|
|
110
|
+
with pytest.raises(SaveAPIError) as exc:
|
|
111
|
+
_client_with(lambda request: _error(429, "QUOTA_EXCEEDED")).download("https://t/1")
|
|
112
|
+
assert exc.value.code == "QUOTA_EXCEEDED"
|
|
113
|
+
assert exc.value.retryable is False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_key_from_environment(monkeypatch):
|
|
117
|
+
monkeypatch.setenv("SAVEAPI_KEY", "sk_env")
|
|
118
|
+
seen = {}
|
|
119
|
+
|
|
120
|
+
def handler(request):
|
|
121
|
+
seen["auth"] = request.headers["Authorization"]
|
|
122
|
+
return httpx.Response(200, json={"success": True, "supported": True, "platform": "tiktok"})
|
|
123
|
+
|
|
124
|
+
SaveAPI(http_client=httpx.Client(transport=httpx.MockTransport(handler))).detect("https://t/1")
|
|
125
|
+
assert seen["auth"] == "Bearer sk_env"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_missing_key_explains_where_to_get_one(monkeypatch):
|
|
129
|
+
monkeypatch.delenv("SAVEAPI_KEY", raising=False)
|
|
130
|
+
with pytest.raises(ValueError, match="saveapi.org"):
|
|
131
|
+
SaveAPI()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_youtube_create_passes_quality():
|
|
135
|
+
seen = {}
|
|
136
|
+
|
|
137
|
+
def handler(request):
|
|
138
|
+
seen["params"] = dict(request.url.params)
|
|
139
|
+
return httpx.Response(200, json={"success": True, "url": "https://api.saveapi.org/tunnel?id=1"})
|
|
140
|
+
|
|
141
|
+
_client_with(handler).youtube_create("https://youtu.be/x", "mp3")
|
|
142
|
+
assert seen["params"] == {"url": "https://youtu.be/x", "quality": "mp3"}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_instagram_profile_needs_username_or_url():
|
|
146
|
+
with pytest.raises(ValueError):
|
|
147
|
+
_client_with(lambda request: httpx.Response(200, json={})).instagram_profile()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_non_json_error_still_raises():
|
|
151
|
+
with pytest.raises(SaveAPIError) as exc:
|
|
152
|
+
_client_with(lambda request: httpx.Response(502, text="<html>bad gateway</html>")).me()
|
|
153
|
+
assert exc.value.status == 502
|
|
154
|
+
assert exc.value.code == "HTTP_ERROR"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def test_async_client_retries_and_returns(no_sleep):
|
|
158
|
+
answers = iter([_error(504, "UPSTREAM_TIMEOUT"), httpx.Response(200, json=OK)])
|
|
159
|
+
transport = httpx.MockTransport(lambda request: next(answers))
|
|
160
|
+
async with AsyncSaveAPI("sk_test", http_client=httpx.AsyncClient(transport=transport)) as api:
|
|
161
|
+
result = await api.download("https://t/1")
|
|
162
|
+
assert result["platform"] == "tiktok"
|
|
163
|
+
assert no_sleep == [1.0]
|