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,200 @@
import { corsHeaders, handleCors } from "../_shared/cors.ts";
import { verifyAdmin, AuthError } from "../_shared/auth.ts";
import { createServiceClient } from "../_shared/supabase-client.ts";
import { downloadAndUploadAudio } from "../_shared/youtube-client.ts";
Deno.serve(async (req: Request) => {
// Handle CORS preflight
const corsResponse = handleCors(req);
if (corsResponse) return corsResponse;
try {
// Only accept POST
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// Verify admin auth
await verifyAdmin(req);
// Parse request body
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" },
});
}
const supabase = createServiceClient();
// Get the job
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" },
});
}
// Get the next pending item
const { data: item, error: itemError } = await supabase
.from("download_items")
.select("*")
.eq("job_id", jobId)
.eq("status", "pending")
.order("created_at", { ascending: true })
.limit(1)
.single();
// No pending items -- finalize the job
if (itemError || !item) {
const finalStatus =
job.completed_items > 0 ? "completed" : "failed";
await supabase
.from("download_jobs")
.update({ status: finalStatus })
.eq("id", jobId);
return new Response(
JSON.stringify({
status: finalStatus,
progress: {
completed: job.completed_items,
failed: job.failed_items,
total: job.total_items,
},
processedItem: null,
done: true,
}),
{
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
// Mark item as downloading, job as processing
await Promise.all([
supabase
.from("download_items")
.update({ status: "downloading" })
.eq("id", item.id),
supabase
.from("download_jobs")
.update({ status: "processing" })
.eq("id", jobId),
]);
try {
// Download audio + upload to storage via the sidecar worker
const { storagePath, publicUrl } = await downloadAndUploadAudio(
item.video_id,
jobId
);
// Mark item as completed
await supabase
.from("download_items")
.update({
status: "completed",
storage_path: storagePath,
public_url: publicUrl,
})
.eq("id", item.id);
// Increment completed count on job
await supabase
.from("download_jobs")
.update({
completed_items: job.completed_items + 1,
})
.eq("id", jobId);
return new Response(
JSON.stringify({
status: "processing",
progress: {
completed: job.completed_items + 1,
failed: job.failed_items,
total: job.total_items,
},
processedItem: {
id: item.id,
videoId: item.video_id,
title: item.title,
status: "completed",
publicUrl,
storagePath,
},
done: false,
}),
{
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
} catch (downloadError) {
const errorMessage =
downloadError instanceof Error
? downloadError.message
: "Unknown download error";
await supabase
.from("download_items")
.update({
status: "failed",
error_message: errorMessage,
})
.eq("id", item.id);
await supabase
.from("download_jobs")
.update({
failed_items: job.failed_items + 1,
})
.eq("id", jobId);
return new Response(
JSON.stringify({
status: "processing",
progress: {
completed: job.completed_items,
failed: job.failed_items + 1,
total: job.total_items,
},
processedItem: {
id: item.id,
videoId: item.video_id,
title: item.title,
status: "failed",
error: errorMessage,
},
done: false,
}),
{
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" },
});
}
});