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

@@ -196,3 +196,32 @@ jobs:
- name: Export web build
run: npx expo export --platform web
continue-on-error: true
deploy-functions:
name: Deploy Edge Functions
runs-on: ubuntu-latest
needs: [typecheck, lint, test]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Deploy to self-hosted Supabase
env:
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.SUPABASE_DEPLOY_PATH }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SUPABASE_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts 2>/dev/null
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.DS_Store' \
-e "ssh -i ~/.ssh/deploy_key" \
supabase/functions/ \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \
"docker restart supabase-edge-functions"

View File

@@ -0,0 +1,640 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Music,
Download,
Square,
Play,
Pause,
Loader2,
ExternalLink,
CheckCircle2,
XCircle,
Clock,
RefreshCw,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import {
useYouTubeDownload,
type JobWithItems,
} from "@/lib/use-youtube-download";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Select } from "@/components/ui/select";
import type { Database, MusicGenre } from "@/lib/supabase";
import { MUSIC_GENRES, GENRE_LABELS } from "@/lib/supabase";
type DownloadJob = Database["public"]["Tables"]["download_jobs"]["Row"];
type DownloadItem = Database["public"]["Tables"]["download_items"]["Row"];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatDuration(seconds: number | null): string {
if (!seconds) return "--:--";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
function statusBadge(status: string) {
switch (status) {
case "completed":
return (
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
<CheckCircle2 className="w-3 h-3 mr-1" />
Completed
</Badge>
);
case "downloading":
case "processing":
return (
<Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30">
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
{status === "downloading" ? "Downloading" : "Processing"}
</Badge>
);
case "failed":
return (
<Badge className="bg-red-500/20 text-red-400 border-red-500/30">
<XCircle className="w-3 h-3 mr-1" />
Failed
</Badge>
);
default:
return (
<Badge className="bg-neutral-500/20 text-neutral-400 border-neutral-500/30">
<Clock className="w-3 h-3 mr-1" />
Pending
</Badge>
);
}
}
function progressPercent(job: DownloadJob): number {
if (job.total_items === 0) return 0;
return Math.round(
((job.completed_items + job.failed_items) / job.total_items) * 100
);
}
const genreOptions = MUSIC_GENRES.map((g) => ({
value: g,
label: GENRE_LABELS[g],
}));
const genreSelectOptions = [
{ value: "", label: "No genre" },
...genreOptions,
];
function genreBadge(genre: MusicGenre | null) {
if (!genre) return null;
return (
<Badge className="bg-violet-500/20 text-violet-400 border-violet-500/30 text-xs">
{GENRE_LABELS[genre]}
</Badge>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function ImportSection({
onImport,
isImporting,
}: {
onImport: (url: string, genre?: MusicGenre) => void;
isImporting: boolean;
}) {
const [url, setUrl] = useState("");
const [genre, setGenre] = useState<string>("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = url.trim();
if (!trimmed) return;
onImport(trimmed, (genre || undefined) as MusicGenre | undefined);
setUrl("");
};
return (
<Card className="bg-neutral-900 border-neutral-800">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Music className="w-5 h-5 text-orange-500" />
Import YouTube Playlist
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex gap-3">
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://www.youtube.com/playlist?list=..."
className="bg-neutral-950 border-neutral-700 text-white placeholder:text-neutral-500 flex-1"
disabled={isImporting}
/>
<Select
value={genre}
onValueChange={setGenre}
options={genreSelectOptions}
placeholder="Genre"
disabled={isImporting}
className="w-40"
/>
<Button
type="submit"
disabled={isImporting || !url.trim()}
className="bg-orange-500 hover:bg-orange-600 text-white"
>
{isImporting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Importing...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Import
</>
)}
</Button>
</form>
</CardContent>
</Card>
);
}
function JobCard({
job,
isSelected,
isProcessing,
onSelect,
onStart,
onStop,
onDelete,
}: {
job: DownloadJob;
isSelected: boolean;
isProcessing: boolean;
onSelect: () => void;
onStart: () => void;
onStop: () => void;
onDelete: () => void;
}) {
const pct = progressPercent(job);
const canStart =
!isProcessing &&
(job.status === "pending" || job.status === "processing");
return (
<Card
className={`bg-neutral-900 border-neutral-800 cursor-pointer transition-colors ${
isSelected ? "ring-1 ring-orange-500 border-orange-500/50" : "hover:border-neutral-700"
}`}
onClick={onSelect}
>
<CardContent className="pt-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">
{job.playlist_title || "Untitled Playlist"}
</p>
<p className="text-neutral-500 text-sm mt-1 truncate">
{job.playlist_url}
</p>
<div className="flex items-center gap-3 mt-2">
{statusBadge(job.status)}
<span className="text-neutral-400 text-sm" style={{ fontVariant: "tabular-nums" }}>
{job.completed_items}/{job.total_items} completed
{job.failed_items > 0 && ` · ${job.failed_items} failed`}
</span>
</div>
</div>
<div className="flex gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
{isProcessing && isSelected ? (
<Button
size="sm"
variant="outline"
className="border-neutral-700 text-neutral-300 hover:bg-neutral-800"
onClick={onStop}
>
<Square className="w-4 h-4 mr-1" />
Stop
</Button>
) : (
canStart && (
<Button
size="sm"
className="bg-orange-500 hover:bg-orange-600 text-white"
onClick={onStart}
>
<Download className="w-4 h-4 mr-1" />
Start
</Button>
)
)}
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 text-neutral-500 hover:text-red-400 hover:bg-red-500/10"
onClick={onDelete}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Progress bar */}
<div className="mt-3 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-orange-500 rounded-full transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
</CardContent>
</Card>
);
}
function AudioPlayer({ url }: { url: string }) {
const [playing, setPlaying] = useState(false);
const [audio] = useState(() => {
if (typeof window === "undefined") return null;
const a = new Audio(url);
a.addEventListener("ended", () => setPlaying(false));
return a;
});
const toggle = () => {
if (!audio) return;
if (playing) {
audio.pause();
setPlaying(false);
} else {
audio.play();
setPlaying(true);
}
};
// Cleanup on unmount
useEffect(() => {
return () => {
audio?.pause();
};
}, [audio]);
return (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-orange-500 hover:text-orange-400"
onClick={toggle}
>
{playing ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5" />}
</Button>
);
}
function ItemsTable({
items,
onGenreChange,
}: {
items: DownloadItem[];
onGenreChange: (itemId: string, genre: MusicGenre | null) => void;
}) {
if (items.length === 0) {
return (
<p className="text-neutral-500 text-sm text-center py-8">
No items in this job.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow className="border-neutral-800 hover:bg-transparent">
<TableHead className="text-neutral-400">#</TableHead>
<TableHead className="text-neutral-400">Title</TableHead>
<TableHead className="text-neutral-400">Genre</TableHead>
<TableHead className="text-neutral-400">Duration</TableHead>
<TableHead className="text-neutral-400">Status</TableHead>
<TableHead className="text-neutral-400 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow key={item.id} className="border-neutral-800">
<TableCell className="text-neutral-500" style={{ fontVariant: "tabular-nums" }}>
{idx + 1}
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
{item.thumbnail_url && (
<img
src={item.thumbnail_url}
alt=""
className="w-10 h-10 rounded object-cover bg-neutral-800 shrink-0"
/>
)}
<span className="text-white truncate max-w-xs">
{item.title || item.video_id}
</span>
</div>
</TableCell>
<TableCell>
<Select
value={item.genre ?? ""}
onValueChange={(val) =>
onGenreChange(item.id, (val || null) as MusicGenre | null)
}
options={genreSelectOptions}
placeholder="--"
className="w-32"
/>
</TableCell>
<TableCell className="text-neutral-400" style={{ fontVariant: "tabular-nums" }}>
{formatDuration(item.duration_seconds)}
</TableCell>
<TableCell>{statusBadge(item.status)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{item.status === "completed" && item.public_url && (
<AudioPlayer url={item.public_url} />
)}
<a
href={`https://www.youtube.com/watch?v=${item.video_id}`}
target="_blank"
rel="noopener noreferrer"
>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-neutral-400 hover:text-white"
>
<ExternalLink className="w-3.5 h-3.5" />
</Button>
</a>
</div>
{item.status === "failed" && item.error_message && (
<p className="text-red-400 text-xs mt-1 text-right max-w-xs truncate">
{item.error_message}
</p>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function MusicPage() {
const {
jobs,
activeJob,
isProcessing,
isImporting,
fetchJobs,
refreshStatus,
importPlaylist,
startProcessing,
stopProcessing,
deleteJob,
updateItemGenre,
} = useYouTubeDownload();
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DownloadJob | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Load jobs on mount
useEffect(() => {
fetchJobs().catch((err) =>
toast.error("Failed to load jobs: " + err.message)
);
}, [fetchJobs]);
const handleImport = async (url: string, genre?: MusicGenre) => {
try {
const result = await importPlaylist(url, genre);
toast.success(
`Imported "${result.playlistTitle}" — ${result.totalItems} tracks`
);
setSelectedJobId(result.jobId);
} catch (err) {
toast.error(
"Import failed: " +
(err instanceof Error ? err.message : "Unknown error")
);
}
};
const handleStart = async (jobId: string) => {
setSelectedJobId(jobId);
try {
await startProcessing(jobId);
toast.success("Download complete!");
} catch (err) {
toast.error(
"Processing error: " +
(err instanceof Error ? err.message : "Unknown error")
);
}
};
const handleSelectJob = async (jobId: string) => {
setSelectedJobId(jobId);
try {
await refreshStatus(jobId);
} catch (err) {
toast.error("Failed to load job details");
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
await deleteJob(deleteTarget.id);
toast.success(
`Deleted "${deleteTarget.playlist_title || "Untitled Playlist"}"`
);
if (selectedJobId === deleteTarget.id) {
setSelectedJobId(null);
}
} catch (err) {
toast.error(
"Delete failed: " +
(err instanceof Error ? err.message : "Unknown error")
);
} finally {
setIsDeleting(false);
setDeleteTarget(null);
}
};
const handleGenreChange = async (itemId: string, genre: MusicGenre | null) => {
try {
await updateItemGenre(itemId, genre);
} catch (err) {
toast.error(
"Failed to update genre: " +
(err instanceof Error ? err.message : "Unknown error")
);
}
};
return (
<div className="p-8 max-w-6xl">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Music</h1>
<p className="text-neutral-400">
Download audio from YouTube playlists for workout tracks
</p>
</div>
<Button
variant="outline"
size="sm"
className="border-neutral-700 text-neutral-300 hover:bg-neutral-800"
onClick={() => fetchJobs()}
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
{/* Import section */}
<div className="mb-8">
<ImportSection onImport={handleImport} isImporting={isImporting} />
</div>
{/* Jobs + Detail layout */}
<div className="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6">
{/* Jobs list */}
<div className="space-y-3">
<h2 className="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-3">
Jobs ({jobs.length})
</h2>
{jobs.length === 0 ? (
<p className="text-neutral-500 text-sm">
No jobs yet. Import a playlist to get started.
</p>
) : (
jobs.map((job) => (
<JobCard
key={job.id}
job={job}
isSelected={selectedJobId === job.id}
isProcessing={isProcessing && selectedJobId === job.id}
onSelect={() => handleSelectJob(job.id)}
onStart={() => handleStart(job.id)}
onStop={stopProcessing}
onDelete={() => setDeleteTarget(job)}
/>
))
)}
</div>
{/* Detail panel */}
<div>
{activeJob ? (
<Card className="bg-neutral-900 border-neutral-800">
<CardHeader>
<CardTitle className="text-white">
{activeJob.playlist_title || "Untitled Playlist"}
</CardTitle>
</CardHeader>
<CardContent>
<ItemsTable items={activeJob.items} onGenreChange={handleGenreChange} />
</CardContent>
</Card>
) : (
<Card className="bg-neutral-900 border-neutral-800">
<CardContent className="py-16 text-center">
<Music className="w-12 h-12 text-neutral-700 mx-auto mb-4" />
<p className="text-neutral-500">
Select a job to view its tracks
</p>
</CardContent>
</Card>
)}
</div>
</div>
{/* Delete confirmation dialog */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent className="bg-neutral-900 border-neutral-800">
<DialogHeader>
<DialogTitle className="text-white">Delete Job</DialogTitle>
<DialogDescription className="text-neutral-400">
This will permanently delete{" "}
<span className="text-white font-medium">
{deleteTarget?.playlist_title || "this job"}
</span>{" "}
and remove {deleteTarget?.completed_items ?? 0} audio files from
storage. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
className="border-neutral-700 text-neutral-300 hover:bg-neutral-800"
onClick={() => setDeleteTarget(null)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
className="bg-red-600 hover:bg-red-700 text-white"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import {
Users,
FolderOpen,
ImageIcon,
Music,
LogOut,
Flame,
} from "lucide-react";
@@ -20,6 +21,7 @@ const navItems = [
{ href: "/trainers", label: "Trainers", icon: Users },
{ href: "/collections", label: "Collections", icon: FolderOpen },
{ href: "/media", label: "Media", icon: ImageIcon },
{ href: "/music", label: "Music", icon: Music },
];
export function Sidebar() {

View File

@@ -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
}
}
}
}
}

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

55
scripts/deploy-functions.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Configuration ──────────────────────────────────────────────
DEPLOY_HOST="${DEPLOY_HOST:-1000co.fr}"
DEPLOY_USER="${DEPLOY_USER:-millian}"
DEPLOY_PATH="${DEPLOY_PATH:-/opt/supabase/volumes/functions}"
WORKER_PATH="${WORKER_PATH:-/opt/supabase/youtube-worker}"
# ───────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FUNCTIONS_DIR="$SCRIPT_DIR/../supabase/functions"
WORKER_DIR="$SCRIPT_DIR/../youtube-worker"
# ── Deploy edge functions ──────────────────────────────────────
echo "==> Deploying edge functions"
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.DS_Store' \
"$FUNCTIONS_DIR/" \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
echo "Restarting supabase-edge-functions..."
ssh "$DEPLOY_USER@$DEPLOY_HOST" "docker restart supabase-edge-functions"
# ── Deploy youtube-worker sidecar ──────────────────────────────
echo ""
echo "==> Deploying youtube-worker sidecar"
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.DS_Store' \
"$WORKER_DIR/" \
"$DEPLOY_USER@$DEPLOY_HOST:$WORKER_PATH/"
echo "Building and restarting youtube-worker..."
ssh "$DEPLOY_USER@$DEPLOY_HOST" "\
cd $WORKER_PATH && \
docker build -t youtube-worker:latest . && \
docker stop youtube-worker 2>/dev/null || true && \
docker rm youtube-worker 2>/dev/null || true && \
docker run -d \
--name youtube-worker \
--restart unless-stopped \
--network supabase_supabase-network \
-e SUPABASE_URL=\$(docker exec supabase-edge-functions printenv SUPABASE_URL) \
-e SUPABASE_SERVICE_ROLE_KEY=\$(docker exec supabase-edge-functions printenv SUPABASE_SERVICE_ROLE_KEY) \
-e SUPABASE_PUBLIC_URL=https://supabase.1000co.fr \
-e STORAGE_BUCKET=workout-audio \
-e PORT=3001 \
youtube-worker:latest"
echo ""
echo "Done. Verifying youtube-worker health..."
sleep 3
ssh "$DEPLOY_USER@$DEPLOY_HOST" "docker logs youtube-worker --tail 5"

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

View File

@@ -0,0 +1,96 @@
-- ============================================================
-- YouTube Playlist Download Jobs
-- ============================================================
-- Tracks playlist-level download jobs and per-video items
-- Used by the youtube-playlist, youtube-process, youtube-status edge functions
-- Download jobs table (one row per playlist import)
CREATE TABLE IF NOT EXISTS public.download_jobs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
playlist_url TEXT NOT NULL,
playlist_title TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
total_items INTEGER NOT NULL DEFAULT 0,
completed_items INTEGER NOT NULL DEFAULT 0,
failed_items INTEGER NOT NULL DEFAULT 0,
created_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Download items table (one row per video in the playlist)
CREATE TABLE IF NOT EXISTS public.download_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
job_id UUID NOT NULL REFERENCES public.download_jobs(id) ON DELETE CASCADE,
video_id TEXT NOT NULL,
title TEXT,
duration_seconds INTEGER,
thumbnail_url TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'downloading', 'completed', 'failed')),
storage_path TEXT,
public_url TEXT,
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- ============================================================
-- INDEXES
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_download_jobs_created_by ON public.download_jobs(created_by);
CREATE INDEX IF NOT EXISTS idx_download_jobs_status ON public.download_jobs(status);
CREATE INDEX IF NOT EXISTS idx_download_jobs_created_at ON public.download_jobs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_download_items_job_id ON public.download_items(job_id);
CREATE INDEX IF NOT EXISTS idx_download_items_status ON public.download_items(status);
CREATE INDEX IF NOT EXISTS idx_download_items_created_at ON public.download_items(created_at);
-- ============================================================
-- TRIGGERS
-- ============================================================
CREATE TRIGGER update_download_jobs_updated_at
BEFORE UPDATE ON public.download_jobs
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- ============================================================
-- ROW LEVEL SECURITY
-- ============================================================
ALTER TABLE public.download_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.download_items ENABLE ROW LEVEL SECURITY;
-- Admin-only read/write (same pattern as existing tables)
CREATE POLICY "Allow admin read access" ON public.download_jobs
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.admin_users WHERE id = auth.uid())
);
CREATE POLICY "Allow admin write access" ON public.download_jobs
FOR ALL USING (
EXISTS (SELECT 1 FROM public.admin_users WHERE id = auth.uid())
);
CREATE POLICY "Allow admin read access" ON public.download_items
FOR SELECT USING (
EXISTS (SELECT 1 FROM public.admin_users WHERE id = auth.uid())
);
CREATE POLICY "Allow admin write access" ON public.download_items
FOR ALL USING (
EXISTS (SELECT 1 FROM public.admin_users WHERE id = auth.uid())
);
-- ============================================================
-- STORAGE BUCKET: workout-audio
-- ============================================================
-- Create via Supabase Dashboard or CLI:
-- supabase storage create workout-audio --public
--
-- Configuration:
-- Bucket name: workout-audio
-- Public: true
-- Allowed MIME types: audio/mp4, audio/webm, audio/mpeg
-- Max file size: 100MB
-- Path pattern: {job_id}/{video_id}.m4a

View File

@@ -0,0 +1,18 @@
-- ============================================================
-- Music Genre Classification
-- ============================================================
-- Adds per-track genre to download_items so users can filter
-- workout music by genre preference.
-- Genre enum values: edm, hip-hop, pop, rock, latin, house,
-- drum-and-bass, dubstep, r-and-b, country, metal, ambient
ALTER TABLE public.download_items
ADD COLUMN IF NOT EXISTS genre TEXT
CHECK (genre IS NULL OR genre IN (
'edm', 'hip-hop', 'pop', 'rock', 'latin', 'house',
'drum-and-bass', 'dubstep', 'r-and-b', 'country', 'metal', 'ambient'
));
CREATE INDEX IF NOT EXISTS idx_download_items_genre
ON public.download_items(genre);

21
youtube-worker/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:20-slim
# Install yt-dlp (standalone binary) and ffmpeg for audio extraction
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 ffmpeg curl ca-certificates && \
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
chmod +x /usr/local/bin/yt-dlp && \
apt-get remove -y curl && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --production
COPY server.js ./
EXPOSE 3001
CMD ["node", "server.js"]

190
youtube-worker/package-lock.json generated Normal file
View File

@@ -0,0 +1,190 @@
{
"name": "youtube-worker",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "youtube-worker",
"version": "1.0.0",
"dependencies": {
"@supabase/supabase-js": "^2.49.1",
"youtubei.js": "^17.0.1"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@supabase/auth-js": {
"version": "2.100.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.100.0.tgz",
"integrity": "sha512-pdT3ye3UVRN1Cg0wom6BmyY+XTtp5DiJaYnPi6j8ht5i8Lq8kfqxJMJz9GI9YDKk3w1nhGOPnh6Qz5qpyYm+1w==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.100.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.100.0.tgz",
"integrity": "sha512-keLg79RPwP+uiwHuxFPTFgDRxPV46LM4j/swjyR2GKJgWniTVSsgiBHfbIBDcrQwehLepy09b/9QSHUywtKRWQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz",
"integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.100.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.100.0.tgz",
"integrity": "sha512-xYNvNbBJaXOGcrZ44wxwp5830uo1okMHGS8h8dm3u4f0xcZ39yzbryUsubTJW41MG2gbL/6U57cA4Pi6YMZ9pA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.100.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.100.0.tgz",
"integrity": "sha512-2AZs00zzEF0HuCKY8grz5eCYlwEfVi5HONLZFoNR6aDfxQivl8zdQYNjyFoqN2MZiVhQHD7u6XV/xHwM8mCEHw==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.0",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.100.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.100.0.tgz",
"integrity": "sha512-d4EeuK6RNIgYNA2MU9kj8lQrLm5AzZ+WwpWjGkii6SADQNIGTC/uiaTRu02XJ5AmFALQfo8fLl9xuCkO6Xw+iQ==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.100.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.100.0.tgz",
"integrity": "sha512-r0tlcukejJXJ1m/2eG/Ya5eYs4W8AC7oZfShpG3+SIo/eIU9uIt76ZeYI1SoUwUmcmzlAbgch+HDZDR/toVQPQ==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.100.0",
"@supabase/functions-js": "2.100.0",
"@supabase/postgrest-js": "2.100.0",
"@supabase/realtime-js": "2.100.0",
"@supabase/storage-js": "2.100.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/meriyah": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz",
"integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==",
"license": "ISC",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/youtubei.js": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-17.0.1.tgz",
"integrity": "sha512-1lO4b8UqMDzE0oh2qEGzbBOd4UYRdxn/4PdpRM7BGTHxM6ddsEsKZTu90jp8V9FHVgC2h1UirQyqoqLiKwl+Zg==",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"license": "MIT",
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"meriyah": "^6.1.4"
}
}
}
}

View File

@@ -0,0 +1,13 @@
{
"name": "youtube-worker",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"youtubei.js": "^17.0.1",
"@supabase/supabase-js": "^2.49.1"
}
}

218
youtube-worker/server.js Normal file
View File

@@ -0,0 +1,218 @@
import http from "node:http";
import { execFile } from "node:child_process";
import { readFile, unlink, mkdir } from "node:fs/promises";
import { promisify } from "node:util";
import { Innertube } from "youtubei.js";
import { createClient } from "@supabase/supabase-js";
const execFileAsync = promisify(execFile);
// ── Config ────────────────────────────────────────────────────
const PORT = parseInt(process.env.PORT || "3001", 10);
const SUPABASE_URL = process.env.SUPABASE_URL;
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
const STORAGE_BUCKET = process.env.STORAGE_BUCKET || "workout-audio";
// Public-facing URL for constructing browser-accessible storage URLs.
// SUPABASE_URL is the internal Docker network URL (e.g. http://kong:8000)
// which browsers cannot reach.
const SUPABASE_PUBLIC_URL = process.env.SUPABASE_PUBLIC_URL || SUPABASE_URL;
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
console.error("Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY");
process.exit(1);
}
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
// ── YouTube client (singleton, reused across requests) ────────
let ytClient = null;
let ytClientPromise = null;
async function getYouTubeClient() {
if (ytClient) return ytClient;
if (ytClientPromise) return ytClientPromise;
ytClientPromise = Innertube.create({
generate_session_locally: true,
retrieve_player: true,
}).then((client) => {
ytClient = client;
ytClientPromise = null;
console.log("YouTube client initialized");
return client;
});
return ytClientPromise;
}
// Pre-warm the client on startup
getYouTubeClient().catch((err) =>
console.error("Failed to pre-warm YouTube client:", err.message)
);
// ── Helpers ───────────────────────────────────────────────────
function parsePlaylistId(url) {
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 {}
throw new Error(`Could not parse playlist ID from: "${url}"`);
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString()));
} catch (e) {
reject(new Error("Invalid JSON body"));
}
});
req.on("error", reject);
});
}
function jsonResponse(res, status, data) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
// ── Routes ────────────────────────────────────────────────────
async function handlePlaylist(req, res) {
const { playlistUrl } = await readBody(req);
if (!playlistUrl) {
return jsonResponse(res, 400, { error: "playlistUrl is required" });
}
const playlistId = parsePlaylistId(playlistUrl);
const yt = await getYouTubeClient();
const playlist = await yt.getPlaylist(playlistId);
const title = playlist.info.title ?? "Untitled Playlist";
const items = [];
function extractItems(page) {
if (!page.items) return;
for (const item of page.items) {
// Only include items that have a video ID (skip Shorts/Reels)
if (!item.id) continue;
items.push({
videoId: item.id,
title: item.title?.toString() ?? "Untitled",
durationSeconds: item.duration?.seconds ?? 0,
thumbnailUrl: item.thumbnails?.[0]?.url ?? null,
});
}
}
// First page
extractItems(playlist);
// Pagination
let page = playlist;
while (page.has_continuation) {
page = await page.getContinuation();
extractItems(page);
}
console.log(`Playlist "${title}": ${items.length} items`);
jsonResponse(res, 200, { title, items });
}
async function handleDownload(req, res) {
const { videoId, jobId } = await readBody(req);
if (!videoId || !jobId) {
return jsonResponse(res, 400, { error: "videoId and jobId are required" });
}
// Use yt-dlp for the actual download — it handles PO tokens, signature
// deciphering, and anti-bot measures that youtubei.js cannot.
const tmpDir = `/tmp/ytdl-${jobId}`;
const outPath = `${tmpDir}/${videoId}.m4a`;
await mkdir(tmpDir, { recursive: true });
try {
console.log(`Downloading ${videoId} via yt-dlp...`);
const { stderr } = await execFileAsync("yt-dlp", [
"-f", "ba[ext=m4a]/ba", // best audio in m4a, fallback to best audio
"--no-playlist",
"--no-warnings",
"-x", // extract audio
"--audio-format", "m4a", // ensure m4a output
"--audio-quality", "0", // best quality
"-o", outPath,
`https://www.youtube.com/watch?v=${videoId}`,
], { timeout: 120_000 });
if (stderr) console.warn(`yt-dlp stderr for ${videoId}: ${stderr}`);
// Read the downloaded file
const audioData = await readFile(outPath);
console.log(
`Downloaded ${videoId}: ${(audioData.length / 1024 / 1024).toFixed(1)} MB`
);
// Upload to Supabase Storage — flat structure, no subfolder
const storagePath = `${videoId}.m4a`;
const { error: uploadError } = await supabase.storage
.from(STORAGE_BUCKET)
.upload(storagePath, audioData, {
contentType: "audio/mp4",
cacheControl: "3600",
upsert: true,
});
if (uploadError) {
throw new Error(`Storage upload failed: ${uploadError.message}`);
}
// Construct public URL using the external-facing base URL, not the
// internal Docker URL that supabase.storage.getPublicUrl() would use.
const publicUrl = `${SUPABASE_PUBLIC_URL}/storage/v1/object/public/${STORAGE_BUCKET}/${storagePath}`;
jsonResponse(res, 200, { storagePath, publicUrl });
} finally {
// Clean up temp file
await unlink(outPath).catch(() => {});
}
}
async function handleHealth(_req, res) {
jsonResponse(res, 200, { status: "ok" });
}
// ── Server ────────────────────────────────────────────────────
const server = http.createServer(async (req, res) => {
try {
if (req.method === "GET" && req.url === "/health") {
return await handleHealth(req, res);
}
if (req.method === "POST" && req.url === "/playlist") {
return await handlePlaylist(req, res);
}
if (req.method === "POST" && req.url === "/download") {
return await handleDownload(req, res);
}
jsonResponse(res, 404, { error: "Not found" });
} catch (err) {
console.error(`${req.method} ${req.url} error:`, err.message);
jsonResponse(res, 500, { error: err.message });
}
});
server.listen(PORT, () => {
console.log(`youtube-worker listening on port ${PORT}`);
});