very-good-ffmpeg 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.
- very_good_ffmpeg-0.1.0/.gitignore +24 -0
- very_good_ffmpeg-0.1.0/PKG-INFO +6 -0
- very_good_ffmpeg-0.1.0/README.md +142 -0
- very_good_ffmpeg-0.1.0/examples/async_basic.py +21 -0
- very_good_ffmpeg-0.1.0/examples/basic.py +16 -0
- very_good_ffmpeg-0.1.0/pyproject.toml +13 -0
- very_good_ffmpeg-0.1.0/pytest.ini +5 -0
- very_good_ffmpeg-0.1.0/requirements.txt +7 -0
- very_good_ffmpeg-0.1.0/tests/__init__.py +0 -0
- very_good_ffmpeg-0.1.0/tests/integration/.env.test.example +3 -0
- very_good_ffmpeg-0.1.0/tests/integration/__init__.py +0 -0
- very_good_ffmpeg-0.1.0/tests/integration/async/__init__.py +0 -0
- very_good_ffmpeg-0.1.0/tests/integration/async/test_cancel.py +20 -0
- very_good_ffmpeg-0.1.0/tests/integration/async/test_failure.py +27 -0
- very_good_ffmpeg-0.1.0/tests/integration/async/test_list_jobs.py +23 -0
- very_good_ffmpeg-0.1.0/tests/integration/async/test_success.py +29 -0
- very_good_ffmpeg-0.1.0/tests/integration/async/test_tmp_file.py +27 -0
- very_good_ffmpeg-0.1.0/tests/integration/async/test_webhook.py +14 -0
- very_good_ffmpeg-0.1.0/tests/integration/conftest.py +33 -0
- very_good_ffmpeg-0.1.0/tests/integration/sample.mp4 +0 -0
- very_good_ffmpeg-0.1.0/tests/integration/sync/__init__.py +0 -0
- very_good_ffmpeg-0.1.0/tests/integration/sync/test_cancel.py +20 -0
- very_good_ffmpeg-0.1.0/tests/integration/sync/test_failure.py +27 -0
- very_good_ffmpeg-0.1.0/tests/integration/sync/test_list_jobs.py +19 -0
- very_good_ffmpeg-0.1.0/tests/integration/sync/test_success.py +29 -0
- very_good_ffmpeg-0.1.0/tests/integration/sync/test_tmp_file.py +25 -0
- very_good_ffmpeg-0.1.0/tests/integration/sync/test_webhook.py +14 -0
- very_good_ffmpeg-0.1.0/tests/integration/test_examples.py +28 -0
- very_good_ffmpeg-0.1.0/very_good_ffmpeg/__init__.py +6 -0
- very_good_ffmpeg-0.1.0/very_good_ffmpeg/_client.py +196 -0
- very_good_ffmpeg-0.1.0/very_good_ffmpeg/_exceptions.py +12 -0
- very_good_ffmpeg-0.1.0/very_good_ffmpeg/_models.py +112 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# env
|
|
2
|
+
.env.test
|
|
3
|
+
.env
|
|
4
|
+
|
|
5
|
+
# python
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.pyc
|
|
8
|
+
*.pyo
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.mypy_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
*.egg-info/
|
|
13
|
+
dist/
|
|
14
|
+
build/
|
|
15
|
+
.venv/
|
|
16
|
+
venv/
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# editors
|
|
20
|
+
.vscode/
|
|
21
|
+
.idea/
|
|
22
|
+
*.swp
|
|
23
|
+
.claude
|
|
24
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# very-good-ffmpeg
|
|
2
|
+
|
|
3
|
+
Python SDK for the Very Good FFmpeg API — run FFmpeg jobs in the cloud.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/very-good-ffmpeg/)
|
|
6
|
+
|
|
7
|
+
[Homepage](https://verygoodffmpeg.com) · [API Docs](https://verygoodffmpeg.com/docs)
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
pip install very-good-ffmpeg
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import os
|
|
19
|
+
from very_good_ffmpeg import VGF
|
|
20
|
+
|
|
21
|
+
client = VGF(os.environ["VGFFMPEG_API_KEY"])
|
|
22
|
+
|
|
23
|
+
# Create a job and wait for it to complete
|
|
24
|
+
job = client.run(
|
|
25
|
+
input_files={"input": "https://example.com/input.mp4"},
|
|
26
|
+
output_files=["output.mp4"],
|
|
27
|
+
ffmpeg_commands=["-i {{input}} -vf scale=1280:720 {{output.mp4}}"],
|
|
28
|
+
wait=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
print(job.status) # "succeeded"
|
|
32
|
+
print(job.output_files) # {"output.mp4": "https://..."}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Async is also supported — use `arun`, `jobs.aget`, `jobs.alist`, `jobs.acancel`, `files.aupload`, etc.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio, os
|
|
39
|
+
from very_good_ffmpeg import VGF
|
|
40
|
+
|
|
41
|
+
async def main():
|
|
42
|
+
client = VGF(os.environ["VGFFMPEG_API_KEY"])
|
|
43
|
+
job = await client.arun(
|
|
44
|
+
input_files={"input": "https://example.com/input.mp4"},
|
|
45
|
+
output_files=["output.mp4"],
|
|
46
|
+
ffmpeg_commands=["-i {{input}} -vf scale=1280:720 {{output.mp4}}"],
|
|
47
|
+
wait=True,
|
|
48
|
+
)
|
|
49
|
+
print(job.status)
|
|
50
|
+
|
|
51
|
+
asyncio.run(main())
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Reference
|
|
55
|
+
|
|
56
|
+
### Create a job — `client.run(...)` / `client.arun(...)`
|
|
57
|
+
|
|
58
|
+
Submits an FFmpeg job. Pass `wait=True` to block until the job reaches a terminal state.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
job = client.run(
|
|
62
|
+
input_files={"input": "https://example.com/input.mp4"},
|
|
63
|
+
output_files=["output.mp4"],
|
|
64
|
+
ffmpeg_commands=["-i {{input}} -c:v libx264 {{output.mp4}}"],
|
|
65
|
+
webhook_url="https://example.com/webhook", # optional
|
|
66
|
+
machine="nvidia", # optional: "cpu" | "nvidia"
|
|
67
|
+
wait=True,
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Get a job — `client.jobs.get(id)` / `client.jobs.aget(id)`
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
job = client.jobs.get("job_abc123")
|
|
75
|
+
print(job.status) # "queued" | "running" | "succeeded" | "failed" | "cancelled"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### List jobs — `client.jobs.list(...)` / `client.jobs.alist(...)`
|
|
79
|
+
|
|
80
|
+
Returns a `JobList` with `data` (list of jobs) and `paging_params`.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
result = client.jobs.list(limit=20, offset=0)
|
|
84
|
+
print(result.data) # list[Job]
|
|
85
|
+
print(result.paging_params) # PagingParams(limit=20, offset=0, total=100, has_more=True)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Cancel a job — `client.jobs.cancel(id)` / `client.jobs.acancel(id)`
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
job = client.jobs.cancel("job_abc123")
|
|
92
|
+
print(job.status) # "cancelled"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Poll until done — `job.wait(...)` / `job.async_wait(...)`
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
job = client.run(input_files=..., output_files=..., ffmpeg_commands=...)
|
|
99
|
+
job.wait(timeout=120)
|
|
100
|
+
print(job.status)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Upload a file — `client.files.upload(data, content_type?)` / `client.files.aupload(...)`
|
|
104
|
+
|
|
105
|
+
Upload bytes or a file path to temporary storage and get back a URL you can use as an `input_files` value.
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
with open("input.mp4", "rb") as f:
|
|
109
|
+
download_url = client.files.upload(f.read(), "video/mp4")
|
|
110
|
+
|
|
111
|
+
job = client.run(
|
|
112
|
+
input_files={"input": download_url},
|
|
113
|
+
output_files=["output.mp4"],
|
|
114
|
+
ffmpeg_commands=["-i {{input}} -vf scale=1280:720 {{output.mp4}}"],
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Pass a file path directly to have the SDK read it for you:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
download_url = client.files.upload("input.mp4")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Prepare upload — `client.files.prepare()` / `client.files.aprepare()`
|
|
125
|
+
|
|
126
|
+
Returns a `TmpFile` with `upload_url` and `download_url` if you need to stream a large file yourself.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
tmp = client.files.prepare()
|
|
130
|
+
# PUT your file to tmp.upload_url, then use tmp.download_url as input
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Examples
|
|
134
|
+
|
|
135
|
+
See the [`examples/`](./examples) folder for minimal runnable scripts:
|
|
136
|
+
|
|
137
|
+
- [`examples/basic.py`](./examples/basic.py) — sync, list jobs
|
|
138
|
+
- [`examples/async_basic.py`](./examples/async_basic.py) — async, list jobs
|
|
139
|
+
|
|
140
|
+
## Support
|
|
141
|
+
|
|
142
|
+
Open an issue at [github.com/verygoodffmpeg/Very-Good-FFmpeg-Python-SDK](https://github.com/verygoodffmpeg/Very-Good-FFmpeg-Python-SDK) or email [please_help@verygoodffmpeg.com](mailto:please_help@verygoodffmpeg.com).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Async example — list jobs.
|
|
2
|
+
|
|
3
|
+
Run:
|
|
4
|
+
python examples/async_basic.py
|
|
5
|
+
|
|
6
|
+
Requires:
|
|
7
|
+
VGFFMPEG_API_KEY env var
|
|
8
|
+
"""
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from very_good_ffmpeg import VGF
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def main() -> None:
|
|
16
|
+
client = VGF(os.environ["VGFFMPEG_API_KEY"])
|
|
17
|
+
result = await client.jobs.alist()
|
|
18
|
+
print("Jobs:", result.data)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Sync example — list jobs.
|
|
2
|
+
|
|
3
|
+
Run:
|
|
4
|
+
python examples/basic.py
|
|
5
|
+
|
|
6
|
+
Requires:
|
|
7
|
+
VGFFMPEG_API_KEY env var
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from very_good_ffmpeg import VGF
|
|
12
|
+
|
|
13
|
+
client = VGF(os.environ["VGFFMPEG_API_KEY"])
|
|
14
|
+
|
|
15
|
+
result = client.jobs.list()
|
|
16
|
+
print("Jobs:", result.data)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "very-good-ffmpeg"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for the Very Good FFmpeg API"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Homepage = "https://verygoodffmpeg.com"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from very_good_ffmpeg import VGF
|
|
2
|
+
|
|
3
|
+
INPUT = "https://storage.verygoodffmpeg.com/sample.mp4"
|
|
4
|
+
COMMANDS = ["-i {{input}} -t 60 {{output.mp4}}"]
|
|
5
|
+
OUTPUTS = ["output.mp4"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def test_cancel(client: VGF):
|
|
9
|
+
job = await client.arun(
|
|
10
|
+
input_files={"input": INPUT},
|
|
11
|
+
output_files=OUTPUTS,
|
|
12
|
+
ffmpeg_commands=COMMANDS,
|
|
13
|
+
)
|
|
14
|
+
assert job.id
|
|
15
|
+
|
|
16
|
+
cancelled = await client.jobs.acancel(job.id)
|
|
17
|
+
assert cancelled.status == "cancelled"
|
|
18
|
+
|
|
19
|
+
await job.async_wait(timeout=30)
|
|
20
|
+
assert job.status == "cancelled"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from very_good_ffmpeg import VGF
|
|
2
|
+
|
|
3
|
+
INVALID_INPUT = "https://invalid.example/nope.mp4"
|
|
4
|
+
COMMANDS = ["-i {{input}} {{output.mp4}}"]
|
|
5
|
+
OUTPUTS = ["output.mp4"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def test_failure_wait(client: VGF):
|
|
9
|
+
job = await client.arun(
|
|
10
|
+
input_files={"input": INVALID_INPUT},
|
|
11
|
+
output_files=OUTPUTS,
|
|
12
|
+
ffmpeg_commands=COMMANDS,
|
|
13
|
+
wait=True,
|
|
14
|
+
)
|
|
15
|
+
assert job.status == "failed"
|
|
16
|
+
assert job.error_message
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def test_failure_poll(client: VGF):
|
|
20
|
+
job = await client.arun(
|
|
21
|
+
input_files={"input": INVALID_INPUT},
|
|
22
|
+
output_files=OUTPUTS,
|
|
23
|
+
ffmpeg_commands=COMMANDS,
|
|
24
|
+
)
|
|
25
|
+
await job.async_wait(timeout=120)
|
|
26
|
+
assert job.status == "failed"
|
|
27
|
+
assert job.error_message
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from very_good_ffmpeg import VGF
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def test_list_jobs_offset(client: VGF):
|
|
7
|
+
page1, page2 = await asyncio.gather(
|
|
8
|
+
client.jobs.alist(limit=2, offset=0),
|
|
9
|
+
client.jobs.alist(limit=2, offset=2),
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
assert len(page1.data) <= 2
|
|
13
|
+
assert len(page2.data) <= 2
|
|
14
|
+
|
|
15
|
+
page1_ids = {j.id for j in page1.data}
|
|
16
|
+
for job in page2.data:
|
|
17
|
+
assert job.id not in page1_ids
|
|
18
|
+
|
|
19
|
+
p = page2.paging_params
|
|
20
|
+
assert isinstance(p.limit, int)
|
|
21
|
+
assert isinstance(p.offset, int)
|
|
22
|
+
assert isinstance(p.total, int)
|
|
23
|
+
assert isinstance(p.has_more, bool)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from very_good_ffmpeg import VGF
|
|
2
|
+
|
|
3
|
+
INPUT = "https://storage.verygoodffmpeg.com/sample.mp4"
|
|
4
|
+
COMMANDS = ["-i {{input}} -t 5 {{output.mp4}}"]
|
|
5
|
+
OUTPUTS = ["output.mp4"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def test_success_wait(client: VGF):
|
|
9
|
+
job = await client.arun(
|
|
10
|
+
input_files={"input": INPUT},
|
|
11
|
+
output_files=OUTPUTS,
|
|
12
|
+
ffmpeg_commands=COMMANDS,
|
|
13
|
+
wait=True,
|
|
14
|
+
)
|
|
15
|
+
assert job.status == "succeeded"
|
|
16
|
+
assert job.output_files
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def test_success_poll(client: VGF):
|
|
20
|
+
job = await client.arun(
|
|
21
|
+
input_files={"input": INPUT},
|
|
22
|
+
output_files=OUTPUTS,
|
|
23
|
+
ffmpeg_commands=COMMANDS,
|
|
24
|
+
)
|
|
25
|
+
assert job.status in ("queued", "running", "succeeded")
|
|
26
|
+
|
|
27
|
+
await job.async_wait(timeout=120)
|
|
28
|
+
assert job.status == "succeeded"
|
|
29
|
+
assert job.output_files
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import urllib.request
|
|
3
|
+
|
|
4
|
+
from very_good_ffmpeg import VGF
|
|
5
|
+
|
|
6
|
+
SAMPLE_URL = "https://storage.verygoodffmpeg.com/sample.mp4"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def test_tmp_file_upload_download_and_run(client: VGF):
|
|
10
|
+
original = await asyncio.to_thread(
|
|
11
|
+
lambda: urllib.request.urlopen(SAMPLE_URL).read()
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
download_url = await client.files.aupload(original, "video/mp4")
|
|
15
|
+
|
|
16
|
+
downloaded = await asyncio.to_thread(
|
|
17
|
+
lambda: urllib.request.urlopen(download_url).read()
|
|
18
|
+
)
|
|
19
|
+
assert downloaded == original
|
|
20
|
+
|
|
21
|
+
job = await client.arun(
|
|
22
|
+
input_files={"input": download_url},
|
|
23
|
+
output_files=["output.mp4"],
|
|
24
|
+
ffmpeg_commands=["-i {{input}} -t 2 {{output.mp4}}"],
|
|
25
|
+
wait=True,
|
|
26
|
+
)
|
|
27
|
+
assert job.status == "succeeded"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from very_good_ffmpeg import VGF
|
|
2
|
+
|
|
3
|
+
WEBHOOK_URL = "https://example.com/webhook"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def test_webhook_url_reflected(client: VGF):
|
|
7
|
+
job = await client.arun(
|
|
8
|
+
input_files={"input": "https://storage.verygoodffmpeg.com/sample.mp4"},
|
|
9
|
+
output_files=["output.mp4"],
|
|
10
|
+
ffmpeg_commands=["-i {{input}} -t 5 {{output.mp4}}"],
|
|
11
|
+
webhook_url=WEBHOOK_URL,
|
|
12
|
+
wait=True,
|
|
13
|
+
)
|
|
14
|
+
assert job.webhook_url == WEBHOOK_URL
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
from very_good_ffmpeg import VGF
|
|
11
|
+
|
|
12
|
+
load_dotenv(Path(__file__).parent / ".env.test")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if os.environ.get("VGFFMPEG_DEBUG"):
|
|
16
|
+
logging.basicConfig(
|
|
17
|
+
level=logging.DEBUG,
|
|
18
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
19
|
+
)
|
|
20
|
+
logging.getLogger("very_good_ffmpeg").setLevel(logging.DEBUG)
|
|
21
|
+
logging.debug("Debug logging enabled")
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("very_good_ffmpeg.tests")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(scope="session")
|
|
27
|
+
def client() -> VGF:
|
|
28
|
+
api_key = os.environ.get("VGFFMPEG_API_KEY")
|
|
29
|
+
if not api_key:
|
|
30
|
+
pytest.skip("VGFFMPEG_API_KEY not set")
|
|
31
|
+
base_url = os.environ.get("VGFFMPEG_BASE_URL", "https://verygoodffmpeg.com/api")
|
|
32
|
+
log.debug("client base_url=%s", base_url)
|
|
33
|
+
return VGF(api_key=api_key, base_url=base_url)
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from very_good_ffmpeg import VGF
|
|
2
|
+
|
|
3
|
+
INPUT = "https://storage.verygoodffmpeg.com/sample.mp4"
|
|
4
|
+
COMMANDS = ["-i {{input}} -t 60 {{output.mp4}}"]
|
|
5
|
+
OUTPUTS = ["output.mp4"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_cancel(client: VGF):
|
|
9
|
+
job = client.run(
|
|
10
|
+
input_files={"input": INPUT},
|
|
11
|
+
output_files=OUTPUTS,
|
|
12
|
+
ffmpeg_commands=COMMANDS,
|
|
13
|
+
)
|
|
14
|
+
assert job.id
|
|
15
|
+
|
|
16
|
+
cancelled = client.jobs.cancel(job.id)
|
|
17
|
+
assert cancelled.status == "cancelled"
|
|
18
|
+
|
|
19
|
+
job.wait(timeout=30)
|
|
20
|
+
assert job.status == "cancelled"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from very_good_ffmpeg import VGF
|
|
2
|
+
|
|
3
|
+
INVALID_INPUT = "https://invalid.example/nope.mp4"
|
|
4
|
+
COMMANDS = ["-i {{input}} {{output.mp4}}"]
|
|
5
|
+
OUTPUTS = ["output.mp4"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_failure_wait(client: VGF):
|
|
9
|
+
job = client.run(
|
|
10
|
+
input_files={"input": INVALID_INPUT},
|
|
11
|
+
output_files=OUTPUTS,
|
|
12
|
+
ffmpeg_commands=COMMANDS,
|
|
13
|
+
wait=True,
|
|
14
|
+
)
|
|
15
|
+
assert job.status == "failed"
|
|
16
|
+
assert job.error_message
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_failure_poll(client: VGF):
|
|
20
|
+
job = client.run(
|
|
21
|
+
input_files={"input": INVALID_INPUT},
|
|
22
|
+
output_files=OUTPUTS,
|
|
23
|
+
ffmpeg_commands=COMMANDS,
|
|
24
|
+
)
|
|
25
|
+
job.wait(timeout=120)
|
|
26
|
+
assert job.status == "failed"
|
|
27
|
+
assert job.error_message
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from very_good_ffmpeg import VGF
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_list_jobs_offset(client: VGF):
|
|
5
|
+
page1 = client.jobs.list(limit=2, offset=0)
|
|
6
|
+
page2 = client.jobs.list(limit=2, offset=2)
|
|
7
|
+
|
|
8
|
+
assert len(page1.data) <= 2
|
|
9
|
+
assert len(page2.data) <= 2
|
|
10
|
+
|
|
11
|
+
page1_ids = {j.id for j in page1.data}
|
|
12
|
+
for job in page2.data:
|
|
13
|
+
assert job.id not in page1_ids
|
|
14
|
+
|
|
15
|
+
p = page2.paging_params
|
|
16
|
+
assert isinstance(p.limit, int)
|
|
17
|
+
assert isinstance(p.offset, int)
|
|
18
|
+
assert isinstance(p.total, int)
|
|
19
|
+
assert isinstance(p.has_more, bool)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from very_good_ffmpeg import VGF
|
|
2
|
+
|
|
3
|
+
INPUT = "https://storage.verygoodffmpeg.com/sample.mp4"
|
|
4
|
+
COMMANDS = ["-i {{input}} -t 5 {{output.mp4}}"]
|
|
5
|
+
OUTPUTS = ["output.mp4"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_success_wait(client: VGF):
|
|
9
|
+
job = client.run(
|
|
10
|
+
input_files={"input": INPUT},
|
|
11
|
+
output_files=OUTPUTS,
|
|
12
|
+
ffmpeg_commands=COMMANDS,
|
|
13
|
+
wait=True,
|
|
14
|
+
)
|
|
15
|
+
assert job.status == "succeeded"
|
|
16
|
+
assert job.output_files
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_success_poll(client: VGF):
|
|
20
|
+
job = client.run(
|
|
21
|
+
input_files={"input": INPUT},
|
|
22
|
+
output_files=OUTPUTS,
|
|
23
|
+
ffmpeg_commands=COMMANDS,
|
|
24
|
+
)
|
|
25
|
+
assert job.status in ("queued", "running", "succeeded")
|
|
26
|
+
|
|
27
|
+
job.wait(timeout=120)
|
|
28
|
+
assert job.status == "succeeded"
|
|
29
|
+
assert job.output_files
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import urllib.request
|
|
2
|
+
|
|
3
|
+
from very_good_ffmpeg import VGF
|
|
4
|
+
|
|
5
|
+
SAMPLE_URL = "https://storage.verygoodffmpeg.com/sample.mp4"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_tmp_file_upload_download_and_run(client: VGF):
|
|
9
|
+
with urllib.request.urlopen(SAMPLE_URL) as r:
|
|
10
|
+
original = r.read()
|
|
11
|
+
|
|
12
|
+
download_url = client.files.upload(original, "video/mp4")
|
|
13
|
+
|
|
14
|
+
with urllib.request.urlopen(download_url) as r:
|
|
15
|
+
downloaded = r.read()
|
|
16
|
+
|
|
17
|
+
assert downloaded == original
|
|
18
|
+
|
|
19
|
+
job = client.run(
|
|
20
|
+
input_files={"input": download_url},
|
|
21
|
+
output_files=["output.mp4"],
|
|
22
|
+
ffmpeg_commands=["-i {{input}} -t 2 {{output.mp4}}"],
|
|
23
|
+
wait=True,
|
|
24
|
+
)
|
|
25
|
+
assert job.status == "succeeded"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from very_good_ffmpeg import VGF
|
|
2
|
+
|
|
3
|
+
WEBHOOK_URL = "https://example.com/webhook"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_webhook_url_reflected(client: VGF):
|
|
7
|
+
job = client.run(
|
|
8
|
+
input_files={"input": "https://storage.verygoodffmpeg.com/sample.mp4"},
|
|
9
|
+
output_files=["output.mp4"],
|
|
10
|
+
ffmpeg_commands=["-i {{input}} -t 5 {{output.mp4}}"],
|
|
11
|
+
webhook_url=WEBHOOK_URL,
|
|
12
|
+
wait=True,
|
|
13
|
+
)
|
|
14
|
+
assert job.webhook_url == WEBHOOK_URL
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _run(script: str) -> str:
|
|
10
|
+
result = subprocess.run(
|
|
11
|
+
[sys.executable, script],
|
|
12
|
+
cwd=EXAMPLES_DIR,
|
|
13
|
+
env={**os.environ},
|
|
14
|
+
capture_output=True,
|
|
15
|
+
text=True,
|
|
16
|
+
)
|
|
17
|
+
assert result.returncode == 0, result.stderr
|
|
18
|
+
return result.stdout
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_basic_example():
|
|
22
|
+
output = _run("basic.py")
|
|
23
|
+
assert "Jobs:" in output
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_async_basic_example():
|
|
27
|
+
output = _run("async_basic.py")
|
|
28
|
+
assert "Jobs:" in output
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from ._client import VGF
|
|
2
|
+
from ._models import Job, JobList, PagingParams, TmpFile
|
|
3
|
+
from ._exceptions import VGFError, VGFAuthError, VGFNotFoundError
|
|
4
|
+
|
|
5
|
+
__all__ = ["VGF", "Job", "JobList", "PagingParams", "TmpFile", "VGFError", "VGFAuthError", "VGFNotFoundError"]
|
|
6
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ._exceptions import VGFAuthError, VGFError, VGFNotFoundError
|
|
11
|
+
from ._models import Job, JobList, PagingParams, TmpFile
|
|
12
|
+
|
|
13
|
+
_BASE_URL = "https://verygoodffmpeg.com/api"
|
|
14
|
+
_DEFAULT_TIMEOUT = 30
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("very_good_ffmpeg")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _raise_for_status(status: int, body: bytes, url: str) -> None:
|
|
20
|
+
if status == 401:
|
|
21
|
+
raise VGFAuthError("Invalid or missing API key", status_code=status)
|
|
22
|
+
if status == 404:
|
|
23
|
+
raise VGFNotFoundError(f"Not found: {url}", status_code=status)
|
|
24
|
+
if status >= 400:
|
|
25
|
+
try:
|
|
26
|
+
msg = json.loads(body).get("message", body.decode())
|
|
27
|
+
except Exception:
|
|
28
|
+
msg = body.decode(errors="replace")
|
|
29
|
+
raise VGFError(msg, status_code=status)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_paging(raw: dict) -> PagingParams:
|
|
33
|
+
p = raw.get("pagingParams", {})
|
|
34
|
+
return PagingParams(
|
|
35
|
+
limit=p.get("limit", 0),
|
|
36
|
+
offset=p.get("offset", 0),
|
|
37
|
+
total=p.get("total", 0),
|
|
38
|
+
has_more=p.get("hasMore", False),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _JobsResource:
|
|
43
|
+
def __init__(self, client: VGF):
|
|
44
|
+
self._c = client
|
|
45
|
+
|
|
46
|
+
def get(self, job_id: str) -> Job:
|
|
47
|
+
data = self._c._request("GET", f"/jobs/{job_id}")
|
|
48
|
+
return Job._from_dict(data["data"], client=self._c)
|
|
49
|
+
|
|
50
|
+
async def aget(self, job_id: str) -> Job:
|
|
51
|
+
data = await self._c._arequest("GET", f"/jobs/{job_id}")
|
|
52
|
+
return Job._from_dict(data["data"], client=self._c)
|
|
53
|
+
|
|
54
|
+
def list(self, *, limit: int = 50, offset: int = 0) -> JobList:
|
|
55
|
+
raw = self._c._request("GET", f"/jobs?limit={limit}&offset={offset}")
|
|
56
|
+
return JobList(
|
|
57
|
+
data=[Job._from_dict(j, client=self._c) for j in raw["data"]],
|
|
58
|
+
paging_params=_parse_paging(raw),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def alist(self, *, limit: int = 50, offset: int = 0) -> JobList:
|
|
62
|
+
raw = await self._c._arequest("GET", f"/jobs?limit={limit}&offset={offset}")
|
|
63
|
+
return JobList(
|
|
64
|
+
data=[Job._from_dict(j, client=self._c) for j in raw["data"]],
|
|
65
|
+
paging_params=_parse_paging(raw),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def cancel(self, job_id: str) -> Job:
|
|
69
|
+
data = self._c._request("POST", f"/jobs/{job_id}/cancel")
|
|
70
|
+
return Job._from_dict(data["data"], client=self._c)
|
|
71
|
+
|
|
72
|
+
async def acancel(self, job_id: str) -> Job:
|
|
73
|
+
data = await self._c._arequest("POST", f"/jobs/{job_id}/cancel")
|
|
74
|
+
return Job._from_dict(data["data"], client=self._c)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _FilesResource:
|
|
78
|
+
def __init__(self, client: VGF):
|
|
79
|
+
self._c = client
|
|
80
|
+
|
|
81
|
+
def prepare(self) -> TmpFile:
|
|
82
|
+
data = self._c._request("POST", "/tmp-file")
|
|
83
|
+
return TmpFile(
|
|
84
|
+
upload_url=data["data"]["upload_url"],
|
|
85
|
+
download_url=data["data"]["download_url"],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def aprepare(self) -> TmpFile:
|
|
89
|
+
data = await self._c._arequest("POST", "/tmp-file")
|
|
90
|
+
return TmpFile(
|
|
91
|
+
upload_url=data["data"]["upload_url"],
|
|
92
|
+
download_url=data["data"]["download_url"],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def upload(self, data: bytes | str | Path, content_type: str = "application/octet-stream") -> str:
|
|
96
|
+
tmp = self.prepare()
|
|
97
|
+
body = data if isinstance(data, bytes) else Path(data).read_bytes()
|
|
98
|
+
_put(tmp.upload_url, body, content_type)
|
|
99
|
+
return tmp.download_url
|
|
100
|
+
|
|
101
|
+
async def aupload(self, data: bytes | str | Path, content_type: str = "application/octet-stream") -> str:
|
|
102
|
+
tmp = await self.aprepare()
|
|
103
|
+
body = data if isinstance(data, bytes) else Path(data).read_bytes()
|
|
104
|
+
await asyncio.to_thread(_put, tmp.upload_url, body, content_type)
|
|
105
|
+
return tmp.download_url
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _put(url: str, body: bytes, content_type: str = "application/octet-stream") -> None:
|
|
109
|
+
log.debug("PUT %s (%d bytes)", url, len(body))
|
|
110
|
+
req = urllib.request.Request(url, data=body, method="PUT")
|
|
111
|
+
req.add_header("Content-Type", content_type)
|
|
112
|
+
req.add_header("Content-Length", str(len(body)))
|
|
113
|
+
try:
|
|
114
|
+
with urllib.request.urlopen(req, timeout=_DEFAULT_TIMEOUT) as resp:
|
|
115
|
+
log.debug("PUT response: %s", resp.status)
|
|
116
|
+
if resp.status not in (200, 204):
|
|
117
|
+
raise VGFError(f"Upload failed with status {resp.status}")
|
|
118
|
+
except urllib.error.HTTPError as e:
|
|
119
|
+
raise VGFError(f"Upload failed: {e}", status_code=e.code) from e
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class VGF:
|
|
123
|
+
def __init__(self, api_key: str, base_url: str = _BASE_URL):
|
|
124
|
+
self._api_key = api_key
|
|
125
|
+
self._base_url = base_url.rstrip("/")
|
|
126
|
+
self.jobs = _JobsResource(self)
|
|
127
|
+
self.files = _FilesResource(self)
|
|
128
|
+
|
|
129
|
+
def _request(self, method: str, path: str, body: dict | None = None) -> dict:
|
|
130
|
+
url = self._base_url + path
|
|
131
|
+
log.debug("%s %s", method, url)
|
|
132
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
133
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
134
|
+
req.add_header("Authorization", f"Bearer {self._api_key}")
|
|
135
|
+
req.add_header("Content-Type", "application/json")
|
|
136
|
+
req.add_header("Accept", "application/json")
|
|
137
|
+
try:
|
|
138
|
+
with urllib.request.urlopen(req, timeout=_DEFAULT_TIMEOUT) as resp:
|
|
139
|
+
result = json.loads(resp.read())
|
|
140
|
+
log.debug("%s %s -> 200", method, url)
|
|
141
|
+
return result
|
|
142
|
+
except urllib.error.HTTPError as e:
|
|
143
|
+
raw = e.read()
|
|
144
|
+
log.debug("%s %s -> %s", method, url, e.code)
|
|
145
|
+
_raise_for_status(e.code, raw, url)
|
|
146
|
+
|
|
147
|
+
async def _arequest(self, method: str, path: str, body: dict | None = None) -> dict:
|
|
148
|
+
return await asyncio.to_thread(self._request, method, path, body)
|
|
149
|
+
|
|
150
|
+
def run(
|
|
151
|
+
self,
|
|
152
|
+
input_files: dict[str, str],
|
|
153
|
+
output_files: list[str],
|
|
154
|
+
ffmpeg_commands: list[str],
|
|
155
|
+
*,
|
|
156
|
+
webhook_url: str | None = None,
|
|
157
|
+
machine: str = "cpu",
|
|
158
|
+
wait: bool = False,
|
|
159
|
+
) -> Job:
|
|
160
|
+
body: dict = {
|
|
161
|
+
"input_files": input_files,
|
|
162
|
+
"output_files": output_files,
|
|
163
|
+
"ffmpeg_commands": ffmpeg_commands,
|
|
164
|
+
"machine": machine,
|
|
165
|
+
}
|
|
166
|
+
if webhook_url is not None:
|
|
167
|
+
body["webhook_url"] = webhook_url
|
|
168
|
+
qs = "?wait=true" if wait else ""
|
|
169
|
+
data = self._request("POST", f"/ffmpeg{qs}", body)
|
|
170
|
+
job = Job._from_dict(data["data"], client=self)
|
|
171
|
+
log.debug("job %s submitted, status=%s", job.id, job.status)
|
|
172
|
+
return job
|
|
173
|
+
|
|
174
|
+
async def arun(
|
|
175
|
+
self,
|
|
176
|
+
input_files: dict[str, str],
|
|
177
|
+
output_files: list[str],
|
|
178
|
+
ffmpeg_commands: list[str],
|
|
179
|
+
*,
|
|
180
|
+
webhook_url: str | None = None,
|
|
181
|
+
machine: str = "cpu",
|
|
182
|
+
wait: bool = False,
|
|
183
|
+
) -> Job:
|
|
184
|
+
body: dict = {
|
|
185
|
+
"input_files": input_files,
|
|
186
|
+
"output_files": output_files,
|
|
187
|
+
"ffmpeg_commands": ffmpeg_commands,
|
|
188
|
+
"machine": machine,
|
|
189
|
+
}
|
|
190
|
+
if webhook_url is not None:
|
|
191
|
+
body["webhook_url"] = webhook_url
|
|
192
|
+
qs = "?wait=true" if wait else ""
|
|
193
|
+
data = await self._arequest("POST", f"/ffmpeg{qs}", body)
|
|
194
|
+
job = Job._from_dict(data["data"], client=self)
|
|
195
|
+
log.debug("job %s submitted, status=%s", job.id, job.status)
|
|
196
|
+
return job
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ._client import VGF
|
|
11
|
+
|
|
12
|
+
_TERMINAL = {"succeeded", "failed", "cancelled"}
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("very_good_ffmpeg")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Job:
|
|
19
|
+
id: str
|
|
20
|
+
status: str
|
|
21
|
+
queued_at: str
|
|
22
|
+
started_at: str | None
|
|
23
|
+
finished_at: str | None
|
|
24
|
+
created_at: str
|
|
25
|
+
updated_at: str
|
|
26
|
+
error_message: str
|
|
27
|
+
ffmpeg_commands: list[str]
|
|
28
|
+
input_files: dict[str, str]
|
|
29
|
+
output_files: dict[str, str]
|
|
30
|
+
webhook_url: str | None
|
|
31
|
+
total_input_bytes: int | None
|
|
32
|
+
total_output_bytes: int | None
|
|
33
|
+
|
|
34
|
+
_client: VGF = field(repr=False, compare=False, default=None)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def _from_dict(cls, data: dict, client: VGF = None) -> Job:
|
|
38
|
+
return cls(
|
|
39
|
+
id=data["id"],
|
|
40
|
+
status=data["status"],
|
|
41
|
+
queued_at=data["queued_at"],
|
|
42
|
+
started_at=data.get("started_at"),
|
|
43
|
+
finished_at=data.get("finished_at"),
|
|
44
|
+
created_at=data["created_at"],
|
|
45
|
+
updated_at=data["updated_at"],
|
|
46
|
+
error_message=data.get("error_message", ""),
|
|
47
|
+
ffmpeg_commands=data.get("ffmpeg_commands", []),
|
|
48
|
+
input_files=data.get("input_files", {}),
|
|
49
|
+
output_files=data.get("output_files", {}),
|
|
50
|
+
webhook_url=data.get("webhook_url"),
|
|
51
|
+
total_input_bytes=data.get("total_input_bytes"),
|
|
52
|
+
total_output_bytes=data.get("total_output_bytes"),
|
|
53
|
+
_client=client,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def _apply_update(self, updated: Job) -> None:
|
|
57
|
+
self.status = updated.status
|
|
58
|
+
self.started_at = updated.started_at
|
|
59
|
+
self.finished_at = updated.finished_at
|
|
60
|
+
self.output_files = updated.output_files
|
|
61
|
+
self.error_message = updated.error_message
|
|
62
|
+
self.total_input_bytes = updated.total_input_bytes
|
|
63
|
+
self.total_output_bytes = updated.total_output_bytes
|
|
64
|
+
self.updated_at = updated.updated_at
|
|
65
|
+
|
|
66
|
+
def wait(self, poll_interval: float = 2.0, timeout: float | None = None) -> Job:
|
|
67
|
+
deadline = time.monotonic() + timeout if timeout else None
|
|
68
|
+
while self.status not in _TERMINAL:
|
|
69
|
+
if deadline and time.monotonic() >= deadline:
|
|
70
|
+
raise TimeoutError(f"Job {self.id} did not finish within timeout")
|
|
71
|
+
log.debug("job %s polling, status=%s, sleeping %.1fs", self.id, self.status, poll_interval)
|
|
72
|
+
time.sleep(poll_interval)
|
|
73
|
+
self._apply_update(self._client.jobs.get(self.id))
|
|
74
|
+
log.debug("job %s terminal, status=%s", self.id, self.status)
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
async def async_wait(self, poll_interval: float = 2.0, timeout: float | None = None) -> Job:
|
|
78
|
+
deadline = time.monotonic() + timeout if timeout else None
|
|
79
|
+
while self.status not in _TERMINAL:
|
|
80
|
+
if deadline and time.monotonic() >= deadline:
|
|
81
|
+
raise TimeoutError(f"Job {self.id} did not finish within timeout")
|
|
82
|
+
log.debug("job %s polling, status=%s, sleeping %.1fs", self.id, self.status, poll_interval)
|
|
83
|
+
await asyncio.sleep(poll_interval)
|
|
84
|
+
self._apply_update(await self._client.jobs.aget(self.id))
|
|
85
|
+
log.debug("job %s terminal, status=%s", self.id, self.status)
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def cancel(self) -> Job:
|
|
89
|
+
return self._client.jobs.cancel(self.id)
|
|
90
|
+
|
|
91
|
+
async def acancel(self) -> Job:
|
|
92
|
+
return await self._client.jobs.acancel(self.id)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class PagingParams:
|
|
97
|
+
limit: int
|
|
98
|
+
offset: int
|
|
99
|
+
total: int
|
|
100
|
+
has_more: bool
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class JobList:
|
|
105
|
+
data: list[Job]
|
|
106
|
+
paging_params: PagingParams
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class TmpFile:
|
|
111
|
+
upload_url: str
|
|
112
|
+
download_url: str
|