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