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.
Files changed (32) hide show
  1. very_good_ffmpeg-0.1.0/.gitignore +24 -0
  2. very_good_ffmpeg-0.1.0/PKG-INFO +6 -0
  3. very_good_ffmpeg-0.1.0/README.md +142 -0
  4. very_good_ffmpeg-0.1.0/examples/async_basic.py +21 -0
  5. very_good_ffmpeg-0.1.0/examples/basic.py +16 -0
  6. very_good_ffmpeg-0.1.0/pyproject.toml +13 -0
  7. very_good_ffmpeg-0.1.0/pytest.ini +5 -0
  8. very_good_ffmpeg-0.1.0/requirements.txt +7 -0
  9. very_good_ffmpeg-0.1.0/tests/__init__.py +0 -0
  10. very_good_ffmpeg-0.1.0/tests/integration/.env.test.example +3 -0
  11. very_good_ffmpeg-0.1.0/tests/integration/__init__.py +0 -0
  12. very_good_ffmpeg-0.1.0/tests/integration/async/__init__.py +0 -0
  13. very_good_ffmpeg-0.1.0/tests/integration/async/test_cancel.py +20 -0
  14. very_good_ffmpeg-0.1.0/tests/integration/async/test_failure.py +27 -0
  15. very_good_ffmpeg-0.1.0/tests/integration/async/test_list_jobs.py +23 -0
  16. very_good_ffmpeg-0.1.0/tests/integration/async/test_success.py +29 -0
  17. very_good_ffmpeg-0.1.0/tests/integration/async/test_tmp_file.py +27 -0
  18. very_good_ffmpeg-0.1.0/tests/integration/async/test_webhook.py +14 -0
  19. very_good_ffmpeg-0.1.0/tests/integration/conftest.py +33 -0
  20. very_good_ffmpeg-0.1.0/tests/integration/sample.mp4 +0 -0
  21. very_good_ffmpeg-0.1.0/tests/integration/sync/__init__.py +0 -0
  22. very_good_ffmpeg-0.1.0/tests/integration/sync/test_cancel.py +20 -0
  23. very_good_ffmpeg-0.1.0/tests/integration/sync/test_failure.py +27 -0
  24. very_good_ffmpeg-0.1.0/tests/integration/sync/test_list_jobs.py +19 -0
  25. very_good_ffmpeg-0.1.0/tests/integration/sync/test_success.py +29 -0
  26. very_good_ffmpeg-0.1.0/tests/integration/sync/test_tmp_file.py +25 -0
  27. very_good_ffmpeg-0.1.0/tests/integration/sync/test_webhook.py +14 -0
  28. very_good_ffmpeg-0.1.0/tests/integration/test_examples.py +28 -0
  29. very_good_ffmpeg-0.1.0/very_good_ffmpeg/__init__.py +6 -0
  30. very_good_ffmpeg-0.1.0/very_good_ffmpeg/_client.py +196 -0
  31. very_good_ffmpeg-0.1.0/very_good_ffmpeg/_exceptions.py +12 -0
  32. 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,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: very-good-ffmpeg
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Very Good FFmpeg API
5
+ Project-URL: Homepage, https://verygoodffmpeg.com
6
+ Requires-Python: >=3.10
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/very-good-ffmpeg)](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"
@@ -0,0 +1,5 @@
1
+ [pytest]
2
+ testpaths = tests
3
+ asyncio_mode = auto
4
+ markers =
5
+ integration: marks tests as integration tests (require VGFFMPEG_API_KEY)
@@ -0,0 +1,7 @@
1
+ iniconfig==2.3.0
2
+ packaging==26.2
3
+ pluggy==1.6.0
4
+ Pygments==2.20.0
5
+ pytest==9.0.3
6
+ pytest-asyncio==1.3.0
7
+ python-dotenv==1.2.2
File without changes
@@ -0,0 +1,3 @@
1
+ VGFFMPEG_API_KEY=sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
2
+ VGFFMPEG_BASE_URL=https://verygoodffmpeg.com/api
3
+ VGFFMPEG_DEBUG=
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)
@@ -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,12 @@
1
+ class VGFError(Exception):
2
+ def __init__(self, message: str, status_code: int | None = None):
3
+ super().__init__(message)
4
+ self.status_code = status_code
5
+
6
+
7
+ class VGFNotFoundError(VGFError):
8
+ pass
9
+
10
+
11
+ class VGFAuthError(VGFError):
12
+ pass
@@ -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