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:
62
supabase/functions/_shared/auth.ts
Normal file
62
supabase/functions/_shared/auth.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
/**
|
||||
* Verifies the request comes from an authenticated admin user.
|
||||
* Extracts the JWT from the Authorization header, validates it,
|
||||
* and checks the user exists in the admin_users table.
|
||||
*
|
||||
* Returns the authenticated user's ID.
|
||||
* Throws an error with status code if auth fails.
|
||||
*/
|
||||
export async function verifyAdmin(req: Request): Promise<string> {
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
throw new AuthError("Missing or invalid Authorization header", 401);
|
||||
}
|
||||
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
|
||||
const url = Deno.env.get("SUPABASE_URL");
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY");
|
||||
|
||||
if (!url || !anonKey) {
|
||||
throw new AuthError("Missing SUPABASE_URL or SUPABASE_ANON_KEY", 500);
|
||||
}
|
||||
|
||||
// Create a user-scoped client with the provided JWT
|
||||
const supabase = createClient(url, anonKey, {
|
||||
global: { headers: { Authorization: `Bearer ${token}` } },
|
||||
auth: { persistSession: false },
|
||||
});
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: userError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (userError || !user) {
|
||||
throw new AuthError("Invalid or expired token", 401);
|
||||
}
|
||||
|
||||
// Verify user is an admin
|
||||
const { data: adminUser, error: adminError } = await supabase
|
||||
.from("admin_users")
|
||||
.select("id")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
if (adminError || !adminUser) {
|
||||
throw new AuthError("User is not an admin", 403);
|
||||
}
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "AuthError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
13
supabase/functions/_shared/cors.ts
Normal file
13
supabase/functions/_shared/cors.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, apikey, content-type",
|
||||
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
||||
};
|
||||
|
||||
export function handleCors(req: Request): Response | null {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
19
supabase/functions/_shared/supabase-client.ts
Normal file
19
supabase/functions/_shared/supabase-client.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
/**
|
||||
* Creates a Supabase client using the service role key.
|
||||
* This bypasses RLS and is used for server-side operations in edge functions.
|
||||
* Auth is verified separately via the admin auth helper.
|
||||
*/
|
||||
export function createServiceClient(): SupabaseClient {
|
||||
const url = Deno.env.get("SUPABASE_URL");
|
||||
const key = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||
|
||||
if (!url || !key) {
|
||||
throw new Error("Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY");
|
||||
}
|
||||
|
||||
return createClient(url, key, {
|
||||
auth: { persistSession: false },
|
||||
});
|
||||
}
|
||||
88
supabase/functions/_shared/youtube-client.ts
Normal file
88
supabase/functions/_shared/youtube-client.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* YouTube client that delegates to the youtube-worker sidecar container.
|
||||
* The sidecar runs youtubei.js in a full Node.js environment without
|
||||
* CPU time limits.
|
||||
*/
|
||||
|
||||
const WORKER_URL =
|
||||
Deno.env.get("YOUTUBE_WORKER_URL") || "http://youtube-worker:3001";
|
||||
|
||||
export interface PlaylistItem {
|
||||
videoId: string;
|
||||
title: string;
|
||||
durationSeconds: number;
|
||||
thumbnailUrl: string | null;
|
||||
}
|
||||
|
||||
export interface PlaylistInfo {
|
||||
title: string;
|
||||
items: PlaylistItem[];
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
storagePath: string;
|
||||
publicUrl: string;
|
||||
}
|
||||
|
||||
async function workerFetch<T>(
|
||||
path: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${WORKER_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `Worker responded with ${res.status}`);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a YouTube playlist ID from various URL formats.
|
||||
*/
|
||||
export function parsePlaylistId(url: string): string {
|
||||
if (/^[A-Za-z0-9_-]+$/.test(url) && !url.includes(".")) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const listParam = parsed.searchParams.get("list");
|
||||
if (listParam) {
|
||||
return listParam;
|
||||
}
|
||||
} catch {
|
||||
// Not a valid URL
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not parse playlist ID from: "${url}". ` +
|
||||
`Expected a YouTube playlist URL or raw playlist ID.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches playlist metadata and items via the sidecar.
|
||||
*/
|
||||
export async function getPlaylistItems(
|
||||
playlistUrl: string
|
||||
): Promise<PlaylistInfo> {
|
||||
return workerFetch<PlaylistInfo>("/playlist", { playlistUrl });
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads audio and uploads to Supabase Storage via the sidecar.
|
||||
* Returns the storage path and public URL.
|
||||
*/
|
||||
export async function downloadAndUploadAudio(
|
||||
videoId: string,
|
||||
jobId: string
|
||||
): Promise<DownloadResult> {
|
||||
return workerFetch<DownloadResult>("/download", { videoId, jobId });
|
||||
}
|
||||
8
supabase/functions/deno.json
Normal file
8
supabase/functions/deno.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"imports": {
|
||||
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
95
supabase/functions/main/index.ts
Normal file
95
supabase/functions/main/index.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as jose from "https://deno.land/x/jose@v4.14.4/index.ts";
|
||||
|
||||
console.log("main function started");
|
||||
|
||||
const JWT_SECRET = Deno.env.get("JWT_SECRET");
|
||||
const VERIFY_JWT = Deno.env.get("VERIFY_JWT") === "true";
|
||||
|
||||
function getAuthToken(req: Request) {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (!authHeader) {
|
||||
throw new Error("Missing authorization header");
|
||||
}
|
||||
const [bearer, token] = authHeader.split(" ");
|
||||
if (bearer !== "Bearer") {
|
||||
throw new Error(`Auth header is not 'Bearer {token}'`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function isValidJWT(jwt: string): Promise<boolean> {
|
||||
if (!JWT_SECRET) {
|
||||
console.error("JWT_SECRET not available for token verification");
|
||||
return false;
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const secretKey = encoder.encode(JWT_SECRET);
|
||||
try {
|
||||
await jose.jwtVerify(jwt, secretKey);
|
||||
} catch (e) {
|
||||
console.error("JWT verification error", e);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
if (req.method !== "OPTIONS" && VERIFY_JWT) {
|
||||
try {
|
||||
const token = getAuthToken(req);
|
||||
const valid = await isValidJWT(token);
|
||||
if (!valid) {
|
||||
return new Response(JSON.stringify({ msg: "Invalid JWT" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return new Response(JSON.stringify({ msg: e.toString() }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const { pathname } = url;
|
||||
const path_parts = pathname.split("/");
|
||||
const service_name = path_parts[1];
|
||||
|
||||
if (!service_name || service_name === "") {
|
||||
return new Response(
|
||||
JSON.stringify({ msg: "missing function name in request" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const servicePath = `/home/deno/functions/${service_name}`;
|
||||
console.error(`serving the request with ${servicePath}`);
|
||||
|
||||
const memoryLimitMb = 512;
|
||||
const workerTimeoutMs = 10 * 60 * 1000;
|
||||
const noModuleCache = false;
|
||||
const importMapPath = null;
|
||||
const envVarsObj = Deno.env.toObject();
|
||||
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]);
|
||||
|
||||
try {
|
||||
const worker = await EdgeRuntime.userWorkers.create({
|
||||
servicePath,
|
||||
memoryLimitMb,
|
||||
workerTimeoutMs,
|
||||
noModuleCache,
|
||||
importMapPath,
|
||||
envVars,
|
||||
});
|
||||
return await worker.fetch(req);
|
||||
} catch (e) {
|
||||
const error = { msg: e.toString() };
|
||||
return new Response(JSON.stringify(error), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
124
supabase/functions/youtube-playlist/index.ts
Normal file
124
supabase/functions/youtube-playlist/index.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { corsHeaders, handleCors } from "../_shared/cors.ts";
|
||||
import { verifyAdmin, AuthError } from "../_shared/auth.ts";
|
||||
import { createServiceClient } from "../_shared/supabase-client.ts";
|
||||
import {
|
||||
parsePlaylistId,
|
||||
getPlaylistItems,
|
||||
} 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
|
||||
const userId = await verifyAdmin(req);
|
||||
|
||||
// Parse request body
|
||||
const { playlistUrl, genre } = await req.json();
|
||||
if (!playlistUrl || typeof playlistUrl !== "string") {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "playlistUrl is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the URL format
|
||||
parsePlaylistId(playlistUrl);
|
||||
|
||||
// Fetch playlist metadata via the sidecar worker
|
||||
const playlist = await getPlaylistItems(playlistUrl);
|
||||
|
||||
if (playlist.items.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Playlist is empty or could not be loaded" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Create the download job in the database
|
||||
const supabase = createServiceClient();
|
||||
|
||||
const { data: job, error: jobError } = await supabase
|
||||
.from("download_jobs")
|
||||
.insert({
|
||||
playlist_url: playlistUrl,
|
||||
playlist_title: playlist.title,
|
||||
status: "pending",
|
||||
total_items: playlist.items.length,
|
||||
completed_items: 0,
|
||||
failed_items: 0,
|
||||
created_by: userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (jobError || !job) {
|
||||
throw new Error(`Failed to create download job: ${jobError?.message}`);
|
||||
}
|
||||
|
||||
// Create download items for each video
|
||||
const itemRows = playlist.items.map((item) => ({
|
||||
job_id: job.id,
|
||||
video_id: item.videoId,
|
||||
title: item.title,
|
||||
duration_seconds: item.durationSeconds,
|
||||
thumbnail_url: item.thumbnailUrl,
|
||||
status: "pending",
|
||||
genre: genre || null,
|
||||
}));
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
.from("download_items")
|
||||
.insert(itemRows);
|
||||
|
||||
if (itemsError) {
|
||||
await supabase.from("download_jobs").delete().eq("id", job.id);
|
||||
throw new Error(`Failed to create download items: ${itemsError.message}`);
|
||||
}
|
||||
|
||||
// Fetch the created items to return
|
||||
const { data: items } = await supabase
|
||||
.from("download_items")
|
||||
.select("*")
|
||||
.eq("job_id", job.id)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
jobId: job.id,
|
||||
playlistTitle: playlist.title,
|
||||
totalItems: playlist.items.length,
|
||||
items: items ?? [],
|
||||
}),
|
||||
{
|
||||
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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
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