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:
200
supabase/functions/youtube-process/index.ts
Normal file
200
supabase/functions/youtube-process/index.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user