feat(admin-web, functions): overhaul music library and add AI genre classification
Some checks failed
CI / TypeScript (push) Failing after 48s
CI / ESLint (push) Failing after 20s
CI / Tests (push) Failing after 33s
CI / Build Check (push) Has been skipped
CI / Admin Web Tests (push) Failing after 18s
CI / Deploy Edge Functions (push) Has been skipped

- admin-web: Added an "All Music" library view with search, genre, and status filters.
- admin-web: Converted Jobs view to use expandable cards instead of a split pane.
- admin-web: Added ability to delete individual tracks from a job.
- functions: Added new `youtube-classify` edge function to automatically categorize tracks using Gemini LLM.
- functions: Integrated AI genre classification during initial playlist import if no manual genre is provided.
- worker: Added `/classify` endpoint for the worker to securely interface with Gemini.
- scripts: Updated deployment script to include `GEMINI_API_KEY`.
This commit is contained in:
Millian Lamiaux
2026-03-29 12:52:02 +02:00
parent 3d8d9efd70
commit edcd857c70
9 changed files with 1331 additions and 180 deletions

View File

@@ -13,11 +13,93 @@ Deno.serve(async (req: Request) => {
const supabase = createServiceClient();
// ── DELETE: remove a job, its items, and storage files ─────
// ── DELETE: remove a job OR a single item ───────────────
if (req.method === "DELETE") {
const { jobId } = await req.json();
const body = await req.json();
const { jobId, itemId } = body as { jobId?: string; itemId?: string };
// ── Delete a single track ──────────────────────────────
if (itemId && typeof itemId === "string") {
// Fetch the item to get its storage_path, status, and parent job_id
const { data: item, error: fetchErr } = await supabase
.from("download_items")
.select("*")
.eq("id", itemId)
.single();
if (fetchErr || !item) {
return new Response(JSON.stringify({ error: "Item not found" }), {
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// Remove audio file from storage if it exists
if (item.storage_path) {
const { error: storageError } = await supabase.storage
.from("workout-audio")
.remove([item.storage_path]);
if (storageError) {
console.error("Storage cleanup error:", storageError.message);
// Continue with DB deletion even if storage cleanup fails
}
}
// Delete the item row
const { error: deleteError } = await supabase
.from("download_items")
.delete()
.eq("id", itemId);
if (deleteError) {
throw new Error(`Failed to delete item: ${deleteError.message}`);
}
// Update the parent job counters
const decrement: Record<string, number> = { total_items: -1 };
if (item.status === "completed") {
decrement.completed_items = -1;
} else if (item.status === "failed") {
decrement.failed_items = -1;
}
// Fetch current job to compute new values (Supabase doesn't support atomic decrement)
const { data: parentJob } = await supabase
.from("download_jobs")
.select("total_items, completed_items, failed_items")
.eq("id", item.job_id)
.single();
if (parentJob) {
const updates: Record<string, number> = {
total_items: Math.max(0, parentJob.total_items + (decrement.total_items ?? 0)),
};
if (decrement.completed_items) {
updates.completed_items = Math.max(0, parentJob.completed_items + decrement.completed_items);
}
if (decrement.failed_items) {
updates.failed_items = Math.max(0, parentJob.failed_items + decrement.failed_items);
}
await supabase
.from("download_jobs")
.update(updates)
.eq("id", item.job_id);
}
return new Response(
JSON.stringify({ deleted: true, itemId, jobId: item.job_id }),
{
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
// ── Delete an entire job ───────────────────────────────
if (!jobId || typeof jobId !== "string") {
return new Response(JSON.stringify({ error: "jobId is required" }), {
return new Response(JSON.stringify({ error: "jobId or itemId is required" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});