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
|
@@ -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}
|
flowmusic_cli/api/sse.py
ADDED
|
@@ -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
|