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