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:
@@ -34,6 +34,28 @@ export type Json =
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[]
|
||||
|
||||
export const MUSIC_GENRES = [
|
||||
'edm', 'hip-hop', 'pop', 'rock', 'latin', 'house',
|
||||
'drum-and-bass', 'dubstep', 'r-and-b', 'country', 'metal', 'ambient',
|
||||
] as const
|
||||
|
||||
export type MusicGenre = typeof MUSIC_GENRES[number]
|
||||
|
||||
export const GENRE_LABELS: Record<MusicGenre, string> = {
|
||||
'edm': 'EDM',
|
||||
'hip-hop': 'Hip Hop',
|
||||
'pop': 'Pop',
|
||||
'rock': 'Rock',
|
||||
'latin': 'Latin',
|
||||
'house': 'House',
|
||||
'drum-and-bass': 'Drum & Bass',
|
||||
'dubstep': 'Dubstep',
|
||||
'r-and-b': 'R&B',
|
||||
'country': 'Country',
|
||||
'metal': 'Metal',
|
||||
'ambient': 'Ambient',
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
public: {
|
||||
Tables: {
|
||||
@@ -137,6 +159,47 @@ export interface Database {
|
||||
last_login: string | null
|
||||
}
|
||||
}
|
||||
download_jobs: {
|
||||
Row: {
|
||||
id: string
|
||||
playlist_url: string
|
||||
playlist_title: string | null
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
total_items: number
|
||||
completed_items: number
|
||||
failed_items: number
|
||||
created_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
playlist_url: string
|
||||
playlist_title?: string | null
|
||||
status?: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
total_items?: number
|
||||
completed_items?: number
|
||||
failed_items?: number
|
||||
created_by: string
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['download_jobs']['Insert'], 'id'>>
|
||||
}
|
||||
download_items: {
|
||||
Row: {
|
||||
id: string
|
||||
job_id: string
|
||||
video_id: string
|
||||
title: string | null
|
||||
duration_seconds: number | null
|
||||
thumbnail_url: string | null
|
||||
status: 'pending' | 'downloading' | 'completed' | 'failed'
|
||||
storage_path: string | null
|
||||
public_url: string | null
|
||||
error_message: string | null
|
||||
genre: MusicGenre | null
|
||||
created_at: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
301
admin-web/lib/use-youtube-download.ts
Normal file
301
admin-web/lib/use-youtube-download.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import type { Database, MusicGenre } from "@/lib/supabase";
|
||||
|
||||
type DownloadJob = Database["public"]["Tables"]["download_jobs"]["Row"];
|
||||
type DownloadItem = Database["public"]["Tables"]["download_items"]["Row"];
|
||||
|
||||
export interface JobWithItems extends DownloadJob {
|
||||
items: DownloadItem[];
|
||||
}
|
||||
|
||||
const PROCESS_DELAY_MS = 1000;
|
||||
|
||||
/**
|
||||
* Construct a GET request to a Supabase edge function with query params.
|
||||
* supabase.functions.invoke() doesn't support query params, so we use fetch.
|
||||
*/
|
||||
async function invokeGet<T>(
|
||||
functionName: string,
|
||||
params?: Record<string, string>
|
||||
): Promise<T> {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) throw new Error("Not authenticated");
|
||||
|
||||
const supabaseUrl =
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL ||
|
||||
"http://localhost:54321";
|
||||
|
||||
const url = new URL(`${supabaseUrl}/functions/v1/${functionName}`);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a DELETE request to a Supabase edge function with a JSON body.
|
||||
*/
|
||||
async function invokeDelete<T>(
|
||||
functionName: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) throw new Error("Not authenticated");
|
||||
|
||||
const supabaseUrl =
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL ||
|
||||
"http://localhost:54321";
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/functions/v1/${functionName}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PATCH request to a Supabase edge function with a JSON body.
|
||||
*/
|
||||
async function invokePatch<T>(
|
||||
functionName: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) throw new Error("Not authenticated");
|
||||
|
||||
const supabaseUrl =
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL ||
|
||||
"http://localhost:54321";
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/functions/v1/${functionName}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useYouTubeDownload() {
|
||||
const [jobs, setJobs] = useState<DownloadJob[]>([]);
|
||||
const [activeJob, setActiveJob] = useState<JobWithItems | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
/** Fetch all jobs (list view). */
|
||||
const fetchJobs = useCallback(async () => {
|
||||
const data = await invokeGet<{ jobs: DownloadJob[] }>("youtube-status");
|
||||
setJobs(data.jobs);
|
||||
return data.jobs;
|
||||
}, []);
|
||||
|
||||
/** Fetch a single job with its items. */
|
||||
const refreshStatus = useCallback(async (jobId: string) => {
|
||||
const data = await invokeGet<{ job: DownloadJob; items: DownloadItem[] }>(
|
||||
"youtube-status",
|
||||
{ jobId }
|
||||
);
|
||||
const jobWithItems: JobWithItems = { ...data.job, items: data.items };
|
||||
setActiveJob(jobWithItems);
|
||||
|
||||
// Also update the job in the list
|
||||
setJobs((prev) =>
|
||||
prev.map((j) => (j.id === jobId ? data.job : j))
|
||||
);
|
||||
|
||||
return jobWithItems;
|
||||
}, []);
|
||||
|
||||
/** Import a playlist: creates a job + download_items rows. */
|
||||
const importPlaylist = useCallback(
|
||||
async (playlistUrl: string, genre?: MusicGenre) => {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"youtube-playlist",
|
||||
{ body: { playlistUrl, genre: genre || null } }
|
||||
);
|
||||
if (error) throw new Error(error.message ?? "Import failed");
|
||||
if (data?.error) throw new Error(data.error);
|
||||
|
||||
// Refresh the jobs list and select the new job
|
||||
await fetchJobs();
|
||||
if (data.jobId) {
|
||||
await refreshStatus(data.jobId);
|
||||
}
|
||||
|
||||
return data as {
|
||||
jobId: string;
|
||||
playlistTitle: string;
|
||||
totalItems: number;
|
||||
};
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
},
|
||||
[fetchJobs, refreshStatus]
|
||||
);
|
||||
|
||||
/** Process all pending items for a job, one at a time. */
|
||||
const startProcessing = useCallback(
|
||||
async (jobId: string) => {
|
||||
// Abort any existing processing loop
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
let done = false;
|
||||
|
||||
while (!done && !controller.signal.aborted) {
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"youtube-process",
|
||||
{ body: { jobId } }
|
||||
);
|
||||
|
||||
if (error) throw new Error(error.message ?? "Processing failed");
|
||||
if (data?.error) throw new Error(data.error);
|
||||
|
||||
done = data.done === true;
|
||||
|
||||
// Refresh the job status to get updated items
|
||||
await refreshStatus(jobId);
|
||||
|
||||
// Delay between calls to avoid hammering the function
|
||||
if (!done && !controller.signal.aborted) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, PROCESS_DELAY_MS);
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
// Graceful stop — not an error
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
abortRef.current = null;
|
||||
// Final refresh to get latest state
|
||||
await refreshStatus(jobId).catch(() => {});
|
||||
}
|
||||
},
|
||||
[refreshStatus]
|
||||
);
|
||||
|
||||
/** Stop the current processing loop. */
|
||||
const stopProcessing = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Delete a job, its items, and associated storage files. */
|
||||
const deleteJob = useCallback(
|
||||
async (jobId: string) => {
|
||||
await invokeDelete<{ deleted: boolean }>("youtube-status", { jobId });
|
||||
|
||||
// Remove the job from local state
|
||||
setJobs((prev) => prev.filter((j) => j.id !== jobId));
|
||||
|
||||
// Clear active job if it was the deleted one
|
||||
setActiveJob((prev) => (prev?.id === jobId ? null : prev));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/** Update the genre on a single download item. */
|
||||
const updateItemGenre = useCallback(
|
||||
async (itemId: string, genre: MusicGenre | null) => {
|
||||
await invokePatch<{ updated: boolean }>("youtube-status", {
|
||||
itemId,
|
||||
genre,
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setActiveJob((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === itemId ? { ...item, genre } : item
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
jobs,
|
||||
activeJob,
|
||||
isProcessing,
|
||||
isImporting,
|
||||
fetchJobs,
|
||||
refreshStatus,
|
||||
importPlaylist,
|
||||
startProcessing,
|
||||
stopProcessing,
|
||||
deleteJob,
|
||||
updateItemGenre,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user