feat: YouTube music download system with admin dashboard

Sidecar architecture: edge functions (auth + DB) → youtube-worker container
(youtubei.js for playlist metadata, yt-dlp for audio downloads).

- Edge functions: youtube-playlist, youtube-process, youtube-status (CRUD)
- youtube-worker sidecar: Node.js + yt-dlp, downloads M4A to Supabase Storage
- Admin music page: import playlists, process queue, inline genre editing
- 12 music genres with per-track assignment and import-time defaults
- DB migrations: download_jobs, download_items tables with genre column
- Deploy script and CI workflow for edge functions + sidecar
This commit is contained in:
Millian Lamiaux
2026-03-26 10:47:05 +01:00
parent 8926de58e5
commit 3d8d9efd70
21 changed files with 2426 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
import { corsHeaders, handleCors } from "../_shared/cors.ts";
import { verifyAdmin, AuthError } from "../_shared/auth.ts";
import { createServiceClient } from "../_shared/supabase-client.ts";
Deno.serve(async (req: Request) => {
// Handle CORS preflight
const corsResponse = handleCors(req);
if (corsResponse) return corsResponse;
try {
// Verify admin auth
await verifyAdmin(req);
const supabase = createServiceClient();
// ── DELETE: remove a job, its items, and storage files ─────
if (req.method === "DELETE") {
const { jobId } = 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" },
});
}
// Fetch items to find storage paths for cleanup
const { data: items } = await supabase
.from("download_items")
.select("storage_path")
.eq("job_id", jobId);
// Delete files from storage bucket
const storagePaths = (items ?? [])
.map((i) => i.storage_path)
.filter(Boolean) as string[];
if (storagePaths.length > 0) {
const { error: storageError } = await supabase.storage
.from("workout-audio")
.remove(storagePaths);
if (storageError) {
console.error("Storage cleanup error:", storageError.message);
// Continue with DB deletion even if storage cleanup fails
}
}
// Delete the job row (items cascade via FK)
const { error: deleteError } = await supabase
.from("download_jobs")
.delete()
.eq("id", jobId);
if (deleteError) {
throw new Error(`Failed to delete job: ${deleteError.message}`);
}
return new Response(
JSON.stringify({ deleted: true, jobId, filesRemoved: storagePaths.length }),
{
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
// ── GET: fetch job(s) ─────────────────────────────────────
if (req.method !== "GET" && req.method !== "PATCH") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// ── PATCH: update genre on download items ─────────────────
if (req.method === "PATCH") {
const { itemId, genre } = await req.json();
if (!itemId || typeof itemId !== "string") {
return new Response(
JSON.stringify({ error: "itemId is required" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
// genre can be null (to clear) or a valid genre string
const { error: updateError } = await supabase
.from("download_items")
.update({ genre: genre || null })
.eq("id", itemId);
if (updateError) {
throw new Error(`Failed to update genre: ${updateError.message}`);
}
return new Response(
JSON.stringify({ updated: true, itemId, genre: genre || null }),
{
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
// ── GET: fetch job(s) ─────────────────────────────────────
// Parse query params
const url = new URL(req.url);
const jobId = url.searchParams.get("jobId");
// If jobId is provided, return that specific job + items
if (jobId) {
const { data: job, error: jobError } = await supabase
.from("download_jobs")
.select("*")
.eq("id", jobId)
.single();
if (jobError || !job) {
return new Response(JSON.stringify({ error: "Job not found" }), {
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const { data: items } = await supabase
.from("download_items")
.select("*")
.eq("job_id", jobId)
.order("created_at", { ascending: true });
return new Response(
JSON.stringify({ job, items: items ?? [] }),
{
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
// No jobId -- return all jobs (most recent first)
const { data: jobs, error: jobsError } = await supabase
.from("download_jobs")
.select("*")
.order("created_at", { ascending: false })
.limit(50);
if (jobsError) {
throw new Error(`Failed to fetch jobs: ${jobsError.message}`);
}
return new Response(
JSON.stringify({ jobs: jobs ?? [] }),
{
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" },
});
}
});