8f10353f0c
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s
77 lines
2.9 KiB
JavaScript
77 lines
2.9 KiB
JavaScript
// Shared, DOM-free job/library helpers used by both the mobile UI and
|
|
// (incrementally) the desktop catalog. Anything in here must stay free of
|
|
// document/window-element access so it can be imported from either entry
|
|
// point. The API (`GET /api/jobs`) is the canonical data source.
|
|
import { fmtTime } from "../utils.js";
|
|
|
|
// Mirror of catalog.js PROCESSING_STATUSES — keep in sync with the backend
|
|
// job lifecycle. These map to the amber "working" dot.
|
|
const PROCESSING_STATUSES = new Set(["queued", "downloading", "analyzing", "separating", "processing"]);
|
|
|
|
export async function fetchJobs() {
|
|
const res = await fetch("/api/jobs", { cache: "no-store" });
|
|
if (!res.ok) throw new Error(`GET /api/jobs -> ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
export function deriveSource(sourceUrl) {
|
|
if (!sourceUrl) return "Local file";
|
|
if (sourceUrl.startsWith("local:")) return "Local file";
|
|
if (sourceUrl.includes("youtube.com") || sourceUrl.includes("youtu.be")) return "YouTube";
|
|
if (sourceUrl.includes("soundcloud.com")) return "SoundCloud";
|
|
return "Web";
|
|
}
|
|
|
|
// One of: "done" (green), "processing" (amber), "unavailable" (grey/red).
|
|
export function statusKind(status) {
|
|
if (PROCESSING_STATUSES.has(status)) return "processing";
|
|
if (status === "done") return "done";
|
|
return "unavailable"; // unavailable / error / failed / unknown
|
|
}
|
|
|
|
export function coverInitial(title) {
|
|
const t = (title || "").trim();
|
|
return t ? t[0].toUpperCase() : "♪";
|
|
}
|
|
|
|
// Deterministic cover gradient: the same track always gets the same color.
|
|
// Palette matches the mobile design's aesthetic (design/mobile prototype).
|
|
const GRADIENTS = [
|
|
"linear-gradient(150deg,#7b46f0,#3f6ef0 55%,#1b245f)",
|
|
"linear-gradient(140deg,#2bd4c4,#1a6d9e)",
|
|
"linear-gradient(150deg,#c44ad0,#5a2b9e)",
|
|
"linear-gradient(150deg,#f06a6a,#7a2b4e)",
|
|
"linear-gradient(150deg,#4a9bf5,#2b3a7a)",
|
|
"linear-gradient(150deg,#f5a516,#d2541f)",
|
|
"linear-gradient(150deg,#3fcf6e,#1a6d4e)",
|
|
];
|
|
|
|
export function coverGradient(seed) {
|
|
const s = String(seed || "");
|
|
let h = 0;
|
|
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
|
return GRADIENTS[h % GRADIENTS.length];
|
|
}
|
|
|
|
// Normalize an /api/jobs state object into the shape the mobile UI renders.
|
|
export function jobToCard(state) {
|
|
const id = state.job_id;
|
|
const title = state.title || "Untitled";
|
|
const stemCount = (state.stems || state.selected_stems || []).length;
|
|
const parts = [];
|
|
if (state.duration) parts.push(fmtTime(state.duration));
|
|
if (stemCount) parts.push(`${stemCount} stem${stemCount === 1 ? "" : "s"}`);
|
|
return {
|
|
id,
|
|
title,
|
|
sub: deriveSource(state.source_url),
|
|
meta: parts.join(" · "),
|
|
stemCount,
|
|
status: statusKind(state.status),
|
|
createdAt: state.created_at || 0,
|
|
initial: coverInitial(title),
|
|
gradient: coverGradient(id || title),
|
|
thumb: typeof state.thumbnail === "string" ? state.thumbnail : "",
|
|
};
|
|
}
|