Files
lidify/frontend/lib/download-context.tsx
Your Name cc8d0f6969 Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features:
- Multi-source download system (Soulseek/Lidarr with fallback)
- Configurable enrichment speed control (1-5x)
- Mobile touch drag support for seek sliders
- iOS PWA media controls (Control Center, Lock Screen)
- Artist name alias resolution via Last.fm
- Circuit breaker pattern for audio analysis

Critical Fixes:
- Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM)
- Discovery system race conditions and import failures
- Radio decade categorization using originalYear
- LastFM API response normalization
- Mood bucket infinite loop prevention

Security:
- Bull Board admin authentication
- Lidarr webhook signature verification
- JWT token expiration and refresh
- Encryption key validation on startup

Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
2026-01-06 20:07:33 -06:00

187 lines
5.5 KiB
TypeScript

"use client";
import {
createContext,
useContext,
useState,
ReactNode,
useEffect,
} from "react";
import { useDownloadStatus } from "@/hooks/useDownloadStatus";
import { useAuth } from "@/lib/auth-context";
interface PendingDownload {
id: string;
type: "artist" | "album";
subject: string;
mbid: string; // Unique identifier for deduplication
timestamp: number;
}
interface DownloadContextType {
pendingDownloads: PendingDownload[];
downloadStatus: {
activeDownloads: any[];
recentDownloads: any[];
hasActiveDownloads: boolean;
failedDownloads: any[];
};
addPendingDownload: (
type: "artist" | "album",
subject: string,
mbid: string
) => string | null;
removePendingDownload: (id: string) => void;
removePendingByMbid: (mbid: string) => void;
isPending: (subject: string) => boolean;
isPendingByMbid: (mbid: string) => boolean;
isAnyPending: () => boolean;
}
const DownloadContext = createContext<DownloadContextType | undefined>(
undefined
);
export function DownloadProvider({ children }: { children: ReactNode }) {
const [pendingDownloads, setPendingDownloads] = useState<PendingDownload[]>(
[]
);
const { isAuthenticated } = useAuth();
const downloadStatus = useDownloadStatus(15000, isAuthenticated);
// Sync pending downloads with actual download status
useEffect(() => {
// Remove pending downloads that have completed or failed
setPendingDownloads((prev) => {
return prev.filter((pending) => {
// Check if this MBID has an active job being tracked by API
const hasActiveJob = downloadStatus.activeDownloads.some(
(job) => job.targetMbid === pending.mbid
);
// If job is now being tracked by the API, remove from local pending
if (hasActiveJob) {
return false;
}
// Check if this MBID has completed or failed
const matchingJob = [
...downloadStatus.recentDownloads,
...downloadStatus.failedDownloads,
].find((job) => job.targetMbid === pending.mbid);
// If job is completed or failed, remove from pending
if (matchingJob) {
return false;
}
// Keep if no job found yet
return true;
});
});
}, [
downloadStatus.activeDownloads,
downloadStatus.recentDownloads,
downloadStatus.failedDownloads,
]);
// Cleanup pending downloads older than 2 minutes
// This handles cases where jobs fail immediately and don't appear in any API response
useEffect(() => {
const STALE_THRESHOLD = 2 * 60 * 1000; // 2 minutes
const cleanup = setInterval(() => {
setPendingDownloads((prev) => {
const now = Date.now();
const filtered = prev.filter((pending) => {
const age = now - pending.timestamp;
if (age > STALE_THRESHOLD) {
return false;
}
return true;
});
return filtered;
});
}, 30000); // Check every 30 seconds
return () => clearInterval(cleanup);
}, []);
const addPendingDownload = (
type: "artist" | "album",
subject: string,
mbid: string
): string | null => {
// Check if already downloading this MBID
if (pendingDownloads.some((d) => d.mbid === mbid)) {
return null;
}
const id = `${Date.now()}-${Math.random()}`;
const download: PendingDownload = {
id,
type,
subject,
mbid,
timestamp: Date.now(),
};
setPendingDownloads((prev) => [...prev, download]);
return id;
};
const removePendingDownload = (id: string) => {
setPendingDownloads((prev) => prev.filter((d) => d.id !== id));
};
const removePendingByMbid = (mbid: string) => {
setPendingDownloads((prev) => prev.filter((d) => d.mbid !== mbid));
};
const isPending = (subject: string): boolean => {
return pendingDownloads.some((d) => d.subject === subject);
};
const isPendingByMbid = (mbid: string): boolean => {
// Check both pending downloads AND active download jobs
const isPendingLocal = pendingDownloads.some((d) => d.mbid === mbid);
const hasActiveJob = downloadStatus.activeDownloads.some(
(job) => job.targetMbid === mbid
);
return isPendingLocal || hasActiveJob;
};
const isAnyPending = (): boolean => {
return pendingDownloads.length > 0;
};
return (
<DownloadContext.Provider
value={{
pendingDownloads,
downloadStatus,
addPendingDownload,
removePendingDownload,
removePendingByMbid,
isPending,
isPendingByMbid,
isAnyPending,
}}
>
{children}
</DownloadContext.Provider>
);
}
export function useDownloadContext() {
const context = useContext(DownloadContext);
if (!context) {
throw new Error(
"useDownloadContext must be used within DownloadProvider"
);
}
return context;
}