sonilo-cli 0.10.0__tar.gz → 0.12.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.
- {sonilo_cli-0.10.0 → sonilo_cli-0.12.0}/PKG-INFO +77 -5
- {sonilo_cli-0.10.0 → sonilo_cli-0.12.0}/README.md +74 -2
- {sonilo_cli-0.10.0 → sonilo_cli-0.12.0}/pyproject.toml +2 -2
- {sonilo_cli-0.10.0 → sonilo_cli-0.12.0}/src/sonilo_cli/__init__.py +1 -1
- {sonilo_cli-0.10.0 → sonilo_cli-0.12.0}/src/sonilo_cli/__main__.py +130 -4
- sonilo_cli-0.12.0/src/sonilo_cli/credentials.py +105 -0
- sonilo_cli-0.12.0/src/sonilo_cli/login.py +347 -0
- sonilo_cli-0.12.0/tests/conftest.py +19 -0
- {sonilo_cli-0.10.0 → sonilo_cli-0.12.0}/tests/test_cli.py +189 -0
- sonilo_cli-0.12.0/tests/test_credentials.py +146 -0
- sonilo_cli-0.12.0/tests/test_login.py +366 -0
- {sonilo_cli-0.10.0 → sonilo_cli-0.12.0}/.gitignore +0 -0
- {sonilo_cli-0.10.0 → sonilo_cli-0.12.0}/LICENSE +0 -0
- {sonilo_cli-0.10.0 → sonilo_cli-0.12.0}/tests/__init__.py +0 -0
- {sonilo_cli-0.10.0 → sonilo_cli-0.12.0}/tests/test_context7.py +0 -0
- {sonilo_cli-0.10.0 → sonilo_cli-0.12.0}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: sonilo-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Command-line interface for the Sonilo API: generate music and sound effects from text or video
|
|
5
5
|
Project-URL: Repository, https://github.com/sonilo-ai/sonilo-python
|
|
6
6
|
Author: Sonilo AI
|
|
@@ -8,7 +8,7 @@ License-Expression: MIT
|
|
|
8
8
|
License-File: LICENSE
|
|
9
9
|
Keywords: ai,cli,music,sfx,sonilo,text-to-music,video-to-music
|
|
10
10
|
Requires-Python: >=3.9
|
|
11
|
-
Requires-Dist: sonilo<0.
|
|
11
|
+
Requires-Dist: sonilo<0.14,>=0.13.0
|
|
12
12
|
Provides-Extra: dev
|
|
13
13
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
14
|
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
@@ -22,16 +22,67 @@ Command-line interface for the [Sonilo API](https://github.com/sonilo-ai/sonilo-
|
|
|
22
22
|
|
|
23
23
|
pip install sonilo-cli
|
|
24
24
|
|
|
25
|
-
##
|
|
25
|
+
## Signing in
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Run this once per machine — there is no key to create, paste, or export:
|
|
28
|
+
|
|
29
|
+
sonilo login
|
|
30
|
+
|
|
31
|
+
It prints a one-time code and opens your browser to platform.sonilo.com. Sign
|
|
32
|
+
in, confirm the code matches what the terminal printed, and approve; the CLI is
|
|
33
|
+
waiting on that page and continues by itself. Every command works from then on.
|
|
34
|
+
|
|
35
|
+
Approving mints an ordinary Sonilo API key on your account, named
|
|
36
|
+
`cli: <hostname>` and valid for **90 days**, stored in
|
|
37
|
+
`~/.config/sonilo/credentials.json` (`$XDG_CONFIG_HOME/sonilo/` when that is
|
|
38
|
+
set), owner-readable only. It is visible and revocable at
|
|
39
|
+
[the dashboard](https://platform.sonilo.com/dashboard/api-keys) like any other
|
|
40
|
+
key.
|
|
41
|
+
|
|
42
|
+
sonilo whoami # which account, key prefix, expiry, and which source is active
|
|
43
|
+
sonilo logout # revoke the key server-side, then forget it locally
|
|
44
|
+
|
|
45
|
+
`logout` revokes before forgetting, so a machine that loses the file never
|
|
46
|
+
leaves a live key behind. If the revoke cannot reach the API the credential is
|
|
47
|
+
deliberately kept and you are pointed at the dashboard, rather than being left
|
|
48
|
+
holding a key nothing can revoke.
|
|
49
|
+
|
|
50
|
+
Signing in again while already signed in reports the existing session; use
|
|
51
|
+
`--force` to replace it (that mints a fresh key and revokes the one it
|
|
52
|
+
replaces). When a credential has expired you do not need the flag — `sonilo
|
|
53
|
+
login` treats an expired sign-in as no sign-in. On a machine with no browser,
|
|
54
|
+
add `--no-browser` and approve the printed URL from another device.
|
|
55
|
+
|
|
56
|
+
The credential file is shared with the JS CLI (`npm install -g sonilo-cli`) and
|
|
57
|
+
read by [sonilo-mcp](https://github.com/sonilo-ai/sonilo-mcp) 0.16.0+, so one
|
|
58
|
+
sign-in covers all three — an MCP host config then needs no `env` block at all.
|
|
59
|
+
|
|
60
|
+
## Auth with an API key
|
|
61
|
+
|
|
62
|
+
Signing in is optional. A key from
|
|
63
|
+
[the dashboard](https://platform.sonilo.com/dashboard/api-keys) works exactly as
|
|
64
|
+
it always has, which is what you want for CI, containers, and anything
|
|
65
|
+
non-interactive — there is no browser there to approve with, and a 90-day expiry
|
|
66
|
+
is not something a pipeline should depend on:
|
|
28
67
|
|
|
29
68
|
export SONILO_API_KEY=sk-...
|
|
30
69
|
|
|
31
70
|
or pass `--api-key sk-...` on any command.
|
|
32
71
|
|
|
72
|
+
Credentials resolve most-explicit-first: `--api-key`, then `SONILO_API_KEY`,
|
|
73
|
+
then the stored sign-in. That order is a guarantee, not an accident — an
|
|
74
|
+
exported `SONILO_API_KEY` keeps winning after you upgrade, so adding a `sonilo
|
|
75
|
+
login` on the same machine cannot quietly move your calls to another account.
|
|
76
|
+
|
|
77
|
+
Set `SONILO_API_URL` (or pass `--api-base` to `login`) to point at another
|
|
78
|
+
environment. Credentials are stored per host, so a staging sign-in and a
|
|
79
|
+
production sign-in coexist without overwriting each other.
|
|
80
|
+
|
|
33
81
|
## Commands
|
|
34
82
|
|
|
83
|
+
sonilo login # sign in with your browser, no key needed
|
|
84
|
+
sonilo whoami # show the active account and credential source
|
|
85
|
+
sonilo logout # revoke the stored key and forget it
|
|
35
86
|
sonilo account # plan limits and available services
|
|
36
87
|
sonilo usage --days 7 # usage summary
|
|
37
88
|
sonilo text-to-music --prompt "warm lo-fi piano, rain" --duration 30
|
|
@@ -44,6 +95,9 @@ or pass `--api-key sk-...` on any command.
|
|
|
44
95
|
sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
|
|
45
96
|
sonilo video-to-video-sfx --video clip.mp4 --segments @segments.json --output scored.mp4
|
|
46
97
|
sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
|
|
98
|
+
sonilo audio-ducking --voice interview.mp4 --music-url https://example.com/bed.wav
|
|
99
|
+
# ducks the existing music bed under the voice; a video voice comes back
|
|
100
|
+
# as a new .mp4 with the ducked mix muxed in
|
|
47
101
|
sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
|
|
48
102
|
# writes dubbed.es.mp4 and dubbed.fr.mp4
|
|
49
103
|
sonilo tasks get <task-id>
|
|
@@ -169,6 +223,24 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
|
|
|
169
223
|
or ducking altered the music bed.
|
|
170
224
|
- Both also take `--variants` — see [Variants](#variants) above.
|
|
171
225
|
|
|
226
|
+
### Audio ducking
|
|
227
|
+
|
|
228
|
+
`audio-ducking` mixes an **existing** music bed under an **existing** voice track — nothing is
|
|
229
|
+
generated, so reach for it when the music is fixed or external. (When the music is being generated
|
|
230
|
+
for the same clip anyway, `video-to-sound` or `video-to-music` duck internally as part of that one
|
|
231
|
+
call instead.)
|
|
232
|
+
|
|
233
|
+
sonilo audio-ducking --voice interview.mp4 --music-url https://example.com/bed.wav
|
|
234
|
+
|
|
235
|
+
- Exactly one of `--voice` / `--voice-url` and one of `--music` / `--music-url`; a local file and a
|
|
236
|
+
URL mix freely across the two inputs.
|
|
237
|
+
- The **voice** may be audio or video: a video's own audio track becomes the voice, and the ducked
|
|
238
|
+
mix is muxed back into a new video, so the result is a `.mp4` instead of a `.wav`. The default
|
|
239
|
+
`--output` name follows what came back (`output.wav` or `output.mp4`).
|
|
240
|
+
- The **music** must be audio (`wav, mp3, m4a, aac, ogg, flac`). The API does not detect a video
|
|
241
|
+
there, so the CLI rejects a local video file up front rather than let it be mishandled silently.
|
|
242
|
+
- Each input is capped at 360 seconds server-side.
|
|
243
|
+
|
|
172
244
|
### Dubbing
|
|
173
245
|
|
|
174
246
|
`dubbing` dubs a video into one or more target languages in a single async call:
|
|
@@ -6,16 +6,67 @@ Command-line interface for the [Sonilo API](https://github.com/sonilo-ai/sonilo-
|
|
|
6
6
|
|
|
7
7
|
pip install sonilo-cli
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Signing in
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Run this once per machine — there is no key to create, paste, or export:
|
|
12
|
+
|
|
13
|
+
sonilo login
|
|
14
|
+
|
|
15
|
+
It prints a one-time code and opens your browser to platform.sonilo.com. Sign
|
|
16
|
+
in, confirm the code matches what the terminal printed, and approve; the CLI is
|
|
17
|
+
waiting on that page and continues by itself. Every command works from then on.
|
|
18
|
+
|
|
19
|
+
Approving mints an ordinary Sonilo API key on your account, named
|
|
20
|
+
`cli: <hostname>` and valid for **90 days**, stored in
|
|
21
|
+
`~/.config/sonilo/credentials.json` (`$XDG_CONFIG_HOME/sonilo/` when that is
|
|
22
|
+
set), owner-readable only. It is visible and revocable at
|
|
23
|
+
[the dashboard](https://platform.sonilo.com/dashboard/api-keys) like any other
|
|
24
|
+
key.
|
|
25
|
+
|
|
26
|
+
sonilo whoami # which account, key prefix, expiry, and which source is active
|
|
27
|
+
sonilo logout # revoke the key server-side, then forget it locally
|
|
28
|
+
|
|
29
|
+
`logout` revokes before forgetting, so a machine that loses the file never
|
|
30
|
+
leaves a live key behind. If the revoke cannot reach the API the credential is
|
|
31
|
+
deliberately kept and you are pointed at the dashboard, rather than being left
|
|
32
|
+
holding a key nothing can revoke.
|
|
33
|
+
|
|
34
|
+
Signing in again while already signed in reports the existing session; use
|
|
35
|
+
`--force` to replace it (that mints a fresh key and revokes the one it
|
|
36
|
+
replaces). When a credential has expired you do not need the flag — `sonilo
|
|
37
|
+
login` treats an expired sign-in as no sign-in. On a machine with no browser,
|
|
38
|
+
add `--no-browser` and approve the printed URL from another device.
|
|
39
|
+
|
|
40
|
+
The credential file is shared with the JS CLI (`npm install -g sonilo-cli`) and
|
|
41
|
+
read by [sonilo-mcp](https://github.com/sonilo-ai/sonilo-mcp) 0.16.0+, so one
|
|
42
|
+
sign-in covers all three — an MCP host config then needs no `env` block at all.
|
|
43
|
+
|
|
44
|
+
## Auth with an API key
|
|
45
|
+
|
|
46
|
+
Signing in is optional. A key from
|
|
47
|
+
[the dashboard](https://platform.sonilo.com/dashboard/api-keys) works exactly as
|
|
48
|
+
it always has, which is what you want for CI, containers, and anything
|
|
49
|
+
non-interactive — there is no browser there to approve with, and a 90-day expiry
|
|
50
|
+
is not something a pipeline should depend on:
|
|
12
51
|
|
|
13
52
|
export SONILO_API_KEY=sk-...
|
|
14
53
|
|
|
15
54
|
or pass `--api-key sk-...` on any command.
|
|
16
55
|
|
|
56
|
+
Credentials resolve most-explicit-first: `--api-key`, then `SONILO_API_KEY`,
|
|
57
|
+
then the stored sign-in. That order is a guarantee, not an accident — an
|
|
58
|
+
exported `SONILO_API_KEY` keeps winning after you upgrade, so adding a `sonilo
|
|
59
|
+
login` on the same machine cannot quietly move your calls to another account.
|
|
60
|
+
|
|
61
|
+
Set `SONILO_API_URL` (or pass `--api-base` to `login`) to point at another
|
|
62
|
+
environment. Credentials are stored per host, so a staging sign-in and a
|
|
63
|
+
production sign-in coexist without overwriting each other.
|
|
64
|
+
|
|
17
65
|
## Commands
|
|
18
66
|
|
|
67
|
+
sonilo login # sign in with your browser, no key needed
|
|
68
|
+
sonilo whoami # show the active account and credential source
|
|
69
|
+
sonilo logout # revoke the stored key and forget it
|
|
19
70
|
sonilo account # plan limits and available services
|
|
20
71
|
sonilo usage --days 7 # usage summary
|
|
21
72
|
sonilo text-to-music --prompt "warm lo-fi piano, rain" --duration 30
|
|
@@ -28,6 +79,9 @@ or pass `--api-key sk-...` on any command.
|
|
|
28
79
|
sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
|
|
29
80
|
sonilo video-to-video-sfx --video clip.mp4 --segments @segments.json --output scored.mp4
|
|
30
81
|
sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
|
|
82
|
+
sonilo audio-ducking --voice interview.mp4 --music-url https://example.com/bed.wav
|
|
83
|
+
# ducks the existing music bed under the voice; a video voice comes back
|
|
84
|
+
# as a new .mp4 with the ducked mix muxed in
|
|
31
85
|
sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
|
|
32
86
|
# writes dubbed.es.mp4 and dubbed.fr.mp4
|
|
33
87
|
sonilo tasks get <task-id>
|
|
@@ -153,6 +207,24 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
|
|
|
153
207
|
or ducking altered the music bed.
|
|
154
208
|
- Both also take `--variants` — see [Variants](#variants) above.
|
|
155
209
|
|
|
210
|
+
### Audio ducking
|
|
211
|
+
|
|
212
|
+
`audio-ducking` mixes an **existing** music bed under an **existing** voice track — nothing is
|
|
213
|
+
generated, so reach for it when the music is fixed or external. (When the music is being generated
|
|
214
|
+
for the same clip anyway, `video-to-sound` or `video-to-music` duck internally as part of that one
|
|
215
|
+
call instead.)
|
|
216
|
+
|
|
217
|
+
sonilo audio-ducking --voice interview.mp4 --music-url https://example.com/bed.wav
|
|
218
|
+
|
|
219
|
+
- Exactly one of `--voice` / `--voice-url` and one of `--music` / `--music-url`; a local file and a
|
|
220
|
+
URL mix freely across the two inputs.
|
|
221
|
+
- The **voice** may be audio or video: a video's own audio track becomes the voice, and the ducked
|
|
222
|
+
mix is muxed back into a new video, so the result is a `.mp4` instead of a `.wav`. The default
|
|
223
|
+
`--output` name follows what came back (`output.wav` or `output.mp4`).
|
|
224
|
+
- The **music** must be audio (`wav, mp3, m4a, aac, ogg, flac`). The API does not detect a video
|
|
225
|
+
there, so the CLI rejects a local video file up front rather than let it be mishandled silently.
|
|
226
|
+
- Each input is capped at 360 seconds server-side.
|
|
227
|
+
|
|
156
228
|
### Dubbing
|
|
157
229
|
|
|
158
230
|
`dubbing` dubs a video into one or more target languages in a single async call:
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sonilo-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.12.0"
|
|
8
8
|
description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.9"
|
|
12
12
|
authors = [{ name = "Sonilo AI" }]
|
|
13
|
-
dependencies = ["sonilo>=0.
|
|
13
|
+
dependencies = ["sonilo>=0.13.0,<0.14"]
|
|
14
14
|
keywords = ["sonilo", "cli", "music", "sfx", "text-to-music", "video-to-music", "ai"]
|
|
15
15
|
|
|
16
16
|
[project.urls]
|
|
@@ -12,7 +12,8 @@ from urllib.parse import urlparse
|
|
|
12
12
|
from sonilo import Sonilo
|
|
13
13
|
from sonilo.errors import APIError, SoniloError
|
|
14
14
|
|
|
15
|
-
from sonilo_cli import __version__
|
|
15
|
+
from sonilo_cli import __version__, credentials
|
|
16
|
+
from sonilo_cli.login import LoginError, cmd_login, cmd_logout, cmd_whoami
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class _Parser(argparse.ArgumentParser):
|
|
@@ -196,12 +197,34 @@ def _segments(args: argparse.Namespace) -> Optional[List[Dict[str, Any]]]:
|
|
|
196
197
|
return parse_segments(args.segments, args.segments_shape, args.command)
|
|
197
198
|
|
|
198
199
|
|
|
199
|
-
def
|
|
200
|
+
def _expired(iso: str) -> bool:
|
|
201
|
+
"""Whether a stored credential's expiry has passed.
|
|
202
|
+
|
|
203
|
+
Reuses login's parser so both paths agree on what "expired" means, and on
|
|
204
|
+
treating an unparseable timestamp as *not* expired — the API is the
|
|
205
|
+
authority on whether a key works, and refusing to run over a formatting
|
|
206
|
+
quirk would be worse than letting a dead key earn its own 401.
|
|
207
|
+
"""
|
|
208
|
+
from sonilo_cli.login import _is_expired
|
|
209
|
+
|
|
210
|
+
return _is_expired(iso)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def build_client(api_key: Optional[str], path: Optional[Path] = None) -> Sonilo:
|
|
214
|
+
api_base = os.environ.get("SONILO_API_URL", credentials.DEFAULT_API_BASE).rstrip("/")
|
|
215
|
+
# Order is a compatibility promise: an exported SONILO_API_KEY keeps winning
|
|
216
|
+
# over a stored credential, so upgrading never moves someone's account.
|
|
200
217
|
key = api_key or os.environ.get("SONILO_API_KEY")
|
|
218
|
+
if not key:
|
|
219
|
+
stored = credentials.read_credential(api_base, path)
|
|
220
|
+
if stored is not None:
|
|
221
|
+
if _expired(stored.get("expires_at", "")):
|
|
222
|
+
_fail('your sonilo login expired — run "sonilo login" again')
|
|
223
|
+
key = stored["api_key"]
|
|
201
224
|
if not key:
|
|
202
225
|
_fail(
|
|
203
|
-
|
|
204
|
-
"SONILO_API_KEY environment variable"
|
|
226
|
+
'no API key — run "sonilo login", or pass --api-key <key>, or set '
|
|
227
|
+
"the SONILO_API_KEY environment variable"
|
|
205
228
|
)
|
|
206
229
|
# Identify as the CLI rather than inheriting the SDK's own name, so CLI
|
|
207
230
|
# traffic stays separable from direct SDK use in server-side analytics.
|
|
@@ -451,6 +474,42 @@ def cmd_video_to_video_sfx(client: Sonilo, args: argparse.Namespace) -> None:
|
|
|
451
474
|
)
|
|
452
475
|
|
|
453
476
|
|
|
477
|
+
# The music-bed extensions audio-ducking accepts for a local --music file —
|
|
478
|
+
# the MCP server's _AUDIO_EXTS, kept identical so the two surfaces accept and
|
|
479
|
+
# reject the same files. A whitelist (not a video-extension blacklist) because
|
|
480
|
+
# the failure it guards against is silent: the API never probes the music
|
|
481
|
+
# input for a video stream, so a video sent there is mishandled without an
|
|
482
|
+
# error. The voice input needs no such guard — it may legitimately be audio
|
|
483
|
+
# or video, and the API probes it.
|
|
484
|
+
_MUSIC_AUDIO_EXTS = (".wav", ".mp3", ".m4a", ".aac", ".ogg", ".flac")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def cmd_audio_ducking(client: Sonilo, args: argparse.Namespace) -> None:
|
|
488
|
+
if args.music is not None and Path(args.music).suffix.lower() not in _MUSIC_AUDIO_EXTS:
|
|
489
|
+
_fail(
|
|
490
|
+
f"--music must be an audio file ({', '.join(_MUSIC_AUDIO_EXTS)}) — "
|
|
491
|
+
"the API does not detect a video here and would mishandle it. "
|
|
492
|
+
"The voice input is the one that may be a video."
|
|
493
|
+
)
|
|
494
|
+
result = client.audio_ducking.generate(
|
|
495
|
+
voice=args.voice,
|
|
496
|
+
voice_url=args.voice_url,
|
|
497
|
+
music=args.music,
|
|
498
|
+
music_url=args.music_url,
|
|
499
|
+
)
|
|
500
|
+
# Default output name follows what actually came back: a .wav, or a .mp4
|
|
501
|
+
# (ducked mix re-muxed in) when the voice input was a video. An explicit
|
|
502
|
+
# --output is used verbatim, same as the video-out commands.
|
|
503
|
+
out = args.output
|
|
504
|
+
if out is None:
|
|
505
|
+
ext = Path(urlparse(result.output_url or "").path).suffix
|
|
506
|
+
if not ext:
|
|
507
|
+
ext = ".mp4" if result.output_type == "video" else ".wav"
|
|
508
|
+
out = f"output{ext}"
|
|
509
|
+
path = result.save(out)
|
|
510
|
+
_wrote(path, path.stat().st_size)
|
|
511
|
+
|
|
512
|
+
|
|
454
513
|
# Matched to the dubbing backend's own ceiling: it polls its pipeline for up
|
|
455
514
|
# to 7200s (2 hours), so anything shorter abandons a job the user has already
|
|
456
515
|
# been charged for. The SDK's generic DEFAULT_WAIT_TIMEOUT of 600s is far too
|
|
@@ -577,6 +636,33 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
577
636
|
_add_global(parser)
|
|
578
637
|
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
579
638
|
|
|
639
|
+
# login/logout/whoami run *before* a client exists — build_client exits when
|
|
640
|
+
# no key is available, which is the exact situation login is for. The
|
|
641
|
+
# needs_client=False flag is what main() checks to skip that.
|
|
642
|
+
p_login = sub.add_parser("login", help="Sign in and store an API key for future commands")
|
|
643
|
+
_add_global(p_login)
|
|
644
|
+
p_login.add_argument("--force", action="store_true",
|
|
645
|
+
help="Sign in again even if already signed in, replacing the stored key.")
|
|
646
|
+
p_login.add_argument("--no-browser", action="store_true", dest="no_browser",
|
|
647
|
+
help="Print the URL instead of opening a browser.")
|
|
648
|
+
p_login.add_argument("--api-base", dest="api_base", default=None,
|
|
649
|
+
help="Sign in against a non-default API base URL.")
|
|
650
|
+
p_login.set_defaults(func=lambda client, args: cmd_login(args), needs_client=False)
|
|
651
|
+
|
|
652
|
+
p_logout = sub.add_parser("logout", help="Revoke the stored key and forget it locally")
|
|
653
|
+
_add_global(p_logout)
|
|
654
|
+
p_logout.add_argument("--local-only", action="store_true", dest="local_only",
|
|
655
|
+
help="Forget the credential without revoking the key server-side.")
|
|
656
|
+
p_logout.add_argument("--api-base", dest="api_base", default=None,
|
|
657
|
+
help="Act on the credential for a non-default API base URL.")
|
|
658
|
+
p_logout.set_defaults(func=lambda client, args: cmd_logout(args), needs_client=False)
|
|
659
|
+
|
|
660
|
+
p_whoami = sub.add_parser("whoami", help="Show which account and key are currently active")
|
|
661
|
+
_add_global(p_whoami)
|
|
662
|
+
p_whoami.add_argument("--api-base", dest="api_base", default=None,
|
|
663
|
+
help="Inspect the credential for a non-default API base URL.")
|
|
664
|
+
p_whoami.set_defaults(func=lambda client, args: cmd_whoami(args), needs_client=False)
|
|
665
|
+
|
|
580
666
|
p_account = sub.add_parser("account", help="Show plan limits and available services")
|
|
581
667
|
_add_global(p_account)
|
|
582
668
|
p_account.set_defaults(func=cmd_account)
|
|
@@ -740,6 +826,38 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
740
826
|
_add_variants(p_v2vsd)
|
|
741
827
|
p_v2vsd.set_defaults(func=cmd_video_to_video_sound)
|
|
742
828
|
|
|
829
|
+
p_duck = sub.add_parser(
|
|
830
|
+
"audio-ducking", help="Duck an existing music bed under a voice track"
|
|
831
|
+
)
|
|
832
|
+
_add_global(p_duck)
|
|
833
|
+
voice_group = p_duck.add_mutually_exclusive_group(required=True)
|
|
834
|
+
voice_group.add_argument(
|
|
835
|
+
"--voice", default=None,
|
|
836
|
+
help="Local voice track. May be audio or video: a video's own audio "
|
|
837
|
+
"track becomes the voice, and the ducked mix is muxed back into "
|
|
838
|
+
"a new video.",
|
|
839
|
+
)
|
|
840
|
+
voice_group.add_argument(
|
|
841
|
+
"--voice-url", dest="voice_url", default=None,
|
|
842
|
+
help="Remote voice audio/video URL.",
|
|
843
|
+
)
|
|
844
|
+
music_group = p_duck.add_mutually_exclusive_group(required=True)
|
|
845
|
+
music_group.add_argument(
|
|
846
|
+
"--music", default=None,
|
|
847
|
+
help="Local music bed. Audio only (wav, mp3, m4a, aac, ogg, flac) — "
|
|
848
|
+
"the API does not detect a video here, so the CLI rejects one.",
|
|
849
|
+
)
|
|
850
|
+
music_group.add_argument(
|
|
851
|
+
"--music-url", dest="music_url", default=None,
|
|
852
|
+
help="Remote music audio URL.",
|
|
853
|
+
)
|
|
854
|
+
p_duck.add_argument(
|
|
855
|
+
"--output", default=None,
|
|
856
|
+
help="Where to save the result. Default: output.wav, or output.mp4 "
|
|
857
|
+
"when the voice input was a video.",
|
|
858
|
+
)
|
|
859
|
+
p_duck.set_defaults(func=cmd_audio_ducking)
|
|
860
|
+
|
|
743
861
|
p_dub = sub.add_parser("dubbing", help="Dub a video into other languages")
|
|
744
862
|
_add_global(p_dub)
|
|
745
863
|
_add_video_source(p_dub)
|
|
@@ -789,6 +907,14 @@ def main(argv: Optional[List[str]] = None) -> None:
|
|
|
789
907
|
func = getattr(args, "func", None)
|
|
790
908
|
if func is None:
|
|
791
909
|
parser.error("missing command (try `sonilo --help`)")
|
|
910
|
+
if getattr(args, "needs_client", True) is False:
|
|
911
|
+
try:
|
|
912
|
+
func(None, args) # type: ignore[arg-type]
|
|
913
|
+
except LoginError as exc:
|
|
914
|
+
# Expected outcomes — denied, expired, throttled, offline — read as
|
|
915
|
+
# `sonilo: <message>`, never as a traceback.
|
|
916
|
+
_fail(str(exc))
|
|
917
|
+
return
|
|
792
918
|
client = build_client(getattr(args, "api_key", None))
|
|
793
919
|
try:
|
|
794
920
|
func(client, args)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The credential file written by `sonilo login`.
|
|
2
|
+
|
|
3
|
+
Format and location are shared with the JS CLI (`packages/cli/src/credentials.ts`
|
|
4
|
+
in sonilo-js) and read by sonilo-mcp, so the path, the JSON shape and the
|
|
5
|
+
0600/0700 modes are a cross-repo contract — change them in all three or not at
|
|
6
|
+
all.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
DEFAULT_API_BASE = "https://api.sonilo.com"
|
|
16
|
+
FORMAT_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CredentialFormatError(Exception):
|
|
20
|
+
"""The file was written by a newer client than this one understands."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def credentials_path() -> Path:
|
|
24
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
25
|
+
root = Path(base) if base else Path(os.environ.get("HOME", str(Path.home()))) / ".config"
|
|
26
|
+
return root / "sonilo" / "credentials.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _empty() -> Dict[str, Any]:
|
|
30
|
+
return {"version": FORMAT_VERSION, "credentials": {}}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load(path: Path) -> Dict[str, Any]:
|
|
34
|
+
try:
|
|
35
|
+
raw = path.read_text(encoding="utf-8")
|
|
36
|
+
except OSError:
|
|
37
|
+
return _empty()
|
|
38
|
+
try:
|
|
39
|
+
parsed = json.loads(raw)
|
|
40
|
+
except ValueError:
|
|
41
|
+
# Corrupt file reads as "no credential"; `login` will replace it.
|
|
42
|
+
return _empty()
|
|
43
|
+
if not isinstance(parsed, dict):
|
|
44
|
+
return _empty()
|
|
45
|
+
version = parsed.get("version")
|
|
46
|
+
if isinstance(version, int) and version > FORMAT_VERSION:
|
|
47
|
+
raise CredentialFormatError(
|
|
48
|
+
f"{path} was written by a newer sonilo CLI (format {version}). "
|
|
49
|
+
"Upgrade the CLI or delete the file."
|
|
50
|
+
)
|
|
51
|
+
creds = parsed.get("credentials")
|
|
52
|
+
return {
|
|
53
|
+
"version": FORMAT_VERSION,
|
|
54
|
+
"credentials": creds if isinstance(creds, dict) else {},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _save(path: Path, data: Dict[str, Any]) -> None:
|
|
59
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
os.chmod(path.parent, 0o700)
|
|
61
|
+
tmp = path.with_name(path.name + ".{}.tmp".format(os.getpid()))
|
|
62
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
63
|
+
os.chmod(tmp, 0o600)
|
|
64
|
+
try:
|
|
65
|
+
# Rename last: an interrupted write can never replace a whole
|
|
66
|
+
# credential with half of one.
|
|
67
|
+
os.replace(tmp, path)
|
|
68
|
+
except OSError:
|
|
69
|
+
# Never leave the temp file behind holding a live key.
|
|
70
|
+
try:
|
|
71
|
+
tmp.unlink()
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
raise
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_credential(api_base: str, path: Optional[Path] = None) -> Optional[Dict[str, Any]]:
|
|
78
|
+
entry = _load(path or credentials_path())["credentials"].get(api_base)
|
|
79
|
+
if isinstance(entry, dict) and isinstance(entry.get("api_key"), str) and entry["api_key"]:
|
|
80
|
+
return entry
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def write_credential(
|
|
85
|
+
api_base: str, cred: Dict[str, Any], path: Optional[Path] = None
|
|
86
|
+
) -> None:
|
|
87
|
+
target = path or credentials_path()
|
|
88
|
+
data = _load(target)
|
|
89
|
+
data["credentials"][api_base] = cred
|
|
90
|
+
_save(target, data)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def remove_credential(api_base: str, path: Optional[Path] = None) -> None:
|
|
94
|
+
target = path or credentials_path()
|
|
95
|
+
data = _load(target)
|
|
96
|
+
if api_base not in data["credentials"]:
|
|
97
|
+
return
|
|
98
|
+
del data["credentials"][api_base]
|
|
99
|
+
if not data["credentials"]:
|
|
100
|
+
try:
|
|
101
|
+
target.unlink()
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
return
|
|
105
|
+
_save(target, data)
|