Implemented queue parsing for deezspot 2.0
This commit is contained in:
@@ -1,65 +1,40 @@
|
||||
import { useState, useCallback, type ReactNode, useEffect, useRef } from "react";
|
||||
import apiClient from "../lib/api-client";
|
||||
import { QueueContext, type QueueItem, type DownloadType, type QueueStatus } from "./queue-context";
|
||||
import {
|
||||
QueueContext,
|
||||
type QueueItem,
|
||||
type DownloadType,
|
||||
type QueueStatus,
|
||||
} from "./queue-context";
|
||||
import { toast } from "sonner";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
// --- Helper Types ---
|
||||
// This represents the raw status object from the backend polling endpoint
|
||||
interface TaskStatusDTO {
|
||||
status: QueueStatus;
|
||||
message?: string;
|
||||
can_retry?: boolean;
|
||||
|
||||
// Progress indicators
|
||||
progress?: number;
|
||||
speed?: string;
|
||||
size?: string;
|
||||
eta?: string;
|
||||
|
||||
// Multi-track progress
|
||||
current_track?: number;
|
||||
total_tracks?: number;
|
||||
summary?: {
|
||||
successful_tracks: string[];
|
||||
skipped_tracks: string[];
|
||||
failed_tracks: number;
|
||||
failed_track_details: { name: string; reason: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
// Task from prgs/list endpoint
|
||||
interface TaskDTO {
|
||||
task_id: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
download_type?: string;
|
||||
status?: string;
|
||||
last_status_obj?: {
|
||||
status?: string;
|
||||
progress?: number;
|
||||
speed?: string;
|
||||
size?: string;
|
||||
eta?: string;
|
||||
current_track?: number;
|
||||
total_tracks?: number;
|
||||
error?: string;
|
||||
can_retry?: boolean;
|
||||
};
|
||||
original_request?: {
|
||||
url?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
summary?: {
|
||||
successful_tracks: string[];
|
||||
skipped_tracks: string[];
|
||||
failed_tracks: number;
|
||||
failed_track_details?: { name: string; reason: string }[];
|
||||
};
|
||||
}
|
||||
import type {
|
||||
CallbackObject,
|
||||
SummaryObject,
|
||||
ProcessingCallbackObject,
|
||||
TrackCallbackObject,
|
||||
AlbumCallbackObject,
|
||||
PlaylistCallbackObject,
|
||||
} from "@/types/callbacks";
|
||||
|
||||
const isTerminalStatus = (status: QueueStatus) =>
|
||||
["completed", "error", "cancelled", "skipped", "done"].includes(status);
|
||||
["completed", "error", "cancelled", "skipped", "done"].includes(status);
|
||||
|
||||
function isProcessingCallback(obj: CallbackObject): obj is ProcessingCallbackObject {
|
||||
return obj && "status" in obj && obj.status === "processing";
|
||||
}
|
||||
|
||||
function isTrackCallback(obj: any): obj is TrackCallbackObject {
|
||||
return obj && "track" in obj && "status_info" in obj;
|
||||
}
|
||||
|
||||
function isAlbumCallback(obj: any): obj is AlbumCallbackObject {
|
||||
return obj && "album" in obj && "status_info" in obj;
|
||||
}
|
||||
|
||||
function isPlaylistCallback(obj: any): obj is PlaylistCallbackObject {
|
||||
return obj && "playlist" in obj && "status_info" in obj;
|
||||
}
|
||||
|
||||
export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<QueueItem[]>(() => {
|
||||
@@ -73,11 +48,26 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const pollingIntervals = useRef<Record<string, number>>({});
|
||||
|
||||
// --- Persistence ---
|
||||
useEffect(() => {
|
||||
localStorage.setItem("queueItems", JSON.stringify(items));
|
||||
}, [items]);
|
||||
|
||||
// Effect to resume polling for active tasks on component mount
|
||||
useEffect(() => {
|
||||
if (items.length > 0) {
|
||||
items.forEach((item) => {
|
||||
// If a task has an ID and is not in a finished state, restart polling.
|
||||
if (item.taskId && !isTerminalStatus(item.status)) {
|
||||
console.log(`Resuming polling for ${item.name} (Task ID: ${item.taskId})`);
|
||||
startPolling(item.id, item.taskId);
|
||||
}
|
||||
});
|
||||
}
|
||||
// This effect should only run once on mount to avoid re-triggering polling unnecessarily.
|
||||
// We are disabling the dependency warning because we intentionally want to use the initial `items` state.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const stopPolling = useCallback((internalId: string) => {
|
||||
if (pollingIntervals.current[internalId]) {
|
||||
clearInterval(pollingIntervals.current[internalId]);
|
||||
@@ -85,101 +75,125 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// --- Polling Logic ---
|
||||
const startPolling = useCallback(
|
||||
(internalId: string, taskId: string) => {
|
||||
if (pollingIntervals.current[internalId]) return;
|
||||
if (pollingIntervals.current[internalId]) return;
|
||||
|
||||
const intervalId = window.setInterval(async () => {
|
||||
try {
|
||||
// Use the prgs endpoint instead of download/status
|
||||
interface PrgsResponse {
|
||||
status?: string;
|
||||
summary?: TaskStatusDTO["summary"];
|
||||
last_line?: {
|
||||
status?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
can_retry?: boolean;
|
||||
progress?: number;
|
||||
speed?: string;
|
||||
size?: string;
|
||||
eta?: string;
|
||||
current_track?: number;
|
||||
total_tracks?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const response = await apiClient.get<PrgsResponse>(`/prgs/${taskId}`);
|
||||
const lastStatus = response.data.last_line || {};
|
||||
const statusUpdate = {
|
||||
status: response.data.status || lastStatus.status || "pending",
|
||||
message: lastStatus.message || lastStatus.error,
|
||||
can_retry: lastStatus.can_retry,
|
||||
progress: lastStatus.progress,
|
||||
speed: lastStatus.speed,
|
||||
size: lastStatus.size,
|
||||
eta: lastStatus.eta,
|
||||
current_track: lastStatus.current_track,
|
||||
total_tracks: lastStatus.total_tracks,
|
||||
summary: response.data.summary,
|
||||
};
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id === internalId) {
|
||||
const updatedItem: QueueItem = {
|
||||
...item,
|
||||
status: statusUpdate.status as QueueStatus,
|
||||
progress: statusUpdate.progress,
|
||||
speed: statusUpdate.speed,
|
||||
size: statusUpdate.size,
|
||||
eta: statusUpdate.eta,
|
||||
error: statusUpdate.status === "error" ? statusUpdate.message : undefined,
|
||||
canRetry: statusUpdate.can_retry,
|
||||
currentTrackNumber: statusUpdate.current_track,
|
||||
totalTracks: statusUpdate.total_tracks,
|
||||
summary: statusUpdate.summary
|
||||
? {
|
||||
successful: statusUpdate.summary.successful_tracks,
|
||||
skipped: statusUpdate.summary.skipped_tracks,
|
||||
failed: statusUpdate.summary.failed_tracks,
|
||||
failedTracks: statusUpdate.summary.failed_track_details || [],
|
||||
}
|
||||
: item.summary,
|
||||
};
|
||||
|
||||
if (isTerminalStatus(statusUpdate.status as QueueStatus)) {
|
||||
stopPolling(internalId);
|
||||
const intervalId = window.setInterval(async () => {
|
||||
try {
|
||||
interface PrgsResponse {
|
||||
status?: string;
|
||||
summary?: SummaryObject;
|
||||
last_line?: CallbackObject;
|
||||
}
|
||||
return updatedItem;
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Polling failed for task ${taskId}:`, error);
|
||||
stopPolling(internalId);
|
||||
setItems((prev) =>
|
||||
prev.map((i) =>
|
||||
i.id === internalId
|
||||
? {
|
||||
...i,
|
||||
status: "error",
|
||||
error: "Connection lost",
|
||||
}
|
||||
: i,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
|
||||
pollingIntervals.current[internalId] = intervalId;
|
||||
const response = await apiClient.get<PrgsResponse>(`/prgs/${taskId}`);
|
||||
const { last_line, summary, status } = response.data;
|
||||
|
||||
setItems(prev =>
|
||||
prev.map(item => {
|
||||
if (item.id !== internalId) return item;
|
||||
|
||||
const updatedItem: QueueItem = { ...item };
|
||||
|
||||
if (status) {
|
||||
updatedItem.status = status as QueueStatus;
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
updatedItem.summary = summary;
|
||||
}
|
||||
|
||||
if (last_line) {
|
||||
if (isProcessingCallback(last_line)) {
|
||||
updatedItem.status = "processing";
|
||||
} else if (isTrackCallback(last_line)) {
|
||||
const { status_info, track, current_track, total_tracks, parent } = last_line;
|
||||
|
||||
updatedItem.currentTrackTitle = track.title;
|
||||
if (current_track) updatedItem.currentTrackNumber = current_track;
|
||||
if (total_tracks) updatedItem.totalTracks = total_tracks;
|
||||
|
||||
// A child track being "done" doesn't mean the whole download is done.
|
||||
// The final "done" status comes from the parent (album/playlist) callback.
|
||||
if (parent && status_info.status === "done") {
|
||||
updatedItem.status = "downloading"; // Or keep current status if not 'error'
|
||||
} else {
|
||||
updatedItem.status = status_info.status as QueueStatus;
|
||||
}
|
||||
|
||||
if (status_info.status === "error" || status_info.status === "retrying") {
|
||||
updatedItem.error = status_info.error;
|
||||
}
|
||||
|
||||
// For single tracks, the "done" status is final.
|
||||
if (!parent && status_info.status === "done") {
|
||||
if (status_info.summary) updatedItem.summary = status_info.summary;
|
||||
}
|
||||
} else if (isAlbumCallback(last_line)) {
|
||||
const { status_info, album } = last_line;
|
||||
updatedItem.status = status_info.status as QueueStatus;
|
||||
updatedItem.name = album.title;
|
||||
updatedItem.artist = album.artists.map(a => a.name).join(", ");
|
||||
if (status_info.status === "done" && status_info.summary) {
|
||||
updatedItem.summary = status_info.summary;
|
||||
}
|
||||
if (status_info.status === "error") {
|
||||
updatedItem.error = status_info.error;
|
||||
}
|
||||
} else if (isPlaylistCallback(last_line)) {
|
||||
const { status_info, playlist } = last_line;
|
||||
updatedItem.status = status_info.status as QueueStatus;
|
||||
updatedItem.name = playlist.title;
|
||||
updatedItem.playlistOwner = playlist.owner.name;
|
||||
if (status_info.status === "done" && status_info.summary) {
|
||||
updatedItem.summary = status_info.summary;
|
||||
}
|
||||
if (status_info.status === "error") {
|
||||
updatedItem.error = status_info.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTerminalStatus(updatedItem.status as QueueStatus)) {
|
||||
stopPolling(internalId);
|
||||
}
|
||||
|
||||
return updatedItem;
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Polling failed for task ${taskId}:`, error);
|
||||
stopPolling(internalId);
|
||||
setItems(prev =>
|
||||
prev.map(i =>
|
||||
i.id === internalId
|
||||
? {
|
||||
...i,
|
||||
status: "error",
|
||||
error: "Connection lost",
|
||||
}
|
||||
: i,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
pollingIntervals.current[internalId] = intervalId;
|
||||
},
|
||||
[stopPolling],
|
||||
);
|
||||
|
||||
// --- Core Action: Add Item ---
|
||||
useEffect(() => {
|
||||
items.forEach((item) => {
|
||||
if (item.taskId && !isTerminalStatus(item.status)) {
|
||||
startPolling(item.id, item.taskId);
|
||||
}
|
||||
});
|
||||
// We only want to run this on mount, so we disable the exhaustive-deps warning.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const addItem = useCallback(
|
||||
async (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => {
|
||||
const internalId = uuidv4();
|
||||
@@ -193,7 +207,6 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
try {
|
||||
let endpoint = "";
|
||||
|
||||
if (item.type === "track") {
|
||||
endpoint = `/track/download/${item.spotifyId}`;
|
||||
} else if (item.type === "album") {
|
||||
@@ -230,36 +243,142 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
[isVisible, startPolling],
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(id: string) => {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item?.taskId) {
|
||||
stopPolling(item.id);
|
||||
}
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
},
|
||||
[items, stopPolling],
|
||||
);
|
||||
|
||||
const cancelItem = useCallback(
|
||||
async (id: string) => {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item || !item.taskId) return;
|
||||
|
||||
try {
|
||||
await apiClient.post(`/prgs/cancel/${item.taskId}`);
|
||||
stopPolling(id);
|
||||
setItems((prev) =>
|
||||
prev.map((i) =>
|
||||
i.id === id
|
||||
? {
|
||||
...i,
|
||||
status: "cancelled",
|
||||
}
|
||||
: i,
|
||||
),
|
||||
);
|
||||
toast.info(`Cancelled download: ${item.name}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to cancel task ${item.taskId}:`, error);
|
||||
toast.error(`Failed to cancel download: ${item.name}`);
|
||||
}
|
||||
},
|
||||
[items, stopPolling],
|
||||
);
|
||||
|
||||
const retryItem = useCallback(
|
||||
(id: string) => {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (item && item.taskId) {
|
||||
setItems((prev) =>
|
||||
prev.map((i) =>
|
||||
i.id === id
|
||||
? {
|
||||
...i,
|
||||
status: "pending",
|
||||
error: undefined,
|
||||
}
|
||||
: i,
|
||||
),
|
||||
);
|
||||
startPolling(id, item.taskId);
|
||||
toast.info(`Retrying download: ${item.name}`);
|
||||
}
|
||||
},
|
||||
[items, startPolling],
|
||||
);
|
||||
|
||||
const toggleVisibility = useCallback(() => {
|
||||
setIsVisible((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const clearCompleted = useCallback(() => {
|
||||
setItems((prev) => prev.filter((item) => !isTerminalStatus(item.status) || item.status === "error"));
|
||||
}, []);
|
||||
|
||||
const cancelAll = useCallback(async () => {
|
||||
const activeItems = items.filter((item) => item.taskId && !isTerminalStatus(item.status));
|
||||
if (activeItems.length === 0) {
|
||||
toast.info("No active downloads to cancel.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const taskIds = activeItems.map((item) => item.taskId!);
|
||||
await apiClient.post("/prgs/cancel/many", { task_ids: taskIds });
|
||||
|
||||
activeItems.forEach((item) => stopPolling(item.id));
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
taskIds.includes(item.taskId!)
|
||||
? {
|
||||
...item,
|
||||
status: "cancelled",
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
toast.info("Cancelled all active downloads.");
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel all tasks:", error);
|
||||
toast.error("Failed to cancel all downloads.");
|
||||
}
|
||||
}, [items, stopPolling]);
|
||||
|
||||
const clearAllPolls = useCallback(() => {
|
||||
Object.values(pollingIntervals.current).forEach(clearInterval);
|
||||
}, []);
|
||||
|
||||
// --- Load existing tasks on startup ---
|
||||
useEffect(() => {
|
||||
interface PrgsListEntry {
|
||||
task_id: string;
|
||||
name?: string;
|
||||
download_type?: string;
|
||||
status?: string;
|
||||
original_request?: { url?: string };
|
||||
last_status_obj?: {
|
||||
progress?: number;
|
||||
current_track?: number;
|
||||
total_tracks?: number;
|
||||
error?: string;
|
||||
can_retry?: boolean;
|
||||
};
|
||||
summary?: SummaryObject;
|
||||
}
|
||||
|
||||
const syncActiveTasks = async () => {
|
||||
try {
|
||||
// Use the prgs/list endpoint instead of download/active
|
||||
const response = await apiClient.get<TaskDTO[]>("/prgs/list");
|
||||
|
||||
// Map the prgs response to the expected QueueItem format
|
||||
const activeTasks = response.data
|
||||
const response = await apiClient.get<PrgsListEntry[]>("/prgs/list");
|
||||
const activeTasks: QueueItem[] = response.data
|
||||
.filter((task) => {
|
||||
// Only include non-terminal tasks
|
||||
const status = task.status?.toLowerCase();
|
||||
return status && !isTerminalStatus(status as QueueStatus);
|
||||
})
|
||||
.map((task) => {
|
||||
// Extract Spotify ID from URL if available
|
||||
const url = task.original_request?.url || "";
|
||||
const spotifyId = url.includes("spotify.com") ? url.split("/").pop() || "" : "";
|
||||
|
||||
// Map download_type to UI type
|
||||
let type: DownloadType = "track";
|
||||
if (task.download_type === "album") type = "album";
|
||||
if (task.download_type === "playlist") type = "playlist";
|
||||
if (task.download_type === "artist") type = "artist";
|
||||
|
||||
return {
|
||||
const queueItem: QueueItem = {
|
||||
id: task.task_id,
|
||||
taskId: task.task_id,
|
||||
name: task.name || "Unknown",
|
||||
@@ -267,138 +386,36 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
spotifyId,
|
||||
status: (task.status?.toLowerCase() || "pending") as QueueStatus,
|
||||
progress: task.last_status_obj?.progress,
|
||||
speed: task.last_status_obj?.speed,
|
||||
size: task.last_status_obj?.size,
|
||||
eta: task.last_status_obj?.eta,
|
||||
currentTrackNumber: task.last_status_obj?.current_track,
|
||||
totalTracks: task.last_status_obj?.total_tracks,
|
||||
error: task.last_status_obj?.error,
|
||||
canRetry: task.last_status_obj?.can_retry,
|
||||
summary: task.summary
|
||||
? {
|
||||
successful: task.summary.successful_tracks,
|
||||
skipped: task.summary.skipped_tracks,
|
||||
failed: task.summary.failed_tracks,
|
||||
failedTracks: task.summary.failed_track_details || [],
|
||||
}
|
||||
: undefined,
|
||||
summary: task.summary,
|
||||
};
|
||||
return queueItem;
|
||||
});
|
||||
|
||||
// Basic reconciliation
|
||||
setItems((prevItems) => {
|
||||
const newItems = [...prevItems];
|
||||
activeTasks.forEach((task) => {
|
||||
if (!newItems.some((item) => item.taskId === task.taskId)) {
|
||||
const existingIndex = newItems.findIndex((item) => item.id === task.id);
|
||||
if (existingIndex === -1) {
|
||||
newItems.push(task);
|
||||
} else {
|
||||
newItems[existingIndex] = { ...newItems[existingIndex], ...task };
|
||||
}
|
||||
startPolling(task.id, task.taskId!);
|
||||
});
|
||||
return newItems;
|
||||
});
|
||||
|
||||
activeTasks.forEach((item) => {
|
||||
if (item.id && item.taskId && !isTerminalStatus(item.status)) {
|
||||
startPolling(item.id, item.taskId);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to sync active tasks:", error);
|
||||
}
|
||||
};
|
||||
|
||||
syncActiveTasks();
|
||||
|
||||
// restart polling for any non-terminal items from localStorage
|
||||
items.forEach((item) => {
|
||||
if (item.id && item.taskId && !isTerminalStatus(item.status)) {
|
||||
startPolling(item.id, item.taskId);
|
||||
}
|
||||
});
|
||||
|
||||
return clearAllPolls;
|
||||
// This effect should only run once on mount to initialize the queue.
|
||||
// We are intentionally omitting 'items' as a dependency to prevent re-runs.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [clearAllPolls, startPolling]);
|
||||
|
||||
// --- Other Actions ---
|
||||
const removeItem = useCallback((id: string) => {
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
|
||||
const cancelItem = useCallback(
|
||||
async (id: string) => {
|
||||
const itemToCancel = items.find((i) => i.id === id);
|
||||
if (itemToCancel && itemToCancel.taskId && !isTerminalStatus(itemToCancel.status)) {
|
||||
stopPolling(id);
|
||||
try {
|
||||
await apiClient.post(`/prgs/cancel/${itemToCancel.taskId}`);
|
||||
toast.success(`Cancelled download: ${itemToCancel.name}`);
|
||||
setItems((prev) => prev.map((i) => (i.id === id ? { ...i, status: "cancelled" } : i)));
|
||||
} catch (err) {
|
||||
console.error(`Failed to cancel task ${itemToCancel.taskId}`, err);
|
||||
toast.error(`Failed to cancel: ${itemToCancel.name}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
[items, stopPolling],
|
||||
);
|
||||
|
||||
const retryItem = useCallback(
|
||||
async (id: string) => {
|
||||
const itemToRetry = items.find((i) => i.id === id);
|
||||
if (!itemToRetry || !itemToRetry.taskId) return;
|
||||
|
||||
try {
|
||||
// Use the prgs/retry endpoint
|
||||
await apiClient.post(`/prgs/retry/${itemToRetry.taskId}`);
|
||||
toast.info(`Retrying download: ${itemToRetry.name}`);
|
||||
|
||||
// Update the item status in the UI
|
||||
setItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
status: "initializing",
|
||||
error: undefined,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
|
||||
// Start polling again
|
||||
startPolling(id, itemToRetry.taskId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to retry download for ${itemToRetry.name}:`, error);
|
||||
toast.error(`Failed to retry download: ${itemToRetry.name}`);
|
||||
}
|
||||
},
|
||||
[items, startPolling],
|
||||
);
|
||||
|
||||
const cancelAll = useCallback(async () => {
|
||||
toast.info("Cancelling all active downloads...");
|
||||
for (const item of items) {
|
||||
if (item.taskId && !isTerminalStatus(item.status)) {
|
||||
stopPolling(item.id);
|
||||
try {
|
||||
await apiClient.post(`/prgs/cancel/${item.taskId}`);
|
||||
// Visually update the item to "cancelled" immediately
|
||||
setItems((prev) => prev.map((i) => (i.id === item.id ? { ...i, status: "cancelled" } : i)));
|
||||
} catch (err) {
|
||||
console.error(`Failed to cancel task ${item.taskId}`, err);
|
||||
toast.error(`Failed to cancel: ${item.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [items, stopPolling]);
|
||||
|
||||
const clearCompleted = useCallback(() => {
|
||||
setItems((prev) => prev.filter((item) => !isTerminalStatus(item.status)));
|
||||
toast.info("Cleared finished downloads.");
|
||||
}, []);
|
||||
|
||||
const toggleVisibility = useCallback(() => setIsVisible((prev) => !prev), []);
|
||||
return () => clearAllPolls();
|
||||
}, [startPolling, clearAllPolls]);
|
||||
|
||||
const value = {
|
||||
items,
|
||||
|
||||
Reference in New Issue
Block a user