import { Box, render, Text, useApp } from "ink"; import { loadToken } from "lib/token"; import { collectAudioFiles, resolveStorageProvider, type StorageProvider, uploadTrack, } from "lib/uploadTrack"; import path from "path"; import React, { useEffect, useState } from "react"; import { ACCENT as VIOLET, ERROR as RED, PRIMARY as TEAL, SECONDARY as BLUE, } from "../theme"; const CONCURRENCY = 3; const BAR_WIDTH = 24; type Status = "pending" | "uploading" | "done" | "skipped" | "error"; interface FileState { file: string; pct: number; status: Status; message?: string; } function Bar({ pct, color }: { pct: number; color: string }) { const filled = Math.round((pct / 100) * BAR_WIDTH); return ( {"█".repeat(filled)} {"─".repeat(BAR_WIDTH - filled)} ); } const ICON: Record = { pending: "◦", uploading: "▸", done: "✔", skipped: "•", error: "✖", }; const ICON_COLOR: Record = { pending: VIOLET, uploading: BLUE, done: TEAL, skipped: VIOLET, error: RED, }; function Row({ s }: { s: FileState }) { return ( {ICON[s.status]} {path.basename(s.file)} {s.status === "error" || s.status === "skipped" ? ( {s.message || (s.status === "skipped" ? "already in library" : "failed")} ) : ( <> {s.status === "done" ? "done" : s.status === "pending" ? "—" : `${s.pct}%`} )} ); } function UploadApp({ token, files, storage, }: { token: string; files: string[]; /** Resolved BYO destination; undefined = Rocksky managed storage. */ storage?: StorageProvider; }) { const { exit } = useApp(); const [states, setStates] = useState( files.map((f) => ({ file: f, pct: 0, status: "pending" })), ); useEffect(() => { let cancelled = false; const update = (i: number, u: Partial) => setStates((prev) => prev.map((s, idx) => (idx === i ? { ...s, ...u } : s))); let next = 0; async function worker() { while (!cancelled) { const i = next++; if (i >= files.length) return; update(i, { status: "uploading", pct: 0 }); try { await uploadTrack( token, files[i], (pct) => !cancelled && update(i, { status: "uploading", pct }), storage?.id, ); update(i, { status: "done", pct: 100 }); } catch (e: any) { const message = e?.response?.data?.message || e?.message || "Upload failed"; const status: Status = e?.response?.status === 409 ? "skipped" : "error"; update(i, { status, message }); } } } Promise.all( Array.from({ length: Math.min(CONCURRENCY, files.length) }, worker), ).then(() => { if (!cancelled) setTimeout(() => exit(), 300); }); return () => { cancelled = true; }; }, []); const done = states.filter((s) => s.status === "done").length; const skipped = states.filter((s) => s.status === "skipped").length; const failed = states.filter((s) => s.status === "error").length; const finished = done + skipped + failed; return ( {`♫ Uploading ${files.length} file${files.length === 1 ? "" : "s"} to ${ storage ? `${storage.label} · ${storage.bucket}` : "Rocksky" }`} {states.map((s, i) => ( ))} {finished === files.length ? `Finished — ${done} uploaded, ${skipped} skipped, ${failed} failed` : `${finished}/${files.length} done · ${done} uploaded · ${skipped} skipped · ${failed} failed`} ); } export async function upload(files: string[], opts: { storage?: string } = {}) { const token = loadToken(); if (!token) { console.error( "You are not logged in. Run `rocksky login ` first.", ); process.exit(1); } // Resolve the destination BEFORE rendering: a typo'd --storage should fail // with the list of what exists, not upload anything anywhere. let storage: StorageProvider | undefined; if (opts.storage) { try { storage = await resolveStorageProvider(token, opts.storage); } catch (e: any) { console.error(e?.message ?? "Failed to resolve storage"); process.exit(1); } } const collected = collectAudioFiles(files); if (collected.length === 0) { console.error( "No audio files found. Supported: mp3, flac, m4a, ogg, wav, aiff.", ); process.exit(1); } const { waitUntilExit } = render( , ); await waitUntilExit(); process.exit(0); }