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