flowmusic-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowmusic_cli/__init__.py +3 -0
- flowmusic_cli/__main__.py +4 -0
- flowmusic_cli/api/__init__.py +0 -0
- flowmusic_cli/api/client.py +328 -0
- flowmusic_cli/api/download.py +136 -0
- flowmusic_cli/api/generate.py +387 -0
- flowmusic_cli/api/sse.py +49 -0
- flowmusic_cli/auth/__init__.py +0 -0
- flowmusic_cli/auth/browser_login.py +118 -0
- flowmusic_cli/auth/session.py +161 -0
- flowmusic_cli/auth/supabase.py +74 -0
- flowmusic_cli/cli.py +665 -0
- flowmusic_cli/config.py +64 -0
- flowmusic_cli/errors.py +100 -0
- flowmusic_cli/mcp_server.py +168 -0
- flowmusic_cli/output.py +46 -0
- flowmusic_cli-0.1.0.dist-info/METADATA +227 -0
- flowmusic_cli-0.1.0.dist-info/RECORD +21 -0
- flowmusic_cli-0.1.0.dist-info/WHEEL +4 -0
- flowmusic_cli-0.1.0.dist-info/entry_points.txt +3 -0
- flowmusic_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""HTTP client for flowmusic.app's backend (reached through the site's ``/__api`` proxy)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import mimetypes
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from flowmusic_cli.api.sse import SSEEvent, parse_sse
|
|
16
|
+
from flowmusic_cli.auth.session import SessionStore
|
|
17
|
+
from flowmusic_cli.auth.supabase import TokenManager
|
|
18
|
+
from flowmusic_cli.config import DEFAULT_MODEL, Settings
|
|
19
|
+
from flowmusic_cli.errors import (
|
|
20
|
+
ApiError,
|
|
21
|
+
AuthExpired,
|
|
22
|
+
Blocked,
|
|
23
|
+
FmusicError,
|
|
24
|
+
NetworkError,
|
|
25
|
+
NoAccess,
|
|
26
|
+
OutOfCredits,
|
|
27
|
+
RateLimited,
|
|
28
|
+
ServerError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
UPLOAD_AUDIO_INSTRUCTIONS = (
|
|
32
|
+
"Tool indicating that the user has uploaded an audio file.\n\n"
|
|
33
|
+
"NOTE: The agent does not have access to this tool directly."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _detail(r: httpx.Response) -> str:
|
|
38
|
+
try:
|
|
39
|
+
body = r.json()
|
|
40
|
+
except ValueError:
|
|
41
|
+
return (r.text or r.reason_phrase or "")[:300]
|
|
42
|
+
if isinstance(body, dict):
|
|
43
|
+
d = body.get("detail") or body.get("error") or body.get("message") or body
|
|
44
|
+
if isinstance(d, (dict, list)):
|
|
45
|
+
return str(d)[:300]
|
|
46
|
+
return str(d)
|
|
47
|
+
return str(body)[:300]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def raise_for_response(r: httpx.Response) -> None:
|
|
51
|
+
if r.status_code < 400:
|
|
52
|
+
return
|
|
53
|
+
detail = _detail(r)
|
|
54
|
+
low = detail.lower()
|
|
55
|
+
s = r.status_code
|
|
56
|
+
if s == 401:
|
|
57
|
+
raise AuthExpired(detail, status=s)
|
|
58
|
+
if s == 402 or "credit" in low or "insufficient" in low:
|
|
59
|
+
raise OutOfCredits(detail, status=s)
|
|
60
|
+
if s == 403:
|
|
61
|
+
raise NoAccess(detail, status=s)
|
|
62
|
+
if s == 429:
|
|
63
|
+
raise RateLimited(detail, status=s)
|
|
64
|
+
if s == 451 or "moderat" in low or "policy" in low or "flagged" in low:
|
|
65
|
+
raise Blocked(detail, status=s)
|
|
66
|
+
if s >= 500:
|
|
67
|
+
raise ServerError(f"HTTP {s}: {detail}", status=s)
|
|
68
|
+
raise ApiError(f"HTTP {s}: {detail}", status=s)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FlowMusicClient:
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
settings: Settings,
|
|
75
|
+
profile: str | None = None,
|
|
76
|
+
*,
|
|
77
|
+
http: httpx.Client | None = None,
|
|
78
|
+
max_retries: int = 3,
|
|
79
|
+
):
|
|
80
|
+
self.settings = settings
|
|
81
|
+
self.store = SessionStore(settings.profile_dir(profile))
|
|
82
|
+
self.http = http or httpx.Client(
|
|
83
|
+
timeout=httpx.Timeout(settings.timeout, connect=20),
|
|
84
|
+
headers={
|
|
85
|
+
"User-Agent": settings.user_agent,
|
|
86
|
+
"Accept": "application/json",
|
|
87
|
+
"Origin": settings.site_url,
|
|
88
|
+
"Referer": settings.site_url + "/",
|
|
89
|
+
},
|
|
90
|
+
follow_redirects=False,
|
|
91
|
+
)
|
|
92
|
+
self.tokens = TokenManager(settings, self.store, self.http)
|
|
93
|
+
self.max_retries = max_retries
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------ core
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
self.http.close()
|
|
98
|
+
|
|
99
|
+
def __enter__(self) -> FlowMusicClient:
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def __exit__(self, *exc) -> None:
|
|
103
|
+
self.close()
|
|
104
|
+
|
|
105
|
+
def _url(self, path: str) -> str:
|
|
106
|
+
return self.settings.api_base + "/" + path.lstrip("/")
|
|
107
|
+
|
|
108
|
+
def _send(self, method: str, path: str, *, stream: bool = False, **kw) -> httpx.Response:
|
|
109
|
+
"""Send with auth, one forced refresh on 401, and backoff on 429/5xx/network errors."""
|
|
110
|
+
refreshed = False
|
|
111
|
+
attempt = 0
|
|
112
|
+
while True:
|
|
113
|
+
headers = dict(kw.pop("headers", None) or {})
|
|
114
|
+
headers["Authorization"] = f"Bearer {self.tokens.access_token()}"
|
|
115
|
+
try:
|
|
116
|
+
req = self.http.build_request(method, self._url(path), headers=headers, **kw)
|
|
117
|
+
r = self.http.send(req, stream=stream)
|
|
118
|
+
except httpx.TimeoutException as e:
|
|
119
|
+
err: FmusicError = NetworkError(f"Timed out talking to flowmusic.app ({e.__class__.__name__}).")
|
|
120
|
+
r = None
|
|
121
|
+
except httpx.HTTPError as e:
|
|
122
|
+
err = NetworkError(str(e) or e.__class__.__name__)
|
|
123
|
+
r = None
|
|
124
|
+
if r is not None:
|
|
125
|
+
if r.status_code == 401 and not refreshed:
|
|
126
|
+
r.close()
|
|
127
|
+
refreshed = True
|
|
128
|
+
self.tokens.access_token(force_refresh=True)
|
|
129
|
+
kw["headers"] = headers
|
|
130
|
+
continue
|
|
131
|
+
if r.status_code < 400:
|
|
132
|
+
return r
|
|
133
|
+
if stream:
|
|
134
|
+
r.read()
|
|
135
|
+
try:
|
|
136
|
+
raise_for_response(r)
|
|
137
|
+
except FmusicError as e:
|
|
138
|
+
err = e
|
|
139
|
+
if not err.retryable or attempt >= self.max_retries:
|
|
140
|
+
raise err
|
|
141
|
+
delay = min(2 ** attempt * 2, 30)
|
|
142
|
+
if r is not None and r.headers.get("Retry-After", "").isdigit():
|
|
143
|
+
delay = min(int(r.headers["Retry-After"]), 60)
|
|
144
|
+
time.sleep(delay)
|
|
145
|
+
attempt += 1
|
|
146
|
+
kw["headers"] = headers
|
|
147
|
+
|
|
148
|
+
def request(self, method: str, path: str, **kw) -> Any:
|
|
149
|
+
r = self._send(method, path, **kw)
|
|
150
|
+
if not r.content:
|
|
151
|
+
return None
|
|
152
|
+
ctype = r.headers.get("content-type", "")
|
|
153
|
+
return r.json() if "json" in ctype else r.text
|
|
154
|
+
|
|
155
|
+
def get(self, path: str, **params) -> Any:
|
|
156
|
+
return self.request("GET", path, params={k: v for k, v in params.items() if v is not None})
|
|
157
|
+
|
|
158
|
+
def post(self, path: str, body: Any = None, **kw) -> Any:
|
|
159
|
+
return self.request("POST", path, json=body, **kw)
|
|
160
|
+
|
|
161
|
+
# -------------------------------------------------------------- account
|
|
162
|
+
def me(self) -> dict:
|
|
163
|
+
return self.get("/users/me")
|
|
164
|
+
|
|
165
|
+
def subscription(self) -> dict:
|
|
166
|
+
return self.get("/subscription")
|
|
167
|
+
|
|
168
|
+
def credits(self) -> dict:
|
|
169
|
+
return self.get("/billing/credits")
|
|
170
|
+
|
|
171
|
+
def models(self) -> list[dict]:
|
|
172
|
+
return (self.get("/models") or {}).get("models", [])
|
|
173
|
+
|
|
174
|
+
def access_granted(self) -> dict:
|
|
175
|
+
return self.get("/producer/access-granted")
|
|
176
|
+
|
|
177
|
+
# -------------------------------------------------------- conversations
|
|
178
|
+
def client_context(self, model: str = DEFAULT_MODEL) -> dict:
|
|
179
|
+
return {
|
|
180
|
+
"song_queue": [],
|
|
181
|
+
"selected_model": model,
|
|
182
|
+
"lyrics_id_map": {},
|
|
183
|
+
"ghostwriter_version": "standard",
|
|
184
|
+
"onboarding_variant": None,
|
|
185
|
+
"client_platform": "web",
|
|
186
|
+
"clarifying_questions": {
|
|
187
|
+
"supports_question_options": False,
|
|
188
|
+
"supports_multi_question_options": False,
|
|
189
|
+
"arm": "questionnaire",
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
def send_message(
|
|
194
|
+
self,
|
|
195
|
+
parts: list[dict],
|
|
196
|
+
*,
|
|
197
|
+
conversation_id: str | None = None,
|
|
198
|
+
model: str = DEFAULT_MODEL,
|
|
199
|
+
mode: str = "standard",
|
|
200
|
+
) -> str:
|
|
201
|
+
body: dict[str, Any] = {
|
|
202
|
+
"parts": parts,
|
|
203
|
+
"client_context": self.client_context(model),
|
|
204
|
+
"model_name": f"producer:{mode}",
|
|
205
|
+
"mode": mode,
|
|
206
|
+
}
|
|
207
|
+
if conversation_id:
|
|
208
|
+
body["conversation_id"] = conversation_id
|
|
209
|
+
return self.post("/conversation", body)["job_id"]
|
|
210
|
+
|
|
211
|
+
def send_tool_call(
|
|
212
|
+
self,
|
|
213
|
+
tool_name: str,
|
|
214
|
+
args: dict,
|
|
215
|
+
*,
|
|
216
|
+
conversation_id: str | None = None,
|
|
217
|
+
model: str = DEFAULT_MODEL,
|
|
218
|
+
) -> str:
|
|
219
|
+
body = {
|
|
220
|
+
"conversation_id": conversation_id,
|
|
221
|
+
"part": {
|
|
222
|
+
"tool_name": tool_name,
|
|
223
|
+
"args": args,
|
|
224
|
+
"tool_call_id": str(uuid.uuid4()),
|
|
225
|
+
"part_kind": "tool-call",
|
|
226
|
+
},
|
|
227
|
+
"client_context": {"ghostwriter_version": "standard", "selected_model": model},
|
|
228
|
+
}
|
|
229
|
+
return self.post("/producer/tool-call", body)["job_id"]
|
|
230
|
+
|
|
231
|
+
@contextmanager
|
|
232
|
+
def _stream(self, job_id: str, last_id: str) -> Iterator[httpx.Response]:
|
|
233
|
+
r = self._send(
|
|
234
|
+
"GET",
|
|
235
|
+
f"/messages/{job_id}/stream",
|
|
236
|
+
params={"last_id": last_id, "tool_progress": "true"},
|
|
237
|
+
headers={"Accept": "text/event-stream"},
|
|
238
|
+
timeout=httpx.Timeout(self.settings.timeout, read=120),
|
|
239
|
+
stream=True,
|
|
240
|
+
)
|
|
241
|
+
try:
|
|
242
|
+
yield r
|
|
243
|
+
finally:
|
|
244
|
+
r.close()
|
|
245
|
+
|
|
246
|
+
def stream_job(self, job_id: str, *, max_reconnects: int = 5) -> Iterator[SSEEvent]:
|
|
247
|
+
"""Yield SSE events for a job, transparently resuming with ``last_id`` on drops."""
|
|
248
|
+
last_id = "0"
|
|
249
|
+
reconnects = 0
|
|
250
|
+
while True:
|
|
251
|
+
try:
|
|
252
|
+
with self._stream(job_id, last_id) as r:
|
|
253
|
+
for ev in parse_sse(r.iter_lines()):
|
|
254
|
+
if ev.id:
|
|
255
|
+
last_id = ev.id
|
|
256
|
+
yield ev
|
|
257
|
+
if ev.event == "final":
|
|
258
|
+
return
|
|
259
|
+
return # server closed the stream cleanly
|
|
260
|
+
except (httpx.HTTPError, NetworkError) as e:
|
|
261
|
+
reconnects += 1
|
|
262
|
+
if reconnects > max_reconnects:
|
|
263
|
+
raise NetworkError(f"Lost the generation stream: {e}") from e
|
|
264
|
+
time.sleep(min(2 * reconnects, 10))
|
|
265
|
+
|
|
266
|
+
def list_conversations(self, limit: int = 20, offset: int = 0) -> list[dict]:
|
|
267
|
+
return self.get("/conversations", limit=limit, offset=offset) or []
|
|
268
|
+
|
|
269
|
+
def get_conversation(self, conversation_id: str) -> dict:
|
|
270
|
+
return self.get(f"/conversations/{conversation_id}")
|
|
271
|
+
|
|
272
|
+
def running_job(self, conversation_id: str) -> dict:
|
|
273
|
+
return self.get(f"/conversations/{conversation_id}/running-job")
|
|
274
|
+
|
|
275
|
+
def stop_conversation(self, conversation_id: str) -> Any:
|
|
276
|
+
return self.post(f"/conversations/{conversation_id}/stop")
|
|
277
|
+
|
|
278
|
+
# ----------------------------------------------------------------- clips
|
|
279
|
+
def song_status(self, operation_id: str) -> dict:
|
|
280
|
+
return self.get(f"/audio-create-song-status/{operation_id}")
|
|
281
|
+
|
|
282
|
+
def get_clips(self, clip_ids: list[str]) -> dict[str, dict]:
|
|
283
|
+
if not clip_ids:
|
|
284
|
+
return {}
|
|
285
|
+
return (self.post("/clips", {"clip_ids": list(clip_ids)}) or {}).get("clips", {})
|
|
286
|
+
|
|
287
|
+
def get_clip(self, clip_id: str) -> dict:
|
|
288
|
+
clip = self.get_clips([clip_id]).get(clip_id)
|
|
289
|
+
# Deleted clips come back as an empty stub ({"id": ...}, everything else null).
|
|
290
|
+
if not clip or clip.get("deleted_at") or not clip.get("created_at"):
|
|
291
|
+
raise ApiError(f"Clip {clip_id} not found.", status=404)
|
|
292
|
+
return clip
|
|
293
|
+
|
|
294
|
+
def my_clips(self, limit: int = 20, offset: int = 0) -> list[dict]:
|
|
295
|
+
return (self.get("/clips/auth-user", limit=limit, offset=offset) or {}).get("clips", [])
|
|
296
|
+
|
|
297
|
+
def delete_clips(self, clip_ids: list[str]) -> Any:
|
|
298
|
+
return self.post("/clips/delete", {"clip_ids": list(clip_ids)})
|
|
299
|
+
|
|
300
|
+
def update_clip(self, clip_id: str, **fields) -> Any:
|
|
301
|
+
return self.request("PATCH", f"/clips/{clip_id}", json=fields)
|
|
302
|
+
|
|
303
|
+
def stems(self, clip_id: str) -> dict[str, str]:
|
|
304
|
+
"""Split (on first call) and return ``{stem_name: base64_audio}``."""
|
|
305
|
+
r = self._send("GET", f"/stems/clip/{clip_id}", timeout=httpx.Timeout(600, connect=20))
|
|
306
|
+
return (r.json() or {}).get("stems", {})
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------- upload
|
|
309
|
+
def upload_audio(self, path: Path) -> dict:
|
|
310
|
+
mime = mimetypes.guess_type(path.name)[0] or "audio/mpeg"
|
|
311
|
+
content = path.read_bytes() # site limit is 40 MB; bytes make retries safe
|
|
312
|
+
r = self._send(
|
|
313
|
+
"POST",
|
|
314
|
+
"/producer/upload-audio",
|
|
315
|
+
files={"file": (path.name, content, mime)},
|
|
316
|
+
data={"file_type": mime, "filename": path.name},
|
|
317
|
+
timeout=httpx.Timeout(300, connect=20),
|
|
318
|
+
)
|
|
319
|
+
return r.json()
|
|
320
|
+
|
|
321
|
+
def upload_check_status(self, clip_id: str, operation_id: str) -> dict:
|
|
322
|
+
return self.get(f"/producer/upload-audio/{clip_id}/upload-check-status", operation_id=operation_id)
|
|
323
|
+
|
|
324
|
+
def confirm_upload(self, clip_id: str, operation_id: str) -> dict:
|
|
325
|
+
return self.post(f"/producer/upload-audio/{clip_id}/confirm", {"operation_id": operation_id})
|
|
326
|
+
|
|
327
|
+
def remove_vocals(self, clip_id: str, operation_id: str) -> dict:
|
|
328
|
+
return self.post(f"/producer/upload-audio/{clip_id}/remove-vocals", {"operation_id": operation_id})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Download clip audio/cover and write a metadata sidecar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import unicodedata
|
|
9
|
+
from datetime import date
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from flowmusic_cli.errors import ApiError, NetworkError
|
|
16
|
+
|
|
17
|
+
ALLOWED_HOSTS = ("storage.googleapis.com", "flowmusic.app", "www.flowmusic.app")
|
|
18
|
+
FORMATS = {"wav": "wav_url", "m4a": "audio_url"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def slugify(text: str | None, fallback: str = "untitled") -> str:
|
|
22
|
+
text = unicodedata.normalize("NFKD", text or "").encode("ascii", "ignore").decode()
|
|
23
|
+
text = re.sub(r"[^\w\s-]", "", text).strip().lower()
|
|
24
|
+
text = re.sub(r"[-\s]+", "-", text)
|
|
25
|
+
return text[:60] or fallback
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def clip_basename(clip: dict) -> str:
|
|
29
|
+
return f"{slugify(clip.get('title'))}_{clip['id'][:8]}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check_url(url: str) -> None:
|
|
33
|
+
host = urlparse(url).hostname or ""
|
|
34
|
+
if urlparse(url).scheme != "https" or not any(host == h or host.endswith("." + h) for h in ALLOWED_HOSTS):
|
|
35
|
+
raise ApiError(f"Refusing to download from unexpected host: {host}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fetch_to(url: str, dest: Path, http: httpx.Client | None = None) -> Path:
|
|
39
|
+
_check_url(url)
|
|
40
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
42
|
+
client = http or httpx.Client(timeout=httpx.Timeout(120, connect=20))
|
|
43
|
+
try:
|
|
44
|
+
with client.stream("GET", url, follow_redirects=False) as r:
|
|
45
|
+
if r.status_code != 200:
|
|
46
|
+
raise ApiError(f"Download failed: HTTP {r.status_code} for {url}", status=r.status_code)
|
|
47
|
+
with tmp.open("wb") as fh:
|
|
48
|
+
for chunk in r.iter_bytes(1 << 16):
|
|
49
|
+
fh.write(chunk)
|
|
50
|
+
except httpx.HTTPError as e:
|
|
51
|
+
raise NetworkError(f"Download failed: {e}") from e
|
|
52
|
+
finally:
|
|
53
|
+
if http is None:
|
|
54
|
+
client.close()
|
|
55
|
+
tmp.replace(dest)
|
|
56
|
+
return dest
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def clip_metadata(clip: dict) -> dict:
|
|
60
|
+
op = clip.get("operation") or {}
|
|
61
|
+
lyrics = clip.get("lyrics") or {}
|
|
62
|
+
dur = clip.get("duration") or {}
|
|
63
|
+
return {
|
|
64
|
+
"id": clip.get("id"),
|
|
65
|
+
"title": clip.get("title"),
|
|
66
|
+
"duration_s": float(dur["value"]) if isinstance(dur, dict) and dur.get("value") else None,
|
|
67
|
+
"sound_prompt": op.get("sound_prompt"),
|
|
68
|
+
"lyrics": (lyrics.get("value") or {}).get("text") if isinstance(lyrics, dict) else None,
|
|
69
|
+
"op_type": clip.get("op_type"),
|
|
70
|
+
"conversation_id": op.get("conversation_id"),
|
|
71
|
+
"source_clip_id": op.get("source_clip_id"),
|
|
72
|
+
"created_at": clip.get("created_at"),
|
|
73
|
+
"privacy": clip.get("privacy"),
|
|
74
|
+
"wav_url": clip.get("wav_url"),
|
|
75
|
+
"audio_url": clip.get("audio_url"),
|
|
76
|
+
"image_url": clip.get("image_url"),
|
|
77
|
+
"web_url": f"https://www.flowmusic.app/song/{clip.get('id')}",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def output_dir_for(base: Path, explicit: bool) -> Path:
|
|
82
|
+
return base if explicit else base / date.today().isoformat()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def download_clip(
|
|
86
|
+
clip: dict,
|
|
87
|
+
out_dir: Path,
|
|
88
|
+
*,
|
|
89
|
+
formats: tuple[str, ...] = ("wav",),
|
|
90
|
+
cover: bool = False,
|
|
91
|
+
sidecar: bool = True,
|
|
92
|
+
http: httpx.Client | None = None,
|
|
93
|
+
) -> dict:
|
|
94
|
+
"""Download a clip; returns ``{"files": [...], "metadata": {...}}``."""
|
|
95
|
+
base = clip_basename(clip)
|
|
96
|
+
files: list[str] = []
|
|
97
|
+
for fmt in formats:
|
|
98
|
+
url = clip.get(FORMATS[fmt])
|
|
99
|
+
if not url and fmt == "wav" and clip.get("audio_url"):
|
|
100
|
+
fmt, url = "m4a", clip["audio_url"] # fall back if no wav rendition
|
|
101
|
+
if not url:
|
|
102
|
+
raise ApiError(f"Clip {clip.get('id')} has no {fmt} audio (yet).")
|
|
103
|
+
files.append(str(fetch_to(url, out_dir / f"{base}.{fmt}", http)))
|
|
104
|
+
if cover and clip.get("image_url"):
|
|
105
|
+
files.append(str(fetch_to(clip["image_url"], out_dir / f"{base}.jpg", http)))
|
|
106
|
+
meta = clip_metadata(clip)
|
|
107
|
+
if sidecar:
|
|
108
|
+
p = out_dir / f"{base}.json"
|
|
109
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
p.write_text(json.dumps(meta, indent=2, ensure_ascii=False), "utf-8")
|
|
111
|
+
files.append(str(p))
|
|
112
|
+
return {"files": files, "metadata": meta}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _sniff_ext(data: bytes) -> str:
|
|
116
|
+
if data[4:8] == b"ftyp":
|
|
117
|
+
return "m4a"
|
|
118
|
+
if data[:4] == b"RIFF":
|
|
119
|
+
return "wav"
|
|
120
|
+
if data[:3] == b"ID3" or data[:2] in (b"\xff\xfb", b"\xff\xf3"):
|
|
121
|
+
return "mp3"
|
|
122
|
+
if data[:4] == b"fLaC":
|
|
123
|
+
return "flac"
|
|
124
|
+
return "bin"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def save_stems(clip: dict, stems: dict[str, str], out_dir: Path) -> list[str]:
|
|
128
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
base = clip_basename(clip)
|
|
130
|
+
files = []
|
|
131
|
+
for name, b64 in stems.items():
|
|
132
|
+
data = base64.b64decode(b64)
|
|
133
|
+
p = out_dir / f"{base}_{slugify(name)}.{_sniff_ext(data)}"
|
|
134
|
+
p.write_bytes(data)
|
|
135
|
+
files.append(str(p))
|
|
136
|
+
return files
|