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