"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( functionName: string, params?: Record ): Promise { 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( functionName: string, body: Record ): Promise { 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( functionName: string, body: Record ): Promise { 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([]); const [activeJob, setActiveJob] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [isImporting, setIsImporting] = useState(false); const abortRef = useRef(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((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, }; }