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,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;
}
}

View 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;
}

View 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 },
});
}

View 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 });
}

View File

@@ -0,0 +1,8 @@
{
"imports": {
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2"
},
"compilerOptions": {
"strict": true
}
}

View 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" },
});
}
});

View 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" },
});
}
});

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" },
});
}
});

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" },
});
}
});