Bum deezspot, fixed a bunch of bugs with the queue, fixed duplicate URL detection

This commit is contained in:
Xoconoch
2025-08-01 20:38:01 -06:00
parent 68bf60ec7e
commit 8cf32ff50f
10 changed files with 338 additions and 4780 deletions

3
.gitignore vendored
View File

@@ -38,4 +38,5 @@ static/js
data
logs/
.env
Test.py
Test.py
spotizerr-ui/dev-dist

View File

@@ -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

View File

@@ -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

View File

@@ -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(),
}
},
)

View File

@@ -1 +0,0 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

View File

@@ -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} didnt 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

View File

@@ -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>
);
};

View File

@@ -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;
@@ -62,6 +76,15 @@ export function QueueProvider({ children }: { children: ReactNode }) {
if (name) updatedItem.name = name;
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)) {
@@ -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 = {

View File

@@ -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 {