"use client"; import { useState, useEffect, useRef } from "react"; import { SettingsSection, SettingsRow, SettingsToggle } from "../ui"; import { SystemSettings } from "../../types"; import { api } from "@/lib/api"; import { enrichmentApi } from "@/lib/enrichmentApi"; import { useQueryClient, useQuery, useMutation } from "@tanstack/react-query"; import { CheckCircle, Loader2, User, Heart, Activity, Pause, Play, StopCircle, AlertTriangle, } from "lucide-react"; import { EnrichmentFailuresModal } from "@/components/EnrichmentFailuresModal"; interface CacheSectionProps { settings: SystemSettings; onUpdate: (updates: Partial) => void; } // Progress bar component function ProgressBar({ progress, color = "bg-[#ecb200]", showPercentage = true, }: { progress: number; color?: string; showPercentage?: boolean; }) { return (
{showPercentage && ( {progress}% )}
); } // Enrichment stage component function EnrichmentStage({ icon: Icon, label, description, completed, total, progress, isBackground = false, failed = 0, processing = 0, }: { icon: React.ElementType; label: string; description: string; completed: number; total: number; progress: number; isBackground?: boolean; failed?: number; processing?: number; }) { const isComplete = progress === 100; const hasActivity = processing > 0; return (
{isComplete ? ( ) : hasActivity ? ( ) : ( )}
{label} {isBackground && !isComplete && ( background )}

{description}

{completed} / {total} {processing > 0 && ( {processing} processing )} {failed > 0 && ( {failed} failed )}
); } export function CacheSection({ settings, onUpdate }: CacheSectionProps) { const [syncing, setSyncing] = useState(false); const [clearingCaches, setClearingCaches] = useState(false); const [reEnriching, setReEnriching] = useState(false); const [cleaningStaleJobs, setCleaningStaleJobs] = useState(false); const [cleanupResult, setCleanupResult] = useState<{ totalCleaned: number; cleaned: { discoveryBatches: { cleaned: number }; downloadJobs: { cleaned: number }; spotifyImportJobs: { cleaned: number }; bullQueues: { cleaned: number }; }; } | null>(null); const [error, setError] = useState(null); const [showFailuresModal, setShowFailuresModal] = useState(false); const queryClient = useQueryClient(); const syncStartTimeRef = useRef(0); // Check URL hash for auto-opening failures modal useEffect(() => { if (window.location.hash === "#enrichment-failures") { setShowFailuresModal(true); } }, []); // Fetch enrichment progress const { data: enrichmentProgress, refetch: refetchProgress } = useQuery({ queryKey: ["enrichment-progress"], queryFn: () => api.getEnrichmentProgress(), refetchInterval: 5000, // Refresh every 5 seconds staleTime: 2000, }); // Fetch enrichment state const { data: enrichmentState } = useQuery({ queryKey: ["enrichment-status"], queryFn: () => enrichmentApi.getStatus(), refetchInterval: 3000, staleTime: 1000, }); // Fetch failure counts const { data: failureCounts } = useQuery({ queryKey: ["enrichment-failure-counts"], queryFn: () => enrichmentApi.getFailureCounts(), refetchInterval: 10000, }); // Fetch concurrency config const { data: concurrencyConfig, isLoading: isConcurrencyLoading } = useQuery({ queryKey: ["enrichment-concurrency"], queryFn: () => enrichmentApi.getConcurrency(), staleTime: 0, }); // Fetch audio analyzer workers config const { data: workersConfig, isLoading: isWorkersLoading } = useQuery({ queryKey: ["analysis-workers"], queryFn: () => enrichmentApi.getAnalysisWorkers(), staleTime: 0, }); // Update concurrency mutation with optimistic updates // Note: We do NOT invalidate on onSettled because the optimistic update // already provides the correct UI state. Invalidating causes a race condition // where the refetch returns stale data before the server update completes, // causing the slider to "bounce" between values. const setConcurrencyMutation = useMutation({ mutationFn: (concurrency: number) => enrichmentApi.setConcurrency(concurrency), onMutate: async (newConcurrency) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ["enrichment-concurrency"], }); // Snapshot previous value const previousConcurrency = queryClient.getQueryData([ "enrichment-concurrency", ]); // Optimistically update to new value queryClient.setQueryData(["enrichment-concurrency"], { concurrency: newConcurrency, artistsPerMin: newConcurrency * 6, // Approximate estimate }); return { previousConcurrency }; }, onError: (err, newConcurrency, context) => { // Rollback on error queryClient.setQueryData( ["enrichment-concurrency"], context?.previousConcurrency ); }, // Removed onSettled invalidation - optimistic update handles UI, // and the query will refetch naturally based on staleTime }); // Update audio analyzer workers mutation with optimistic updates const setAnalysisWorkersMutation = useMutation({ mutationFn: (workers: number) => enrichmentApi.setAnalysisWorkers(workers), onMutate: async (newWorkers) => { await queryClient.cancelQueries({ queryKey: ["analysis-workers"], }); const previousWorkers = queryClient.getQueryData([ "analysis-workers", ]); queryClient.setQueryData(["analysis-workers"], { workers: newWorkers, cpuCores: workersConfig?.cpuCores || 4, recommended: workersConfig?.recommended || 2, description: `Using ${newWorkers} of ${workersConfig?.cpuCores || 4} available CPU cores`, }); return { previousWorkers }; }, onError: (err, newWorkers, context) => { queryClient.setQueryData( ["analysis-workers"], context?.previousWorkers ); }, }); // Use query data directly instead of local state const enrichmentSpeed = concurrencyConfig?.concurrency ?? 1; // Poll enrichment status when syncing to detect completion useEffect(() => { if (!syncing) return; const maxPollDuration = 5 * 60 * 1000; // 5 minutes max const pollInterval = 2000; // Check every 2 seconds const startTime = syncStartTimeRef.current; const checkStatus = async () => { try { const status = await enrichmentApi.getStatus(); const elapsed = Date.now() - startTime; // Stop polling if idle or max duration exceeded if (status?.status === "idle" || elapsed > maxPollDuration) { setSyncing(false); refetchProgress(); } } catch (err) { console.error("Failed to check enrichment status:", err); } }; const intervalId = setInterval(checkStatus, pollInterval); return () => clearInterval(intervalId); }, [syncing, refetchProgress]); const refreshNotifications = () => { queryClient.invalidateQueries({ queryKey: ["notifications"] }); queryClient.invalidateQueries({ queryKey: ["unread-notification-count"], }); window.dispatchEvent(new CustomEvent("notifications-changed")); }; const handleSyncAndEnrich = async () => { setSyncing(true); syncStartTimeRef.current = Date.now(); setError(null); try { // Always sync audiobooks if Audiobookshelf is enabled (independent of enrichment setting) if (settings.audiobookshelfEnabled) { await api.post("/audiobooks/sync", {}); } await api.post("/podcasts/sync-covers", {}); // Use the new fast incremental sync endpoint await api.syncLibraryEnrichment(); refreshNotifications(); refetchProgress(); // Don't set syncing to false here - let the polling effect handle it } catch (err) { console.error("Sync error:", err); setError("Failed to sync"); setSyncing(false); // Only stop on error } }; const handleFullEnrichment = async () => { setReEnriching(true); setError(null); try { await api.triggerFullEnrichment(); refreshNotifications(); refetchProgress(); } catch (err) { console.error("Full enrichment error:", err); setError("Failed to start full enrichment"); } finally { setReEnriching(false); } }; const handleClearCaches = async () => { setClearingCaches(true); setError(null); try { await api.clearAllCaches(); refreshNotifications(); } catch (err) { setError("Failed to clear caches"); } finally { setClearingCaches(false); } }; const handleCleanupStaleJobs = async () => { setCleaningStaleJobs(true); setCleanupResult(null); setError(null); try { const result = await api.cleanupStaleJobs(); setCleanupResult(result); refreshNotifications(); } catch (err) { console.error("Stale job cleanup error:", err); setError("Failed to cleanup stale jobs"); } finally { setCleaningStaleJobs(false); } }; const handlePause = async () => { try { await enrichmentApi.pause(); queryClient.invalidateQueries({ queryKey: ["enrichment-status"] }); } catch (err) { console.error("Pause error:", err); setError("Failed to pause enrichment"); } }; const handleResume = async () => { try { await enrichmentApi.resume(); queryClient.invalidateQueries({ queryKey: ["enrichment-status"] }); } catch (err) { console.error("Resume error:", err); setError("Failed to resume enrichment"); } }; const handleStop = async () => { try { await enrichmentApi.stop(); queryClient.invalidateQueries({ queryKey: ["enrichment-status"] }); queryClient.invalidateQueries({ queryKey: ["enrichment-progress"], }); } catch (err) { console.error("Stop error:", err); setError("Failed to stop enrichment"); } }; const isEnrichmentActive = enrichmentState?.status === "running" || enrichmentState?.status === "paused"; const totalFailures = failureCounts?.total || 0; return ( <> {/* Enrichment Progress */} {enrichmentProgress && (

Library Enrichment

{enrichmentProgress.coreComplete && !enrichmentProgress.isFullyComplete && ( Audio analysis running )} {enrichmentProgress.isFullyComplete && ( Complete )}
{/* Control Buttons */}
{/* Main Actions */} {/* Control Actions */} {isEnrichmentActive && ( <> {enrichmentState?.status === "running" ? ( ) : ( )} )} {/* Failures Button */} {totalFailures > 0 && ( )}
{/* Status Message */} {enrichmentState && enrichmentState.status !== "idle" && (
{enrichmentState.status === "running" && ( )} {enrichmentState.status === "paused" && ( )} {enrichmentState.status === "stopping" && ( )} {enrichmentState.status === "running" && `Processing ${enrichmentState.currentPhase}...`} {enrichmentState.status === "paused" && "Enrichment paused"} {enrichmentState.status === "stopping" && `Stopping... finishing ${ enrichmentState.stoppingInfo ?.currentItem || "current item" }`}
{enrichmentState.status === "running" && enrichmentState.currentPhase === "artists" && enrichmentState.artists?.current && (
Current:{" "} { enrichmentState.artists .current }
)} {enrichmentState.status === "running" && enrichmentState.currentPhase === "tracks" && enrichmentState.tracks?.current && (
Current:{" "} {enrichmentState.tracks.current}
)}
)}
)} {/* Cache Sizes */}
onUpdate({ maxCacheSizeMb: parseInt(e.target.value), }) } className="w-32 h-1 bg-[#404040] rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white" /> {(settings.maxCacheSizeMb / 1024).toFixed(1)} GB
onUpdate({ transcodeCacheMaxGb: parseInt( e.target.value ), }) } className="w-32 h-1 bg-[#404040] rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white" /> {settings.transcodeCacheMaxGb} GB
{/* Automation */} onUpdate({ autoSync: checked })} /> onUpdate({ autoEnrichMetadata: checked }) } /> {/* Enrichment Speed Control */} {settings.autoEnrichMetadata && (
{ const newSpeed = parseInt(e.target.value); setConcurrencyMutation.mutate(newSpeed); }} className="w-32 h-1 bg-[#404040] rounded-lg appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white" />
{isConcurrencyLoading ? ( Loading... ) : ( <> {enrichmentSpeed === 1 ? "Conservative" : enrichmentSpeed === 2 ? "Moderate" : enrichmentSpeed === 3 ? "Balanced" : enrichmentSpeed === 4 ? "Fast" : "Maximum"} {concurrencyConfig && ( ~ { concurrencyConfig.artistsPerMin }{" "} artists/min )} )}
)} {/* Audio Analyzer Workers Control */} {settings.autoEnrichMetadata && (
{ const newWorkers = parseInt(e.target.value); setAnalysisWorkersMutation.mutate(newWorkers); }} className="w-32 h-1 bg-[#404040] rounded-lg appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white" />
{isWorkersLoading ? ( Loading... ) : ( <> {workersConfig?.workers ?? 2} workers {workersConfig && ( {workersConfig.cpuCores} cores available )} )}
)} {/* Cache Actions */}
{cleanupResult && cleanupResult.totalCleaned > 0 && (

Cleaned:{" "} {cleanupResult.cleaned.discoveryBatches.cleaned}{" "} batches,{" "} {cleanupResult.cleaned.downloadJobs.cleaned}{" "} downloads,{" "} {cleanupResult.cleaned.spotifyImportJobs.cleaned}{" "} imports, {cleanupResult.cleaned.bullQueues.cleaned}{" "} queue jobs

)} {cleanupResult && cleanupResult.totalCleaned === 0 && (

No stale jobs found

)} {error &&

{error}

}
setShowFailuresModal(false)} /> ); }