@marswave/listenhub-cli
Advanced tools
+1285
| #!/usr/bin/env node | ||
| import { Command, Option } from "commander"; | ||
| import process from "node:process"; | ||
| import { ListenHubClient, ListenHubError } from "@marswave/listenhub-sdk"; | ||
| import fs from "node:fs"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
| import http from "node:http"; | ||
| import ora from "ora"; | ||
| import { access, readFile, stat } from "node:fs/promises"; | ||
| //#region source/_shared/output.ts | ||
| function printJson(data) { | ||
| console.log(JSON.stringify(data, null, 2)); | ||
| } | ||
| function printDetail(label, rows) { | ||
| console.log(`\u2713 ${label}\n`); | ||
| for (const [key, value] of rows) if (value !== void 0) console.log(` ${key.padEnd(10)} ${String(value)}`); | ||
| } | ||
| function printTable(headers, rows) { | ||
| const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length))); | ||
| console.log(" " + headers.map((h, i) => h.padEnd(widths[i])).join(" ")); | ||
| for (const row of rows) console.log(" " + row.map((c, i) => c.padEnd(widths[i])).join(" ")); | ||
| } | ||
| var CliTimeoutError = class extends Error { | ||
| constructor(message) { | ||
| super(message); | ||
| this.name = "CliTimeoutError"; | ||
| } | ||
| }; | ||
| var CliAuthError = class extends Error { | ||
| constructor(message) { | ||
| super(message); | ||
| this.name = "CliAuthError"; | ||
| } | ||
| }; | ||
| function handleError(error, json) { | ||
| if (json) { | ||
| const message = error instanceof ListenHubError ? { | ||
| error: error.message, | ||
| code: error.code, | ||
| requestId: error.requestId | ||
| } : { | ||
| error: error instanceof Error ? error.message : String(error), | ||
| code: "UNKNOWN" | ||
| }; | ||
| console.error(JSON.stringify(message, null, 2)); | ||
| } else { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| console.error(`\u2717 Error: ${message}`); | ||
| } | ||
| if (error instanceof CliAuthError || error instanceof ListenHubError && (error.status === 401 || error.status === 403)) process.exit(2); | ||
| if (error instanceof CliTimeoutError) process.exit(3); | ||
| process.exit(1); | ||
| } | ||
| //#endregion | ||
| //#region source/_shared/credentials.ts | ||
| function getConfigDir() { | ||
| const xdg = process.env["XDG_CONFIG_HOME"]; | ||
| return path.join(xdg ?? path.join(os.homedir(), ".config"), "listenhub"); | ||
| } | ||
| function getCredentialsPath() { | ||
| return path.join(getConfigDir(), "credentials.json"); | ||
| } | ||
| async function loadCredentials() { | ||
| const filePath = getCredentialsPath(); | ||
| try { | ||
| const raw = fs.readFileSync(filePath, "utf8"); | ||
| return JSON.parse(raw); | ||
| } catch { | ||
| return; | ||
| } | ||
| } | ||
| async function saveCredentials(creds) { | ||
| const dir = getConfigDir(); | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| const filePath = getCredentialsPath(); | ||
| const temporaryPath = `${filePath}.tmp.${process.pid}`; | ||
| fs.writeFileSync(temporaryPath, JSON.stringify(creds, null, " "), { mode: 384 }); | ||
| fs.renameSync(temporaryPath, filePath); | ||
| } | ||
| async function deleteCredentials() { | ||
| const filePath = getCredentialsPath(); | ||
| try { | ||
| fs.unlinkSync(filePath); | ||
| } catch (error) { | ||
| if (error instanceof Error && "code" in error && error.code === "ENOENT") return; | ||
| throw error; | ||
| } | ||
| } | ||
| //#endregion | ||
| //#region source/auth/login-server.ts | ||
| const loginTimeoutMs = 300 * 1e3; | ||
| async function startCallbackServer() { | ||
| let resolveCode; | ||
| let rejectCode; | ||
| const codePromise = new Promise((resolve, reject) => { | ||
| resolveCode = resolve; | ||
| rejectCode = reject; | ||
| }); | ||
| const timeout = setTimeout(() => { | ||
| rejectCode(/* @__PURE__ */ new Error("Login timed out after 5 minutes. Please try again.")); | ||
| }, loginTimeoutMs); | ||
| const server = http.createServer((request, response) => { | ||
| const url = new URL(request.url, "http://localhost"); | ||
| const code = url.searchParams.get("code"); | ||
| const error = url.searchParams.get("error"); | ||
| if (error) { | ||
| const description = url.searchParams.get("error_description") ?? error; | ||
| response.writeHead(200, { "Content-Type": "text/plain" }); | ||
| response.end(`Login failed: ${description}`); | ||
| clearTimeout(timeout); | ||
| rejectCode(/* @__PURE__ */ new Error(`OAuth error: ${description}`)); | ||
| return; | ||
| } | ||
| if (code) { | ||
| response.writeHead(200, { "Content-Type": "text/html" }); | ||
| response.end("<html><body><h1>Login successful!</h1><p>You can close this tab.</p></body></html>"); | ||
| clearTimeout(timeout); | ||
| resolveCode({ code }); | ||
| } else { | ||
| response.writeHead(400, { "Content-Type": "text/plain" }); | ||
| response.end("Missing code parameter"); | ||
| } | ||
| }); | ||
| await new Promise((resolve) => { | ||
| server.listen(0, "127.0.0.1", resolve); | ||
| }); | ||
| const { port } = server.address(); | ||
| return { | ||
| port, | ||
| waitForCode: async () => codePromise, | ||
| close() { | ||
| clearTimeout(timeout); | ||
| server.close(); | ||
| } | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region source/auth/auth.ts | ||
| async function runLogin() { | ||
| const server = await startCallbackServer(); | ||
| try { | ||
| const client = new ListenHubClient(); | ||
| const { sessionId, authUrl } = await client.connectInit({ callbackPort: server.port }); | ||
| console.error("Opening browser for login..."); | ||
| const { default: open } = await import("open"); | ||
| await open(authUrl); | ||
| const { code } = await server.waitForCode(); | ||
| const tokens = await client.connectToken({ | ||
| sessionId, | ||
| code | ||
| }); | ||
| await saveCredentials({ | ||
| accessToken: tokens.accessToken, | ||
| refreshToken: tokens.refreshToken, | ||
| expiresAt: Date.now() + tokens.expiresIn * 1e3 | ||
| }); | ||
| const user = await new ListenHubClient({ accessToken: tokens.accessToken }).getCurrentUser(); | ||
| console.log(`\u2713 Logged in as ${user.nickname || user.email || "user"}`); | ||
| } finally { | ||
| server.close(); | ||
| } | ||
| } | ||
| async function runLogout() { | ||
| const creds = await loadCredentials(); | ||
| if (creds?.refreshToken) try { | ||
| await new ListenHubClient({ accessToken: creds.accessToken }).revoke({ refreshToken: creds.refreshToken }); | ||
| } catch { | ||
| console.error("Warning: remote revoke failed, local credentials cleared"); | ||
| } | ||
| await deleteCredentials(); | ||
| console.log("✓ Logged out"); | ||
| } | ||
| async function runStatus(json) { | ||
| const creds = await loadCredentials(); | ||
| if (!creds) { | ||
| if (json) console.log(JSON.stringify({ loggedIn: false })); | ||
| else console.log("Not logged in"); | ||
| process.exit(1); | ||
| } | ||
| try { | ||
| const user = await new ListenHubClient({ accessToken: creds.accessToken }).getCurrentUser(); | ||
| const expiresAt = new Date(creds.expiresAt).toISOString(); | ||
| if (json) console.log(JSON.stringify({ | ||
| loggedIn: true, | ||
| user: user.nickname, | ||
| email: user.email, | ||
| expiresAt | ||
| }, null, 2)); | ||
| else { | ||
| console.log(`\u2713 Logged in as ${user.nickname || "user"}`); | ||
| console.log(` Email: ${user.email}`); | ||
| console.log(` Expires at: ${expiresAt}`); | ||
| } | ||
| } catch { | ||
| if (json) console.log(JSON.stringify({ | ||
| loggedIn: false, | ||
| error: "Token expired or invalid" | ||
| })); | ||
| else console.log("Not logged in (token expired or invalid)"); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| //#endregion | ||
| //#region source/auth/_cli.ts | ||
| function register$9(program) { | ||
| const auth = program.command("auth").description("Manage authentication"); | ||
| auth.command("login").description("Log in via browser OAuth").action(async () => { | ||
| try { | ||
| await runLogin(); | ||
| } catch (error) { | ||
| handleError(error, false); | ||
| } | ||
| }); | ||
| auth.command("logout").description("Log out and revoke tokens").action(async () => { | ||
| try { | ||
| await runLogout(); | ||
| } catch (error) { | ||
| handleError(error, false); | ||
| } | ||
| }); | ||
| auth.command("status").description("Show current login status").option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await runStatus(options.json); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region source/_shared/client.ts | ||
| const refreshBufferMs = 6e4; | ||
| let refreshPromise; | ||
| async function ensureFreshCredentials() { | ||
| const creds = await loadCredentials(); | ||
| if (!creds) throw new CliAuthError("Not logged in. Run `listenhub auth login` first."); | ||
| if (creds.expiresAt - Date.now() >= refreshBufferMs) return; | ||
| const tokens = await new ListenHubClient({ accessToken: creds.accessToken }).refresh({ refreshToken: creds.refreshToken }); | ||
| await saveCredentials({ | ||
| ...creds, | ||
| accessToken: tokens.accessToken, | ||
| refreshToken: tokens.refreshToken, | ||
| expiresAt: Date.now() + tokens.expiresIn * 1e3 | ||
| }); | ||
| } | ||
| async function getClient() { | ||
| refreshPromise ??= ensureFreshCredentials().finally(() => { | ||
| refreshPromise = void 0; | ||
| }); | ||
| await refreshPromise; | ||
| const creds = await loadCredentials(); | ||
| if (!creds) throw new CliAuthError("Not logged in. Run `listenhub auth login` first."); | ||
| return new ListenHubClient({ accessToken: creds.accessToken }); | ||
| } | ||
| //#endregion | ||
| //#region source/creation/creation.ts | ||
| async function getCreation(client, episodeId, json) { | ||
| const detail = await client.getCreation(episodeId); | ||
| if (json) { | ||
| printJson(detail); | ||
| return; | ||
| } | ||
| printDetail("Creation details", [ | ||
| ["ID:", detail.id], | ||
| ["Type:", detail.generationType], | ||
| ["Status:", detail.processStatus], | ||
| ["Language:", detail.language], | ||
| ["Created:", new Date(detail.createdAt).toISOString()] | ||
| ]); | ||
| } | ||
| async function deleteCreations(client, ids, json) { | ||
| await client.deleteCreations({ ids }); | ||
| if (json) printJson({ deleted: ids }); | ||
| else console.log(`\u2713 Deleted ${String(ids.length)} creation(s)`); | ||
| } | ||
| //#endregion | ||
| //#region source/creation/_cli.ts | ||
| function register$8(program) { | ||
| const cmd = program.command("creation").description("Manage creations"); | ||
| cmd.command("get <id>").description("Get creation details").option("-j, --json", "Output JSON", false).action(async (id, options) => { | ||
| try { | ||
| await getCreation(await getClient(), id, options.json); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd.command("delete <id...>").description("Delete one or more creations").option("-j, --json", "Output JSON", false).action(async (ids, options) => { | ||
| try { | ||
| await deleteCreations(await getClient(), ids, options.json); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region source/_shared/language.ts | ||
| const cjkRegex = /[\u4E00-\u9FFF\u3400-\u4DBF]/v; | ||
| const kanaRegex = /[\u3040-\u309F\u30A0-\u30FF]/v; | ||
| function inferLanguage(text) { | ||
| if (!text) return "en"; | ||
| if (kanaRegex.test(text)) return "ja"; | ||
| if (cjkRegex.test(text)) return "zh"; | ||
| return "en"; | ||
| } | ||
| //#endregion | ||
| //#region source/_shared/polling.ts | ||
| const pollIntervalMs = 1e4; | ||
| const defaultTimeoutS = 300; | ||
| async function sleep(ms) { | ||
| return new Promise((resolve) => { | ||
| setTimeout(resolve, ms); | ||
| }); | ||
| } | ||
| async function pollUntilDone(client, episodeId, options) { | ||
| const timeoutS = options.timeout ?? defaultTimeoutS; | ||
| const maxAttempts = Math.ceil(timeoutS / (pollIntervalMs / 1e3)); | ||
| const spinner = options.json ? void 0 : ora({ text: `${options.label ?? "Creating"}... (1/${maxAttempts})` }).start(); | ||
| for (let i = 0; i < maxAttempts; i++) { | ||
| if (i > 0) await sleep(pollIntervalMs); | ||
| const detail = await client.getCreation(episodeId); | ||
| if (detail.processStatus === "success") { | ||
| spinner?.succeed(`${options.label ?? "Created"} successfully`); | ||
| return detail; | ||
| } | ||
| if (detail.processStatus === "fail") { | ||
| spinner?.fail("Creation failed"); | ||
| throw new Error(`Creation failed (code: ${detail.failCode})`); | ||
| } | ||
| if (spinner) spinner.text = `${options.label ?? "Creating"}... (${String(i + 2)}/${maxAttempts})`; | ||
| } | ||
| spinner?.fail("Timed out"); | ||
| throw new CliTimeoutError(`Timed out after ${timeoutS}s`); | ||
| } | ||
| async function pollImageUntilDone(client, imageId, options) { | ||
| const timeoutS = options.timeout ?? 120; | ||
| const maxAttempts = Math.ceil(timeoutS / (pollIntervalMs / 1e3)); | ||
| const spinner = options.json ? void 0 : ora({ text: `Creating image... (1/${maxAttempts})` }).start(); | ||
| for (let i = 0; i < maxAttempts; i++) { | ||
| if (i > 0) await sleep(pollIntervalMs); | ||
| const item = await client.getAIImage(imageId); | ||
| if (item.status === "success") { | ||
| spinner?.succeed("Image created successfully"); | ||
| return item; | ||
| } | ||
| if (item.status === "fail") { | ||
| spinner?.fail("Image creation failed"); | ||
| throw new Error("Image creation failed"); | ||
| } | ||
| if (spinner) spinner.text = `Creating image... (${String(i + 2)}/${maxAttempts})`; | ||
| } | ||
| spinner?.fail("Timed out"); | ||
| throw new CliTimeoutError(`Timed out after ${timeoutS}s`); | ||
| } | ||
| async function pollMusicTaskUntilDone(client, taskId, options) { | ||
| const timeoutS = options.timeout ?? 600; | ||
| const maxAttempts = Math.ceil(timeoutS / (pollIntervalMs / 1e3)); | ||
| const spinner = options.json ? void 0 : ora({ text: `Creating music... (1/${maxAttempts})` }).start(); | ||
| for (let i = 0; i < maxAttempts; i++) { | ||
| if (i > 0) await sleep(pollIntervalMs); | ||
| const task = await client.getMusicTask(taskId); | ||
| if (task.status === "success") { | ||
| spinner?.succeed("Music created successfully"); | ||
| return task; | ||
| } | ||
| if (task.status === "failed") { | ||
| spinner?.fail("Music creation failed"); | ||
| throw new Error(`Music creation failed${task.errorMessage ? `: ${task.errorMessage}` : ""}`); | ||
| } | ||
| if (spinner) spinner.text = `Creating music... (${String(i + 2)}/${maxAttempts})`; | ||
| } | ||
| spinner?.fail("Timed out"); | ||
| throw new CliTimeoutError(`Timed out after ${timeoutS}s`); | ||
| } | ||
| const lyricsIntervalMs = 5e3; | ||
| async function pollLyricsTaskUntilDone(client, taskId, options) { | ||
| const timeoutS = options.timeout ?? 120; | ||
| const maxAttempts = Math.ceil(timeoutS / (lyricsIntervalMs / 1e3)); | ||
| const spinner = options.json ? void 0 : ora({ text: `Creating lyrics... (1/${maxAttempts})` }).start(); | ||
| for (let i = 0; i < maxAttempts; i++) { | ||
| if (i > 0) await sleep(lyricsIntervalMs); | ||
| const task = await client.getLyricsTask(taskId); | ||
| if (task.status === "success") { | ||
| spinner?.succeed("Lyrics generated successfully"); | ||
| return task; | ||
| } | ||
| if (task.status === "failed") { | ||
| spinner?.fail("Lyrics generation failed"); | ||
| throw new Error(`Lyrics generation failed${task.errorMessage ? `: ${task.errorMessage}` : ""}`); | ||
| } | ||
| if (spinner) spinner.text = `Creating lyrics... (${String(i + 2)}/${maxAttempts})`; | ||
| } | ||
| spinner?.fail("Timed out"); | ||
| throw new CliTimeoutError(`Timed out after ${timeoutS}s`); | ||
| } | ||
| //#endregion | ||
| //#region source/_shared/sources.ts | ||
| function buildSources(urls, texts) { | ||
| const sources = []; | ||
| for (const uri of urls ?? []) sources.push({ | ||
| type: "url", | ||
| uri | ||
| }); | ||
| for (const content of texts ?? []) sources.push({ | ||
| type: "text", | ||
| content | ||
| }); | ||
| return sources; | ||
| } | ||
| //#endregion | ||
| //#region source/_shared/speaker-resolver.ts | ||
| const defaultSpeakers = { | ||
| zh: ["CN-Man-Beijing-V2", "gaoqing3-bfb5c88a"], | ||
| en: ["cozy-man-english", "travel-girl-english"], | ||
| ja: ["tianzhongdunzi-5d612542", "1shenguhaoshivocals-c002bc47"] | ||
| }; | ||
| async function resolveSpeakers(client, options) { | ||
| if (options.speakerIds?.length) return options.speakerIds; | ||
| if (!options.speakerNames?.length) { | ||
| const defaults = defaultSpeakers[options.language] ?? defaultSpeakers.en; | ||
| const count = options.count ?? defaults.length; | ||
| return defaults.slice(0, count); | ||
| } | ||
| const { items } = await client.listSpeakers({ language: options.language }); | ||
| const resolved = []; | ||
| for (const name of options.speakerNames) { | ||
| const match = items.find((s) => s.name.toLowerCase() === name.toLowerCase()); | ||
| if (!match) { | ||
| const available = items.map((s) => s.name).join(", "); | ||
| throw new Error(`Speaker "${name}" not found. Available: ${available}`); | ||
| } | ||
| resolved.push(match.speakerInnerId); | ||
| } | ||
| return resolved; | ||
| } | ||
| //#endregion | ||
| //#region source/explainer/explainer.ts | ||
| async function createExplainer(client, options) { | ||
| const lang = options.lang ?? inferLanguage(options.query); | ||
| const speakers = await resolveSpeakers(client, { | ||
| speakerNames: options.speaker ? [options.speaker] : void 0, | ||
| speakerIds: options.speakerId ? [options.speakerId] : void 0, | ||
| language: lang, | ||
| count: 1 | ||
| }); | ||
| const size = options.imageSize; | ||
| const { aspectRatio } = options; | ||
| const { episodeId } = await client.createExplainerVideo({ | ||
| query: options.query, | ||
| sources: buildSources(options.sourceUrl, options.sourceText), | ||
| style: options.style, | ||
| skipAudio: options.skipAudio, | ||
| imageConfig: { | ||
| size, | ||
| aspectRatio | ||
| }, | ||
| template: { | ||
| type: "storybook", | ||
| mode: options.mode, | ||
| speakers, | ||
| language: lang, | ||
| style: options.style, | ||
| size, | ||
| aspectRatio | ||
| } | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) printJson({ episodeId }); | ||
| else console.log(`\u2713 Explainer submitted: ${episodeId}`); | ||
| return; | ||
| } | ||
| const detail = await pollUntilDone(client, episodeId, { | ||
| timeout: options.timeout, | ||
| label: "Creating explainer", | ||
| json: options.json | ||
| }); | ||
| if (options.json) printJson(detail); | ||
| else printDetail("Explainer created", [ | ||
| ["ID:", detail.id], | ||
| ["Title:", detail.topicDetail.title.data], | ||
| ["Status:", detail.processStatus] | ||
| ]); | ||
| } | ||
| async function listExplainerVideos(client, options) { | ||
| const { items } = await client.listExplainerVideos({ | ||
| page: options.page, | ||
| pageSize: options.pageSize | ||
| }); | ||
| if (options.json) { | ||
| printJson(items); | ||
| return; | ||
| } | ||
| printTable([ | ||
| "ID", | ||
| "Title", | ||
| "Status", | ||
| "Created" | ||
| ], items.map((episode) => [ | ||
| episode.id, | ||
| episode.title, | ||
| episode.processStatus, | ||
| new Date(episode.createdAt).toISOString().slice(0, 10) | ||
| ])); | ||
| } | ||
| //#endregion | ||
| //#region source/explainer/_cli.ts | ||
| function collect$4(value, previous) { | ||
| return [...previous, value]; | ||
| } | ||
| function register$7(program) { | ||
| const cmd = program.command("explainer").description("Explainer video generation"); | ||
| cmd.command("create").description("Create an explainer video").option("--query <text>", "Topic text").option("--source-url <url>", "Reference URL (repeatable)", collect$4, []).option("--source-text <text>", "Reference text (repeatable)", collect$4, []).option("--mode <mode>", "Generation mode: info, story", "info").option("--lang <lang>", "Language: en, zh, ja (auto-detected if omitted)").option("--speaker <name>", "Speaker name").option("--speaker-id <id>", "Speaker inner ID").option("--skip-audio", "Skip audio generation", false).option("--image-size <size>", "Image size: 2K, 4K", "2K").option("--aspect-ratio <ratio>", "Aspect ratio: 16:9, 9:16, 1:1", "16:9").option("--style <style>", "Visual style").option("--no-wait", "Return immediately without polling").option("--timeout <seconds>", "Polling timeout", Number, 300).option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await createExplainer(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd.command("list").description("List explainer videos").option("--page <n>", "Page number", Number, 1).option("--page-size <n>", "Items per page", Number, 20).option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await listExplainerVideos(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region source/_shared/upload.ts | ||
| const audioExtensions = new Set([ | ||
| ".mp3", | ||
| ".wav", | ||
| ".flac", | ||
| ".m4a", | ||
| ".ogg", | ||
| ".aac" | ||
| ]); | ||
| const imageExtensions = new Set([ | ||
| ".jpg", | ||
| ".jpeg", | ||
| ".png", | ||
| ".webp", | ||
| ".gif" | ||
| ]); | ||
| const maxSizeBytes = { | ||
| audio: 20 * 1024 * 1024, | ||
| image: 10 * 1024 * 1024 | ||
| }; | ||
| const categoryForType = { | ||
| audio: "episode", | ||
| image: "banana" | ||
| }; | ||
| const mimeTypes = new Map([ | ||
| [".mp3", "audio/mpeg"], | ||
| [".wav", "audio/wav"], | ||
| [".flac", "audio/flac"], | ||
| [".m4a", "audio/mp4"], | ||
| [".ogg", "audio/ogg"], | ||
| [".aac", "audio/aac"], | ||
| [".jpg", "image/jpeg"], | ||
| [".jpeg", "image/jpeg"], | ||
| [".png", "image/png"], | ||
| [".webp", "image/webp"], | ||
| [".gif", "image/gif"] | ||
| ]); | ||
| function allowedExtensions(accept) { | ||
| return accept === "audio" ? audioExtensions : imageExtensions; | ||
| } | ||
| async function resolveFileOrUrl(client, input, options) { | ||
| const trimmed = input.trim(); | ||
| if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return trimmed; | ||
| const filePath = path.resolve(trimmed); | ||
| try { | ||
| await access(filePath); | ||
| } catch { | ||
| throw new Error(`File not found: ${trimmed}`); | ||
| } | ||
| const ext = path.extname(filePath).toLowerCase(); | ||
| const allowed = allowedExtensions(options.accept); | ||
| if (!allowed.has(ext)) { | ||
| const expected = [...allowed].join(", "); | ||
| throw new Error(`Unsupported ${options.accept} format: ${ext} (expected: ${expected})`); | ||
| } | ||
| const fileStat = await stat(filePath); | ||
| const maxBytes = maxSizeBytes[options.accept]; | ||
| if (fileStat.size > maxBytes) { | ||
| const sizeMb = (fileStat.size / (1024 * 1024)).toFixed(1); | ||
| const maxMb = maxBytes / (1024 * 1024); | ||
| throw new Error(`File too large: ${sizeMb} MB (max ${String(maxMb)} MB for ${options.accept})`); | ||
| } | ||
| const contentType = mimeTypes.get(ext); | ||
| const fileKey = path.basename(filePath); | ||
| const category = categoryForType[options.accept]; | ||
| const { presignedUrl, fileUrl } = await client.createFileUpload({ | ||
| fileKey, | ||
| contentType, | ||
| category | ||
| }); | ||
| const buffer = await readFile(filePath); | ||
| const response = await fetch(presignedUrl, { | ||
| method: "PUT", | ||
| body: buffer, | ||
| headers: { | ||
| "Content-Type": contentType, | ||
| "Content-Length": String(buffer.length) | ||
| } | ||
| }); | ||
| if (!response.ok) throw new Error(`Upload failed: ${String(response.status)} ${response.statusText}`); | ||
| const { pathname } = new URL(fileUrl); | ||
| return `https://storage.googleapis.com${pathname}`; | ||
| } | ||
| //#endregion | ||
| //#region source/image/image.ts | ||
| async function createImage(client, options) { | ||
| const allReferences = [...options.reference, ...options.referenceUrl]; | ||
| if (allReferences.length > 5) throw new Error("Too many reference images (max 5)"); | ||
| const referenceImageUrls = allReferences.length > 0 ? await Promise.all(allReferences.map(async (ref) => resolveFileOrUrl(client, ref, { accept: "image" }))) : void 0; | ||
| const { imageId } = await client.createAIImage({ | ||
| prompt: options.prompt, | ||
| ...options.model && { model: options.model }, | ||
| ...options.lang && { language: options.lang }, | ||
| aspectRatio: options.aspectRatio, | ||
| imageSize: options.size, | ||
| ...referenceImageUrls && { referenceImageUrls } | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) printJson({ imageId }); | ||
| else console.log(`\u2713 Image submitted: ${imageId}`); | ||
| return; | ||
| } | ||
| const item = await pollImageUntilDone(client, imageId, { | ||
| timeout: options.timeout, | ||
| json: options.json | ||
| }); | ||
| if (options.json) printJson(item); | ||
| else printDetail("Image created", [ | ||
| ["ID:", item.id], | ||
| ["URL:", item.imageUrl], | ||
| ["Status:", item.status] | ||
| ]); | ||
| } | ||
| async function listImages(client, options) { | ||
| const { items } = await client.listAIImages({ | ||
| page: options.page, | ||
| pageSize: options.pageSize | ||
| }); | ||
| if (options.json) { | ||
| printJson(items); | ||
| return; | ||
| } | ||
| printTable([ | ||
| "ID", | ||
| "Prompt", | ||
| "Status", | ||
| "Created" | ||
| ], items.map((image) => [ | ||
| image.id, | ||
| image.prompt.slice(0, 40), | ||
| image.status, | ||
| new Date(image.createdAt).toISOString().slice(0, 10) | ||
| ])); | ||
| } | ||
| async function getImage(client, imageId, json) { | ||
| const item = await client.getAIImage(imageId); | ||
| if (json) { | ||
| printJson(item); | ||
| return; | ||
| } | ||
| printDetail("Image details", [ | ||
| ["ID:", item.id], | ||
| ["Prompt:", item.prompt], | ||
| ["URL:", item.imageUrl], | ||
| ["Size:", item.imageSize], | ||
| ["Ratio:", item.aspectRatio], | ||
| ["Status:", item.status], | ||
| ["Created:", new Date(item.createdAt).toISOString()] | ||
| ]); | ||
| } | ||
| //#endregion | ||
| //#region source/image/_cli.ts | ||
| function collect$3(value, previous) { | ||
| return [...previous, value]; | ||
| } | ||
| function register$6(program) { | ||
| const cmd = program.command("image").description("AI image generation"); | ||
| cmd.command("create").description("Create an AI image").requiredOption("--prompt <text>", "Image description").option("--model <model>", "Model name").option("--lang <lang>", "Prompt language hint").option("--aspect-ratio <ratio>", "Aspect ratio", "1:1").option("--size <size>", "Image size: 1K, 2K, 4K", "2K").option("--reference <path-or-url>", "Reference image, local file or URL (repeatable, max 5)", collect$3, []).option("--no-wait", "Return immediately without polling").option("--timeout <seconds>", "Polling timeout", Number, 120).option("-j, --json", "Output JSON", false).addOption(new Option("--reference-url <url>", "").hideHelp().argParser((value, previous) => [...previous, value]).default([])).action(async (options) => { | ||
| try { | ||
| await createImage(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd.command("list").description("List AI images").option("--page <n>", "Page number", Number, 1).option("--page-size <n>", "Items per page", Number, 20).option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await listImages(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd.command("get <id>").description("Get image details").option("-j, --json", "Output JSON", false).action(async (id, options) => { | ||
| try { | ||
| await getImage(await getClient(), id, options.json); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region source/lyrics/lyrics.ts | ||
| function formatDateTime$1(timestamp) { | ||
| const d = new Date(timestamp); | ||
| return `${d.toLocaleDateString("sv-SE")} ${d.toLocaleTimeString("en-GB", { hour12: false })}`; | ||
| } | ||
| function formatDate$1(timestamp) { | ||
| return new Date(timestamp).toLocaleDateString("sv-SE"); | ||
| } | ||
| function printLyricsDetail(task) { | ||
| if (task.status === "failed") { | ||
| const rows = [ | ||
| ["Task ID:", task.id], | ||
| ["Status:", task.status], | ||
| ["Error:", task.errorMessage], | ||
| ["Created:", formatDateTime$1(task.createdAt)] | ||
| ]; | ||
| console.log(`\u2717 Lyrics task\n`); | ||
| for (const [key, value] of rows) if (value !== void 0) console.log(` ${key.padEnd(10)} ${String(value)}`); | ||
| return; | ||
| } | ||
| console.log(`\u2713 Lyrics generated (${task.variants.length} variants)\n`); | ||
| for (const [i, variant] of task.variants.entries()) { | ||
| console.log(` \u2500\u2500 Variant ${String(i + 1)} \u2500\u2500`); | ||
| console.log(` Title: ${variant.title}\n`); | ||
| for (const line of variant.text.split("\n")) console.log(` ${line}`); | ||
| console.log(); | ||
| } | ||
| } | ||
| async function createGenerate$1(client, options) { | ||
| if (!options.prompt.trim()) throw new Error("Prompt is required"); | ||
| const result = await client.createLyrics({ prompt: options.prompt }); | ||
| if (!options.wait) { | ||
| if (options.json) printJson(result); | ||
| else console.log(`\u2713 Lyrics task submitted: ${result.taskId}`); | ||
| return; | ||
| } | ||
| const task = await pollLyricsTaskUntilDone(client, result.taskId, { | ||
| timeout: options.timeout, | ||
| json: options.json | ||
| }); | ||
| if (options.json) printJson(task); | ||
| else printLyricsDetail(task); | ||
| } | ||
| async function listTasks$1(client, options) { | ||
| const response = await client.listLyricsTasks({ | ||
| page: options.page, | ||
| pageSize: options.pageSize, | ||
| ...options.status && { status: options.status } | ||
| }); | ||
| if (options.json) { | ||
| printJson(response); | ||
| return; | ||
| } | ||
| printTable([ | ||
| "ID", | ||
| "Status", | ||
| "Prompt", | ||
| "Variants", | ||
| "Created" | ||
| ], response.items.map((task) => [ | ||
| task.id, | ||
| task.status, | ||
| task.params.prompt.length > 30 ? task.params.prompt.slice(0, 30) + "…" : task.params.prompt, | ||
| String(task.variants.length), | ||
| formatDate$1(task.createdAt) | ||
| ])); | ||
| } | ||
| async function getTask$1(client, taskId, json) { | ||
| const task = await client.getLyricsTask(taskId); | ||
| if (json) { | ||
| printJson(task); | ||
| return; | ||
| } | ||
| printLyricsDetail(task); | ||
| } | ||
| //#endregion | ||
| //#region source/lyrics/_cli.ts | ||
| function register$5(program) { | ||
| const cmd = program.command("lyrics").description("Lyrics generation"); | ||
| cmd.command("generate").description("Generate lyrics from a text prompt").requiredOption("--prompt <text>", "Lyrics description (max 200 chars)").option("--no-wait", "Return immediately without polling").option("--timeout <seconds>", "Polling timeout", Number, 120).option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await createGenerate$1(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd.command("list").description("List lyrics tasks").option("--page <n>", "Page number", Number, 1).option("--page-size <n>", "Items per page", Number, 20).option("--status <status>", "Filter by status (pending, generating, success, failed)").option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await listTasks$1(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd.command("get <taskId>").description("Get lyrics task details").option("-j, --json", "Output JSON", false).action(async (taskId, options) => { | ||
| try { | ||
| await getTask$1(await getClient(), taskId, options.json); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region source/music/music.ts | ||
| function formatDuration(seconds) { | ||
| const m = Math.floor(seconds / 60); | ||
| const s = Math.floor(seconds % 60); | ||
| return `${String(m)}:${String(s).padStart(2, "0")}`; | ||
| } | ||
| function formatDate(timestamp) { | ||
| return new Date(timestamp).toLocaleDateString("sv-SE"); | ||
| } | ||
| function formatDateTime(timestamp) { | ||
| const d = new Date(timestamp); | ||
| return `${d.toLocaleDateString("sv-SE")} ${d.toLocaleTimeString("en-GB", { hour12: false })}`; | ||
| } | ||
| function printMusicDetail(task) { | ||
| const rows = [ | ||
| ["Task ID:", task.id], | ||
| ["Type:", task.taskType.toLowerCase()], | ||
| ["Status:", task.status] | ||
| ]; | ||
| if (task.status === "failed") rows.push(["Error:", task.errorMessage]); | ||
| else { | ||
| const trackTitle = task.tracks[0]?.title ?? task.params.title; | ||
| if (trackTitle) rows.push(["Title:", trackTitle]); | ||
| rows.push(["Tracks:", task.tracks.length]); | ||
| for (const [i, track] of task.tracks.entries()) rows.push([`Track ${String(i + 1)}:`, `${track.audioUrl} (${formatDuration(track.duration)})`]); | ||
| } | ||
| rows.push(["Created:", formatDateTime(task.createdAt)]); | ||
| if (task.status === "failed") { | ||
| console.log(`\u2717 Music task\n`); | ||
| for (const [key, value] of rows) if (value !== void 0) console.log(` ${key.padEnd(10)} ${String(value)}`); | ||
| } else printDetail("Music task", rows); | ||
| } | ||
| async function createGenerate(client, options) { | ||
| if (!options.prompt.trim()) throw new Error("Prompt is required"); | ||
| const result = await client.createMusicGenerate({ | ||
| prompt: options.prompt, | ||
| ...options.style && { style: options.style }, | ||
| ...options.title && { title: options.title }, | ||
| ...options.instrumental && { instrumental: true } | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) printJson(result); | ||
| else console.log(`\u2713 Music task submitted: ${result.taskId}`); | ||
| return; | ||
| } | ||
| const task = await pollMusicTaskUntilDone(client, result.taskId, { | ||
| timeout: options.timeout, | ||
| json: options.json | ||
| }); | ||
| if (options.json) printJson(task); | ||
| else printMusicDetail(task); | ||
| } | ||
| async function createCover(client, options) { | ||
| const uploadUrl = await resolveFileOrUrl(client, options.audio, { accept: "audio" }); | ||
| const result = await client.createMusicCover({ | ||
| uploadUrl, | ||
| ...options.prompt && { prompt: options.prompt }, | ||
| ...options.style && { style: options.style }, | ||
| ...options.title && { title: options.title }, | ||
| ...options.instrumental && { instrumental: true } | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) printJson(result); | ||
| else console.log(`\u2713 Music task submitted: ${result.taskId}`); | ||
| return; | ||
| } | ||
| const task = await pollMusicTaskUntilDone(client, result.taskId, { | ||
| timeout: options.timeout, | ||
| json: options.json | ||
| }); | ||
| if (options.json) printJson(task); | ||
| else printMusicDetail(task); | ||
| } | ||
| async function createExtend(client, options) { | ||
| const parameters = { | ||
| uploadUrl: await resolveFileOrUrl(client, options.audio, { accept: "audio" }), | ||
| model: options.model, | ||
| continueAt: options.continueAt, | ||
| ...options.prompt && { prompt: options.prompt }, | ||
| ...options.style && { style: options.style }, | ||
| ...options.title && { title: options.title }, | ||
| ...options.instrumental && { instrumental: true }, | ||
| ...options.negativeTags && { negativeTags: options.negativeTags }, | ||
| ...options.vocalGender && { vocalGender: options.vocalGender }, | ||
| ...options.styleWeight !== void 0 && { styleWeight: options.styleWeight }, | ||
| ...options.weirdness !== void 0 && { weirdnessConstraint: options.weirdness }, | ||
| ...options.audioWeight !== void 0 && { audioWeight: options.audioWeight } | ||
| }; | ||
| const result = await client.createMusicExtend(parameters); | ||
| if (!options.wait) { | ||
| if (options.json) printJson(result); | ||
| else console.log(`\u2713 Music task submitted: ${result.taskId}`); | ||
| return; | ||
| } | ||
| const task = await pollMusicTaskUntilDone(client, result.taskId, { | ||
| timeout: options.timeout, | ||
| json: options.json | ||
| }); | ||
| if (options.json) printJson(task); | ||
| else printMusicDetail(task); | ||
| } | ||
| async function listTasks(client, options) { | ||
| const { items } = await client.listMusicTasks({ | ||
| page: options.page, | ||
| pageSize: options.pageSize, | ||
| ...options.status && { status: options.status } | ||
| }); | ||
| if (options.json) { | ||
| printJson({ items }); | ||
| return; | ||
| } | ||
| printTable([ | ||
| "ID", | ||
| "Type", | ||
| "Status", | ||
| "Title", | ||
| "Tracks", | ||
| "Created" | ||
| ], items.map((task) => [ | ||
| task.id, | ||
| task.taskType.toLowerCase(), | ||
| task.status, | ||
| task.tracks[0]?.title ?? task.params.title ?? "—", | ||
| String(task.tracks.length), | ||
| formatDate(task.createdAt) | ||
| ])); | ||
| } | ||
| async function getTask(client, taskId, json) { | ||
| const task = await client.getMusicTask(taskId); | ||
| if (json) { | ||
| printJson(task); | ||
| return; | ||
| } | ||
| printMusicDetail(task); | ||
| } | ||
| //#endregion | ||
| //#region source/music/_cli.ts | ||
| function register$4(program) { | ||
| const cmd = program.command("music").description("Music generation"); | ||
| cmd.command("generate").description("Generate music from a text prompt").requiredOption("--prompt <text>", "Music description").option("--style <text>", "Music style/mood").option("--title <text>", "Track title").option("--instrumental", "Instrumental only, no vocals", false).option("--no-wait", "Return immediately without polling").option("--timeout <seconds>", "Polling timeout", Number, 600).option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await createGenerate(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd.command("cover").description("Create a cover from reference audio").requiredOption("--audio <path-or-url>", "Reference audio file or URL").option("--prompt <text>", "Music description").option("--style <text>", "Music style/mood").option("--title <text>", "Track title").option("--instrumental", "Instrumental only, no vocals", false).option("--no-wait", "Return immediately without polling").option("--timeout <seconds>", "Polling timeout", Number, 600).option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await createCover(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd.command("extend").description("Extend music from reference audio").requiredOption("--audio <path-or-url>", "Reference audio file or URL").requiredOption("--model <version>", "Model version (V4, V4_5, V4_5PLUS, V4_5ALL, V5, V5_5)").requiredOption("--continue-at <seconds>", "Start extending from this time point", Number).option("--prompt <text>", "Lyrics or description").option("--style <text>", "Music style/mood").option("--title <text>", "Track title").option("--instrumental", "Instrumental only, no vocals", false).option("--negative-tags <text>", "Styles to exclude").option("--vocal-gender <gender>", "Vocal gender (m or f)").option("--style-weight <weight>", "Style guidance weight (0-1)", Number).option("--weirdness <weight>", "Creativity/weirdness constraint (0-1)", Number).option("--audio-weight <weight>", "Input audio influence weight (0-1)", Number).option("--no-wait", "Return immediately without polling").option("--timeout <seconds>", "Polling timeout", Number, 600).option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await createExtend(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd.command("list").description("List music tasks").option("--page <n>", "Page number", Number, 1).option("--page-size <n>", "Items per page", Number, 20).option("--status <status>", "Filter by status (pending, generating, uploading, success, failed)").option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await listTasks(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd.command("get <taskId>").description("Get music task details").option("-j, --json", "Output JSON", false).action(async (taskId, options) => { | ||
| try { | ||
| await getTask(await getClient(), taskId, options.json); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region source/podcast/podcast.ts | ||
| async function createPodcast(client, options) { | ||
| const lang = options.lang ?? inferLanguage(options.query); | ||
| const speakers = await resolveSpeakers(client, { | ||
| speakerNames: options.speaker, | ||
| speakerIds: options.speakerId, | ||
| language: lang | ||
| }); | ||
| const { episodeId } = await client.createPodcast({ | ||
| type: speakers.length <= 1 ? "podcast-solo" : "podcast-duo", | ||
| query: options.query, | ||
| sources: buildSources(options.sourceUrl, options.sourceText), | ||
| template: { | ||
| type: "podcast", | ||
| mode: options.mode, | ||
| speakers, | ||
| language: lang | ||
| } | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) printJson({ episodeId }); | ||
| else console.log(`\u2713 Podcast submitted: ${episodeId}`); | ||
| return; | ||
| } | ||
| const detail = await pollUntilDone(client, episodeId, { | ||
| timeout: options.timeout, | ||
| label: "Creating podcast", | ||
| json: options.json | ||
| }); | ||
| if (options.json) printJson(detail); | ||
| else printDetail("Podcast created", [ | ||
| ["ID:", detail.id], | ||
| ["Title:", detail.topicDetail.title.data], | ||
| ["Status:", detail.processStatus] | ||
| ]); | ||
| } | ||
| async function listPodcasts(client, options) { | ||
| const { items } = await client.listPodcasts({ | ||
| page: options.page, | ||
| pageSize: options.pageSize | ||
| }); | ||
| if (options.json) { | ||
| printJson(items); | ||
| return; | ||
| } | ||
| printTable([ | ||
| "ID", | ||
| "Title", | ||
| "Status", | ||
| "Created" | ||
| ], items.map((episode) => [ | ||
| episode.id, | ||
| episode.title, | ||
| episode.processStatus, | ||
| new Date(episode.createdAt).toISOString().slice(0, 10) | ||
| ])); | ||
| } | ||
| //#endregion | ||
| //#region source/podcast/_cli.ts | ||
| function collect$2(value, previous) { | ||
| return [...previous, value]; | ||
| } | ||
| function register$3(program) { | ||
| const cmd = program.command("podcast").description("Podcast generation"); | ||
| cmd.command("create").description("Create a podcast episode").option("--query <text>", "Topic text").option("--source-url <url>", "Reference URL (repeatable)", collect$2, []).option("--source-text <text>", "Reference text (repeatable)", collect$2, []).option("--mode <mode>", "Generation mode: quick, deep, debate", "quick").option("--lang <lang>", "Language: en, zh, ja (auto-detected if omitted)").option("--speaker <name>", "Speaker name (repeatable)", collect$2, []).option("--speaker-id <id>", "Speaker inner ID (repeatable)", collect$2, []).option("--no-wait", "Return immediately without polling").option("--timeout <seconds>", "Polling timeout", Number, 300).option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await createPodcast(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd.command("list").description("List podcast episodes").option("--page <n>", "Page number", Number, 1).option("--page-size <n>", "Items per page", Number, 20).option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await listPodcasts(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region source/slides/slides.ts | ||
| async function createSlides(client, options) { | ||
| const lang = options.lang ?? inferLanguage(options.query); | ||
| const speakers = await resolveSpeakers(client, { | ||
| speakerNames: options.speaker ? [options.speaker] : void 0, | ||
| speakerIds: options.speakerId ? [options.speakerId] : void 0, | ||
| language: lang, | ||
| count: 1 | ||
| }); | ||
| const size = options.imageSize; | ||
| const { aspectRatio } = options; | ||
| const { episodeId } = await client.createSlides({ | ||
| query: options.query, | ||
| sources: buildSources(options.sourceUrl, options.sourceText), | ||
| style: options.style, | ||
| skipAudio: options.skipAudio, | ||
| imageConfig: { | ||
| size, | ||
| aspectRatio | ||
| }, | ||
| template: { | ||
| type: "storybook", | ||
| mode: "slides", | ||
| speakers, | ||
| language: lang, | ||
| style: options.style, | ||
| size, | ||
| aspectRatio | ||
| } | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) printJson({ episodeId }); | ||
| else console.log(`\u2713 Slides submitted: ${episodeId}`); | ||
| return; | ||
| } | ||
| const detail = await pollUntilDone(client, episodeId, { | ||
| timeout: options.timeout, | ||
| label: "Creating slides", | ||
| json: options.json | ||
| }); | ||
| if (options.json) printJson(detail); | ||
| else printDetail("Slides created", [ | ||
| ["ID:", detail.id], | ||
| ["Title:", detail.topicDetail.title.data], | ||
| ["Status:", detail.processStatus] | ||
| ]); | ||
| } | ||
| async function listSlides(client, options) { | ||
| const { items } = await client.listSlides({ | ||
| page: options.page, | ||
| pageSize: options.pageSize | ||
| }); | ||
| if (options.json) { | ||
| printJson(items); | ||
| return; | ||
| } | ||
| printTable([ | ||
| "ID", | ||
| "Title", | ||
| "Status", | ||
| "Created" | ||
| ], items.map((episode) => [ | ||
| episode.id, | ||
| episode.title, | ||
| episode.processStatus, | ||
| new Date(episode.createdAt).toISOString().slice(0, 10) | ||
| ])); | ||
| } | ||
| //#endregion | ||
| //#region source/slides/_cli.ts | ||
| function collect$1(value, previous) { | ||
| return [...previous, value]; | ||
| } | ||
| function register$2(program) { | ||
| const cmd = program.command("slides").description("Slides generation"); | ||
| cmd.command("create").description("Create a slide deck").option("--query <text>", "Topic text").option("--source-url <url>", "Reference URL (repeatable)", collect$1, []).option("--source-text <text>", "Reference text (repeatable)", collect$1, []).option("--lang <lang>", "Language: en, zh, ja (auto-detected if omitted)").option("--speaker <name>", "Speaker name").option("--speaker-id <id>", "Speaker inner ID").option("--no-skip-audio", "Generate audio narration").option("--image-size <size>", "Image size: 2K, 4K", "2K").option("--aspect-ratio <ratio>", "Aspect ratio: 16:9, 9:16, 1:1", "16:9").option("--style <style>", "Visual style").option("--no-wait", "Return immediately without polling").option("--timeout <seconds>", "Polling timeout", Number, 300).option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await createSlides(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd.command("list").description("List slide decks").option("--page <n>", "Page number", Number, 1).option("--page-size <n>", "Items per page", Number, 20).option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await listSlides(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region source/speakers/speakers.ts | ||
| async function listSpeakers(client, options) { | ||
| const { items } = await client.listSpeakers({ language: options.lang }); | ||
| if (options.json) { | ||
| printJson(items); | ||
| return; | ||
| } | ||
| printTable([ | ||
| "Name", | ||
| "ID", | ||
| "Gender", | ||
| "Personality" | ||
| ], items.map((s) => [ | ||
| s.name, | ||
| s.speakerInnerId, | ||
| s.gender, | ||
| s.personality | ||
| ])); | ||
| } | ||
| //#endregion | ||
| //#region source/speakers/_cli.ts | ||
| function register$1(program) { | ||
| program.command("speakers").description("Manage speakers").command("list").description("List available speakers").option("--lang <lang>", "Filter by language (en, zh, ja)").option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await listSpeakers(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region source/tts/tts.ts | ||
| async function createTts(client, options) { | ||
| const lang = options.lang ?? inferLanguage(options.text); | ||
| const speakers = await resolveSpeakers(client, { | ||
| speakerNames: options.speaker ? [options.speaker] : void 0, | ||
| speakerIds: options.speakerId ? [options.speakerId] : void 0, | ||
| language: lang, | ||
| count: 1 | ||
| }); | ||
| const sources = options.text ? [{ | ||
| type: "text", | ||
| content: options.text | ||
| }] : buildSources(options.sourceUrl, options.sourceText); | ||
| const { episodeId } = await client.createTTS({ | ||
| sources, | ||
| template: { | ||
| type: "flowspeech", | ||
| mode: options.mode, | ||
| speakers, | ||
| language: lang | ||
| } | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) printJson({ episodeId }); | ||
| else console.log(`\u2713 TTS submitted: ${episodeId}`); | ||
| return; | ||
| } | ||
| const detail = await pollUntilDone(client, episodeId, { | ||
| timeout: options.timeout, | ||
| label: "Creating TTS", | ||
| json: options.json | ||
| }); | ||
| if (options.json) printJson(detail); | ||
| else printDetail("TTS created", [ | ||
| ["ID:", detail.id], | ||
| ["Title:", detail.topicDetail.title.data], | ||
| ["Status:", detail.processStatus] | ||
| ]); | ||
| } | ||
| async function listTts(client, options) { | ||
| const { items } = await client.listTTS({ | ||
| page: options.page, | ||
| pageSize: options.pageSize | ||
| }); | ||
| if (options.json) { | ||
| printJson(items); | ||
| return; | ||
| } | ||
| printTable([ | ||
| "ID", | ||
| "Title", | ||
| "Status", | ||
| "Created" | ||
| ], items.map((episode) => [ | ||
| episode.id, | ||
| episode.title, | ||
| episode.processStatus, | ||
| new Date(episode.createdAt).toISOString().slice(0, 10) | ||
| ])); | ||
| } | ||
| //#endregion | ||
| //#region source/tts/_cli.ts | ||
| function register(program) { | ||
| const cmd = program.command("tts").description("Text-to-speech generation"); | ||
| cmd.command("create").description("Create a TTS audio").option("--text <text>", "Text to convert to speech").option("--source-url <url>", "Reference URL (repeatable)", collect, []).option("--source-text <text>", "Reference text (repeatable)", collect, []).option("--mode <mode>", "Generation mode: smart, direct", "smart").option("--lang <lang>", "Language: en, zh, ja (auto-detected if omitted)").option("--speaker <name>", "Speaker name").option("--speaker-id <id>", "Speaker inner ID").option("--no-wait", "Return immediately without polling").option("--timeout <seconds>", "Polling timeout", Number, 300).option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await createTts(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd.command("list").description("List TTS episodes").option("--page <n>", "Page number", Number, 1).option("--page-size <n>", "Items per page", Number, 20).option("-j, --json", "Output JSON", false).action(async (options) => { | ||
| try { | ||
| await listTts(await getClient(), options); | ||
| } catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } | ||
| function collect(value, previous) { | ||
| return [...previous, value]; | ||
| } | ||
| //#endregion | ||
| //#region source/cli.ts | ||
| const program = new Command(); | ||
| program.name("listenhub").description("ListenHub CLI").version("0.1.0"); | ||
| register$9(program); | ||
| register$3(program); | ||
| register(program); | ||
| register$7(program); | ||
| register$2(program); | ||
| register$6(program); | ||
| register$4(program); | ||
| register$5(program); | ||
| register$1(program); | ||
| register$8(program); | ||
| program.parse(); | ||
| //#endregion | ||
| export {}; |
+44
-41
| { | ||
| "name": "@marswave/listenhub-cli", | ||
| "version": "0.0.3", | ||
| "description": "Command-line interface for ListenHub", | ||
| "repository": "marswaveai/listenhub-cli", | ||
| "type": "module", | ||
| "bin": { | ||
| "listenhub": "distribution/source/cli.js" | ||
| }, | ||
| "files": [ | ||
| "distribution/source" | ||
| ], | ||
| "license": "MIT", | ||
| "scripts": { | ||
| "prepublishOnly": "pnpm run build", | ||
| "clean": "del-cli distribution", | ||
| "dev": "pnpm run clean && tsc --watch", | ||
| "build": "pnpm run clean && tsc && chmod +x distribution/source/cli.js", | ||
| "pretest": "pnpm run build", | ||
| "test": "xo" | ||
| }, | ||
| "dependencies": { | ||
| "@marswave/listenhub-sdk": "^0.0.4", | ||
| "commander": "^14.0.3", | ||
| "open": "^10.0.0", | ||
| "ora": "^8.0.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@sindresorhus/tsconfig": "^8.1.0", | ||
| "@types/node": "^25.5.0", | ||
| "del-cli": "^7.0.0", | ||
| "typescript": "^6.0.2", | ||
| "xo": "^2.0.2" | ||
| }, | ||
| "engines": { | ||
| "node": ">=20" | ||
| }, | ||
| "pnpm": { | ||
| "overrides": { | ||
| "typescript": "^6.0.2" | ||
| } | ||
| } | ||
| "name": "@marswave/listenhub-cli", | ||
| "version": "0.0.4", | ||
| "description": "Command-line interface for ListenHub", | ||
| "license": "MIT", | ||
| "repository": "marswaveai/listenhub-cli", | ||
| "bin": { | ||
| "listenhub": "dist/cli.mjs" | ||
| }, | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "type": "module", | ||
| "scripts": { | ||
| "dev": "vp pack --watch", | ||
| "build": "vp pack", | ||
| "lint": "vp lint", | ||
| "lint:fix": "vp lint --fix", | ||
| "fmt": "vp fmt", | ||
| "fmt:check": "vp fmt --check", | ||
| "check": "vp check", | ||
| "test": "vp test run", | ||
| "test:watch": "vp test", | ||
| "ready": "vp check && vp test run", | ||
| "prepublishOnly": "pnpm run build" | ||
| }, | ||
| "dependencies": { | ||
| "@marswave/listenhub-sdk": "^0.0.4", | ||
| "commander": "^14.0.3", | ||
| "open": "^10.0.0", | ||
| "ora": "^8.0.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@sindresorhus/tsconfig": "^8.1.0", | ||
| "@types/node": "^25.5.0", | ||
| "picomatch": "^4.0.4", | ||
| "typescript": "^5.9.3", | ||
| "vite": "^8.0.3", | ||
| "vite-plus": "^0.1.18", | ||
| "vitest": "^4.0.0" | ||
| }, | ||
| "engines": { | ||
| "node": ">=20" | ||
| }, | ||
| "packageManager": "pnpm@10.32.1" | ||
| } |
+29
-29
@@ -43,5 +43,5 @@ # ListenHub CLI | ||
| | Command | Description | | ||
| |---------|-------------| | ||
| | `listenhub auth login` | Log in via browser OAuth | | ||
| | Command | Description | | ||
| | ----------------------- | ------------------------- | | ||
| | `listenhub auth login` | Log in via browser OAuth | | ||
| | `listenhub auth logout` | Log out and revoke tokens | | ||
@@ -52,37 +52,37 @@ | `listenhub auth status` | Show current login status | | ||
| | Command | Description | | ||
| |---------|-------------| | ||
| | `listenhub music generate` | Generate music from a text prompt | | ||
| | `listenhub music cover` | Create a cover from reference audio | | ||
| | `listenhub music list` | List music tasks | | ||
| | `listenhub music get <id>` | Get music task details | | ||
| | Command | Description | | ||
| | -------------------------- | ----------------------------------- | | ||
| | `listenhub music generate` | Generate music from a text prompt | | ||
| | `listenhub music cover` | Create a cover from reference audio | | ||
| | `listenhub music list` | List music tasks | | ||
| | `listenhub music get <id>` | Get music task details | | ||
| ### Content Creation | ||
| | Command | Description | | ||
| |---------|-------------| | ||
| | `listenhub podcast create` | Create a podcast episode | | ||
| | `listenhub podcast list` | List podcast episodes | | ||
| | `listenhub tts create` | Create text-to-speech audio | | ||
| | `listenhub tts list` | List TTS creations | | ||
| | `listenhub explainer create` | Create an explainer video | | ||
| | `listenhub explainer list` | List explainer videos | | ||
| | `listenhub slides create` | Create a slide deck | | ||
| | `listenhub slides list` | List slide decks | | ||
| | Command | Description | | ||
| | ---------------------------- | --------------------------- | | ||
| | `listenhub podcast create` | Create a podcast episode | | ||
| | `listenhub podcast list` | List podcast episodes | | ||
| | `listenhub tts create` | Create text-to-speech audio | | ||
| | `listenhub tts list` | List TTS creations | | ||
| | `listenhub explainer create` | Create an explainer video | | ||
| | `listenhub explainer list` | List explainer videos | | ||
| | `listenhub slides create` | Create a slide deck | | ||
| | `listenhub slides list` | List slide decks | | ||
| ### Images | ||
| | Command | Description | | ||
| |---------|-------------| | ||
| | `listenhub image create` | Generate an AI image | | ||
| | `listenhub image list` | List AI images | | ||
| | `listenhub image get <id>` | Get image details | | ||
| | Command | Description | | ||
| | -------------------------- | -------------------- | | ||
| | `listenhub image create` | Generate an AI image | | ||
| | `listenhub image list` | List AI images | | ||
| | `listenhub image get <id>` | Get image details | | ||
| ### Other | ||
| | Command | Description | | ||
| |---------|-------------| | ||
| | `listenhub speakers list` | List available speakers | | ||
| | `listenhub creation get <id>` | Get creation details | | ||
| | `listenhub creation delete <id...>` | Delete creations | | ||
| | Command | Description | | ||
| | ----------------------------------- | ----------------------- | | ||
| | `listenhub speakers list` | List available speakers | | ||
| | `listenhub creation get <id>` | Get creation details | | ||
| | `listenhub creation delete <id...>` | Delete creations | | ||
@@ -89,0 +89,0 @@ Run `listenhub <command> --help` for full options. |
+28
-28
@@ -43,36 +43,36 @@ # ListenHub CLI | ||
| | 命令 | 说明 | | ||
| |------|------| | ||
| | `listenhub auth login` | 浏览器 OAuth 登录 | | ||
| | `listenhub auth logout` | 登出并撤销 token | | ||
| | `listenhub auth status` | 查看登录状态 | | ||
| | 命令 | 说明 | | ||
| | ----------------------- | ----------------- | | ||
| | `listenhub auth login` | 浏览器 OAuth 登录 | | ||
| | `listenhub auth logout` | 登出并撤销 token | | ||
| | `listenhub auth status` | 查看登录状态 | | ||
| ### 音乐 | ||
| | 命令 | 说明 | | ||
| |------|------| | ||
| | 命令 | 说明 | | ||
| | -------------------------- | -------------------- | | ||
| | `listenhub music generate` | 根据文字描述生成音乐 | | ||
| | `listenhub music cover` | 用参考音频创建翻唱 | | ||
| | `listenhub music list` | 列出音乐任务 | | ||
| | `listenhub music get <id>` | 查看音乐任务详情 | | ||
| | `listenhub music cover` | 用参考音频创建翻唱 | | ||
| | `listenhub music list` | 列出音乐任务 | | ||
| | `listenhub music get <id>` | 查看音乐任务详情 | | ||
| ### 内容创作 | ||
| | 命令 | 说明 | | ||
| |------|------| | ||
| | `listenhub podcast create` | 创建播客 | | ||
| | `listenhub podcast list` | 列出播客 | | ||
| | `listenhub tts create` | 创建语音合成 | | ||
| | `listenhub tts list` | 列出语音合成 | | ||
| | 命令 | 说明 | | ||
| | ---------------------------- | ------------ | | ||
| | `listenhub podcast create` | 创建播客 | | ||
| | `listenhub podcast list` | 列出播客 | | ||
| | `listenhub tts create` | 创建语音合成 | | ||
| | `listenhub tts list` | 列出语音合成 | | ||
| | `listenhub explainer create` | 创建讲解视频 | | ||
| | `listenhub explainer list` | 列出讲解视频 | | ||
| | `listenhub slides create` | 创建幻灯片 | | ||
| | `listenhub slides list` | 列出幻灯片 | | ||
| | `listenhub explainer list` | 列出讲解视频 | | ||
| | `listenhub slides create` | 创建幻灯片 | | ||
| | `listenhub slides list` | 列出幻灯片 | | ||
| ### 图片 | ||
| | 命令 | 说明 | | ||
| |------|------| | ||
| | `listenhub image create` | AI 生图 | | ||
| | `listenhub image list` | 列出图片 | | ||
| | 命令 | 说明 | | ||
| | -------------------------- | ------------ | | ||
| | `listenhub image create` | AI 生图 | | ||
| | `listenhub image list` | 列出图片 | | ||
| | `listenhub image get <id>` | 查看图片详情 | | ||
@@ -82,7 +82,7 @@ | ||
| | 命令 | 说明 | | ||
| |------|------| | ||
| | `listenhub speakers list` | 列出可用声音 | | ||
| | `listenhub creation get <id>` | 查看作品详情 | | ||
| | `listenhub creation delete <id...>` | 删除作品 | | ||
| | 命令 | 说明 | | ||
| | ----------------------------------- | ------------ | | ||
| | `listenhub speakers list` | 列出可用声音 | | ||
| | `listenhub creation get <id>` | 查看作品详情 | | ||
| | `listenhub creation delete <id...>` | 删除作品 | | ||
@@ -89,0 +89,0 @@ 每个命令都可以加 `--help` 查看完整选项。 |
| import { ListenHubClient } from '@marswave/listenhub-sdk'; | ||
| import { loadCredentials, saveCredentials } from './credentials.js'; | ||
| import { CliAuthError } from './output.js'; | ||
| const refreshBufferMs = 60_000; | ||
| // Single-flight: if a refresh is already in progress, reuse the same promise | ||
| let refreshPromise; | ||
| async function ensureFreshCredentials() { | ||
| const creds = await loadCredentials(); | ||
| if (!creds) { | ||
| throw new CliAuthError('Not logged in. Run `listenhub auth login` first.'); | ||
| } | ||
| if (creds.expiresAt - Date.now() >= refreshBufferMs) { | ||
| return; // Still fresh | ||
| } | ||
| const temporaryClient = new ListenHubClient({ | ||
| accessToken: creds.accessToken, | ||
| }); | ||
| const tokens = await temporaryClient.refresh({ | ||
| refreshToken: creds.refreshToken, | ||
| }); | ||
| await saveCredentials({ | ||
| ...creds, | ||
| accessToken: tokens.accessToken, | ||
| refreshToken: tokens.refreshToken, | ||
| expiresAt: Date.now() + tokens.expiresIn * 1000, | ||
| }); | ||
| } | ||
| export async function getClient() { | ||
| // Single-flight: concurrent callers share one refresh | ||
| refreshPromise ??= ensureFreshCredentials().finally(() => { | ||
| refreshPromise = undefined; | ||
| }); | ||
| await refreshPromise; | ||
| const creds = await loadCredentials(); | ||
| if (!creds) { | ||
| throw new CliAuthError('Not logged in. Run `listenhub auth login` first.'); | ||
| } | ||
| return new ListenHubClient({ accessToken: creds.accessToken }); | ||
| } |
| import fs from 'node:fs'; | ||
| import os from 'node:os'; | ||
| import path from 'node:path'; | ||
| import process from 'node:process'; | ||
| function getConfigDir() { | ||
| const xdg = process.env['XDG_CONFIG_HOME']; | ||
| return path.join(xdg ?? path.join(os.homedir(), '.config'), 'listenhub'); | ||
| } | ||
| function getCredentialsPath() { | ||
| return path.join(getConfigDir(), 'credentials.json'); | ||
| } | ||
| export async function loadCredentials() { | ||
| const filePath = getCredentialsPath(); | ||
| try { | ||
| const raw = fs.readFileSync(filePath, 'utf8'); | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- JSON structure matches StoredCredentials | ||
| return JSON.parse(raw); | ||
| } | ||
| catch { | ||
| return undefined; | ||
| } | ||
| } | ||
| export async function saveCredentials(creds) { | ||
| const dir = getConfigDir(); | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| const filePath = getCredentialsPath(); | ||
| const temporaryPath = `${filePath}.tmp.${process.pid}`; | ||
| fs.writeFileSync(temporaryPath, JSON.stringify(creds, null, '\t'), { | ||
| mode: 0o600, | ||
| }); | ||
| fs.renameSync(temporaryPath, filePath); | ||
| } | ||
| export async function deleteCredentials() { | ||
| const filePath = getCredentialsPath(); | ||
| try { | ||
| fs.unlinkSync(filePath); | ||
| } | ||
| catch (error) { | ||
| // ENOENT is fine (already gone), anything else is a real problem | ||
| if (error instanceof Error && | ||
| 'code' in error && | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- checking errno code on Error with 'code' property | ||
| error.code === 'ENOENT') { | ||
| return; | ||
| } | ||
| throw error; | ||
| } | ||
| } |
| const cjkRegex = /[\u4E00-\u9FFF\u3400-\u4DBF]/v; | ||
| const kanaRegex = /[\u3040-\u309F\u30A0-\u30FF]/v; | ||
| export function inferLanguage(text) { | ||
| if (!text) | ||
| return 'en'; | ||
| if (kanaRegex.test(text)) | ||
| return 'ja'; | ||
| if (cjkRegex.test(text)) | ||
| return 'zh'; | ||
| return 'en'; | ||
| } |
| import process from 'node:process'; | ||
| import { ListenHubError } from '@marswave/listenhub-sdk'; | ||
| export function printJson(data) { | ||
| console.log(JSON.stringify(data, null, 2)); | ||
| } | ||
| export function printDetail(label, rows) { | ||
| console.log(`\u2713 ${label}\n`); | ||
| for (const [key, value] of rows) { | ||
| if (value !== undefined) { | ||
| console.log(` ${key.padEnd(10)} ${String(value)}`); | ||
| } | ||
| } | ||
| } | ||
| export function printTable(headers, rows) { | ||
| const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))); | ||
| console.log(' ' + headers.map((h, i) => h.padEnd(widths[i])).join(' ')); | ||
| for (const row of rows) { | ||
| console.log(' ' + row.map((c, i) => c.padEnd(widths[i])).join(' ')); | ||
| } | ||
| } | ||
| export class CliTimeoutError extends Error { | ||
| constructor(message) { | ||
| super(message); | ||
| this.name = 'CliTimeoutError'; | ||
| } | ||
| } | ||
| export class CliAuthError extends Error { | ||
| constructor(message) { | ||
| super(message); | ||
| this.name = 'CliAuthError'; | ||
| } | ||
| } | ||
| export function handleError(error, json) { | ||
| if (json) { | ||
| const message = error instanceof ListenHubError | ||
| ? { error: error.message, code: error.code, requestId: error.requestId } | ||
| : { | ||
| error: error instanceof Error ? error.message : String(error), | ||
| code: 'UNKNOWN', | ||
| }; | ||
| console.error(JSON.stringify(message, null, 2)); | ||
| } | ||
| else { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| console.error(`\u2717 Error: ${message}`); | ||
| } | ||
| if (error instanceof CliAuthError || | ||
| (error instanceof ListenHubError && | ||
| (error.status === 401 || error.status === 403))) { | ||
| process.exit(2); // eslint-disable-line unicorn/no-process-exit | ||
| } | ||
| if (error instanceof CliTimeoutError) { | ||
| process.exit(3); // eslint-disable-line unicorn/no-process-exit | ||
| } | ||
| process.exit(1); // eslint-disable-line unicorn/no-process-exit | ||
| } |
| import ora from 'ora'; | ||
| import { CliTimeoutError } from './output.js'; | ||
| const pollIntervalMs = 10_000; | ||
| const defaultTimeoutS = 300; | ||
| async function sleep(ms) { | ||
| return new Promise((resolve) => { | ||
| setTimeout(resolve, ms); | ||
| }); | ||
| } | ||
| export async function pollUntilDone(client, episodeId, options) { | ||
| const timeoutS = options.timeout ?? defaultTimeoutS; | ||
| const maxAttempts = Math.ceil(timeoutS / (pollIntervalMs / 1000)); | ||
| const spinner = options.json | ||
| ? undefined | ||
| : ora({ | ||
| text: `${options.label ?? 'Creating'}... (1/${maxAttempts})`, | ||
| }).start(); | ||
| for (let i = 0; i < maxAttempts; i++) { | ||
| if (i > 0) { | ||
| await sleep(pollIntervalMs); // eslint-disable-line no-await-in-loop | ||
| } | ||
| const detail = await client.getCreation(episodeId); // eslint-disable-line no-await-in-loop | ||
| if (detail.processStatus === 'success') { | ||
| spinner?.succeed(`${options.label ?? 'Created'} successfully`); | ||
| return detail; | ||
| } | ||
| if (detail.processStatus === 'fail') { | ||
| spinner?.fail('Creation failed'); | ||
| throw new Error(`Creation failed (code: ${detail.failCode})`); | ||
| } | ||
| if (spinner) { | ||
| spinner.text = `${options.label ?? 'Creating'}... (${String(i + 2)}/${maxAttempts})`; | ||
| } | ||
| } | ||
| spinner?.fail('Timed out'); | ||
| throw new CliTimeoutError(`Timed out after ${timeoutS}s`); | ||
| } | ||
| export async function pollImageUntilDone(client, imageId, options) { | ||
| const timeoutS = options.timeout ?? 120; | ||
| const maxAttempts = Math.ceil(timeoutS / (pollIntervalMs / 1000)); | ||
| const spinner = options.json | ||
| ? undefined | ||
| : ora({ text: `Creating image... (1/${maxAttempts})` }).start(); | ||
| for (let i = 0; i < maxAttempts; i++) { | ||
| if (i > 0) { | ||
| await sleep(pollIntervalMs); // eslint-disable-line no-await-in-loop | ||
| } | ||
| const item = await client.getAIImage(imageId); // eslint-disable-line no-await-in-loop | ||
| if (item.status === 'success') { | ||
| spinner?.succeed('Image created successfully'); | ||
| return item; | ||
| } | ||
| if (item.status === 'fail') { | ||
| spinner?.fail('Image creation failed'); | ||
| throw new Error('Image creation failed'); | ||
| } | ||
| if (spinner) { | ||
| spinner.text = `Creating image... (${String(i + 2)}/${maxAttempts})`; | ||
| } | ||
| } | ||
| spinner?.fail('Timed out'); | ||
| throw new CliTimeoutError(`Timed out after ${timeoutS}s`); | ||
| } | ||
| export async function pollMusicTaskUntilDone(client, taskId, options) { | ||
| const timeoutS = options.timeout ?? 600; | ||
| const maxAttempts = Math.ceil(timeoutS / (pollIntervalMs / 1000)); | ||
| const spinner = options.json | ||
| ? undefined | ||
| : ora({ text: `Creating music... (1/${maxAttempts})` }).start(); | ||
| for (let i = 0; i < maxAttempts; i++) { | ||
| if (i > 0) { | ||
| await sleep(pollIntervalMs); // eslint-disable-line no-await-in-loop | ||
| } | ||
| const task = await client.getMusicTask(taskId); // eslint-disable-line no-await-in-loop | ||
| if (task.status === 'success') { | ||
| spinner?.succeed('Music created successfully'); | ||
| return task; | ||
| } | ||
| if (task.status === 'failed') { | ||
| spinner?.fail('Music creation failed'); | ||
| throw new Error(`Music creation failed${task.errorMessage ? `: ${task.errorMessage}` : ''}`); | ||
| } | ||
| if (spinner) { | ||
| spinner.text = `Creating music... (${String(i + 2)}/${maxAttempts})`; | ||
| } | ||
| } | ||
| spinner?.fail('Timed out'); | ||
| throw new CliTimeoutError(`Timed out after ${timeoutS}s`); | ||
| } | ||
| const lyricsIntervalMs = 5000; | ||
| export async function pollLyricsTaskUntilDone(client, taskId, options) { | ||
| const timeoutS = options.timeout ?? 120; | ||
| const maxAttempts = Math.ceil(timeoutS / (lyricsIntervalMs / 1000)); | ||
| const spinner = options.json | ||
| ? undefined | ||
| : ora({ text: `Creating lyrics... (1/${maxAttempts})` }).start(); | ||
| for (let i = 0; i < maxAttempts; i++) { | ||
| if (i > 0) { | ||
| await sleep(lyricsIntervalMs); // eslint-disable-line no-await-in-loop | ||
| } | ||
| const task = await client.getLyricsTask(taskId); // eslint-disable-line no-await-in-loop | ||
| if (task.status === 'success') { | ||
| spinner?.succeed('Lyrics generated successfully'); | ||
| return task; | ||
| } | ||
| if (task.status === 'failed') { | ||
| spinner?.fail('Lyrics generation failed'); | ||
| throw new Error(`Lyrics generation failed${task.errorMessage ? `: ${task.errorMessage}` : ''}`); | ||
| } | ||
| if (spinner) { | ||
| spinner.text = `Creating lyrics... (${String(i + 2)}/${maxAttempts})`; | ||
| } | ||
| } | ||
| spinner?.fail('Timed out'); | ||
| throw new CliTimeoutError(`Timed out after ${timeoutS}s`); | ||
| } |
| export function buildSources(urls, texts) { | ||
| const sources = []; | ||
| for (const uri of urls ?? []) { | ||
| sources.push({ type: 'url', uri }); | ||
| } | ||
| for (const content of texts ?? []) { | ||
| sources.push({ type: 'text', content }); | ||
| } | ||
| return sources; | ||
| } |
| // Default speaker innerIds per language (confirmed from skills shared/speaker-selection.md) | ||
| const defaultSpeakers = { | ||
| zh: ['CN-Man-Beijing-V2', 'gaoqing3-bfb5c88a'], | ||
| en: ['cozy-man-english', 'travel-girl-english'], | ||
| ja: ['tianzhongdunzi-5d612542', '1shenguhaoshivocals-c002bc47'], | ||
| }; | ||
| export async function resolveSpeakers(client, options) { | ||
| // Direct IDs bypass resolution | ||
| if (options.speakerIds?.length) { | ||
| return options.speakerIds; | ||
| } | ||
| // No speaker specified -> use defaults | ||
| if (!options.speakerNames?.length) { | ||
| const defaults = defaultSpeakers[options.language] ?? defaultSpeakers.en; | ||
| const count = options.count ?? defaults.length; | ||
| return defaults.slice(0, count); | ||
| } | ||
| // Resolve names via API | ||
| const { items } = await client.listSpeakers({ language: options.language }); | ||
| const resolved = []; | ||
| for (const name of options.speakerNames) { | ||
| const match = items.find((s) => s.name.toLowerCase() === name.toLowerCase()); | ||
| if (!match) { | ||
| const available = items.map((s) => s.name).join(', '); | ||
| throw new Error(`Speaker "${name}" not found. Available: ${available}`); | ||
| } | ||
| resolved.push(match.speakerInnerId); | ||
| } | ||
| return resolved; | ||
| } |
| import { access, readFile, stat } from 'node:fs/promises'; | ||
| import path from 'node:path'; | ||
| const audioExtensions = new Set([ | ||
| '.mp3', | ||
| '.wav', | ||
| '.flac', | ||
| '.m4a', | ||
| '.ogg', | ||
| '.aac', | ||
| ]); | ||
| const imageExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']); | ||
| const maxSizeBytes = { | ||
| audio: 20 * 1024 * 1024, | ||
| image: 10 * 1024 * 1024, | ||
| }; | ||
| const categoryForType = { | ||
| audio: 'episode', | ||
| image: 'banana', | ||
| }; | ||
| const mimeTypes = new Map([ | ||
| ['.mp3', 'audio/mpeg'], | ||
| ['.wav', 'audio/wav'], | ||
| ['.flac', 'audio/flac'], | ||
| ['.m4a', 'audio/mp4'], | ||
| ['.ogg', 'audio/ogg'], | ||
| ['.aac', 'audio/aac'], | ||
| ['.jpg', 'image/jpeg'], | ||
| ['.jpeg', 'image/jpeg'], | ||
| ['.png', 'image/png'], | ||
| ['.webp', 'image/webp'], | ||
| ['.gif', 'image/gif'], | ||
| ]); | ||
| function allowedExtensions(accept) { | ||
| return accept === 'audio' ? audioExtensions : imageExtensions; | ||
| } | ||
| export async function resolveFileOrUrl(client, input, options) { | ||
| const trimmed = input.trim(); | ||
| // URL — pass through | ||
| if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { | ||
| return trimmed; | ||
| } | ||
| // Local file — resolve to absolute path | ||
| const filePath = path.resolve(trimmed); | ||
| // Existence check | ||
| try { | ||
| await access(filePath); | ||
| } | ||
| catch { | ||
| throw new Error(`File not found: ${trimmed}`); | ||
| } | ||
| // Extension check | ||
| const ext = path.extname(filePath).toLowerCase(); | ||
| const allowed = allowedExtensions(options.accept); | ||
| if (!allowed.has(ext)) { | ||
| const expected = [...allowed].join(', '); | ||
| throw new Error(`Unsupported ${options.accept} format: ${ext} (expected: ${expected})`); | ||
| } | ||
| // Size check | ||
| const fileStat = await stat(filePath); | ||
| const maxBytes = maxSizeBytes[options.accept]; | ||
| if (fileStat.size > maxBytes) { | ||
| const sizeMb = (fileStat.size / (1024 * 1024)).toFixed(1); | ||
| const maxMb = maxBytes / (1024 * 1024); | ||
| throw new Error(`File too large: ${sizeMb} MB (max ${String(maxMb)} MB for ${options.accept})`); | ||
| } | ||
| // Get presigned upload URL | ||
| const contentType = mimeTypes.get(ext); | ||
| const fileKey = path.basename(filePath); | ||
| const category = categoryForType[options.accept]; | ||
| const { presignedUrl, fileUrl } = await client.createFileUpload({ | ||
| fileKey, | ||
| contentType, | ||
| category, | ||
| }); | ||
| // Upload to GCS | ||
| const buffer = await readFile(filePath); | ||
| const response = await fetch(presignedUrl, { | ||
| method: 'PUT', | ||
| body: buffer, | ||
| headers: { | ||
| 'Content-Type': contentType, | ||
| 'Content-Length': String(buffer.length), | ||
| }, | ||
| }); | ||
| if (!response.ok) { | ||
| throw new Error(`Upload failed: ${String(response.status)} ${response.statusText}`); | ||
| } | ||
| // Return a storage.googleapis.com URL so the server's resolveUploadUrl | ||
| // can correctly strip the bucket name prefix and re-sign for downstream use. | ||
| const { pathname } = new URL(fileUrl); | ||
| return `https://storage.googleapis.com${pathname}`; | ||
| } |
| import { handleError } from '../_shared/output.js'; | ||
| import { runLogin, runLogout, runStatus } from './auth.js'; | ||
| export function register(program) { | ||
| const auth = program.command('auth').description('Manage authentication'); | ||
| auth | ||
| .command('login') | ||
| .description('Log in via browser OAuth') | ||
| .action(async () => { | ||
| try { | ||
| await runLogin(); | ||
| } | ||
| catch (error) { | ||
| handleError(error, false); | ||
| } | ||
| }); | ||
| auth | ||
| .command('logout') | ||
| .description('Log out and revoke tokens') | ||
| .action(async () => { | ||
| try { | ||
| await runLogout(); | ||
| } | ||
| catch (error) { | ||
| handleError(error, false); | ||
| } | ||
| }); | ||
| auth | ||
| .command('status') | ||
| .description('Show current login status') | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| await runStatus(options.json); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } |
| import process from 'node:process'; | ||
| import { ListenHubClient } from '@marswave/listenhub-sdk'; | ||
| import { deleteCredentials, loadCredentials, saveCredentials, } from '../_shared/credentials.js'; | ||
| import { startCallbackServer } from './login-server.js'; | ||
| export async function runLogin() { | ||
| const server = await startCallbackServer(); | ||
| try { | ||
| const client = new ListenHubClient(); | ||
| const { sessionId, authUrl } = await client.connectInit({ | ||
| callbackPort: server.port, | ||
| }); | ||
| console.error('Opening browser for login...'); | ||
| // Dynamic import because `open` is ESM-only | ||
| const { default: open } = await import('open'); | ||
| await open(authUrl); | ||
| const { code } = await server.waitForCode(); | ||
| const tokens = await client.connectToken({ sessionId, code }); | ||
| await saveCredentials({ | ||
| accessToken: tokens.accessToken, | ||
| refreshToken: tokens.refreshToken, | ||
| expiresAt: Date.now() + tokens.expiresIn * 1000, | ||
| }); | ||
| // Fetch username to confirm | ||
| const authedClient = new ListenHubClient({ | ||
| accessToken: tokens.accessToken, | ||
| }); | ||
| const user = await authedClient.getCurrentUser(); | ||
| console.log(`\u2713 Logged in as ${user.nickname || user.email || 'user'}`); | ||
| } | ||
| finally { | ||
| server.close(); | ||
| } | ||
| } | ||
| export async function runLogout() { | ||
| const creds = await loadCredentials(); | ||
| if (creds?.refreshToken) { | ||
| try { | ||
| const client = new ListenHubClient({ accessToken: creds.accessToken }); | ||
| await client.revoke({ refreshToken: creds.refreshToken }); | ||
| } | ||
| catch { | ||
| console.error('Warning: remote revoke failed, local credentials cleared'); | ||
| } | ||
| } | ||
| await deleteCredentials(); | ||
| console.log('\u2713 Logged out'); | ||
| } | ||
| export async function runStatus(json) { | ||
| const creds = await loadCredentials(); | ||
| if (!creds) { | ||
| if (json) { | ||
| console.log(JSON.stringify({ loggedIn: false })); | ||
| } | ||
| else { | ||
| console.log('Not logged in'); | ||
| } | ||
| process.exit(1); // eslint-disable-line unicorn/no-process-exit | ||
| } | ||
| try { | ||
| const client = new ListenHubClient({ accessToken: creds.accessToken }); | ||
| const user = await client.getCurrentUser(); | ||
| const expiresAt = new Date(creds.expiresAt).toISOString(); | ||
| if (json) { | ||
| console.log(JSON.stringify({ | ||
| loggedIn: true, | ||
| user: user.nickname, | ||
| email: user.email, | ||
| expiresAt, | ||
| }, null, 2)); | ||
| } | ||
| else { | ||
| console.log(`\u2713 Logged in as ${user.nickname || 'user'}`); | ||
| console.log(` Email: ${user.email}`); | ||
| console.log(` Expires at: ${expiresAt}`); | ||
| } | ||
| } | ||
| catch { | ||
| if (json) { | ||
| console.log(JSON.stringify({ loggedIn: false, error: 'Token expired or invalid' })); | ||
| } | ||
| else { | ||
| console.log('Not logged in (token expired or invalid)'); | ||
| } | ||
| process.exit(1); // eslint-disable-line unicorn/no-process-exit | ||
| } | ||
| } |
| import http from 'node:http'; | ||
| const loginTimeoutMs = 5 * 60 * 1000; // 5 minutes | ||
| export async function startCallbackServer() { | ||
| let resolveCode; | ||
| let rejectCode; | ||
| const codePromise = new Promise((resolve, reject) => { | ||
| resolveCode = resolve; | ||
| rejectCode = reject; | ||
| }); | ||
| const timeout = setTimeout(() => { | ||
| rejectCode(new Error('Login timed out after 5 minutes. Please try again.')); | ||
| }, loginTimeoutMs); | ||
| const server = http.createServer((request, response) => { | ||
| const url = new URL(request.url, 'http://localhost'); | ||
| const code = url.searchParams.get('code'); | ||
| const error = url.searchParams.get('error'); | ||
| if (error) { | ||
| const description = url.searchParams.get('error_description') ?? error; | ||
| response.writeHead(200, { 'Content-Type': 'text/plain' }); | ||
| response.end(`Login failed: ${description}`); | ||
| clearTimeout(timeout); | ||
| rejectCode(new Error(`OAuth error: ${description}`)); | ||
| return; | ||
| } | ||
| if (code) { | ||
| response.writeHead(200, { 'Content-Type': 'text/html' }); | ||
| response.end('<html><body><h1>Login successful!</h1><p>You can close this tab.</p></body></html>'); | ||
| clearTimeout(timeout); | ||
| resolveCode({ code }); | ||
| } | ||
| else { | ||
| response.writeHead(400, { 'Content-Type': 'text/plain' }); | ||
| response.end('Missing code parameter'); | ||
| } | ||
| }); | ||
| await new Promise((resolve) => { | ||
| server.listen(0, '127.0.0.1', resolve); | ||
| }); | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- server.listen on '127.0.0.1' always returns AddressInfo | ||
| const { port } = server.address(); | ||
| return { | ||
| port, | ||
| waitForCode: async () => codePromise, | ||
| close() { | ||
| clearTimeout(timeout); | ||
| server.close(); | ||
| }, | ||
| }; | ||
| } |
| #!/usr/bin/env node | ||
| import { Command } from 'commander'; | ||
| import { register as registerAuth } from './auth/_cli.js'; | ||
| import { register as registerCreation } from './creation/_cli.js'; | ||
| import { register as registerExplainer } from './explainer/_cli.js'; | ||
| import { register as registerImage } from './image/_cli.js'; | ||
| import { register as registerLyrics } from './lyrics/_cli.js'; | ||
| import { register as registerMusic } from './music/_cli.js'; | ||
| import { register as registerPodcast } from './podcast/_cli.js'; | ||
| import { register as registerSlides } from './slides/_cli.js'; | ||
| import { register as registerSpeakers } from './speakers/_cli.js'; | ||
| import { register as registerTts } from './tts/_cli.js'; | ||
| const program = new Command(); | ||
| program.name('listenhub').description('ListenHub CLI').version('0.1.0'); | ||
| registerAuth(program); | ||
| registerPodcast(program); | ||
| registerTts(program); | ||
| registerExplainer(program); | ||
| registerSlides(program); | ||
| registerImage(program); | ||
| registerMusic(program); | ||
| registerLyrics(program); | ||
| registerSpeakers(program); | ||
| registerCreation(program); | ||
| program.parse(); |
| import { getClient } from '../_shared/client.js'; | ||
| import { handleError } from '../_shared/output.js'; | ||
| import { deleteCreations, getCreation } from './creation.js'; | ||
| export function register(program) { | ||
| const cmd = program.command('creation').description('Manage creations'); | ||
| cmd | ||
| .command('get <id>') | ||
| .description('Get creation details') | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (id, options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await getCreation(client, id, options.json); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd | ||
| .command('delete <id...>') | ||
| .description('Delete one or more creations') | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (ids, options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await deleteCreations(client, ids, options.json); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } |
| import { printDetail, printJson } from '../_shared/output.js'; | ||
| export async function getCreation(client, episodeId, json) { | ||
| const detail = await client.getCreation(episodeId); | ||
| if (json) { | ||
| printJson(detail); | ||
| return; | ||
| } | ||
| printDetail('Creation details', [ | ||
| ['ID:', detail.id], | ||
| ['Type:', detail.generationType], | ||
| ['Status:', detail.processStatus], | ||
| ['Language:', detail.language], | ||
| ['Created:', new Date(detail.createdAt).toISOString()], | ||
| ]); | ||
| } | ||
| export async function deleteCreations(client, ids, json) { | ||
| await client.deleteCreations({ ids }); | ||
| if (json) { | ||
| printJson({ deleted: ids }); | ||
| } | ||
| else { | ||
| console.log(`\u2713 Deleted ${String(ids.length)} creation(s)`); | ||
| } | ||
| } |
| import { getClient } from '../_shared/client.js'; | ||
| import { handleError } from '../_shared/output.js'; | ||
| import { createExplainer, listExplainerVideos, } from './explainer.js'; | ||
| function collect(value, previous) { | ||
| return [...previous, value]; | ||
| } | ||
| export function register(program) { | ||
| const cmd = program | ||
| .command('explainer') | ||
| .description('Explainer video generation'); | ||
| cmd | ||
| .command('create') | ||
| .description('Create an explainer video') | ||
| .option('--query <text>', 'Topic text') | ||
| .option('--source-url <url>', 'Reference URL (repeatable)', collect, []) | ||
| .option('--source-text <text>', 'Reference text (repeatable)', collect, []) | ||
| .option('--mode <mode>', 'Generation mode: info, story', 'info') | ||
| .option('--lang <lang>', 'Language: en, zh, ja (auto-detected if omitted)') | ||
| .option('--speaker <name>', 'Speaker name') | ||
| .option('--speaker-id <id>', 'Speaker inner ID') | ||
| .option('--skip-audio', 'Skip audio generation', false) | ||
| .option('--image-size <size>', 'Image size: 2K, 4K', '2K') | ||
| .option('--aspect-ratio <ratio>', 'Aspect ratio: 16:9, 9:16, 1:1', '16:9') | ||
| .option('--style <style>', 'Visual style') | ||
| .option('--no-wait', 'Return immediately without polling') | ||
| .option('--timeout <seconds>', 'Polling timeout', Number, 300) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await createExplainer(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd | ||
| .command('list') | ||
| .description('List explainer videos') | ||
| .option('--page <n>', 'Page number', Number, 1) | ||
| .option('--page-size <n>', 'Items per page', Number, 20) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await listExplainerVideos(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } |
| import { inferLanguage } from '../_shared/language.js'; | ||
| import { printDetail, printJson, printTable } from '../_shared/output.js'; | ||
| import { pollUntilDone } from '../_shared/polling.js'; | ||
| import { buildSources } from '../_shared/sources.js'; | ||
| import { resolveSpeakers } from '../_shared/speaker-resolver.js'; | ||
| export async function createExplainer(client, options) { | ||
| const lang = options.lang ?? inferLanguage(options.query); | ||
| const speakers = await resolveSpeakers(client, { | ||
| speakerNames: options.speaker ? [options.speaker] : undefined, | ||
| speakerIds: options.speakerId ? [options.speakerId] : undefined, | ||
| language: lang, | ||
| count: 1, | ||
| }); | ||
| const size = options.imageSize; | ||
| const { aspectRatio } = options; | ||
| const { episodeId } = await client.createExplainerVideo({ | ||
| query: options.query, | ||
| sources: buildSources(options.sourceUrl, options.sourceText), | ||
| style: options.style, | ||
| skipAudio: options.skipAudio, | ||
| imageConfig: { size, aspectRatio }, | ||
| template: { | ||
| type: 'storybook', | ||
| mode: options.mode, | ||
| speakers, | ||
| language: lang, | ||
| style: options.style, | ||
| size, | ||
| aspectRatio, | ||
| }, | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) { | ||
| printJson({ episodeId }); | ||
| } | ||
| else { | ||
| console.log(`\u2713 Explainer submitted: ${episodeId}`); | ||
| } | ||
| return; | ||
| } | ||
| const detail = await pollUntilDone(client, episodeId, { | ||
| timeout: options.timeout, | ||
| label: 'Creating explainer', | ||
| json: options.json, | ||
| }); | ||
| if (options.json) { | ||
| printJson(detail); | ||
| } | ||
| else { | ||
| printDetail('Explainer created', [ | ||
| ['ID:', detail.id], | ||
| ['Title:', detail.topicDetail.title.data], | ||
| ['Status:', detail.processStatus], | ||
| ]); | ||
| } | ||
| } | ||
| export async function listExplainerVideos(client, options) { | ||
| const { items } = await client.listExplainerVideos({ | ||
| page: options.page, | ||
| pageSize: options.pageSize, | ||
| }); | ||
| if (options.json) { | ||
| printJson(items); | ||
| return; | ||
| } | ||
| const headers = ['ID', 'Title', 'Status', 'Created']; | ||
| const rows = items.map((episode) => [ | ||
| episode.id, | ||
| episode.title, | ||
| episode.processStatus, | ||
| new Date(episode.createdAt).toISOString().slice(0, 10), | ||
| ]); | ||
| printTable(headers, rows); | ||
| } |
| import { Option } from 'commander'; | ||
| import { getClient } from '../_shared/client.js'; | ||
| import { handleError } from '../_shared/output.js'; | ||
| import { createImage, getImage, listImages, } from './image.js'; | ||
| function collect(value, previous) { | ||
| return [...previous, value]; | ||
| } | ||
| export function register(program) { | ||
| const cmd = program.command('image').description('AI image generation'); | ||
| cmd | ||
| .command('create') | ||
| .description('Create an AI image') | ||
| .requiredOption('--prompt <text>', 'Image description') | ||
| .option('--model <model>', 'Model name') | ||
| .option('--lang <lang>', 'Prompt language hint') | ||
| .option('--aspect-ratio <ratio>', 'Aspect ratio', '1:1') | ||
| .option('--size <size>', 'Image size: 1K, 2K, 4K', '2K') | ||
| .option('--reference <path-or-url>', 'Reference image, local file or URL (repeatable, max 5)', collect, []) | ||
| .option('--no-wait', 'Return immediately without polling') | ||
| .option('--timeout <seconds>', 'Polling timeout', Number, 120) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .addOption(new Option('--reference-url <url>', '') | ||
| .hideHelp() | ||
| .argParser((value, previous) => [...previous, value]) | ||
| .default([])) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await createImage(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd | ||
| .command('list') | ||
| .description('List AI images') | ||
| .option('--page <n>', 'Page number', Number, 1) | ||
| .option('--page-size <n>', 'Items per page', Number, 20) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await listImages(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd | ||
| .command('get <id>') | ||
| .description('Get image details') | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (id, options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await getImage(client, id, options.json); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } |
| import { printDetail, printJson, printTable } from '../_shared/output.js'; | ||
| import { pollImageUntilDone } from '../_shared/polling.js'; | ||
| import { resolveFileOrUrl } from '../_shared/upload.js'; | ||
| export async function createImage(client, options) { | ||
| const allReferences = [...options.reference, ...options.referenceUrl]; | ||
| if (allReferences.length > 5) { | ||
| throw new Error('Too many reference images (max 5)'); | ||
| } | ||
| const referenceImageUrls = allReferences.length > 0 | ||
| ? await Promise.all(allReferences.map(async (ref) => resolveFileOrUrl(client, ref, { accept: 'image' }))) | ||
| : undefined; | ||
| const { imageId } = await client.createAIImage({ | ||
| prompt: options.prompt, | ||
| ...(options.model && { model: options.model }), | ||
| ...(options.lang && { language: options.lang }), | ||
| aspectRatio: options.aspectRatio, | ||
| imageSize: options.size, | ||
| ...(referenceImageUrls && { referenceImageUrls }), | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) { | ||
| printJson({ imageId }); | ||
| } | ||
| else { | ||
| console.log(`\u2713 Image submitted: ${imageId}`); | ||
| } | ||
| return; | ||
| } | ||
| const item = await pollImageUntilDone(client, imageId, { | ||
| timeout: options.timeout, | ||
| json: options.json, | ||
| }); | ||
| if (options.json) { | ||
| printJson(item); | ||
| } | ||
| else { | ||
| printDetail('Image created', [ | ||
| ['ID:', item.id], | ||
| ['URL:', item.imageUrl], | ||
| ['Status:', item.status], | ||
| ]); | ||
| } | ||
| } | ||
| export async function listImages(client, options) { | ||
| const { items } = await client.listAIImages({ | ||
| page: options.page, | ||
| pageSize: options.pageSize, | ||
| }); | ||
| if (options.json) { | ||
| printJson(items); | ||
| return; | ||
| } | ||
| const headers = ['ID', 'Prompt', 'Status', 'Created']; | ||
| const rows = items.map((image) => [ | ||
| image.id, | ||
| image.prompt.slice(0, 40), | ||
| image.status, | ||
| new Date(image.createdAt).toISOString().slice(0, 10), | ||
| ]); | ||
| printTable(headers, rows); | ||
| } | ||
| export async function getImage(client, imageId, json) { | ||
| const item = await client.getAIImage(imageId); | ||
| if (json) { | ||
| printJson(item); | ||
| return; | ||
| } | ||
| printDetail('Image details', [ | ||
| ['ID:', item.id], | ||
| ['Prompt:', item.prompt], | ||
| ['URL:', item.imageUrl], | ||
| ['Size:', item.imageSize], | ||
| ['Ratio:', item.aspectRatio], | ||
| ['Status:', item.status], | ||
| ['Created:', new Date(item.createdAt).toISOString()], | ||
| ]); | ||
| } |
| import { getClient } from '../_shared/client.js'; | ||
| import { handleError } from '../_shared/output.js'; | ||
| import { createGenerate, getTask, listTasks, } from './lyrics.js'; | ||
| export function register(program) { | ||
| const cmd = program.command('lyrics').description('Lyrics generation'); | ||
| cmd | ||
| .command('generate') | ||
| .description('Generate lyrics from a text prompt') | ||
| .requiredOption('--prompt <text>', 'Lyrics description (max 200 chars)') | ||
| .option('--no-wait', 'Return immediately without polling') | ||
| .option('--timeout <seconds>', 'Polling timeout', Number, 120) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await createGenerate(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd | ||
| .command('list') | ||
| .description('List lyrics tasks') | ||
| .option('--page <n>', 'Page number', Number, 1) | ||
| .option('--page-size <n>', 'Items per page', Number, 20) | ||
| .option('--status <status>', 'Filter by status (pending, generating, success, failed)') | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await listTasks(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd | ||
| .command('get <taskId>') | ||
| .description('Get lyrics task details') | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (taskId, options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await getTask(client, taskId, options.json); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } |
| import { printJson, printTable } from '../_shared/output.js'; | ||
| import { pollLyricsTaskUntilDone } from '../_shared/polling.js'; | ||
| // --- Helpers --- | ||
| function formatDateTime(timestamp) { | ||
| const d = new Date(timestamp); | ||
| return `${d.toLocaleDateString('sv-SE')} ${d.toLocaleTimeString('en-GB', { hour12: false })}`; | ||
| } | ||
| function formatDate(timestamp) { | ||
| return new Date(timestamp).toLocaleDateString('sv-SE'); | ||
| } | ||
| function printLyricsDetail(task) { | ||
| if (task.status === 'failed') { | ||
| const rows = [ | ||
| ['Task ID:', task.id], | ||
| ['Status:', task.status], | ||
| ['Error:', task.errorMessage], | ||
| ['Created:', formatDateTime(task.createdAt)], | ||
| ]; | ||
| console.log(`\u2717 Lyrics task\n`); | ||
| for (const [key, value] of rows) { | ||
| if (value !== undefined) { | ||
| console.log(` ${key.padEnd(10)} ${String(value)}`); | ||
| } | ||
| } | ||
| return; | ||
| } | ||
| console.log(`\u2713 Lyrics generated (${task.variants.length} variants)\n`); | ||
| for (const [i, variant] of task.variants.entries()) { | ||
| console.log(` \u2500\u2500 Variant ${String(i + 1)} \u2500\u2500`); | ||
| console.log(` Title: ${variant.title}\n`); | ||
| for (const line of variant.text.split('\n')) { | ||
| console.log(` ${line}`); | ||
| } | ||
| console.log(); | ||
| } | ||
| } | ||
| // --- Commands --- | ||
| export async function createGenerate(client, options) { | ||
| if (!options.prompt.trim()) { | ||
| throw new Error('Prompt is required'); | ||
| } | ||
| const result = await client.createLyrics({ | ||
| prompt: options.prompt, | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) { | ||
| printJson(result); | ||
| } | ||
| else { | ||
| console.log(`\u2713 Lyrics task submitted: ${result.taskId}`); | ||
| } | ||
| return; | ||
| } | ||
| const task = await pollLyricsTaskUntilDone(client, result.taskId, { | ||
| timeout: options.timeout, | ||
| json: options.json, | ||
| }); | ||
| if (options.json) { | ||
| printJson(task); | ||
| } | ||
| else { | ||
| printLyricsDetail(task); | ||
| } | ||
| } | ||
| export async function listTasks(client, options) { | ||
| const response = await client.listLyricsTasks({ | ||
| page: options.page, | ||
| pageSize: options.pageSize, | ||
| ...(options.status && { status: options.status }), | ||
| }); | ||
| if (options.json) { | ||
| printJson(response); | ||
| return; | ||
| } | ||
| const headers = ['ID', 'Status', 'Prompt', 'Variants', 'Created']; | ||
| const rows = response.items.map((task) => [ | ||
| task.id, | ||
| task.status, | ||
| task.params.prompt.length > 30 | ||
| ? task.params.prompt.slice(0, 30) + '\u2026' | ||
| : task.params.prompt, | ||
| String(task.variants.length), | ||
| formatDate(task.createdAt), | ||
| ]); | ||
| printTable(headers, rows); | ||
| } | ||
| export async function getTask(client, taskId, json) { | ||
| const task = await client.getLyricsTask(taskId); | ||
| if (json) { | ||
| printJson(task); | ||
| return; | ||
| } | ||
| printLyricsDetail(task); | ||
| } |
| import { getClient } from '../_shared/client.js'; | ||
| import { handleError } from '../_shared/output.js'; | ||
| import { createCover, createExtend, createGenerate, getTask, listTasks, } from './music.js'; | ||
| export function register(program) { | ||
| const cmd = program.command('music').description('Music generation'); | ||
| cmd | ||
| .command('generate') | ||
| .description('Generate music from a text prompt') | ||
| .requiredOption('--prompt <text>', 'Music description') | ||
| .option('--style <text>', 'Music style/mood') | ||
| .option('--title <text>', 'Track title') | ||
| .option('--instrumental', 'Instrumental only, no vocals', false) | ||
| .option('--no-wait', 'Return immediately without polling') | ||
| .option('--timeout <seconds>', 'Polling timeout', Number, 600) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await createGenerate(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd | ||
| .command('cover') | ||
| .description('Create a cover from reference audio') | ||
| .requiredOption('--audio <path-or-url>', 'Reference audio file or URL') | ||
| .option('--prompt <text>', 'Music description') | ||
| .option('--style <text>', 'Music style/mood') | ||
| .option('--title <text>', 'Track title') | ||
| .option('--instrumental', 'Instrumental only, no vocals', false) | ||
| .option('--no-wait', 'Return immediately without polling') | ||
| .option('--timeout <seconds>', 'Polling timeout', Number, 600) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await createCover(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd | ||
| .command('extend') | ||
| .description('Extend music from reference audio') | ||
| .requiredOption('--audio <path-or-url>', 'Reference audio file or URL') | ||
| .requiredOption('--model <version>', 'Model version (V4, V4_5, V4_5PLUS, V4_5ALL, V5, V5_5)') | ||
| .requiredOption('--continue-at <seconds>', 'Start extending from this time point', Number) | ||
| .option('--prompt <text>', 'Lyrics or description') | ||
| .option('--style <text>', 'Music style/mood') | ||
| .option('--title <text>', 'Track title') | ||
| .option('--instrumental', 'Instrumental only, no vocals', false) | ||
| .option('--negative-tags <text>', 'Styles to exclude') | ||
| .option('--vocal-gender <gender>', 'Vocal gender (m or f)') | ||
| .option('--style-weight <weight>', 'Style guidance weight (0-1)', Number) | ||
| .option('--weirdness <weight>', 'Creativity/weirdness constraint (0-1)', Number) | ||
| .option('--audio-weight <weight>', 'Input audio influence weight (0-1)', Number) | ||
| .option('--no-wait', 'Return immediately without polling') | ||
| .option('--timeout <seconds>', 'Polling timeout', Number, 600) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await createExtend(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd | ||
| .command('list') | ||
| .description('List music tasks') | ||
| .option('--page <n>', 'Page number', Number, 1) | ||
| .option('--page-size <n>', 'Items per page', Number, 20) | ||
| .option('--status <status>', 'Filter by status (pending, generating, uploading, success, failed)') | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await listTasks(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd | ||
| .command('get <taskId>') | ||
| .description('Get music task details') | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (taskId, options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await getTask(client, taskId, options.json); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } |
| import { printDetail, printJson, printTable } from '../_shared/output.js'; | ||
| import { pollMusicTaskUntilDone } from '../_shared/polling.js'; | ||
| import { resolveFileOrUrl } from '../_shared/upload.js'; | ||
| // --- Helpers --- | ||
| function formatDuration(seconds) { | ||
| const m = Math.floor(seconds / 60); | ||
| const s = Math.floor(seconds % 60); | ||
| return `${String(m)}:${String(s).padStart(2, '0')}`; | ||
| } | ||
| function formatDate(timestamp) { | ||
| return new Date(timestamp).toLocaleDateString('sv-SE'); // YYYY-MM-DD | ||
| } | ||
| function formatDateTime(timestamp) { | ||
| const d = new Date(timestamp); | ||
| return `${d.toLocaleDateString('sv-SE')} ${d.toLocaleTimeString('en-GB', { hour12: false })}`; | ||
| } | ||
| function printMusicDetail(task) { | ||
| const rows = [ | ||
| ['Task ID:', task.id], | ||
| ['Type:', task.taskType.toLowerCase()], | ||
| ['Status:', task.status], | ||
| ]; | ||
| if (task.status === 'failed') { | ||
| rows.push(['Error:', task.errorMessage]); | ||
| } | ||
| else { | ||
| const trackTitle = task.tracks[0]?.title ?? task.params.title; | ||
| if (trackTitle) { | ||
| rows.push(['Title:', trackTitle]); | ||
| } | ||
| rows.push(['Tracks:', task.tracks.length]); | ||
| for (const [i, track] of task.tracks.entries()) { | ||
| rows.push([ | ||
| `Track ${String(i + 1)}:`, | ||
| `${track.audioUrl} (${formatDuration(track.duration)})`, | ||
| ]); | ||
| } | ||
| } | ||
| rows.push(['Created:', formatDateTime(task.createdAt)]); | ||
| if (task.status === 'failed') { | ||
| console.log(`\u2717 Music task\n`); | ||
| for (const [key, value] of rows) { | ||
| if (value !== undefined) { | ||
| console.log(` ${key.padEnd(10)} ${String(value)}`); | ||
| } | ||
| } | ||
| } | ||
| else { | ||
| printDetail('Music task', rows); | ||
| } | ||
| } | ||
| // --- Commands --- | ||
| export async function createGenerate(client, options) { | ||
| if (!options.prompt.trim()) { | ||
| throw new Error('Prompt is required'); | ||
| } | ||
| const result = await client.createMusicGenerate({ | ||
| prompt: options.prompt, | ||
| ...(options.style && { style: options.style }), | ||
| ...(options.title && { title: options.title }), | ||
| ...(options.instrumental && { instrumental: true }), | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) { | ||
| printJson(result); | ||
| } | ||
| else { | ||
| console.log(`\u2713 Music task submitted: ${result.taskId}`); | ||
| } | ||
| return; | ||
| } | ||
| const task = await pollMusicTaskUntilDone(client, result.taskId, { | ||
| timeout: options.timeout, | ||
| json: options.json, | ||
| }); | ||
| if (options.json) { | ||
| printJson(task); | ||
| } | ||
| else { | ||
| printMusicDetail(task); | ||
| } | ||
| } | ||
| export async function createCover(client, options) { | ||
| const uploadUrl = await resolveFileOrUrl(client, options.audio, { | ||
| accept: 'audio', | ||
| }); | ||
| const result = await client.createMusicCover({ | ||
| uploadUrl, | ||
| ...(options.prompt && { prompt: options.prompt }), | ||
| ...(options.style && { style: options.style }), | ||
| ...(options.title && { title: options.title }), | ||
| ...(options.instrumental && { instrumental: true }), | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) { | ||
| printJson(result); | ||
| } | ||
| else { | ||
| console.log(`\u2713 Music task submitted: ${result.taskId}`); | ||
| } | ||
| return; | ||
| } | ||
| const task = await pollMusicTaskUntilDone(client, result.taskId, { | ||
| timeout: options.timeout, | ||
| json: options.json, | ||
| }); | ||
| if (options.json) { | ||
| printJson(task); | ||
| } | ||
| else { | ||
| printMusicDetail(task); | ||
| } | ||
| } | ||
| export async function createExtend(client, options) { | ||
| const uploadUrl = await resolveFileOrUrl(client, options.audio, { | ||
| accept: 'audio', | ||
| }); | ||
| const parameters = { | ||
| uploadUrl, | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- CLI string validated by Commander choices | ||
| model: options.model, | ||
| continueAt: options.continueAt, | ||
| ...(options.prompt && { prompt: options.prompt }), | ||
| ...(options.style && { style: options.style }), | ||
| ...(options.title && { title: options.title }), | ||
| ...(options.instrumental && { instrumental: true }), | ||
| ...(options.negativeTags && { negativeTags: options.negativeTags }), | ||
| ...(options.vocalGender && { | ||
| vocalGender: | ||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- CLI string validated by Commander choices | ||
| options.vocalGender, | ||
| }), | ||
| ...(options.styleWeight !== undefined && { | ||
| styleWeight: options.styleWeight, | ||
| }), | ||
| ...(options.weirdness !== undefined && { | ||
| weirdnessConstraint: options.weirdness, | ||
| }), | ||
| ...(options.audioWeight !== undefined && { | ||
| audioWeight: options.audioWeight, | ||
| }), | ||
| }; | ||
| const result = await client.createMusicExtend(parameters); | ||
| if (!options.wait) { | ||
| if (options.json) { | ||
| printJson(result); | ||
| } | ||
| else { | ||
| console.log(`\u2713 Music task submitted: ${result.taskId}`); | ||
| } | ||
| return; | ||
| } | ||
| const task = await pollMusicTaskUntilDone(client, result.taskId, { | ||
| timeout: options.timeout, | ||
| json: options.json, | ||
| }); | ||
| if (options.json) { | ||
| printJson(task); | ||
| } | ||
| else { | ||
| printMusicDetail(task); | ||
| } | ||
| } | ||
| export async function listTasks(client, options) { | ||
| const { items } = await client.listMusicTasks({ | ||
| page: options.page, | ||
| pageSize: options.pageSize, | ||
| ...(options.status && { status: options.status }), | ||
| }); | ||
| if (options.json) { | ||
| printJson({ items }); | ||
| return; | ||
| } | ||
| const headers = ['ID', 'Type', 'Status', 'Title', 'Tracks', 'Created']; | ||
| const rows = items.map((task) => [ | ||
| task.id, | ||
| task.taskType.toLowerCase(), | ||
| task.status, | ||
| task.tracks[0]?.title ?? task.params.title ?? '\u2014', | ||
| String(task.tracks.length), | ||
| formatDate(task.createdAt), | ||
| ]); | ||
| printTable(headers, rows); | ||
| } | ||
| export async function getTask(client, taskId, json) { | ||
| const task = await client.getMusicTask(taskId); | ||
| if (json) { | ||
| printJson(task); | ||
| return; | ||
| } | ||
| printMusicDetail(task); | ||
| } |
| import { getClient } from '../_shared/client.js'; | ||
| import { handleError } from '../_shared/output.js'; | ||
| import { createPodcast, listPodcasts, } from './podcast.js'; | ||
| function collect(value, previous) { | ||
| return [...previous, value]; | ||
| } | ||
| export function register(program) { | ||
| const cmd = program.command('podcast').description('Podcast generation'); | ||
| cmd | ||
| .command('create') | ||
| .description('Create a podcast episode') | ||
| .option('--query <text>', 'Topic text') | ||
| .option('--source-url <url>', 'Reference URL (repeatable)', collect, []) | ||
| .option('--source-text <text>', 'Reference text (repeatable)', collect, []) | ||
| .option('--mode <mode>', 'Generation mode: quick, deep, debate', 'quick') | ||
| .option('--lang <lang>', 'Language: en, zh, ja (auto-detected if omitted)') | ||
| .option('--speaker <name>', 'Speaker name (repeatable)', collect, []) | ||
| .option('--speaker-id <id>', 'Speaker inner ID (repeatable)', collect, []) | ||
| .option('--no-wait', 'Return immediately without polling') | ||
| .option('--timeout <seconds>', 'Polling timeout', Number, 300) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await createPodcast(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd | ||
| .command('list') | ||
| .description('List podcast episodes') | ||
| .option('--page <n>', 'Page number', Number, 1) | ||
| .option('--page-size <n>', 'Items per page', Number, 20) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await listPodcasts(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } |
| import { inferLanguage } from '../_shared/language.js'; | ||
| import { printDetail, printJson, printTable } from '../_shared/output.js'; | ||
| import { pollUntilDone } from '../_shared/polling.js'; | ||
| import { buildSources } from '../_shared/sources.js'; | ||
| import { resolveSpeakers } from '../_shared/speaker-resolver.js'; | ||
| export async function createPodcast(client, options) { | ||
| const lang = options.lang ?? inferLanguage(options.query); | ||
| const speakers = await resolveSpeakers(client, { | ||
| speakerNames: options.speaker, | ||
| speakerIds: options.speakerId, | ||
| language: lang, | ||
| }); | ||
| const { episodeId } = await client.createPodcast({ | ||
| type: speakers.length <= 1 ? 'podcast-solo' : 'podcast-duo', | ||
| query: options.query, | ||
| sources: buildSources(options.sourceUrl, options.sourceText), | ||
| template: { | ||
| type: 'podcast', | ||
| mode: options.mode, | ||
| speakers, | ||
| language: lang, | ||
| }, | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) { | ||
| printJson({ episodeId }); | ||
| } | ||
| else { | ||
| console.log(`\u2713 Podcast submitted: ${episodeId}`); | ||
| } | ||
| return; | ||
| } | ||
| const detail = await pollUntilDone(client, episodeId, { | ||
| timeout: options.timeout, | ||
| label: 'Creating podcast', | ||
| json: options.json, | ||
| }); | ||
| if (options.json) { | ||
| printJson(detail); | ||
| } | ||
| else { | ||
| printDetail('Podcast created', [ | ||
| ['ID:', detail.id], | ||
| ['Title:', detail.topicDetail.title.data], | ||
| ['Status:', detail.processStatus], | ||
| ]); | ||
| } | ||
| } | ||
| export async function listPodcasts(client, options) { | ||
| const { items } = await client.listPodcasts({ | ||
| page: options.page, | ||
| pageSize: options.pageSize, | ||
| }); | ||
| if (options.json) { | ||
| printJson(items); | ||
| return; | ||
| } | ||
| const headers = ['ID', 'Title', 'Status', 'Created']; | ||
| const rows = items.map((episode) => [ | ||
| episode.id, | ||
| episode.title, | ||
| episode.processStatus, | ||
| new Date(episode.createdAt).toISOString().slice(0, 10), | ||
| ]); | ||
| printTable(headers, rows); | ||
| } |
| import { getClient } from '../_shared/client.js'; | ||
| import { handleError } from '../_shared/output.js'; | ||
| import { createSlides, listSlides, } from './slides.js'; | ||
| function collect(value, previous) { | ||
| return [...previous, value]; | ||
| } | ||
| export function register(program) { | ||
| const cmd = program.command('slides').description('Slides generation'); | ||
| cmd | ||
| .command('create') | ||
| .description('Create a slide deck') | ||
| .option('--query <text>', 'Topic text') | ||
| .option('--source-url <url>', 'Reference URL (repeatable)', collect, []) | ||
| .option('--source-text <text>', 'Reference text (repeatable)', collect, []) | ||
| .option('--lang <lang>', 'Language: en, zh, ja (auto-detected if omitted)') | ||
| .option('--speaker <name>', 'Speaker name') | ||
| .option('--speaker-id <id>', 'Speaker inner ID') | ||
| .option('--no-skip-audio', 'Generate audio narration') | ||
| .option('--image-size <size>', 'Image size: 2K, 4K', '2K') | ||
| .option('--aspect-ratio <ratio>', 'Aspect ratio: 16:9, 9:16, 1:1', '16:9') | ||
| .option('--style <style>', 'Visual style') | ||
| .option('--no-wait', 'Return immediately without polling') | ||
| .option('--timeout <seconds>', 'Polling timeout', Number, 300) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await createSlides(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd | ||
| .command('list') | ||
| .description('List slide decks') | ||
| .option('--page <n>', 'Page number', Number, 1) | ||
| .option('--page-size <n>', 'Items per page', Number, 20) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await listSlides(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } |
| import { inferLanguage } from '../_shared/language.js'; | ||
| import { printDetail, printJson, printTable } from '../_shared/output.js'; | ||
| import { pollUntilDone } from '../_shared/polling.js'; | ||
| import { buildSources } from '../_shared/sources.js'; | ||
| import { resolveSpeakers } from '../_shared/speaker-resolver.js'; | ||
| export async function createSlides(client, options) { | ||
| const lang = options.lang ?? inferLanguage(options.query); | ||
| const speakers = await resolveSpeakers(client, { | ||
| speakerNames: options.speaker ? [options.speaker] : undefined, | ||
| speakerIds: options.speakerId ? [options.speakerId] : undefined, | ||
| language: lang, | ||
| count: 1, | ||
| }); | ||
| const size = options.imageSize; | ||
| const { aspectRatio } = options; | ||
| const { episodeId } = await client.createSlides({ | ||
| query: options.query, | ||
| sources: buildSources(options.sourceUrl, options.sourceText), | ||
| style: options.style, | ||
| skipAudio: options.skipAudio, | ||
| imageConfig: { size, aspectRatio }, | ||
| template: { | ||
| type: 'storybook', | ||
| mode: 'slides', | ||
| speakers, | ||
| language: lang, | ||
| style: options.style, | ||
| size, | ||
| aspectRatio, | ||
| }, | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) { | ||
| printJson({ episodeId }); | ||
| } | ||
| else { | ||
| console.log(`\u2713 Slides submitted: ${episodeId}`); | ||
| } | ||
| return; | ||
| } | ||
| const detail = await pollUntilDone(client, episodeId, { | ||
| timeout: options.timeout, | ||
| label: 'Creating slides', | ||
| json: options.json, | ||
| }); | ||
| if (options.json) { | ||
| printJson(detail); | ||
| } | ||
| else { | ||
| printDetail('Slides created', [ | ||
| ['ID:', detail.id], | ||
| ['Title:', detail.topicDetail.title.data], | ||
| ['Status:', detail.processStatus], | ||
| ]); | ||
| } | ||
| } | ||
| export async function listSlides(client, options) { | ||
| const { items } = await client.listSlides({ | ||
| page: options.page, | ||
| pageSize: options.pageSize, | ||
| }); | ||
| if (options.json) { | ||
| printJson(items); | ||
| return; | ||
| } | ||
| const headers = ['ID', 'Title', 'Status', 'Created']; | ||
| const rows = items.map((episode) => [ | ||
| episode.id, | ||
| episode.title, | ||
| episode.processStatus, | ||
| new Date(episode.createdAt).toISOString().slice(0, 10), | ||
| ]); | ||
| printTable(headers, rows); | ||
| } |
| import { getClient } from '../_shared/client.js'; | ||
| import { handleError } from '../_shared/output.js'; | ||
| import { listSpeakers } from './speakers.js'; | ||
| export function register(program) { | ||
| const cmd = program.command('speakers').description('Manage speakers'); | ||
| cmd | ||
| .command('list') | ||
| .description('List available speakers') | ||
| .option('--lang <lang>', 'Filter by language (en, zh, ja)') | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await listSpeakers(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } |
| import { printJson, printTable } from '../_shared/output.js'; | ||
| export async function listSpeakers(client, options) { | ||
| const { items } = await client.listSpeakers({ | ||
| language: options.lang, | ||
| }); | ||
| if (options.json) { | ||
| printJson(items); | ||
| return; | ||
| } | ||
| const headers = ['Name', 'ID', 'Gender', 'Personality']; | ||
| const rows = items.map((s) => [ | ||
| s.name, | ||
| s.speakerInnerId, | ||
| s.gender, | ||
| s.personality, | ||
| ]); | ||
| printTable(headers, rows); | ||
| } |
| import { getClient } from '../_shared/client.js'; | ||
| import { handleError } from '../_shared/output.js'; | ||
| import { createTts, listTts, } from './tts.js'; | ||
| export function register(program) { | ||
| const cmd = program.command('tts').description('Text-to-speech generation'); | ||
| cmd | ||
| .command('create') | ||
| .description('Create a TTS audio') | ||
| .option('--text <text>', 'Text to convert to speech') | ||
| .option('--source-url <url>', 'Reference URL (repeatable)', collect, []) | ||
| .option('--source-text <text>', 'Reference text (repeatable)', collect, []) | ||
| .option('--mode <mode>', 'Generation mode: smart, direct', 'smart') | ||
| .option('--lang <lang>', 'Language: en, zh, ja (auto-detected if omitted)') | ||
| .option('--speaker <name>', 'Speaker name') | ||
| .option('--speaker-id <id>', 'Speaker inner ID') | ||
| .option('--no-wait', 'Return immediately without polling') | ||
| .option('--timeout <seconds>', 'Polling timeout', Number, 300) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await createTts(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| cmd | ||
| .command('list') | ||
| .description('List TTS episodes') | ||
| .option('--page <n>', 'Page number', Number, 1) | ||
| .option('--page-size <n>', 'Items per page', Number, 20) | ||
| .option('-j, --json', 'Output JSON', false) | ||
| .action(async (options) => { | ||
| try { | ||
| const client = await getClient(); | ||
| await listTts(client, options); | ||
| } | ||
| catch (error) { | ||
| handleError(error, options.json); | ||
| } | ||
| }); | ||
| } | ||
| function collect(value, previous) { | ||
| return [...previous, value]; | ||
| } |
| import { inferLanguage } from '../_shared/language.js'; | ||
| import { printDetail, printJson, printTable } from '../_shared/output.js'; | ||
| import { pollUntilDone } from '../_shared/polling.js'; | ||
| import { buildSources } from '../_shared/sources.js'; | ||
| import { resolveSpeakers } from '../_shared/speaker-resolver.js'; | ||
| export async function createTts(client, options) { | ||
| const lang = options.lang ?? inferLanguage(options.text); | ||
| const speakers = await resolveSpeakers(client, { | ||
| speakerNames: options.speaker ? [options.speaker] : undefined, | ||
| speakerIds: options.speakerId ? [options.speakerId] : undefined, | ||
| language: lang, | ||
| count: 1, | ||
| }); | ||
| const sources = options.text | ||
| ? [{ type: 'text', content: options.text }] | ||
| : buildSources(options.sourceUrl, options.sourceText); | ||
| const { episodeId } = await client.createTTS({ | ||
| sources, | ||
| template: { | ||
| type: 'flowspeech', | ||
| mode: options.mode, | ||
| speakers, | ||
| language: lang, | ||
| }, | ||
| }); | ||
| if (!options.wait) { | ||
| if (options.json) { | ||
| printJson({ episodeId }); | ||
| } | ||
| else { | ||
| console.log(`\u2713 TTS submitted: ${episodeId}`); | ||
| } | ||
| return; | ||
| } | ||
| const detail = await pollUntilDone(client, episodeId, { | ||
| timeout: options.timeout, | ||
| label: 'Creating TTS', | ||
| json: options.json, | ||
| }); | ||
| if (options.json) { | ||
| printJson(detail); | ||
| } | ||
| else { | ||
| printDetail('TTS created', [ | ||
| ['ID:', detail.id], | ||
| ['Title:', detail.topicDetail.title.data], | ||
| ['Status:', detail.processStatus], | ||
| ]); | ||
| } | ||
| } | ||
| export async function listTts(client, options) { | ||
| const { items } = await client.listTTS({ | ||
| page: options.page, | ||
| pageSize: options.pageSize, | ||
| }); | ||
| if (options.json) { | ||
| printJson(items); | ||
| return; | ||
| } | ||
| const headers = ['ID', 'Title', 'Status', 'Created']; | ||
| const rows = items.map((episode) => [ | ||
| episode.id, | ||
| episode.title, | ||
| episode.processStatus, | ||
| new Date(episode.createdAt).toISOString().slice(0, 10), | ||
| ]); | ||
| printTable(headers, rows); | ||
| } |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
58790
-20.69%7
40%5
-85.29%1304
-25.4%