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.
@@ -0,0 +1,3 @@
1
+ """Unofficial CLI for Google Flow Music (flowmusic.app)."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from flowmusic_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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