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);
}