Bum deezspot, fixed a bunch of bugs with the queue, fixed duplicate URL detection
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,3 +39,4 @@ data
|
||||
logs/
|
||||
.env
|
||||
Test.py
|
||||
spotizerr-ui/dev-dist
|
||||
|
||||
@@ -2,4 +2,4 @@ waitress==3.0.2
|
||||
celery==5.5.3
|
||||
Flask==3.1.1
|
||||
flask_cors==6.0.0
|
||||
deezspot-spotizerr==2.0.3
|
||||
deezspot-spotizerr==2.2.0
|
||||
@@ -108,6 +108,14 @@ def get_existing_task_id(url, download_type=None):
|
||||
ProgressState.ERROR,
|
||||
ProgressState.ERROR_RETRIED,
|
||||
ProgressState.ERROR_AUTO_CLEANED,
|
||||
# Include string variants from standardized status_info structure
|
||||
"cancelled",
|
||||
"error",
|
||||
"done",
|
||||
"complete",
|
||||
"completed",
|
||||
"failed",
|
||||
"skipped",
|
||||
}
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: Terminal states defined as: {TERMINAL_STATES}")
|
||||
|
||||
@@ -129,7 +137,13 @@ def get_existing_task_id(url, download_type=None):
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: No last status object for task_id='{existing_task_id}'. Skipping.")
|
||||
continue
|
||||
|
||||
existing_status = existing_last_status_obj.get("status")
|
||||
# Extract status from standard structure (status_info.status) or fallback to top-level status
|
||||
existing_status = None
|
||||
if "status_info" in existing_last_status_obj and existing_last_status_obj["status_info"]:
|
||||
existing_status = existing_last_status_obj["status_info"].get("status")
|
||||
if not existing_status:
|
||||
existing_status = existing_last_status_obj.get("status")
|
||||
|
||||
logger.debug(f"GET_EXISTING_TASK_ID: Task_id='{existing_task_id}', last_status_obj='{existing_last_status_obj}', extracted status='{existing_status}'.")
|
||||
|
||||
# If the task is in a terminal state, ignore it and move to the next one.
|
||||
@@ -215,6 +229,14 @@ class CeleryDownloadQueueManager:
|
||||
ProgressState.ERROR,
|
||||
ProgressState.ERROR_RETRIED,
|
||||
ProgressState.ERROR_AUTO_CLEANED,
|
||||
# Include string variants from standardized status_info structure
|
||||
"cancelled",
|
||||
"error",
|
||||
"done",
|
||||
"complete",
|
||||
"completed",
|
||||
"failed",
|
||||
"skipped",
|
||||
}
|
||||
|
||||
all_existing_tasks_summary = get_all_tasks()
|
||||
@@ -233,7 +255,13 @@ class CeleryDownloadQueueManager:
|
||||
|
||||
existing_url = existing_task_info.get("url")
|
||||
existing_type = existing_task_info.get("download_type")
|
||||
existing_status = existing_last_status_obj.get("status")
|
||||
|
||||
# Extract status from standard structure (status_info.status) or fallback to top-level status
|
||||
existing_status = None
|
||||
if "status_info" in existing_last_status_obj and existing_last_status_obj["status_info"]:
|
||||
existing_status = existing_last_status_obj["status_info"].get("status")
|
||||
if not existing_status:
|
||||
existing_status = existing_last_status_obj.get("status")
|
||||
|
||||
if (
|
||||
existing_url == incoming_url
|
||||
|
||||
@@ -224,9 +224,11 @@ def cancel_task(task_id):
|
||||
store_task_status(
|
||||
task_id,
|
||||
{
|
||||
"status": ProgressState.CANCELLED,
|
||||
"error": "Task cancelled by user",
|
||||
"timestamp": time.time(),
|
||||
"status_info": {
|
||||
"status": ProgressState.CANCELLED,
|
||||
"error": "Task cancelled by user",
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-f70c5944'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "registerSW.js",
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.im8ejsgjrtc"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/],
|
||||
denylist: [/^\/_/, /\/[^/?]+\.[^/]+$/]
|
||||
}));
|
||||
workbox.registerRoute(/^https:\/\/api\./i, new workbox.NetworkFirst({
|
||||
"cacheName": "api-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 86400
|
||||
})]
|
||||
}), 'GET');
|
||||
workbox.registerRoute(/\.(?:png|jpg|jpeg|svg|gif|webp)$/, new workbox.CacheFirst({
|
||||
"cacheName": "images-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 500,
|
||||
maxAgeSeconds: 2592000
|
||||
})]
|
||||
}), 'GET');
|
||||
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -94,17 +94,140 @@ const statusStyles: Record<
|
||||
borderColor: "border-border dark:border-border-dark",
|
||||
name: "Pending",
|
||||
},
|
||||
"real-time": {
|
||||
icon: <FaSync className="animate-spin icon-accent" />,
|
||||
color: "text-info",
|
||||
bgColor: "bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/30",
|
||||
borderColor: "border-info/30 dark:border-info/40",
|
||||
name: "Real-time Download",
|
||||
},
|
||||
};
|
||||
|
||||
// Circular Progress Component
|
||||
const CircularProgress = ({
|
||||
progress,
|
||||
isCompleted = false,
|
||||
isRealProgress = false,
|
||||
size = 60,
|
||||
strokeWidth = 6,
|
||||
className = ""
|
||||
}: {
|
||||
progress: number;
|
||||
isCompleted?: boolean;
|
||||
isRealProgress?: boolean;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
className?: string;
|
||||
}) => {
|
||||
// Apply a logarithmic curve to make progress slower near the end - ONLY for fake progress
|
||||
const getAdjustedProgress = (rawProgress: number) => {
|
||||
if (isCompleted) return 100;
|
||||
if (rawProgress <= 0) return 0;
|
||||
|
||||
// If this is real progress data, show it as-is without any artificial manipulation
|
||||
if (isRealProgress) {
|
||||
return Math.min(Math.max(rawProgress, 0), 100);
|
||||
}
|
||||
|
||||
// Only apply logarithmic curve for fake/simulated progress
|
||||
// Use a logarithmic curve that slows down significantly near 100%
|
||||
// This creates the effect of filling more slowly as it approaches completion
|
||||
const normalized = Math.min(Math.max(rawProgress, 0), 100) / 100;
|
||||
|
||||
// Apply easing function that slows down dramatically near the end
|
||||
const eased = 1 - Math.pow(1 - normalized, 3); // Cubic ease-out
|
||||
const logarithmic = Math.log(normalized * 9 + 1) / Math.log(10); // Logarithmic scaling
|
||||
|
||||
// Combine both for a very slow approach to 100%
|
||||
const combined = (eased * 0.7 + logarithmic * 0.3) * 95; // Cap at 95% during download
|
||||
|
||||
// Ensure minimum visibility for any progress > 0
|
||||
const minVisible = rawProgress > 0 ? Math.max(combined, 8) : 0;
|
||||
|
||||
return Math.min(minVisible, 95); // Never quite reach 100% during download
|
||||
};
|
||||
|
||||
const adjustedProgress = getAdjustedProgress(progress);
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
const strokeDasharray = circumference;
|
||||
const strokeDashoffset = circumference - (adjustedProgress / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className={`relative inline-flex ${className}`}>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className="transform -rotate-90"
|
||||
>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-border dark:text-border-dark opacity-60"
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="transparent"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className={`transition-all duration-500 ease-out ${
|
||||
isCompleted
|
||||
? "text-success"
|
||||
: "text-info"
|
||||
}`}
|
||||
style={{
|
||||
strokeDashoffset: isCompleted ? 0 : strokeDashoffset,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
{/* Center content */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{isCompleted ? (
|
||||
<FaCheckCircle className="text-success text-lg" />
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-content-primary dark:text-content-primary-dark">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
const { removeItem, retryItem, cancelItem } = useContext(QueueContext) || {};
|
||||
const statusInfo = statusStyles[item.status] || statusStyles.queued;
|
||||
const isTerminal = isTerminalStatus(item.status);
|
||||
|
||||
// Extract the actual status - prioritize status_info.status, then last_line.status, then item.status
|
||||
const actualStatus = (item.last_line?.status_info?.status as QueueStatus) ||
|
||||
(item.last_line?.status as QueueStatus) ||
|
||||
item.status;
|
||||
const statusInfo = statusStyles[actualStatus] || statusStyles.queued;
|
||||
const isTerminal = isTerminalStatus(actualStatus);
|
||||
|
||||
const getProgressText = () => {
|
||||
const { status, type, progress, totalTracks, summary } = item;
|
||||
const { type, progress, totalTracks, summary, last_line } = item;
|
||||
|
||||
if (status === "downloading" || status === "processing") {
|
||||
// Handle real-time downloads
|
||||
if (actualStatus === "real-time") {
|
||||
const realTimeProgress = last_line?.status_info?.progress;
|
||||
if (type === "track" && realTimeProgress !== undefined) {
|
||||
return `${realTimeProgress.toFixed(0)}%`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (actualStatus === "downloading" || actualStatus === "processing") {
|
||||
if (type === "track") {
|
||||
return progress !== undefined ? `${progress.toFixed(0)}%` : null;
|
||||
}
|
||||
@@ -112,11 +235,10 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((status === "completed" || status === "done") && summary) {
|
||||
if ((actualStatus === "completed" || actualStatus === "done") && summary) {
|
||||
if (type === "track") {
|
||||
if (summary.total_successful > 0) return "Completed";
|
||||
if (summary.total_failed > 0) return "Failed";
|
||||
return "Finished";
|
||||
// For single tracks, don't show redundant text since status badge already shows "Done"
|
||||
return null;
|
||||
}
|
||||
return `${summary.total_successful}/${totalTracks} tracks`;
|
||||
}
|
||||
@@ -198,8 +320,77 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
<div className={`inline-flex items-center px-3 py-1 md:px-3 md:py-1 rounded-full text-sm md:text-xs font-semibold ${statusInfo.color} bg-white/60 dark:bg-surface-dark/60 shadow-sm`}>
|
||||
{statusInfo.name}
|
||||
</div>
|
||||
{progressText && <p className="text-sm md:text-xs text-content-muted dark:text-content-muted-dark mt-1">{progressText}</p>}
|
||||
{(() => {
|
||||
// Only show text progress if we're not showing circular progress
|
||||
const hasCircularProgress = item.type === "track" && !isTerminal &&
|
||||
(item.last_line?.status_info?.progress !== undefined || item.progress !== undefined);
|
||||
|
||||
return !hasCircularProgress && progressText && (
|
||||
<p className="text-sm md:text-xs text-content-muted dark:text-content-muted-dark mt-1">{progressText}</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Add circular progress for downloading tracks */}
|
||||
{(() => {
|
||||
// Calculate progress based on item type and data availability
|
||||
let currentProgress: number | undefined;
|
||||
let isRealProgress = false;
|
||||
|
||||
if (item.type === "track") {
|
||||
// For tracks, use direct progress
|
||||
const realTimeProgress = item.last_line?.status_info?.progress;
|
||||
const fallbackProgress = item.progress;
|
||||
currentProgress = realTimeProgress ?? fallbackProgress;
|
||||
isRealProgress = realTimeProgress !== undefined;
|
||||
} else if ((item.type === "album" || item.type === "playlist") && item.last_line?.status_info?.progress !== undefined) {
|
||||
// For albums/playlists with real-time data, calculate overall progress
|
||||
const trackProgress = item.last_line.status_info.progress;
|
||||
const currentTrack = item.last_line.current_track || 1;
|
||||
const totalTracks = item.last_line.total_tracks || item.totalTracks || 1;
|
||||
|
||||
// Formula: ((completed_tracks + current_track_progress/100) / total_tracks) * 100
|
||||
const completedTracks = currentTrack - 1; // current_track is 1-indexed
|
||||
currentProgress = ((completedTracks + (trackProgress / 100)) / totalTracks) * 100;
|
||||
isRealProgress = true;
|
||||
} else if ((item.type === "album" || item.type === "playlist") && item.progress !== undefined) {
|
||||
// Fallback for albums/playlists without real-time data
|
||||
currentProgress = item.progress;
|
||||
isRealProgress = false;
|
||||
}
|
||||
|
||||
// Show circular progress for items that are not in terminal state
|
||||
const shouldShowProgress = !isTerminal && currentProgress !== undefined;
|
||||
|
||||
return shouldShowProgress && (
|
||||
<div className="flex-shrink-0">
|
||||
<CircularProgress
|
||||
progress={currentProgress!}
|
||||
isCompleted={false}
|
||||
isRealProgress={isRealProgress}
|
||||
size={44}
|
||||
strokeWidth={4}
|
||||
className="md:mr-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Show completed circular progress for completed tracks */}
|
||||
{(actualStatus === "completed" || actualStatus === "done") &&
|
||||
item.type === "track" && (
|
||||
<div className="flex-shrink-0">
|
||||
<CircularProgress
|
||||
progress={100}
|
||||
isCompleted={true}
|
||||
isRealProgress={true}
|
||||
size={44}
|
||||
strokeWidth={4}
|
||||
className="md:mr-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 md:gap-1 flex-shrink-0">
|
||||
{isTerminal ? (
|
||||
<button
|
||||
@@ -230,9 +421,12 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(item.status === "error" || item.status === "retrying") && item.error && (
|
||||
{(actualStatus === "error" || actualStatus === "retrying" || actualStatus === "cancelled") && (item.error || item.last_line?.error || item.last_line?.status_info?.error) && (
|
||||
<div className="mt-3 p-3 md:p-2 bg-error/10 border border-error/20 rounded-lg">
|
||||
<p className="text-sm md:text-xs text-error font-medium break-words">Error: {item.error}</p>
|
||||
<p className="text-sm md:text-xs text-error font-medium break-words">
|
||||
{actualStatus === "cancelled" ? "Cancelled: " : "Error: "}
|
||||
{item.last_line?.status_info?.error || item.last_line?.error || item.error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isTerminal && item.summary && (item.summary.total_failed > 0 || item.summary.total_skipped > 0) && (
|
||||
@@ -253,24 +447,6 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(item.status === "downloading" || item.status === "processing") &&
|
||||
item.type === "track" &&
|
||||
item.progress !== undefined && (
|
||||
<div className="mt-4 md:mt-3">
|
||||
<div className="flex justify-between items-center mb-2 md:mb-1">
|
||||
<span className="text-sm md:text-xs text-content-muted dark:text-content-muted-dark">Progress</span>
|
||||
<span className="text-sm md:text-xs font-semibold text-content-primary dark:text-content-primary-dark">{item.progress.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-3 md:h-2 w-full bg-surface/50 dark:bg-surface-dark/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ease-out ${
|
||||
item.status === "downloading" ? "bg-info" : "bg-processing"
|
||||
}`}
|
||||
style={{ width: `${item.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -40,6 +40,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<QueueItem[]>([]);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const pollingIntervals = useRef<Record<string, number>>({});
|
||||
const cancelledRemovalTimers = useRef<Record<string, number>>({});
|
||||
|
||||
// Calculate active downloads count
|
||||
const activeCount = useMemo(() => {
|
||||
@@ -53,6 +54,19 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleCancelledTaskRemoval = useCallback((taskId: string) => {
|
||||
// Clear any existing timer for this task
|
||||
if (cancelledRemovalTimers.current[taskId]) {
|
||||
clearTimeout(cancelledRemovalTimers.current[taskId]);
|
||||
}
|
||||
|
||||
// Schedule removal after 5 seconds
|
||||
cancelledRemovalTimers.current[taskId] = window.setTimeout(() => {
|
||||
setItems(prevItems => prevItems.filter(item => item.id !== taskId));
|
||||
delete cancelledRemovalTimers.current[taskId];
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
const updateItemFromPrgs = useCallback((item: QueueItem, prgsData: any): QueueItem => {
|
||||
const updatedItem: QueueItem = { ...item };
|
||||
const { last_line, summary, status, name, artist, download_type } = prgsData;
|
||||
@@ -63,6 +77,15 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
if (artist) updatedItem.artist = artist;
|
||||
if (download_type) updatedItem.type = download_type;
|
||||
|
||||
// Preserve the last_line object for progress tracking
|
||||
if (last_line) updatedItem.last_line = last_line;
|
||||
|
||||
// Check if task is cancelled and schedule removal
|
||||
const actualStatus = last_line?.status_info?.status || last_line?.status || status;
|
||||
if (actualStatus === "cancelled") {
|
||||
scheduleCancelledTaskRemoval(updatedItem.id);
|
||||
}
|
||||
|
||||
if (last_line) {
|
||||
if (isProcessingCallback(last_line)) {
|
||||
updatedItem.status = "processing";
|
||||
@@ -83,6 +106,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
updatedItem.status = status_info.status as QueueStatus;
|
||||
updatedItem.name = album.title;
|
||||
updatedItem.artist = album.artists.map(a => a.name).join(", ");
|
||||
updatedItem.totalTracks = album.total_tracks;
|
||||
if (status_info.status === "done") {
|
||||
if (status_info.summary) updatedItem.summary = status_info.summary;
|
||||
updatedItem.currentTrackTitle = undefined;
|
||||
@@ -104,7 +128,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return updatedItem;
|
||||
}, []);
|
||||
}, [scheduleCancelledTaskRemoval]);
|
||||
|
||||
const startPolling = useCallback(
|
||||
(taskId: string) => {
|
||||
@@ -138,26 +162,32 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
pollingIntervals.current[taskId] = intervalId;
|
||||
},
|
||||
[stopPolling, updateItemFromPrgs],
|
||||
[stopPolling, updateItemFromPrgs, scheduleCancelledTaskRemoval],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQueue = async () => {
|
||||
try {
|
||||
const response = await apiClient.get<any[]>("/prgs/list");
|
||||
const backendItems = response.data.map((task: any) => {
|
||||
const spotifyId = task.original_url?.split("/").pop() || "";
|
||||
const baseItem: QueueItem = {
|
||||
id: task.task_id,
|
||||
taskId: task.task_id,
|
||||
name: task.name || "Unknown",
|
||||
type: task.download_type || "track",
|
||||
spotifyId: spotifyId,
|
||||
status: "initializing",
|
||||
artist: task.artist,
|
||||
};
|
||||
return updateItemFromPrgs(baseItem, task);
|
||||
});
|
||||
const backendItems = response.data
|
||||
.filter((task: any) => {
|
||||
// Filter out cancelled tasks on initial fetch
|
||||
const status = task.last_line?.status_info?.status || task.last_line?.status || task.status;
|
||||
return status !== "cancelled";
|
||||
})
|
||||
.map((task: any) => {
|
||||
const spotifyId = task.original_url?.split("/").pop() || "";
|
||||
const baseItem: QueueItem = {
|
||||
id: task.task_id,
|
||||
taskId: task.task_id,
|
||||
name: task.name || "Unknown",
|
||||
type: task.download_type || "track",
|
||||
spotifyId: spotifyId,
|
||||
status: "initializing",
|
||||
artist: task.artist,
|
||||
};
|
||||
return updateItemFromPrgs(baseItem, task);
|
||||
});
|
||||
|
||||
setItems(backendItems);
|
||||
|
||||
@@ -240,23 +270,38 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
await apiClient.post(`/prgs/cancel/${item.taskId}`);
|
||||
stopPolling(item.taskId);
|
||||
|
||||
// Immediately update UI to show cancelled status
|
||||
setItems(prev =>
|
||||
prev.map(i =>
|
||||
i.id === id
|
||||
? {
|
||||
...i,
|
||||
status: "cancelled",
|
||||
last_line: {
|
||||
...i.last_line,
|
||||
status_info: {
|
||||
...i.last_line?.status_info,
|
||||
status: "cancelled",
|
||||
error: "Task cancelled by user",
|
||||
timestamp: Date.now() / 1000,
|
||||
}
|
||||
}
|
||||
}
|
||||
: i,
|
||||
),
|
||||
);
|
||||
|
||||
// Schedule removal after 5 seconds
|
||||
scheduleCancelledTaskRemoval(id);
|
||||
|
||||
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],
|
||||
[items, stopPolling, scheduleCancelledTaskRemoval],
|
||||
);
|
||||
|
||||
const retryItem = useCallback(
|
||||
@@ -396,7 +441,10 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
syncActiveTasks();
|
||||
return () => clearAllPolls();
|
||||
return () => {
|
||||
clearAllPolls();
|
||||
Object.values(cancelledRemovalTimers.current).forEach(clearTimeout);
|
||||
};
|
||||
}, [startPolling, clearAllPolls]);
|
||||
|
||||
const value = {
|
||||
|
||||
@@ -13,7 +13,8 @@ export type QueueStatus =
|
||||
| "cancelled"
|
||||
| "done"
|
||||
| "queued"
|
||||
| "retrying";
|
||||
| "retrying"
|
||||
| "real-time";
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
@@ -39,6 +40,34 @@ export interface QueueItem {
|
||||
currentTrackNumber?: number;
|
||||
totalTracks?: number;
|
||||
summary?: SummaryObject;
|
||||
|
||||
// Real-time download data
|
||||
last_line?: {
|
||||
// Direct status and error fields
|
||||
status?: string;
|
||||
error?: string;
|
||||
id?: number;
|
||||
timestamp?: number;
|
||||
|
||||
// Album/playlist progress fields
|
||||
current_track?: number;
|
||||
total_tracks?: number;
|
||||
parent?: any; // Parent album/playlist information
|
||||
|
||||
// Real-time progress data (when status is "real-time")
|
||||
status_info?: {
|
||||
progress?: number;
|
||||
status?: string;
|
||||
time_elapsed?: number;
|
||||
error?: string;
|
||||
timestamp?: number;
|
||||
ids?: {
|
||||
isrc?: string;
|
||||
spotify?: string;
|
||||
};
|
||||
};
|
||||
track?: any; // Contains detailed track information
|
||||
};
|
||||
}
|
||||
|
||||
export interface QueueContextType {
|
||||
|
||||
Reference in New Issue
Block a user