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

@@ -0,0 +1,119 @@
import { corsHeaders, handleCors } from "../_shared/cors.ts";
import { verifyAdmin, AuthError } from "../_shared/auth.ts";
import { createServiceClient } from "../_shared/supabase-client.ts";
import { classifyGenres } from "../_shared/youtube-client.ts";
Deno.serve(async (req: Request) => {
const corsResponse = handleCors(req);
if (corsResponse) return corsResponse;
try {
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
await verifyAdmin(req);
const { jobId, force = false } = await req.json();
if (!jobId || typeof jobId !== "string") {
return new Response(JSON.stringify({ error: "jobId is required" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const supabase = createServiceClient();
// Verify job exists
const { data: job, error: jobError } = await supabase
.from("download_jobs")
.select("id")
.eq("id", jobId)
.single();
if (jobError || !job) {
return new Response(JSON.stringify({ error: "Job not found" }), {
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// Fetch items — if force=false, only those with null genre
let query = supabase
.from("download_items")
.select("id, video_id, title")
.eq("job_id", jobId);
if (!force) {
query = query.is("genre", null);
}
const { data: items, error: itemsError } = await query;
if (itemsError) {
throw new Error(`Failed to fetch items: ${itemsError.message}`);
}
if (!items || items.length === 0) {
return new Response(
JSON.stringify({ classified: 0, skipped: 0, message: "No items to classify" }),
{
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
// Call sidecar to classify genres
const genres = await classifyGenres(
items.map((item) => ({
videoId: item.video_id,
title: item.title ?? "",
author: null,
}))
);
// Update each item's genre in the database
let classified = 0;
let skipped = 0;
for (const item of items) {
const genre = genres[item.video_id];
if (genre) {
const { error: updateError } = await supabase
.from("download_items")
.update({ genre })
.eq("id", item.id);
if (updateError) {
console.error(`Failed to update genre for ${item.video_id}:`, updateError.message);
skipped++;
} else {
classified++;
}
} else {
skipped++;
}
}
return new Response(
JSON.stringify({ classified, skipped }),
{
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
} catch (error) {
const status = error instanceof AuthError ? error.status : 500;
const message =
error instanceof Error ? error.message : "Unknown error occurred";
return new Response(JSON.stringify({ error: message }), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});