feat(admin-web, functions): overhaul music library and add AI genre classification
Some checks failed
Some checks failed
- 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:
@@ -10,6 +10,7 @@ const WORKER_URL =
|
||||
export interface PlaylistItem {
|
||||
videoId: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
durationSeconds: number;
|
||||
thumbnailUrl: string | null;
|
||||
}
|
||||
@@ -86,3 +87,33 @@ export async function downloadAndUploadAudio(
|
||||
): Promise<DownloadResult> {
|
||||
return workerFetch<DownloadResult>("/download", { videoId, jobId });
|
||||
}
|
||||
|
||||
export interface ClassifyInput {
|
||||
videoId: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
}
|
||||
|
||||
interface ClassifyResult {
|
||||
genres: Record<string, string>;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies tracks into music genres via YouTube metadata + Gemini LLM.
|
||||
* Best-effort: returns empty object on failure (never throws).
|
||||
*/
|
||||
export async function classifyGenres(
|
||||
items: ClassifyInput[]
|
||||
): Promise<Record<string, string>> {
|
||||
try {
|
||||
const result = await workerFetch<ClassifyResult>("/classify", { items });
|
||||
if (result.warning) {
|
||||
console.warn("Genre classification warning:", result.warning);
|
||||
}
|
||||
return result.genres ?? {};
|
||||
} catch (err) {
|
||||
console.error("Genre classification failed:", err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
119
supabase/functions/youtube-classify/index.ts
Normal file
119
supabase/functions/youtube-classify/index.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { createServiceClient } from "../_shared/supabase-client.ts";
|
||||
import {
|
||||
parsePlaylistId,
|
||||
getPlaylistItems,
|
||||
classifyGenres,
|
||||
} from "../_shared/youtube-client.ts";
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
@@ -54,6 +55,19 @@ Deno.serve(async (req: Request) => {
|
||||
// Create the download job in the database
|
||||
const supabase = createServiceClient();
|
||||
|
||||
// Auto-classify genres via YouTube metadata + Gemini (best-effort).
|
||||
// Only runs when no manual genre was provided — manual genre overrides all.
|
||||
let classifiedGenres: Record<string, string> = {};
|
||||
if (!genre) {
|
||||
classifiedGenres = await classifyGenres(
|
||||
playlist.items.map((item) => ({
|
||||
videoId: item.videoId,
|
||||
title: item.title,
|
||||
author: item.author ?? null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const { data: job, error: jobError } = await supabase
|
||||
.from("download_jobs")
|
||||
.insert({
|
||||
@@ -73,6 +87,7 @@ Deno.serve(async (req: Request) => {
|
||||
}
|
||||
|
||||
// Create download items for each video
|
||||
// Priority: manual genre > auto-classified genre > null
|
||||
const itemRows = playlist.items.map((item) => ({
|
||||
job_id: job.id,
|
||||
video_id: item.videoId,
|
||||
@@ -80,7 +95,7 @@ Deno.serve(async (req: Request) => {
|
||||
duration_seconds: item.durationSeconds,
|
||||
thumbnail_url: item.thumbnailUrl,
|
||||
status: "pending",
|
||||
genre: genre || null,
|
||||
genre: genre || classifiedGenres[item.videoId] || null,
|
||||
}));
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user