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:
@@ -120,11 +120,17 @@ async function invokePatch<T>(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface ItemWithPlaylist extends DownloadItem {
|
||||
playlist_title: string | null;
|
||||
}
|
||||
|
||||
export function useYouTubeDownload() {
|
||||
const [jobs, setJobs] = useState<DownloadJob[]>([]);
|
||||
const [allItems, setAllItems] = useState<ItemWithPlaylist[]>([]);
|
||||
const [activeJob, setActiveJob] = useState<JobWithItems | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isClassifying, setIsClassifying] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(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<string, string | null> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user