From edcd857c70bd9075d5646f245cdf31c8e734b5ad Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Sun, 29 Mar 2026 12:52:02 +0200 Subject: [PATCH] feat(admin-web, functions): overhaul music library and add AI genre classification - 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`. --- admin-web/app/music/page.tsx | 957 +++++++++++++++---- admin-web/lib/use-youtube-download.ts | 111 +++ scripts/deploy-functions.sh | 1 + supabase/functions/_shared/youtube-client.ts | 31 + supabase/functions/youtube-classify/index.ts | 119 +++ supabase/functions/youtube-playlist/index.ts | 17 +- supabase/functions/youtube-status/index.ts | 88 +- youtube-worker/package.json | 5 +- youtube-worker/server.js | 182 ++++ 9 files changed, 1331 insertions(+), 180 deletions(-) create mode 100644 supabase/functions/youtube-classify/index.ts diff --git a/admin-web/app/music/page.tsx b/admin-web/app/music/page.tsx index 5f45b60..ac8c348 100644 --- a/admin-web/app/music/page.tsx +++ b/admin-web/app/music/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -26,11 +26,18 @@ import { Clock, RefreshCw, Trash2, + Search, + ChevronDown, + ChevronRight, + Library, + Briefcase, + Sparkles, } from "lucide-react"; import { toast } from "sonner"; import { useYouTubeDownload, type JobWithItems, + type ItemWithPlaylist, } from "@/lib/use-youtube-download"; import { Dialog, @@ -41,12 +48,20 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Select } from "@/components/ui/select"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import type { Database, MusicGenre } from "@/lib/supabase"; import { MUSIC_GENRES, GENRE_LABELS } from "@/lib/supabase"; type DownloadJob = Database["public"]["Tables"]["download_jobs"]["Row"]; type DownloadItem = Database["public"]["Tables"]["download_items"]["Row"]; +type MusicView = "library" | "jobs"; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -109,19 +124,82 @@ const genreSelectOptions = [ ...genreOptions, ]; -function genreBadge(genre: MusicGenre | null) { - if (!genre) return null; - return ( - - {GENRE_LABELS[genre]} - - ); -} +const genreFilterOptions = [ + { value: "", label: "All genres" }, + ...genreOptions, +]; + +const statusFilterOptions = [ + { value: "", label: "All statuses" }, + { value: "completed", label: "Completed" }, + { value: "pending", label: "Pending" }, + { value: "downloading", label: "Downloading" }, + { value: "failed", label: "Failed" }, +]; // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- +function ViewChips({ + active, + onChange, + allCount, + jobCount, +}: { + active: MusicView; + onChange: (view: MusicView) => void; + allCount: number; + jobCount: number; +}) { + return ( +
+ + +
+ ); +} + function ImportSection({ onImport, isImporting, @@ -188,98 +266,6 @@ function ImportSection({ ); } -function JobCard({ - job, - isSelected, - isProcessing, - onSelect, - onStart, - onStop, - onDelete, -}: { - job: DownloadJob; - isSelected: boolean; - isProcessing: boolean; - onSelect: () => void; - onStart: () => void; - onStop: () => void; - onDelete: () => void; -}) { - const pct = progressPercent(job); - const canStart = - !isProcessing && - (job.status === "pending" || job.status === "processing"); - - return ( - - -
-
-

- {job.playlist_title || "Untitled Playlist"} -

-

- {job.playlist_url} -

-
- {statusBadge(job.status)} - - {job.completed_items}/{job.total_items} completed - {job.failed_items > 0 && ` · ${job.failed_items} failed`} - -
-
-
e.stopPropagation()}> - {isProcessing && isSelected ? ( - - ) : ( - canStart && ( - - ) - )} - -
-
- - {/* Progress bar */} -
-
-
- - - ); -} - function AudioPlayer({ url }: { url: string }) { const [playing, setPlaying] = useState(false); const [audio] = useState(() => { @@ -300,7 +286,6 @@ function AudioPlayer({ url }: { url: string }) { } }; - // Cleanup on unmount useEffect(() => { return () => { audio?.pause(); @@ -314,23 +299,84 @@ function AudioPlayer({ url }: { url: string }) { className="h-7 w-7 p-0 text-orange-500 hover:text-orange-400" onClick={toggle} > - {playing ? : } + {playing ? ( + + ) : ( + + )} ); } -function ItemsTable({ +// --------------------------------------------------------------------------- +// Filter bar for library view +// --------------------------------------------------------------------------- + +function FilterBar({ + search, + onSearchChange, + genre, + onGenreChange, + status, + onStatusChange, +}: { + search: string; + onSearchChange: (val: string) => void; + genre: string; + onGenreChange: (val: string) => void; + status: string; + onStatusChange: (val: string) => void; +}) { + return ( +
+
+ + onSearchChange(e.target.value)} + placeholder="Search by title..." + className="bg-neutral-950 border-neutral-700 text-white placeholder:text-neutral-500 pl-9" + /> +
+ +
+ ); +} + +// --------------------------------------------------------------------------- +// All Music library table +// --------------------------------------------------------------------------- + +function LibraryTable({ items, onGenreChange, + onDeleteItem, }: { - items: DownloadItem[]; + items: ItemWithPlaylist[]; onGenreChange: (itemId: string, genre: MusicGenre | null) => void; + onDeleteItem: (item: ItemWithPlaylist) => void; }) { if (items.length === 0) { return ( -

- No items in this job. -

+
+ +

+ No tracks match your filters. +

+
); } @@ -338,18 +384,24 @@ function ItemsTable({ - # + # Title + Playlist Genre Duration Status - Actions + + Actions + {items.map((item, idx) => ( - + {idx + 1} @@ -366,18 +418,29 @@ function ItemsTable({ + + + {item.playlist_title || "—"} + +
+ + + # + + Title + + + Genre + + + Duration + + + Status + + + Actions + + + + + {items.map((item, idx) => ( + + + {idx + 1} + + +
+ {item.thumbnail_url && ( + + )} + + {item.title || item.video_id} + +
+
+ +
+ )} +
+ )} +
+
+ ); +} + // --------------------------------------------------------------------------- // Main page // --------------------------------------------------------------------------- @@ -420,28 +772,65 @@ function ItemsTable({ export default function MusicPage() { const { jobs, + allItems, activeJob, isProcessing, isImporting, + isClassifying, fetchJobs, + fetchAllItems, refreshStatus, importPlaylist, startProcessing, stopProcessing, deleteJob, + deleteItem, updateItemGenre, + reclassifyJob, } = useYouTubeDownload(); - const [selectedJobId, setSelectedJobId] = useState(null); + const [view, setView] = useState("library"); + const [expandedJobId, setExpandedJobId] = useState(null); + const [processingJobId, setProcessingJobId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteItemTarget, setDeleteItemTarget] = useState(null); const [isDeleting, setIsDeleting] = useState(false); + const [reclassifyTarget, setReclassifyTarget] = useState(null); - // Load jobs on mount + // Filter state + const [searchQuery, setSearchQuery] = useState(""); + const [filterGenre, setFilterGenre] = useState(""); + const [filterStatus, setFilterStatus] = useState(""); + + // Load data on mount useEffect(() => { fetchJobs().catch((err) => toast.error("Failed to load jobs: " + err.message) ); - }, [fetchJobs]); + fetchAllItems().catch((err) => + toast.error("Failed to load music library: " + err.message) + ); + }, [fetchJobs, fetchAllItems]); + + // Filtered library items + const filteredItems = useMemo(() => { + let result = allItems; + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + result = result.filter( + (item) => + item.title?.toLowerCase().includes(q) || + item.video_id.toLowerCase().includes(q) + ); + } + if (filterGenre) { + result = result.filter((item) => item.genre === filterGenre); + } + if (filterStatus) { + result = result.filter((item) => item.status === filterStatus); + } + return result; + }, [allItems, searchQuery, filterGenre, filterStatus]); const handleImport = async (url: string, genre?: MusicGenre) => { try { @@ -449,7 +838,9 @@ export default function MusicPage() { toast.success( `Imported "${result.playlistTitle}" — ${result.totalItems} tracks` ); - setSelectedJobId(result.jobId); + // Refresh library after import + fetchAllItems().catch(() => {}); + setView("jobs"); } catch (err) { toast.error( "Import failed: " + @@ -459,24 +850,33 @@ export default function MusicPage() { }; const handleStart = async (jobId: string) => { - setSelectedJobId(jobId); + setProcessingJobId(jobId); + setExpandedJobId(jobId); try { await startProcessing(jobId); toast.success("Download complete!"); + // Refresh the library view after processing + fetchAllItems().catch(() => {}); } catch (err) { toast.error( "Processing error: " + (err instanceof Error ? err.message : "Unknown error") ); + } finally { + setProcessingJobId(null); } }; - const handleSelectJob = async (jobId: string) => { - setSelectedJobId(jobId); - try { - await refreshStatus(jobId); - } catch (err) { - toast.error("Failed to load job details"); + const handleToggleJob = async (jobId: string) => { + if (expandedJobId === jobId) { + setExpandedJobId(null); + } else { + setExpandedJobId(jobId); + try { + await refreshStatus(jobId); + } catch (err) { + toast.error("Failed to load job details"); + } } }; @@ -488,9 +888,11 @@ export default function MusicPage() { toast.success( `Deleted "${deleteTarget.playlist_title || "Untitled Playlist"}"` ); - if (selectedJobId === deleteTarget.id) { - setSelectedJobId(null); + if (expandedJobId === deleteTarget.id) { + setExpandedJobId(null); } + // Refresh library after delete + fetchAllItems().catch(() => {}); } catch (err) { toast.error( "Delete failed: " + @@ -502,9 +904,35 @@ export default function MusicPage() { } }; - const handleGenreChange = async (itemId: string, genre: MusicGenre | null) => { + const handleDeleteItem = async () => { + if (!deleteItemTarget) return; + setIsDeleting(true); + try { + await deleteItem(deleteItemTarget.id); + toast.success( + `Deleted "${deleteItemTarget.title || deleteItemTarget.video_id}"` + ); + // Refresh job counters + fetchJobs().catch(() => {}); + } catch (err) { + toast.error( + "Delete failed: " + + (err instanceof Error ? err.message : "Unknown error") + ); + } finally { + setIsDeleting(false); + setDeleteItemTarget(null); + } + }; + + const handleGenreChange = async ( + itemId: string, + genre: MusicGenre | null + ) => { try { await updateItemGenre(itemId, genre); + // Also update in allItems state + fetchAllItems().catch(() => {}); } catch (err) { toast.error( "Failed to update genre: " + @@ -513,8 +941,59 @@ export default function MusicPage() { } }; + const handleRefresh = async () => { + try { + await Promise.all([fetchJobs(), fetchAllItems()]); + } catch (err) { + toast.error("Failed to refresh"); + } + }; + + /** Classify only items with missing genres (force=false). */ + const handleReclassify = async (jobId: string) => { + try { + const result = await reclassifyJob(jobId, false); + if (result.classified === 0) { + toast.info("All tracks already have genres assigned"); + } else { + toast.success( + `Classified ${result.classified} track${result.classified === 1 ? "" : "s"}` + ); + } + } catch (err) { + toast.error( + "Classification failed: " + + (err instanceof Error ? err.message : "Unknown error") + ); + } + }; + + /** Force re-classify ALL items (overwrites manual edits). */ + const handleForceReclassify = async () => { + if (!reclassifyTarget) return; + try { + const result = await reclassifyJob(reclassifyTarget.id, true); + toast.success( + `Re-classified ${result.classified} track${result.classified === 1 ? "" : "s"}` + ); + } catch (err) { + toast.error( + "Classification failed: " + + (err instanceof Error ? err.message : "Unknown error") + ); + } finally { + setReclassifyTarget(null); + } + }; + + // Get items for a specific expanded job + const getJobItems = (jobId: string): DownloadItem[] => { + if (activeJob?.id === jobId) return activeJob.items; + return []; + }; + return ( -
+
{/* Header */}
@@ -527,73 +1006,103 @@ export default function MusicPage() { variant="outline" size="sm" className="border-neutral-700 text-neutral-300 hover:bg-neutral-800" - onClick={() => fetchJobs()} + onClick={handleRefresh} > Refresh
- {/* Import section */} -
+ {/* Import section — always visible */} +
- {/* Jobs + Detail layout */} -
- {/* Jobs list */} -
-

- Jobs ({jobs.length}) -

- {jobs.length === 0 ? ( -

- No jobs yet. Import a playlist to get started. + {/* View toggle chips */} +

+ +
+ + {/* --- ALL MUSIC library view --- */} + {view === "library" && ( +
+ + + + + + + {filteredItems.length > 0 && ( +

+ Showing {filteredItems.length} of {allItems.length} tracks

+ )} +
+ )} + + {/* --- JOBS view --- */} + {view === "jobs" && ( +
+ {jobs.length === 0 ? ( + + + +

+ No jobs yet. Import a playlist to get started. +

+
+
) : ( jobs.map((job) => ( - handleSelectJob(job.id)} + isProcessing={ + isProcessing && processingJobId === job.id + } + isClassifying={isClassifying} + isExpanded={expandedJobId === job.id} + onToggle={() => handleToggleJob(job.id)} onStart={() => handleStart(job.id)} onStop={stopProcessing} onDelete={() => setDeleteTarget(job)} + onReclassify={() => handleReclassify(job.id)} + onForceReclassify={() => setReclassifyTarget(job)} + onGenreChange={handleGenreChange} + onDeleteItem={setDeleteItemTarget} + items={getJobItems(job.id)} /> )) )}
- - {/* Detail panel */} -
- {activeJob ? ( - - - - {activeJob.playlist_title || "Untitled Playlist"} - - - - - - - ) : ( - - - -

- Select a job to view its tracks -

-
-
- )} -
-
+ )} {/* Delete confirmation dialog */} - { if (!open) setDeleteTarget(null); }}> + { + if (!open) setDeleteTarget(null); + }} + > Delete Job @@ -602,8 +1111,8 @@ export default function MusicPage() { {deleteTarget?.playlist_title || "this job"} {" "} - and remove {deleteTarget?.completed_items ?? 0} audio files from - storage. This action cannot be undone. + and remove {deleteTarget?.completed_items ?? 0} audio files + from storage. This action cannot be undone. @@ -635,6 +1144,106 @@ export default function MusicPage() { + + {/* Delete single track confirmation dialog */} + { + if (!open) setDeleteItemTarget(null); + }} + > + + + Delete Track + + This will permanently delete{" "} + + {deleteItemTarget?.title || deleteItemTarget?.video_id || "this track"} + {" "} + and remove its audio file from storage. This action cannot be + undone. + + + + + + + + + + {/* Re-classify confirmation dialog (force mode) */} + { + if (!open) setReclassifyTarget(null); + }} + > + + + + Re-classify All Genres? + + + This will overwrite all existing genres for{" "} + + {reclassifyTarget?.playlist_title || "this job"} + + , including any you've manually set. Use the default + "Classify" button to only fill in missing genres. + + + + + + + +
); } diff --git a/admin-web/lib/use-youtube-download.ts b/admin-web/lib/use-youtube-download.ts index 104b54f..2ff2c9e 100644 --- a/admin-web/lib/use-youtube-download.ts +++ b/admin-web/lib/use-youtube-download.ts @@ -120,11 +120,17 @@ async function invokePatch( return res.json(); } +export interface ItemWithPlaylist extends DownloadItem { + playlist_title: string | null; +} + export function useYouTubeDownload() { const [jobs, setJobs] = useState([]); + const [allItems, setAllItems] = useState([]); const [activeJob, setActiveJob] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [isImporting, setIsImporting] = useState(false); + const [isClassifying, setIsClassifying] = useState(false); const abortRef = useRef(null); /** Fetch all jobs (list view). */ @@ -134,6 +140,43 @@ export function useYouTubeDownload() { return data.jobs; }, []); + /** Fetch ALL download items across all jobs, enriched with playlist title. */ + const fetchAllItems = useCallback(async () => { + // Fetch all items via Supabase directly (RLS ensures admin-only). + // Cast needed because the Database type only defines Row (no Insert/Update) + // for download_items, causing Supabase client to infer `never`. + const { data: items, error: itemsErr } = (await supabase + .from("download_items") + .select("*") + .order("created_at", { ascending: false })) as { + data: DownloadItem[] | null; + error: { message: string } | null; + }; + + if (itemsErr) throw new Error(itemsErr.message); + + // Build a map of job_id -> playlist_title from the current jobs list, + // or fetch jobs if we don't have them yet. + let jobMap: Record = {}; + let currentJobs = jobs; + if (currentJobs.length === 0) { + const data = await invokeGet<{ jobs: DownloadJob[] }>("youtube-status"); + currentJobs = data.jobs; + setJobs(currentJobs); + } + for (const j of currentJobs) { + jobMap[j.id] = j.playlist_title; + } + + const enriched: ItemWithPlaylist[] = (items ?? []).map((item) => ({ + ...item, + playlist_title: jobMap[item.job_id] ?? null, + })); + + setAllItems(enriched); + return enriched; + }, [jobs]); + /** Fetch a single job with its items. */ const refreshStatus = useCallback(async (jobId: string) => { const data = await invokeGet<{ job: DownloadJob; items: DownloadItem[] }>( @@ -263,6 +306,45 @@ export function useYouTubeDownload() { [] ); + /** Delete a single download item and its audio file from storage. */ + const deleteItem = useCallback( + async (itemId: string) => { + const result = await invokeDelete<{ + deleted: boolean; + itemId: string; + jobId: string; + }>("youtube-status", { itemId }); + + // Remove from allItems + setAllItems((prev) => prev.filter((i) => i.id !== itemId)); + + // Remove from activeJob items if present + setActiveJob((prev) => { + if (!prev) return prev; + return { + ...prev, + items: prev.items.filter((i) => i.id !== itemId), + }; + }); + + // Update the parent job counters in jobs list + setJobs((prev) => + prev.map((j) => { + if (j.id !== result.jobId) return j; + // We don't know the item status here, so just decrement total. + // The next fetchJobs() will reconcile exact counts from the server. + return { + ...j, + total_items: Math.max(0, j.total_items - 1), + }; + }) + ); + + return result; + }, + [] + ); + /** Update the genre on a single download item. */ const updateItemGenre = useCallback( async (itemId: string, genre: MusicGenre | null) => { @@ -285,17 +367,46 @@ export function useYouTubeDownload() { [] ); + /** Re-classify genres for a job's items via YouTube metadata + Gemini. */ + const reclassifyJob = useCallback( + async (jobId: string, force = false) => { + setIsClassifying(true); + try { + const { data, error } = await supabase.functions.invoke( + "youtube-classify", + { body: { jobId, force } } + ); + if (error) throw new Error(error.message ?? "Classification failed"); + if (data?.error) throw new Error(data.error); + + // Refresh job items and library to reflect updated genres + await refreshStatus(jobId); + await fetchAllItems().catch(() => {}); + + return data as { classified: number; skipped: number }; + } finally { + setIsClassifying(false); + } + }, + [refreshStatus, fetchAllItems] + ); + return { jobs, + allItems, activeJob, isProcessing, isImporting, + isClassifying, fetchJobs, + fetchAllItems, refreshStatus, importPlaylist, startProcessing, stopProcessing, deleteJob, + deleteItem, updateItemGenre, + reclassifyJob, }; } diff --git a/scripts/deploy-functions.sh b/scripts/deploy-functions.sh index e336bc7..043cb69 100755 --- a/scripts/deploy-functions.sh +++ b/scripts/deploy-functions.sh @@ -45,6 +45,7 @@ ssh "$DEPLOY_USER@$DEPLOY_HOST" "\ -e SUPABASE_URL=\$(docker exec supabase-edge-functions printenv SUPABASE_URL) \ -e SUPABASE_SERVICE_ROLE_KEY=\$(docker exec supabase-edge-functions printenv SUPABASE_SERVICE_ROLE_KEY) \ -e SUPABASE_PUBLIC_URL=https://supabase.1000co.fr \ + -e GEMINI_API_KEY=\$(cat /opt/supabase/.env.gemini 2>/dev/null || echo '') \ -e STORAGE_BUCKET=workout-audio \ -e PORT=3001 \ youtube-worker:latest" diff --git a/supabase/functions/_shared/youtube-client.ts b/supabase/functions/_shared/youtube-client.ts index db5011f..f39d0e4 100644 --- a/supabase/functions/_shared/youtube-client.ts +++ b/supabase/functions/_shared/youtube-client.ts @@ -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 { return workerFetch("/download", { videoId, jobId }); } + +export interface ClassifyInput { + videoId: string; + title: string; + author: string | null; +} + +interface ClassifyResult { + genres: Record; + 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> { + try { + const result = await workerFetch("/classify", { items }); + if (result.warning) { + console.warn("Genre classification warning:", result.warning); + } + return result.genres ?? {}; + } catch (err) { + console.error("Genre classification failed:", err); + return {}; + } +} diff --git a/supabase/functions/youtube-classify/index.ts b/supabase/functions/youtube-classify/index.ts new file mode 100644 index 0000000..7aa9875 --- /dev/null +++ b/supabase/functions/youtube-classify/index.ts @@ -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" }, + }); + } +}); diff --git a/supabase/functions/youtube-playlist/index.ts b/supabase/functions/youtube-playlist/index.ts index 47b575a..062309b 100644 --- a/supabase/functions/youtube-playlist/index.ts +++ b/supabase/functions/youtube-playlist/index.ts @@ -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 = {}; + 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 diff --git a/supabase/functions/youtube-status/index.ts b/supabase/functions/youtube-status/index.ts index 9e50163..0504c5a 100644 --- a/supabase/functions/youtube-status/index.ts +++ b/supabase/functions/youtube-status/index.ts @@ -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 = { 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 = { + 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" }, }); diff --git a/youtube-worker/package.json b/youtube-worker/package.json index f474e9d..2d263b2 100644 --- a/youtube-worker/package.json +++ b/youtube-worker/package.json @@ -7,7 +7,8 @@ "start": "node server.js" }, "dependencies": { - "youtubei.js": "^17.0.1", - "@supabase/supabase-js": "^2.49.1" + "@google/genai": "^1.46.0", + "@supabase/supabase-js": "^2.49.1", + "youtubei.js": "^17.0.1" } } diff --git a/youtube-worker/server.js b/youtube-worker/server.js index 5455a31..f803f49 100644 --- a/youtube-worker/server.js +++ b/youtube-worker/server.js @@ -4,6 +4,7 @@ import { readFile, unlink, mkdir } from "node:fs/promises"; import { promisify } from "node:util"; import { Innertube } from "youtubei.js"; import { createClient } from "@supabase/supabase-js"; +import { GoogleGenAI, Type } from "@google/genai"; const execFileAsync = promisify(execFile); @@ -16,6 +17,7 @@ const STORAGE_BUCKET = process.env.STORAGE_BUCKET || "workout-audio"; // SUPABASE_URL is the internal Docker network URL (e.g. http://kong:8000) // which browsers cannot reach. const SUPABASE_PUBLIC_URL = process.env.SUPABASE_PUBLIC_URL || SUPABASE_URL; +const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ""; if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { console.error("Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY"); @@ -107,6 +109,7 @@ async function handlePlaylist(req, res) { items.push({ videoId: item.id, title: item.title?.toString() ?? "Untitled", + author: item.author?.name ?? null, durationSeconds: item.duration?.seconds ?? 0, thumbnailUrl: item.thumbnails?.[0]?.url ?? null, }); @@ -193,6 +196,182 @@ async function handleHealth(_req, res) { jsonResponse(res, 200, { status: "ok" }); } +// ── Genre classification ───────────────────────────────────── + +const VALID_GENRES = new Set([ + "edm", "hip-hop", "pop", "rock", "latin", "house", + "drum-and-bass", "dubstep", "r-and-b", "country", "metal", "ambient", +]); + +const CLASSIFY_SYSTEM_PROMPT = `You classify music tracks into exactly one genre for a fitness/workout app. + +Available genres (pick exactly one per track): +- edm: Electronic dance music, techno, trance, electro +- hip-hop: Hip-hop, rap, trap beats +- pop: Pop music, mainstream hits +- rock: Rock, alternative, indie rock, punk +- latin: Reggaeton, salsa, bachata, latin pop +- house: House music, deep house, tech house +- drum-and-bass: Drum and bass, jungle, liquid DnB +- dubstep: Dubstep, bass music, brostep +- r-and-b: R&B, soul, neo-soul +- country: Country, country pop, Americana +- metal: Heavy metal, metalcore, hard rock +- ambient: Ambient, chill, lo-fi, downtempo, meditation + +For each track, pick the single best-fit genre. If the track is clearly a +workout/tabata/HIIT track, infer genre from musical style cues in the title. +If truly ambiguous, default to "edm" for workout/tabata tracks.`; + +/** + * Fetch YouTube metadata (category + keywords) for a batch of video IDs. + * Runs in parallel batches of 10 to avoid rate limits. + * Returns Map. + */ +async function fetchVideoMetadata(videoIds) { + const yt = await getYouTubeClient(); + const metadata = new Map(); + const BATCH_SIZE = 10; + + for (let i = 0; i < videoIds.length; i += BATCH_SIZE) { + const batch = videoIds.slice(i, i + BATCH_SIZE); + const results = await Promise.allSettled( + batch.map(async (id) => { + const info = await yt.getBasicInfo(id); + return { + id, + category: info.basic_info?.category ?? null, + keywords: info.basic_info?.keywords ?? [], + }; + }) + ); + + for (const result of results) { + if (result.status === "fulfilled") { + const { id, category, keywords } = result.value; + metadata.set(id, { category, keywords }); + } + // Rejected = skip that video's metadata silently + } + } + + return metadata; +} + +/** + * Classify tracks into genres using Gemini. + * Input: array of {videoId, title, author, category?, keywords?} + * Batches into groups of 50 to keep schema/prompt manageable. + * Returns: Record + */ +async function classifyWithGemini(tracks) { + if (!GEMINI_API_KEY) { + console.warn("GEMINI_API_KEY not set — skipping genre classification"); + return {}; + } + + const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY }); + const BATCH_SIZE = 50; + const allGenres = {}; + + for (let i = 0; i < tracks.length; i += BATCH_SIZE) { + const batch = tracks.slice(i, i + BATCH_SIZE); + + // Build concise input for the prompt + const trackDescriptions = batch.map((t) => { + const parts = [`"${t.title}"`]; + if (t.author) parts.push(`by ${t.author}`); + if (t.category) parts.push(`[${t.category}]`); + if (t.keywords?.length) parts.push(`tags: ${t.keywords.slice(0, 8).join(", ")}`); + return `${t.videoId}: ${parts.join(" — ")}`; + }); + + const userPrompt = `Classify each track into one genre. Return a JSON object mapping videoId to genre string.\n\n${trackDescriptions.join("\n")}`; + + try { + const response = await ai.models.generateContent({ + model: "gemini-3.1-flash-lite-preview", + contents: userPrompt, + config: { + systemInstruction: CLASSIFY_SYSTEM_PROMPT, + responseMimeType: "application/json", + responseSchema: { + type: Type.OBJECT, + properties: Object.fromEntries( + batch.map((t) => [ + t.videoId, + { + type: Type.STRING, + description: "Genre classification", + enum: [...VALID_GENRES], + }, + ]) + ), + required: batch.map((t) => t.videoId), + }, + temperature: 0.1, + }, + }); + + const parsed = JSON.parse(response.text); + + // Validate each genre against the allowed set + for (const [videoId, genre] of Object.entries(parsed)) { + if (VALID_GENRES.has(genre)) { + allGenres[videoId] = genre; + } else { + console.warn(`Invalid genre "${genre}" for ${videoId} — skipping`); + } + } + } catch (batchErr) { + console.error(`Gemini batch ${i / BATCH_SIZE + 1} failed:`, batchErr.message); + // Continue with next batch — partial results are fine + } + } + + return allGenres; +} + +async function handleClassify(req, res) { + const { items } = await readBody(req); + if (!Array.isArray(items) || items.length === 0) { + return jsonResponse(res, 400, { error: "items array is required" }); + } + + console.log(`Classifying ${items.length} tracks...`); + + try { + // Step 1: Fetch YouTube metadata for enrichment + const videoIds = items.map((i) => i.videoId); + console.log("Fetching YouTube metadata..."); + const metadata = await fetchVideoMetadata(videoIds); + console.log(`Got metadata for ${metadata.size}/${items.length} videos`); + + // Step 2: Merge metadata with input items + const enriched = items.map((item) => { + const meta = metadata.get(item.videoId); + return { + videoId: item.videoId, + title: item.title, + author: item.author ?? null, + category: meta?.category ?? null, + keywords: meta?.keywords ?? [], + }; + }); + + // Step 3: Classify via Gemini + console.log("Calling Gemini for classification..."); + const genres = await classifyWithGemini(enriched); + console.log(`Classified ${Object.keys(genres).length}/${items.length} tracks`); + + jsonResponse(res, 200, { genres }); + } catch (err) { + // Classification is best-effort — never block the import + console.error("Classification failed:", err.message); + jsonResponse(res, 200, { genres: {}, warning: err.message }); + } +} + // ── Server ──────────────────────────────────────────────────── const server = http.createServer(async (req, res) => { @@ -206,6 +385,9 @@ const server = http.createServer(async (req, res) => { if (req.method === "POST" && req.url === "/download") { return await handleDownload(req, res); } + if (req.method === "POST" && req.url === "/classify") { + return await handleClassify(req, res); + } jsonResponse(res, 404, { error: "Not found" }); } catch (err) { console.error(`${req.method} ${req.url} error:`, err.message);