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,387 @@
1
+ """Generation orchestration: send → stream → wait for audio → fetch clip metadata.
2
+
3
+ The web app talks to an LLM "co-producer" (chat mode). It decides which tools to
4
+ call (``audio__create_song`` etc.); each successful tool-return carries a
5
+ ``clip_id`` + ``operation_id`` which we poll until the audio has rendered.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ import uuid
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime, timezone
15
+ from typing import Any
16
+
17
+ from flowmusic_cli.api.client import UPLOAD_AUDIO_INSTRUCTIONS, FlowMusicClient
18
+ from flowmusic_cli.config import DEFAULT_MODEL
19
+ from flowmusic_cli.errors import ApiError, Blocked, GenerationFailed, NoAccess, UsageError
20
+
21
+ ProgressFn = Callable[[str, dict], None]
22
+
23
+ # Tools whose result is a new audio clip.
24
+ AUDIO_TOOLS = {
25
+ "audio__create_song",
26
+ "lyria__generate_song",
27
+ "audio__modify_song",
28
+ "audio__render_edit",
29
+ "audio__apply_effect",
30
+ "audio__convert_format",
31
+ "audio__split_stems",
32
+ }
33
+
34
+
35
+ @dataclass
36
+ class JobOutcome:
37
+ job_id: str
38
+ conversation_id: str | None = None
39
+ title: str | None = None
40
+ text: str = ""
41
+ tool_calls: list[dict] = field(default_factory=list)
42
+ tool_returns: list[dict] = field(default_factory=list)
43
+ suggestions: list[str] = field(default_factory=list)
44
+ completed: bool = False
45
+
46
+ @property
47
+ def clip_refs(self) -> list[dict]:
48
+ """``[{clip_id, operation_id, title, tool}]`` for every audio result."""
49
+ refs: list[dict] = []
50
+ for tr in self.tool_returns:
51
+ content = tr.get("content")
52
+ if not isinstance(content, dict):
53
+ continue
54
+ # Edit/remix tools (audio__render_edit, ...) return a list of outputs.
55
+ for o in content.get("clip_outputs") or []:
56
+ if isinstance(o, dict) and o.get("clip_id"):
57
+ refs.append({
58
+ "clip_id": o["clip_id"],
59
+ "operation_id": content.get("operation_id"),
60
+ "title": o.get("title"),
61
+ "tool": tr.get("tool_name"),
62
+ "estimated_time": content.get("estimated_time"),
63
+ })
64
+ for cid_key, op_key in (("clip_id", "operation_id"), ("clip_id_b", "operation_id_b")):
65
+ if content.get(cid_key):
66
+ refs.append({
67
+ "clip_id": content[cid_key],
68
+ "operation_id": content.get(op_key),
69
+ "title": content.get("title"),
70
+ "tool": tr.get("tool_name"),
71
+ "estimated_time": content.get("estimated_time"),
72
+ })
73
+ return refs
74
+
75
+ @property
76
+ def failures(self) -> list[dict]:
77
+ out = []
78
+ for tr in self.tool_returns:
79
+ c = tr.get("content")
80
+ if isinstance(c, dict) and c.get("status") not in (None, "success", "complete", "completed"):
81
+ out.append({"tool": tr.get("tool_name"), **c})
82
+ elif isinstance(c, str) and tr.get("tool_name") in AUDIO_TOOLS and "error" in c.lower():
83
+ out.append({"tool": tr.get("tool_name"), "status": "error", "message": c})
84
+ return out
85
+
86
+
87
+ def run_job(client: FlowMusicClient, job_id: str, on_progress: ProgressFn | None = None) -> JobOutcome:
88
+ out = JobOutcome(job_id=job_id)
89
+ emit = on_progress or (lambda kind, data: None)
90
+ for ev in client.stream_job(job_id):
91
+ d = ev.data if isinstance(ev.data, dict) else {}
92
+ if ev.event == "conversation_id":
93
+ out.conversation_id = d.get("id")
94
+ emit("conversation", d)
95
+ elif ev.event == "part":
96
+ part = d.get("part") or {}
97
+ kind = part.get("part_kind")
98
+ status = d.get("status")
99
+ if kind == "tool-call" and status == "final":
100
+ out.tool_calls.append(part)
101
+ emit("tool_call", part)
102
+ elif kind == "tool-return":
103
+ out.tool_returns.append(part)
104
+ emit("tool_return", part)
105
+ elif kind == "text":
106
+ if status == "delta":
107
+ emit("text_delta", {"delta": d.get("delta", "")})
108
+ elif status == "final":
109
+ out.text = (out.text + "\n\n" + part.get("content", "")).strip()
110
+ emit("text", part)
111
+ elif ev.event == "message" and isinstance(d.get("parts"), list):
112
+ # Direct tool-call jobs stream whole messages instead of incremental parts.
113
+ for part in d["parts"]:
114
+ kind = part.get("part_kind")
115
+ if kind == "tool-call":
116
+ out.tool_calls.append(part)
117
+ emit("tool_call", part)
118
+ elif kind == "tool-return":
119
+ out.tool_returns.append(part)
120
+ emit("tool_return", part)
121
+ elif kind == "text":
122
+ out.text = (out.text + "\n\n" + part.get("content", "")).strip()
123
+ emit("text", part)
124
+ elif ev.event == "complete":
125
+ out.completed = True
126
+ emit("complete", d)
127
+ elif ev.event == "generated-title":
128
+ out.title = d.get("title")
129
+ elif ev.event == "suggestion":
130
+ for p in d.get("parts", []):
131
+ if p.get("tool_name") == "synthetic__suggest_actions" and isinstance(p.get("args"), dict):
132
+ out.suggestions = [v for v in p["args"].values() if isinstance(v, str)]
133
+ elif ev.event == "error":
134
+ msg = d.get("message") or d.get("detail") or str(ev.data)
135
+ raise GenerationFailed(f"Producer error: {msg}")
136
+ return out
137
+
138
+
139
+ def wait_for_clips(
140
+ client: FlowMusicClient,
141
+ refs: list[dict],
142
+ *,
143
+ timeout: float = 600,
144
+ interval: float = 3,
145
+ on_progress: ProgressFn | None = None,
146
+ ) -> list[dict]:
147
+ """Poll render status until every clip is done; return full clip objects."""
148
+ emit = on_progress or (lambda kind, data: None)
149
+ pending = {r["clip_id"]: r for r in refs}
150
+ errors: list[str] = []
151
+ deadline = time.monotonic() + timeout
152
+ while pending and time.monotonic() < deadline:
153
+ for cid, ref in list(pending.items()):
154
+ op = ref.get("operation_id")
155
+ if op and ref.get("tool") in (None, "audio__create_song", "lyria__generate_song"):
156
+ try:
157
+ st = client.song_status(op)
158
+ except ApiError:
159
+ st = None
160
+ if st:
161
+ status = (st.get("status") or "").lower()
162
+ if status in ("complete", "completed", "success"):
163
+ pending.pop(cid)
164
+ emit("clip_ready", ref)
165
+ elif status in ("error", "failed", "failure", "cancelled"):
166
+ pending.pop(cid)
167
+ errors.append(f"{ref.get('title') or cid}: {st.get('error_type')} {st.get('error_message')}")
168
+ else:
169
+ emit("clip_progress", {**ref, "progress": st.get("progress")})
170
+ continue
171
+ # Fallback for other tools: the clip is ready once it has audio.
172
+ clip = client.get_clips([cid]).get(cid) or {}
173
+ if clip.get("wav_url") or clip.get("audio_url"):
174
+ pending.pop(cid)
175
+ emit("clip_ready", ref)
176
+ if pending:
177
+ time.sleep(interval)
178
+ if pending:
179
+ errors.append(f"timed out waiting for {', '.join(pending)}")
180
+ ids = [r["clip_id"] for r in refs]
181
+ clips_by_id = client.get_clips(ids)
182
+ clips = [clips_by_id[i] for i in ids if i in clips_by_id]
183
+ if errors and not clips:
184
+ _raise_failure("; ".join(errors))
185
+ for c in clips:
186
+ c.setdefault("_errors", [])
187
+ if errors:
188
+ clips[0]["_errors"] = errors
189
+ return clips
190
+
191
+
192
+ def _raise_failure(message: str):
193
+ low = message.lower()
194
+ if any(w in low for w in ("moderat", "policy", "blocked", "safety", "copyright", "flag")):
195
+ raise Blocked(message)
196
+ raise GenerationFailed(message)
197
+
198
+
199
+ def build_prompt(prompt: str, *, lyrics: str | None = None, instrumental: bool = False) -> str:
200
+ text = prompt.strip()
201
+ if instrumental:
202
+ text += "\n\nMake it instrumental (no vocals)."
203
+ if lyrics:
204
+ text += "\n\nUse exactly these lyrics, unchanged:\n\n" + lyrics.strip()
205
+ return text
206
+
207
+
208
+ def upload_parts(clip: dict, *, name: str | None = None) -> list[dict]:
209
+ """The synthetic tool-call/return pair the web app sends for an attached upload."""
210
+ call_id = str(uuid.uuid4())
211
+ args: dict[str, Any] = {
212
+ "id": clip["id"],
213
+ "url": clip.get("audio_url") or clip.get("url") or clip.get("wav_url"),
214
+ "name": name or clip.get("title") or "upload",
215
+ "instructions": UPLOAD_AUDIO_INSTRUCTIONS,
216
+ }
217
+ dur = (clip.get("duration") or {}).get("value") if isinstance(clip.get("duration"), dict) else clip.get("duration")
218
+ if dur:
219
+ try:
220
+ args["duration_s"] = float(dur)
221
+ except (TypeError, ValueError):
222
+ pass
223
+ return [
224
+ {"tool_name": "synthetic__upload_audio", "args": args, "tool_call_id": call_id, "part_kind": "tool-call"},
225
+ {
226
+ "tool_name": "synthetic__upload_audio",
227
+ "content": None,
228
+ "tool_call_id": call_id,
229
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
230
+ "part_kind": "tool-return",
231
+ },
232
+ ]
233
+
234
+
235
+ @dataclass
236
+ class GenerationResult:
237
+ conversation_id: str | None
238
+ job_ids: list[str]
239
+ clips: list[dict]
240
+ message: str = ""
241
+ title: str | None = None
242
+ suggestions: list[str] = field(default_factory=list)
243
+
244
+
245
+ def chat(
246
+ client: FlowMusicClient,
247
+ prompt: str,
248
+ *,
249
+ conversation_id: str | None = None,
250
+ model: str = DEFAULT_MODEL,
251
+ lyrics: str | None = None,
252
+ instrumental: bool = False,
253
+ attachments: list[dict] | None = None,
254
+ wait: bool = True,
255
+ timeout: float = 600,
256
+ on_progress: ProgressFn | None = None,
257
+ ) -> GenerationResult:
258
+ """Send a message to the co-producer (new or existing conversation)."""
259
+ parts: list[dict] = []
260
+ for clip in attachments or []:
261
+ parts.extend(upload_parts(clip))
262
+ parts.append({"content": build_prompt(prompt, lyrics=lyrics, instrumental=instrumental),
263
+ "part_kind": "user-prompt"})
264
+ job_id = client.send_message(parts, conversation_id=conversation_id, model=model)
265
+ (on_progress or (lambda k, d: None))("job", {"job_id": job_id})
266
+ if not wait:
267
+ return GenerationResult(conversation_id=conversation_id, job_ids=[job_id], clips=[])
268
+ outcome = run_job(client, job_id, on_progress)
269
+ refs = outcome.clip_refs
270
+ if not refs and outcome.failures:
271
+ _raise_failure("; ".join(str(f.get("message") or f.get("error_message") or f) for f in outcome.failures))
272
+ clips = wait_for_clips(client, refs, timeout=timeout, on_progress=on_progress) if refs else []
273
+ return GenerationResult(
274
+ conversation_id=outcome.conversation_id or conversation_id,
275
+ job_ids=[job_id],
276
+ clips=clips,
277
+ message=outcome.text,
278
+ title=outcome.title,
279
+ suggestions=outcome.suggestions,
280
+ )
281
+
282
+
283
+ def direct_create(
284
+ client: FlowMusicClient,
285
+ sound_prompt: str,
286
+ *,
287
+ count: int = 1,
288
+ title: str | None = None,
289
+ lyrics: str | None = None,
290
+ instrumental: bool = False,
291
+ conversation_id: str | None = None,
292
+ model: str = DEFAULT_MODEL,
293
+ timeout: float = 600,
294
+ on_progress: ProgressFn | None = None,
295
+ ) -> GenerationResult:
296
+ """EXPERIMENTAL: call ``audio__create_song`` directly, bypassing the LLM producer.
297
+
298
+ The prompt is used verbatim as ``sound_prompt``. Lyrics are first registered
299
+ with ``lyrics__register`` (the same call the web lyrics editor makes).
300
+ """
301
+ if count < 1:
302
+ raise UsageError("--count must be >= 1")
303
+ lyrics_id = None
304
+ if instrumental and not lyrics:
305
+ lyrics = "[Instrumental]"
306
+ if lyrics:
307
+ job = client.send_tool_call("lyrics__register", {"lyrics": lyrics}, conversation_id=conversation_id,
308
+ model=model)
309
+ outcome = run_job(client, job, on_progress)
310
+ conversation_id = outcome.conversation_id or conversation_id
311
+ for tr in outcome.tool_returns:
312
+ c = tr.get("content")
313
+ if isinstance(c, dict) and c.get("lyrics_id"):
314
+ lyrics_id = c["lyrics_id"]
315
+ if not lyrics_id:
316
+ raise GenerationFailed("lyrics__register returned no lyrics_id")
317
+ job_ids, refs = [], []
318
+ for _ in range(count):
319
+ args: dict[str, Any] = {"sound_prompt": sound_prompt} # the tool rejects a `title` arg
320
+ if lyrics_id:
321
+ args["lyrics_id"] = lyrics_id
322
+ job = client.send_tool_call("audio__create_song", args, conversation_id=conversation_id, model=model)
323
+ job_ids.append(job)
324
+ outcome = run_job(client, job, on_progress)
325
+ conversation_id = outcome.conversation_id or conversation_id
326
+ if not outcome.clip_refs and outcome.failures:
327
+ _raise_failure("; ".join(str(f) for f in outcome.failures))
328
+ refs.extend(outcome.clip_refs)
329
+ clips = wait_for_clips(client, refs, timeout=timeout, on_progress=on_progress) if refs else []
330
+ if title:
331
+ for i, clip in enumerate(clips):
332
+ t = title if len(clips) == 1 else f"{title} ({i + 1})"
333
+ try:
334
+ client.update_clip(clip["id"], title=t)
335
+ clip["title"] = t
336
+ except ApiError:
337
+ pass
338
+ return GenerationResult(conversation_id=conversation_id, job_ids=job_ids, clips=clips)
339
+
340
+
341
+ def upload_audio(
342
+ client: FlowMusicClient,
343
+ path,
344
+ *,
345
+ remove_vocals: bool = False,
346
+ timeout: float = 300,
347
+ on_progress: ProgressFn | None = None,
348
+ ) -> dict:
349
+ """Upload an audio file the way the web app does; returns ``{clip_id, url, ...}``."""
350
+ emit = on_progress or (lambda k, d: None)
351
+ up = client.upload_audio(path)
352
+ clip_id, op_id = up.get("clip_id"), up.get("operation_id")
353
+ if not clip_id or not op_id:
354
+ raise ApiError(f"Unexpected upload response: {up}")
355
+ emit("upload_checking", {"clip_id": clip_id})
356
+ deadline = time.monotonic() + timeout
357
+ st: dict = {}
358
+ while time.monotonic() < deadline:
359
+ st = client.upload_check_status(clip_id, op_id) or {}
360
+ if st.get("status") != "pending":
361
+ break
362
+ time.sleep(2)
363
+ if st.get("status") != "complete":
364
+ raise GenerationFailed(f"Upload check did not complete: {st or 'timeout'}")
365
+ decision = st.get("decision") or "allowed"
366
+ reason = st.get("reason")
367
+ if decision == "allowed":
368
+ res = client.confirm_upload(clip_id, op_id)
369
+ elif decision == "requires_vocal_removal" or (st.get("has_vocals") and remove_vocals):
370
+ # Observed decisions: "allowed", "requires_vocal_removal", "blocked" (e.g. vocals
371
+ # in a region where vocal uploads are restricted: there removal is refused too).
372
+ if not remove_vocals:
373
+ raise Blocked(f"This track can only be used without vocals ({reason or 'vocals detected'}).",
374
+ hint="Re-run with --remove-vocals.")
375
+ emit("removing_vocals", {"clip_id": clip_id})
376
+ try:
377
+ res = client.remove_vocals(clip_id, op_id)
378
+ except (NoAccess, ApiError) as e:
379
+ raise Blocked(f"Vocal removal was refused: {e.detail}",
380
+ hint="Vocal uploads/removal aren't available for your account or region; "
381
+ "upload an instrumental instead.") from e
382
+ else:
383
+ hint = "Upload an instrumental (no vocals) instead." if st.get("has_vocals") else None
384
+ if st.get("has_cid_match"):
385
+ hint = "The track matched existing copyrighted content."
386
+ raise Blocked(f"Upload rejected: {reason or decision}", hint=hint)
387
+ return {"clip_id": res.get("clip_id") or clip_id, "url": res.get("url"), "check": st}
@@ -0,0 +1,49 @@
1
+ """Minimal Server-Sent-Events parser for ``/messages/{job_id}/stream``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable, Iterator
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class SSEEvent:
13
+ event: str
14
+ data: Any
15
+ id: str | None = None
16
+
17
+
18
+ def parse_sse(lines: Iterable[str]) -> Iterator[SSEEvent]:
19
+ event, data_lines, ev_id = "message", [], None
20
+ for raw in lines:
21
+ line = raw.rstrip("\r\n")
22
+ if line == "":
23
+ if data_lines:
24
+ text = "\n".join(data_lines)
25
+ try:
26
+ data = json.loads(text)
27
+ except ValueError:
28
+ data = text
29
+ yield SSEEvent(event=event, data=data, id=ev_id)
30
+ event, data_lines = "message", []
31
+ continue
32
+ if line.startswith(":"):
33
+ continue # keep-alive comment (": ping - ...")
34
+ field, _, value = line.partition(":")
35
+ if value.startswith(" "):
36
+ value = value[1:]
37
+ if field == "event":
38
+ event = value
39
+ elif field == "data":
40
+ data_lines.append(value)
41
+ elif field == "id":
42
+ ev_id = value
43
+ if data_lines:
44
+ text = "\n".join(data_lines)
45
+ try:
46
+ data = json.loads(text)
47
+ except ValueError:
48
+ data = text
49
+ yield SSEEvent(event=event, data=data, id=ev_id)
File without changes
@@ -0,0 +1,118 @@
1
+ """One-time interactive login in a real browser window.
2
+
3
+ The user signs in with Google on flowmusic.app as usual; we only watch the
4
+ cookie jar until the Supabase session cookie appears, then close the window.
5
+ After that, the CLI never needs a browser again (tokens refresh over HTTP).
6
+
7
+ The launch flags mirror gflow-cli's measured "stealth set": without them Google
8
+ may flag the automated window and reject the sign-in.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from flowmusic_cli.auth.session import COOKIE_BASE, Session, decode_cookie_value, join_cookie_chunks
19
+ from flowmusic_cli.errors import LoginFailed
20
+
21
+ _CHANNELS = ("chrome", "msedge", None) # None = Playwright's bundled Chromium
22
+
23
+
24
+ def _launch_kwargs(profile_dir: Path, channel: str | None) -> dict[str, Any]:
25
+ return {
26
+ "user_data_dir": str(profile_dir),
27
+ "channel": channel,
28
+ "headless": False,
29
+ "no_viewport": True,
30
+ "chromium_sandbox": True,
31
+ "ignore_default_args": ["--enable-automation"],
32
+ "args": [
33
+ "--disable-blink-features=AutomationControlled",
34
+ "--password-store=basic",
35
+ "--window-size=1200,900",
36
+ ],
37
+ }
38
+
39
+
40
+ def browser_login(
41
+ site_url: str,
42
+ browser_profile_dir: Path,
43
+ *,
44
+ timeout: float = 300,
45
+ channel: str | None = "auto",
46
+ on_status=lambda msg: None,
47
+ validate=None,
48
+ ) -> Session:
49
+ """Return a session once one is found and ``validate(session)`` accepts it.
50
+
51
+ ``validate`` should exchange the refresh token (returning the fresh session)
52
+ or raise. A profile reused from an earlier login may still hold a cookie
53
+ whose refresh token the CLI has since rotated; such a stale session is
54
+ discarded and the user is asked to sign in again.
55
+ """
56
+ try:
57
+ from playwright.sync_api import Error as PlaywrightError
58
+ from playwright.sync_api import sync_playwright
59
+ except ImportError as e: # pragma: no cover
60
+ raise LoginFailed("Playwright is not installed.", hint="pip install playwright") from e
61
+
62
+ browser_profile_dir.mkdir(parents=True, exist_ok=True)
63
+ channels = _CHANNELS if channel == "auto" else (channel,)
64
+
65
+ with sync_playwright() as p:
66
+ ctx = None
67
+ last_err: Exception | None = None
68
+ for ch in channels:
69
+ try:
70
+ ctx = p.chromium.launch_persistent_context(**_launch_kwargs(browser_profile_dir, ch))
71
+ on_status(f"Opened {ch or 'bundled Chromium'}.")
72
+ break
73
+ except PlaywrightError as e:
74
+ last_err = e
75
+ if ctx is None:
76
+ raise LoginFailed(
77
+ f"Could not start a browser ({last_err}).",
78
+ hint="Install Google Chrome, or run `playwright install chromium`.",
79
+ )
80
+ try:
81
+ page = ctx.pages[0] if ctx.pages else ctx.new_page()
82
+ page.goto(site_url, wait_until="domcontentloaded")
83
+ on_status("Sign in with Google in the browser window (this window closes by itself).")
84
+ deadline = time.monotonic() + timeout
85
+ while time.monotonic() < deadline:
86
+ # All domains: the auth callback may set the cookie on the apex or www host.
87
+ jar = {c["name"]: c["value"] for c in ctx.cookies()
88
+ if c["name"].startswith(COOKIE_BASE) and "flowmusic" in c.get("domain", "")}
89
+ joined = join_cookie_chunks(jar)
90
+ session = None
91
+ if joined:
92
+ try:
93
+ data = decode_cookie_value(joined)
94
+ if data.get("access_token") and data.get("refresh_token"):
95
+ session = Session.from_dict(data)
96
+ except ValueError:
97
+ pass # chunks still being written
98
+ if session is not None:
99
+ if validate is None:
100
+ return session
101
+ try:
102
+ return validate(session)
103
+ except Exception as e: # stale/revoked: start over
104
+ on_status(f"Stored browser session is no longer valid ({e}); please sign in again.")
105
+ try:
106
+ ctx.clear_cookies(domain=re.compile(r"flowmusic\.app$"))
107
+ except TypeError: # older Playwright
108
+ ctx.clear_cookies()
109
+ page.goto(site_url, wait_until="domcontentloaded")
110
+ if not ctx.pages:
111
+ raise LoginFailed("The browser window was closed before sign-in completed.")
112
+ time.sleep(1.0)
113
+ raise LoginFailed(f"No sign-in detected within {int(timeout)}s.")
114
+ finally:
115
+ try:
116
+ ctx.close()
117
+ except Exception:
118
+ pass