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:
640
admin-web/app/music/page.tsx
Normal file
640
admin-web/app/music/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user