fix a lot
fix dockerfile fix queue operations improve style
This commit is contained in:
@@ -52,4 +52,4 @@ repos:
|
|||||||
args: [--no-strict-optional, --ignore-missing-imports]
|
args: [--no-strict-optional, --ignore-missing-imports]
|
||||||
exclude: ^spotizerr-ui/
|
exclude: ^spotizerr-ui/
|
||||||
# NOTE: you might need to add some deps here:
|
# NOTE: you might need to add some deps here:
|
||||||
additional_dependencies: [waitress==3.0.2, types-waitress]
|
additional_dependencies: [waitress==3.0.2, types-waitress, types-requests]
|
||||||
|
|||||||
13
Dockerfile
13
Dockerfile
@@ -1,12 +1,4 @@
|
|||||||
# Stage 1: TypeScript to JavaScript compilation
|
# Stage 1: Frontend build
|
||||||
FROM node:22-slim AS typescript-builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY tsconfig.json .
|
|
||||||
COPY src/js ./src/js
|
|
||||||
RUN npm install -g typescript
|
|
||||||
RUN tsc
|
|
||||||
|
|
||||||
# Stage 2: Frontend build
|
|
||||||
FROM node:22-slim AS frontend-builder
|
FROM node:22-slim AS frontend-builder
|
||||||
WORKDIR /app/spotizerr-ui
|
WORKDIR /app/spotizerr-ui
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm
|
||||||
@@ -15,7 +7,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
COPY spotizerr-ui/. .
|
COPY spotizerr-ui/. .
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
# Stage 3: Final application image
|
# Stage 2: Final application image
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
# Set an environment variable for non-interactive frontend installation
|
# Set an environment variable for non-interactive frontend installation
|
||||||
@@ -42,7 +34,6 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Copy compiled assets from previous stages
|
# Copy compiled assets from previous stages
|
||||||
COPY --from=typescript-builder /app/static/js ./static/js
|
|
||||||
COPY --from=frontend-builder /app/spotizerr-ui/dist ./spotizerr-ui/dist
|
COPY --from=frontend-builder /app/spotizerr-ui/dist ./spotizerr-ui/dist
|
||||||
|
|
||||||
# Create necessary directories with proper permissions
|
# Create necessary directories with proper permissions
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
} from "react-icons/fa";
|
} from "react-icons/fa";
|
||||||
import { QueueContext, type QueueItem, type QueueStatus } from "@/contexts/queue-context";
|
import { QueueContext, type QueueItem, type QueueStatus } from "@/contexts/queue-context";
|
||||||
|
|
||||||
|
const isTerminalStatus = (status: QueueStatus) =>
|
||||||
|
["completed", "error", "cancelled", "skipped", "done"].includes(status);
|
||||||
|
|
||||||
const statusStyles: Record<QueueStatus, { icon: React.ReactNode; color: string; bgColor: string; name: string }> = {
|
const statusStyles: Record<QueueStatus, { icon: React.ReactNode; color: string; bgColor: string; name: string }> = {
|
||||||
queued: {
|
queued: {
|
||||||
icon: <FaHourglassHalf />,
|
icon: <FaHourglassHalf />,
|
||||||
@@ -74,10 +77,10 @@ const statusStyles: Record<QueueStatus, { icon: React.ReactNode; color: string;
|
|||||||
};
|
};
|
||||||
|
|
||||||
const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
||||||
const { removeItem, retryItem } = useContext(QueueContext) || {};
|
const { removeItem, retryItem, cancelItem } = useContext(QueueContext) || {};
|
||||||
const statusInfo = statusStyles[item.status] || statusStyles.queued;
|
const statusInfo = statusStyles[item.status] || statusStyles.queued;
|
||||||
|
|
||||||
const isTerminal = item.status === "completed" || item.status === "done";
|
const isTerminal = isTerminalStatus(item.status);
|
||||||
const currentCount = isTerminal ? (item.summary?.successful?.length ?? item.totalTracks) : item.currentTrackNumber;
|
const currentCount = isTerminal ? (item.summary?.successful?.length ?? item.totalTracks) : item.currentTrackNumber;
|
||||||
|
|
||||||
const progressText =
|
const progressText =
|
||||||
@@ -114,6 +117,7 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
|||||||
<p className={`text-sm font-semibold ${statusInfo.color}`}>{statusInfo.name}</p>
|
<p className={`text-sm font-semibold ${statusInfo.color}`}>{statusInfo.name}</p>
|
||||||
{progressText && <p className="text-xs text-gray-500">{progressText}</p>}
|
{progressText && <p className="text-xs text-gray-500">{progressText}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
{isTerminal ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => removeItem?.(item.id)}
|
onClick={() => removeItem?.(item.id)}
|
||||||
className="text-gray-400 hover:text-red-500 transition-colors"
|
className="text-gray-400 hover:text-red-500 transition-colors"
|
||||||
@@ -121,6 +125,15 @@ const QueueItemCard = ({ item }: { item: QueueItem }) => {
|
|||||||
>
|
>
|
||||||
<FaTimes />
|
<FaTimes />
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => cancelItem?.(item.id)}
|
||||||
|
className="text-gray-400 hover:text-orange-500 transition-colors"
|
||||||
|
aria-label="Cancel"
|
||||||
|
>
|
||||||
|
<FaTimes />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{item.canRetry && (
|
{item.canRetry && (
|
||||||
<button
|
<button
|
||||||
onClick={() => retryItem?.(item.id)}
|
onClick={() => retryItem?.(item.id)}
|
||||||
@@ -149,22 +162,33 @@ export const Queue = () => {
|
|||||||
const context = useContext(QueueContext);
|
const context = useContext(QueueContext);
|
||||||
|
|
||||||
if (!context) return null;
|
if (!context) return null;
|
||||||
const { items, isVisible, toggleVisibility, clearQueue } = context;
|
const { items, isVisible, toggleVisibility, cancelAll, clearCompleted } = context;
|
||||||
|
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
const hasActive = items.some((item) => !isTerminalStatus(item.status));
|
||||||
|
const hasFinished = items.some((item) => isTerminalStatus(item.status));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 right-4 w-full max-w-md bg-white rounded-lg shadow-xl border border-gray-200 z-50">
|
<div className="fixed bottom-4 right-4 w-full max-w-md bg-white rounded-lg shadow-xl border border-gray-200 z-50">
|
||||||
<header className="flex items-center justify-between p-4 border-b border-gray-200">
|
<header className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||||
<h2 className="text-lg font-bold">Download Queue ({items.length})</h2>
|
<h2 className="text-lg font-bold">Download Queue ({items.length})</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={clearQueue}
|
onClick={cancelAll}
|
||||||
className="text-sm text-gray-500 hover:text-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="text-sm text-gray-500 hover:text-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
disabled={items.length === 0}
|
disabled={!hasActive}
|
||||||
aria-label="Clear all items in queue"
|
aria-label="Cancel all active downloads"
|
||||||
>
|
>
|
||||||
Clear All
|
Cancel All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearCompleted}
|
||||||
|
className="text-sm text-gray-500 hover:text-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={!hasFinished}
|
||||||
|
aria-label="Clear all finished downloads"
|
||||||
|
>
|
||||||
|
Clear Finished
|
||||||
</button>
|
</button>
|
||||||
<button onClick={toggleVisibility} className="text-gray-500 hover:text-gray-800" aria-label="Close queue">
|
<button onClick={toggleVisibility} className="text-gray-500 hover:text-gray-800" aria-label="Close queue">
|
||||||
<FaTimes />
|
<FaTimes />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useForm, type SubmitHandler } from "react-hook-form";
|
|||||||
import apiClient from "../../lib/api-client";
|
import apiClient from "../../lib/api-client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
// --- Type Definitions ---
|
// --- Type Definitions ---
|
||||||
interface DownloadSettings {
|
interface DownloadSettings {
|
||||||
@@ -18,6 +19,8 @@ interface DownloadSettings {
|
|||||||
skipExisting: boolean;
|
skipExisting: boolean;
|
||||||
m3u: boolean;
|
m3u: boolean;
|
||||||
hlsThreads: number;
|
hlsThreads: number;
|
||||||
|
deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
|
||||||
|
spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadsTabProps {
|
interface DownloadsTabProps {
|
||||||
@@ -56,10 +59,16 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register, handleSubmit, watch } = useForm<DownloadSettings>({
|
const { register, handleSubmit, watch, reset } = useForm<DownloadSettings>({
|
||||||
values: config,
|
defaultValues: config,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) {
|
||||||
|
reset(config);
|
||||||
|
}
|
||||||
|
}, [config, reset]);
|
||||||
|
|
||||||
const selectedFormat = watch("convertTo");
|
const selectedFormat = watch("convertTo");
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<DownloadSettings> = (data) => {
|
const onSubmit: SubmitHandler<DownloadSettings> = (data) => {
|
||||||
@@ -101,6 +110,38 @@ export function DownloadsTab({ config, isLoading }: DownloadsTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Source Quality Settings */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-semibold">Source Quality</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="spotifyQuality">Spotify Quality</label>
|
||||||
|
<select
|
||||||
|
id="spotifyQuality"
|
||||||
|
{...register("spotifyQuality")}
|
||||||
|
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="NORMAL">OGG 96kbps</option>
|
||||||
|
<option value="HIGH">OGG 160kbps</option>
|
||||||
|
<option value="VERY_HIGH">OGG 320kbps (Premium)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="deezerQuality">Deezer Quality</label>
|
||||||
|
<select
|
||||||
|
id="deezerQuality"
|
||||||
|
{...register("deezerQuality")}
|
||||||
|
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="MP3_128">MP3 128kbps</option>
|
||||||
|
<option value="MP3_320">MP3 320kbps</option>
|
||||||
|
<option value="FLAC">FLAC (HiFi)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
This sets the quality of the original download. Conversion settings below are applied after download.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Conversion Settings */}
|
{/* Conversion Settings */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-xl font-semibold">Conversion</h3>
|
<h3 className="text-xl font-semibold">Conversion</h3>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import apiClient from "../../lib/api-client";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSettings } from "../../contexts/settings-context";
|
import { useSettings } from "../../contexts/settings-context";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
// --- Type Definitions ---
|
// --- Type Definitions ---
|
||||||
interface Credential {
|
interface Credential {
|
||||||
@@ -12,9 +13,7 @@ interface Credential {
|
|||||||
interface GeneralSettings {
|
interface GeneralSettings {
|
||||||
service: "spotify" | "deezer";
|
service: "spotify" | "deezer";
|
||||||
spotify: string;
|
spotify: string;
|
||||||
spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH";
|
|
||||||
deezer: string;
|
deezer: string;
|
||||||
deezerQuality: "MP3_128" | "MP3_320" | "FLAC";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GeneralTabProps {
|
interface GeneralTabProps {
|
||||||
@@ -47,10 +46,16 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
|||||||
queryFn: () => fetchCredentials("deezer"),
|
queryFn: () => fetchCredentials("deezer"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register, handleSubmit } = useForm<GeneralSettings>({
|
const { register, handleSubmit, reset } = useForm<GeneralSettings>({
|
||||||
values: config,
|
defaultValues: config,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) {
|
||||||
|
reset(config);
|
||||||
|
}
|
||||||
|
}, [config, reset]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: saveGeneralConfig,
|
mutationFn: saveGeneralConfig,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -100,18 +105,6 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="spotifyQuality">Spotify Quality</label>
|
|
||||||
<select
|
|
||||||
id="spotifyQuality"
|
|
||||||
{...register("spotifyQuality")}
|
|
||||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="NORMAL">OGG 96kbps</option>
|
|
||||||
<option value="HIGH">OGG 160kbps</option>
|
|
||||||
<option value="VERY_HIGH">OGG 320kbps (Premium)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -130,18 +123,6 @@ export function GeneralTab({ config, isLoading: isConfigLoading }: GeneralTabPro
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="deezerQuality">Deezer Quality</label>
|
|
||||||
<select
|
|
||||||
id="deezerQuality"
|
|
||||||
{...register("deezerQuality")}
|
|
||||||
className="block w-full p-2 border rounded-md bg-gray-50 dark:bg-gray-800 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="MP3_128">MP3 128kbps</option>
|
|
||||||
<option value="MP3_320">MP3 320kbps</option>
|
|
||||||
<option value="FLAC">FLAC (HiFi)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
|||||||
const response = await apiClient.get<PrgsResponse>(`/prgs/${taskId}`);
|
const response = await apiClient.get<PrgsResponse>(`/prgs/${taskId}`);
|
||||||
const lastStatus = response.data.last_line || {};
|
const lastStatus = response.data.last_line || {};
|
||||||
const statusUpdate = {
|
const statusUpdate = {
|
||||||
status: lastStatus.status || response.data.status || "pending",
|
status: response.data.status || lastStatus.status || "pending",
|
||||||
message: lastStatus.message || lastStatus.error,
|
message: lastStatus.message || lastStatus.error,
|
||||||
can_retry: lastStatus.can_retry,
|
can_retry: lastStatus.can_retry,
|
||||||
progress: lastStatus.progress,
|
progress: lastStatus.progress,
|
||||||
@@ -195,10 +195,7 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
|||||||
let endpoint = "";
|
let endpoint = "";
|
||||||
|
|
||||||
if (item.type === "track") {
|
if (item.type === "track") {
|
||||||
// WORKAROUND: Use the playlist endpoint for single tracks to avoid
|
endpoint = `/track/download/${item.spotifyId}`;
|
||||||
// connection issues with the direct track downloader.
|
|
||||||
const trackUrl = `https://open.spotify.com/track/${item.spotifyId}`;
|
|
||||||
endpoint = `/playlist/download?url=${encodeURIComponent(trackUrl)}&name=${encodeURIComponent(item.name)}`;
|
|
||||||
} else if (item.type === "album") {
|
} else if (item.type === "album") {
|
||||||
endpoint = `/album/download/${item.spotifyId}`;
|
endpoint = `/album/download/${item.spotifyId}`;
|
||||||
} else if (item.type === "playlist") {
|
} else if (item.type === "playlist") {
|
||||||
@@ -324,22 +321,24 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [clearAllPolls, startPolling]);
|
}, [clearAllPolls, startPolling]);
|
||||||
|
|
||||||
// --- Other Actions ---
|
// --- Other Actions ---
|
||||||
const removeItem = useCallback(
|
const removeItem = useCallback((id: string) => {
|
||||||
async (id: string) => {
|
|
||||||
const itemToRemove = items.find((i) => i.id === id);
|
|
||||||
if (itemToRemove) {
|
|
||||||
stopPolling(itemToRemove.id);
|
|
||||||
if (itemToRemove.taskId) {
|
|
||||||
try {
|
|
||||||
// Use the prgs endpoint to cancel tasks
|
|
||||||
await apiClient.post(`/prgs/cancel/${itemToRemove.taskId}`);
|
|
||||||
toast.success(`Cancelled download: ${itemToRemove.name}`);
|
|
||||||
} catch {
|
|
||||||
toast.error(`Failed to cancel download: ${itemToRemove.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
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],
|
[items, stopPolling],
|
||||||
);
|
);
|
||||||
@@ -377,24 +376,26 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
|||||||
[items, startPolling],
|
[items, startPolling],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearQueue = useCallback(async () => {
|
const cancelAll = useCallback(async () => {
|
||||||
|
toast.info("Cancelling all active downloads...");
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.taskId) {
|
if (item.taskId && !isTerminalStatus(item.status)) {
|
||||||
stopPolling(item.id);
|
stopPolling(item.id);
|
||||||
try {
|
try {
|
||||||
// Use the prgs endpoint to cancel tasks
|
|
||||||
await apiClient.post(`/prgs/cancel/${item.taskId}`);
|
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) {
|
} catch (err) {
|
||||||
console.error(`Failed to cancel task ${item.taskId}`, err);
|
console.error(`Failed to cancel task ${item.taskId}`, err);
|
||||||
|
toast.error(`Failed to cancel: ${item.name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setItems([]);
|
|
||||||
toast.info("Queue cleared.");
|
|
||||||
}, [items, stopPolling]);
|
}, [items, stopPolling]);
|
||||||
|
|
||||||
const clearCompleted = useCallback(() => {
|
const clearCompleted = useCallback(() => {
|
||||||
setItems((prev) => prev.filter((item) => !isTerminalStatus(item.status)));
|
setItems((prev) => prev.filter((item) => !isTerminalStatus(item.status)));
|
||||||
|
toast.info("Cleared finished downloads.");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleVisibility = useCallback(() => setIsVisible((prev) => !prev), []);
|
const toggleVisibility = useCallback(() => setIsVisible((prev) => !prev), []);
|
||||||
@@ -405,9 +406,10 @@ export function QueueProvider({ children }: { children: ReactNode }) {
|
|||||||
addItem,
|
addItem,
|
||||||
removeItem,
|
removeItem,
|
||||||
retryItem,
|
retryItem,
|
||||||
clearQueue,
|
|
||||||
toggleVisibility,
|
toggleVisibility,
|
||||||
clearCompleted,
|
clearCompleted,
|
||||||
|
cancelAll,
|
||||||
|
cancelItem,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <QueueContext.Provider value={value}>{children}</QueueContext.Provider>;
|
return <QueueContext.Provider value={value}>{children}</QueueContext.Provider>;
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ export interface QueueContextType {
|
|||||||
addItem: (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => void;
|
addItem: (item: { name: string; type: DownloadType; spotifyId: string; artist?: string }) => void;
|
||||||
removeItem: (id: string) => void;
|
removeItem: (id: string) => void;
|
||||||
retryItem: (id: string) => void;
|
retryItem: (id: string) => void;
|
||||||
clearQueue: () => void;
|
|
||||||
toggleVisibility: () => void;
|
toggleVisibility: () => void;
|
||||||
clearCompleted: () => void;
|
clearCompleted: () => void;
|
||||||
|
cancelAll: () => void;
|
||||||
|
cancelItem: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QueueContext = createContext<QueueContextType | undefined>(undefined);
|
export const QueueContext = createContext<QueueContextType | undefined>(undefined);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function AppLayout() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<Queue />
|
<Queue />
|
||||||
<Toaster richColors />
|
<Toaster richColors duration={1500} position="bottom-left" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const historyTableBody = document.getElementById('history-table-body') as HTMLTableSectionElement | null;
|
|
||||||
const prevButton = document.getElementById('prev-page') as HTMLButtonElement | null;
|
|
||||||
const nextButton = document.getElementById('next-page') as HTMLButtonElement | null;
|
|
||||||
const pageInfo = document.getElementById('page-info') as HTMLSpanElement | null;
|
|
||||||
const limitSelect = document.getElementById('limit-select') as HTMLSelectElement | null;
|
|
||||||
const statusFilter = document.getElementById('status-filter') as HTMLSelectElement | null;
|
|
||||||
const typeFilter = document.getElementById('type-filter') as HTMLSelectElement | null;
|
|
||||||
const trackFilter = document.getElementById('track-filter') as HTMLSelectElement | null;
|
|
||||||
const hideChildTracksCheckbox = document.getElementById('hide-child-tracks') as HTMLInputElement | null;
|
|
||||||
|
|
||||||
let currentPage = 1;
|
|
||||||
let limit = 25;
|
|
||||||
let totalEntries = 0;
|
|
||||||
let currentSortBy = 'timestamp_completed';
|
|
||||||
let currentSortOrder = 'DESC';
|
|
||||||
let currentParentTaskId: string | null = null;
|
|
||||||
|
|
||||||
async function fetchHistory(page = 1) {
|
|
||||||
if (!historyTableBody || !prevButton || !nextButton || !pageInfo || !limitSelect || !statusFilter || !typeFilter) {
|
|
||||||
console.error('One or more critical UI elements are missing for history page.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
let apiUrl = `/api/history?limit=${limit}&offset=${offset}&sort_by=${currentSortBy}&sort_order=${currentSortOrder}`;
|
|
||||||
|
|
||||||
const statusVal = statusFilter.value;
|
|
||||||
if (statusVal) {
|
|
||||||
apiUrl += `&status_final=${statusVal}`;
|
|
||||||
}
|
|
||||||
const typeVal = typeFilter.value;
|
|
||||||
if (typeVal) {
|
|
||||||
apiUrl += `&download_type=${typeVal}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add track status filter if present
|
|
||||||
if (trackFilter && trackFilter.value) {
|
|
||||||
apiUrl += `&track_status=${trackFilter.value}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add parent task filter if viewing a specific parent's tracks
|
|
||||||
if (currentParentTaskId) {
|
|
||||||
apiUrl += `&parent_task_id=${currentParentTaskId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add hide child tracks filter if checkbox is checked
|
|
||||||
if (hideChildTracksCheckbox && hideChildTracksCheckbox.checked) {
|
|
||||||
apiUrl += `&hide_child_tracks=true`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(apiUrl);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
renderHistory(data.entries);
|
|
||||||
totalEntries = data.total_count;
|
|
||||||
currentPage = Math.floor(offset / limit) + 1;
|
|
||||||
updatePagination();
|
|
||||||
updateSortIndicators();
|
|
||||||
|
|
||||||
// Update page title if viewing tracks for a parent
|
|
||||||
updatePageTitle();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching history:', error);
|
|
||||||
if (historyTableBody) {
|
|
||||||
historyTableBody.innerHTML = '<tr><td colspan="10">Error loading history.</td></tr>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHistory(entries: any[]) {
|
|
||||||
if (!historyTableBody) return;
|
|
||||||
|
|
||||||
historyTableBody.innerHTML = ''; // Clear existing rows
|
|
||||||
if (!entries || entries.length === 0) {
|
|
||||||
historyTableBody.innerHTML = '<tr><td colspan="10">No history entries found.</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.forEach(entry => {
|
|
||||||
const row = historyTableBody.insertRow();
|
|
||||||
|
|
||||||
// Add class for parent/child styling
|
|
||||||
if (entry.parent_task_id) {
|
|
||||||
row.classList.add('child-track-row');
|
|
||||||
} else if (entry.download_type === 'album' || entry.download_type === 'playlist') {
|
|
||||||
row.classList.add('parent-task-row');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Item name with indentation for child tracks
|
|
||||||
const nameCell = row.insertCell();
|
|
||||||
if (entry.parent_task_id) {
|
|
||||||
nameCell.innerHTML = `<span class="child-track-indent">└─ </span>${entry.item_name || 'N/A'}`;
|
|
||||||
} else {
|
|
||||||
nameCell.textContent = entry.item_name || 'N/A';
|
|
||||||
}
|
|
||||||
|
|
||||||
row.insertCell().textContent = entry.item_artist || 'N/A';
|
|
||||||
|
|
||||||
// Type cell - show track status for child tracks
|
|
||||||
const typeCell = row.insertCell();
|
|
||||||
if (entry.parent_task_id && entry.track_status) {
|
|
||||||
typeCell.textContent = entry.track_status;
|
|
||||||
typeCell.classList.add(`track-status-${entry.track_status.toLowerCase()}`);
|
|
||||||
} else {
|
|
||||||
typeCell.textContent = entry.download_type ? entry.download_type.charAt(0).toUpperCase() + entry.download_type.slice(1) : 'N/A';
|
|
||||||
}
|
|
||||||
|
|
||||||
row.insertCell().textContent = entry.service_used || 'N/A';
|
|
||||||
|
|
||||||
// Construct Quality display string
|
|
||||||
const qualityCell = row.insertCell();
|
|
||||||
let qualityDisplay = entry.quality_profile || 'N/A';
|
|
||||||
|
|
||||||
// Check if convert_to exists and is not "None"
|
|
||||||
if (entry.convert_to && entry.convert_to !== "None") {
|
|
||||||
qualityDisplay = `${entry.convert_to.toUpperCase()}`;
|
|
||||||
// Check if bitrate exists and is not "None"
|
|
||||||
if (entry.bitrate && entry.bitrate !== "None") {
|
|
||||||
qualityDisplay += ` ${entry.bitrate}k`;
|
|
||||||
}
|
|
||||||
qualityDisplay += ` (${entry.quality_profile || 'Original'})`;
|
|
||||||
} else if (entry.bitrate && entry.bitrate !== "None") { // Case where convert_to might not be set, but bitrate is (e.g. for OGG Vorbis quality settings)
|
|
||||||
qualityDisplay = `${entry.bitrate}k (${entry.quality_profile || 'Profile'})`;
|
|
||||||
}
|
|
||||||
// If both are "None" or null, it will just use the quality_profile value set above
|
|
||||||
qualityCell.textContent = qualityDisplay;
|
|
||||||
|
|
||||||
const statusCell = row.insertCell();
|
|
||||||
statusCell.textContent = entry.status_final || 'N/A';
|
|
||||||
statusCell.className = `status-${entry.status_final?.toLowerCase() || 'unknown'}`;
|
|
||||||
|
|
||||||
row.insertCell().textContent = entry.timestamp_added ? new Date(entry.timestamp_added * 1000).toLocaleString() : 'N/A';
|
|
||||||
row.insertCell().textContent = entry.timestamp_completed ? new Date(entry.timestamp_completed * 1000).toLocaleString() : 'N/A';
|
|
||||||
|
|
||||||
const actionsCell = row.insertCell();
|
|
||||||
|
|
||||||
// Add details button
|
|
||||||
const detailsButton = document.createElement('button');
|
|
||||||
detailsButton.innerHTML = `<img src="/static/images/info.svg" alt="Details">`;
|
|
||||||
detailsButton.className = 'details-btn btn-icon';
|
|
||||||
detailsButton.title = 'Show Details';
|
|
||||||
detailsButton.onclick = () => showDetailsModal(entry);
|
|
||||||
actionsCell.appendChild(detailsButton);
|
|
||||||
|
|
||||||
// Add view tracks button for album/playlist entries with child tracks
|
|
||||||
if (!entry.parent_task_id && (entry.download_type === 'album' || entry.download_type === 'playlist') &&
|
|
||||||
(entry.total_successful > 0 || entry.total_skipped > 0 || entry.total_failed > 0)) {
|
|
||||||
const viewTracksButton = document.createElement('button');
|
|
||||||
viewTracksButton.innerHTML = `<img src="/static/images/list.svg" alt="Tracks">`;
|
|
||||||
viewTracksButton.className = 'tracks-btn btn-icon';
|
|
||||||
viewTracksButton.title = 'View Tracks';
|
|
||||||
viewTracksButton.setAttribute('data-task-id', entry.task_id);
|
|
||||||
viewTracksButton.onclick = () => viewTracksForParent(entry.task_id);
|
|
||||||
actionsCell.appendChild(viewTracksButton);
|
|
||||||
|
|
||||||
// Add track counts display
|
|
||||||
const trackCountsSpan = document.createElement('span');
|
|
||||||
trackCountsSpan.className = 'track-counts';
|
|
||||||
trackCountsSpan.title = `Successful: ${entry.total_successful || 0}, Skipped: ${entry.total_skipped || 0}, Failed: ${entry.total_failed || 0}`;
|
|
||||||
trackCountsSpan.innerHTML = `
|
|
||||||
<span class="track-count success">${entry.total_successful || 0}</span> /
|
|
||||||
<span class="track-count skipped">${entry.total_skipped || 0}</span> /
|
|
||||||
<span class="track-count failed">${entry.total_failed || 0}</span>
|
|
||||||
`;
|
|
||||||
actionsCell.appendChild(trackCountsSpan);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.status_final === 'ERROR' && entry.error_message) {
|
|
||||||
const errorSpan = document.createElement('span');
|
|
||||||
errorSpan.textContent = ' (Show Error)';
|
|
||||||
errorSpan.className = 'error-message-toggle';
|
|
||||||
errorSpan.style.marginLeft = '5px';
|
|
||||||
errorSpan.onclick = (e) => {
|
|
||||||
e.stopPropagation(); // Prevent click on row if any
|
|
||||||
let errorDetailsDiv = row.querySelector('.error-details') as HTMLElement | null;
|
|
||||||
if (!errorDetailsDiv) {
|
|
||||||
errorDetailsDiv = document.createElement('div');
|
|
||||||
errorDetailsDiv.className = 'error-details';
|
|
||||||
const newCell = row.insertCell(); // This will append to the end of the row
|
|
||||||
newCell.colSpan = 10; // Span across all columns
|
|
||||||
newCell.appendChild(errorDetailsDiv);
|
|
||||||
}
|
|
||||||
errorDetailsDiv.textContent = entry.error_message;
|
|
||||||
// Toggle display by directly manipulating the style of the details div
|
|
||||||
errorDetailsDiv.style.display = errorDetailsDiv.style.display === 'none' ? 'block' : 'none';
|
|
||||||
};
|
|
||||||
statusCell.appendChild(errorSpan);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePagination() {
|
|
||||||
if (!pageInfo || !prevButton || !nextButton) return;
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalEntries / limit) || 1;
|
|
||||||
pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
|
|
||||||
prevButton.disabled = currentPage === 1;
|
|
||||||
nextButton.disabled = currentPage === totalPages;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePageTitle() {
|
|
||||||
const titleElement = document.getElementById('history-title');
|
|
||||||
if (!titleElement) return;
|
|
||||||
|
|
||||||
if (currentParentTaskId) {
|
|
||||||
titleElement.textContent = 'Download History - Viewing Tracks';
|
|
||||||
|
|
||||||
// Add back button
|
|
||||||
if (!document.getElementById('back-to-history')) {
|
|
||||||
const backButton = document.createElement('button');
|
|
||||||
backButton.id = 'back-to-history';
|
|
||||||
backButton.className = 'btn btn-secondary';
|
|
||||||
backButton.innerHTML = '← Back to All History';
|
|
||||||
backButton.onclick = () => {
|
|
||||||
currentParentTaskId = null;
|
|
||||||
updatePageTitle();
|
|
||||||
fetchHistory(1);
|
|
||||||
};
|
|
||||||
titleElement.parentNode?.insertBefore(backButton, titleElement);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
titleElement.textContent = 'Download History';
|
|
||||||
|
|
||||||
// Remove back button if it exists
|
|
||||||
const backButton = document.getElementById('back-to-history');
|
|
||||||
if (backButton) {
|
|
||||||
backButton.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showDetailsModal(entry: any) {
|
|
||||||
// Create more detailed modal content with new fields
|
|
||||||
let details = `Task ID: ${entry.task_id}\n` +
|
|
||||||
`Type: ${entry.download_type}\n` +
|
|
||||||
`Name: ${entry.item_name}\n` +
|
|
||||||
`Artist: ${entry.item_artist}\n` +
|
|
||||||
`Album: ${entry.item_album || 'N/A'}\n` +
|
|
||||||
`URL: ${entry.item_url || 'N/A'}\n` +
|
|
||||||
`Spotify ID: ${entry.spotify_id || 'N/A'}\n` +
|
|
||||||
`Service Used: ${entry.service_used || 'N/A'}\n` +
|
|
||||||
`Quality Profile (Original): ${entry.quality_profile || 'N/A'}\n` +
|
|
||||||
`ConvertTo: ${entry.convert_to || 'N/A'}\n` +
|
|
||||||
`Bitrate: ${entry.bitrate ? entry.bitrate + 'k' : 'N/A'}\n` +
|
|
||||||
`Status: ${entry.status_final}\n` +
|
|
||||||
`Error: ${entry.error_message || 'None'}\n` +
|
|
||||||
`Added: ${new Date(entry.timestamp_added * 1000).toLocaleString()}\n` +
|
|
||||||
`Completed/Ended: ${new Date(entry.timestamp_completed * 1000).toLocaleString()}\n`;
|
|
||||||
|
|
||||||
// Add track-specific details if this is a track
|
|
||||||
if (entry.parent_task_id) {
|
|
||||||
details += `Parent Task ID: ${entry.parent_task_id}\n` +
|
|
||||||
`Track Status: ${entry.track_status || 'N/A'}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add summary details if this is a parent task
|
|
||||||
if (entry.total_successful !== null || entry.total_skipped !== null || entry.total_failed !== null) {
|
|
||||||
details += `\nTrack Summary:\n` +
|
|
||||||
`Successful: ${entry.total_successful || 0}\n` +
|
|
||||||
`Skipped: ${entry.total_skipped || 0}\n` +
|
|
||||||
`Failed: ${entry.total_failed || 0}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
details += `\nOriginal Request: ${JSON.stringify(JSON.parse(entry.original_request_json || '{}'), null, 2)}\n\n` +
|
|
||||||
`Last Status Object: ${JSON.stringify(JSON.parse(entry.last_status_obj_json || '{}'), null, 2)}`;
|
|
||||||
|
|
||||||
// Try to parse and display summary if available
|
|
||||||
if (entry.summary_json) {
|
|
||||||
try {
|
|
||||||
const summary = JSON.parse(entry.summary_json);
|
|
||||||
details += `\nSummary: ${JSON.stringify(summary, null, 2)}`;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing summary JSON:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
alert(details);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to view tracks for a parent task
|
|
||||||
async function viewTracksForParent(taskId: string) {
|
|
||||||
currentParentTaskId = taskId;
|
|
||||||
currentPage = 1;
|
|
||||||
fetchHistory(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll('th[data-sort]').forEach(headerCell => {
|
|
||||||
headerCell.addEventListener('click', () => {
|
|
||||||
const sortField = (headerCell as HTMLElement).dataset.sort;
|
|
||||||
if (!sortField) return;
|
|
||||||
|
|
||||||
if (currentSortBy === sortField) {
|
|
||||||
currentSortOrder = currentSortOrder === 'ASC' ? 'DESC' : 'ASC';
|
|
||||||
} else {
|
|
||||||
currentSortBy = sortField;
|
|
||||||
currentSortOrder = 'DESC';
|
|
||||||
}
|
|
||||||
fetchHistory(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateSortIndicators() {
|
|
||||||
document.querySelectorAll('th[data-sort]').forEach(headerCell => {
|
|
||||||
const th = headerCell as HTMLElement;
|
|
||||||
th.classList.remove('sort-asc', 'sort-desc');
|
|
||||||
if (th.dataset.sort === currentSortBy) {
|
|
||||||
th.classList.add(currentSortOrder === 'ASC' ? 'sort-asc' : 'sort-desc');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event listeners for pagination and filters
|
|
||||||
prevButton?.addEventListener('click', () => fetchHistory(currentPage - 1));
|
|
||||||
nextButton?.addEventListener('click', () => fetchHistory(currentPage + 1));
|
|
||||||
limitSelect?.addEventListener('change', (e) => {
|
|
||||||
limit = parseInt((e.target as HTMLSelectElement).value, 10);
|
|
||||||
fetchHistory(1);
|
|
||||||
});
|
|
||||||
statusFilter?.addEventListener('change', () => fetchHistory(1));
|
|
||||||
typeFilter?.addEventListener('change', () => fetchHistory(1));
|
|
||||||
trackFilter?.addEventListener('change', () => fetchHistory(1));
|
|
||||||
hideChildTracksCheckbox?.addEventListener('change', () => fetchHistory(1));
|
|
||||||
|
|
||||||
// Initial fetch
|
|
||||||
fetchHistory();
|
|
||||||
});
|
|
||||||
2895
src/js/queue.ts
2895
src/js/queue.ts
File diff suppressed because it is too large
Load Diff
@@ -1,203 +0,0 @@
|
|||||||
body {
|
|
||||||
font-family: sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
background-color: #121212;
|
|
||||||
color: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
padding: 20px;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #1DB954; /* Spotify Green */
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 20px;
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
border: 1px solid #333;
|
|
||||||
padding: 10px 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background-color: #282828;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:nth-child(even) {
|
|
||||||
background-color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Parent and child track styling */
|
|
||||||
.parent-task-row {
|
|
||||||
background-color: #282828 !important;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.child-track-row {
|
|
||||||
background-color: #1a1a1a !important;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.child-track-indent {
|
|
||||||
color: #1DB954;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track status styling */
|
|
||||||
.track-status-successful {
|
|
||||||
color: #1DB954;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-status-skipped {
|
|
||||||
color: #FFD700;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-status-failed {
|
|
||||||
color: #FF4136;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track counts display */
|
|
||||||
.track-counts {
|
|
||||||
margin-left: 10px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-count.success {
|
|
||||||
color: #1DB954;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-count.skipped {
|
|
||||||
color: #FFD700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-count.failed {
|
|
||||||
color: #FF4136;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Back button */
|
|
||||||
#back-to-history {
|
|
||||||
margin-right: 15px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
background-color: #333;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#back-to-history:hover {
|
|
||||||
background-color: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
margin-top: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination button, .pagination select {
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin: 0 5px;
|
|
||||||
background-color: #1DB954;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination button:disabled {
|
|
||||||
background-color: #555;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters label, .filters select, .filters input {
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters select, .filters input {
|
|
||||||
padding: 8px;
|
|
||||||
background-color: #282828;
|
|
||||||
color: #e0e0e0;
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-filter {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-COMPLETED { color: #1DB954; font-weight: bold; }
|
|
||||||
.status-ERROR { color: #FF4136; font-weight: bold; }
|
|
||||||
.status-CANCELLED { color: #AAAAAA; }
|
|
||||||
.status-skipped { color: #FFD700; font-weight: bold; }
|
|
||||||
|
|
||||||
.error-message-toggle {
|
|
||||||
cursor: pointer;
|
|
||||||
color: #FF4136; /* Red for error indicator */
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-details {
|
|
||||||
display: none; /* Hidden by default */
|
|
||||||
white-space: pre-wrap; /* Preserve formatting */
|
|
||||||
background-color: #303030;
|
|
||||||
padding: 5px;
|
|
||||||
margin-top: 5px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling for the buttons in the table */
|
|
||||||
.btn-icon {
|
|
||||||
background-color: transparent; /* Or a subtle color like #282828 */
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%; /* Make it circular */
|
|
||||||
padding: 5px; /* Adjust padding to control size */
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex; /* Important for aligning the image */
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon img {
|
|
||||||
width: 16px; /* Icon size */
|
|
||||||
height: 16px;
|
|
||||||
filter: invert(1); /* Make icon white if it's dark, adjust if needed */
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon:hover {
|
|
||||||
background-color: #333; /* Darker on hover */
|
|
||||||
}
|
|
||||||
|
|
||||||
.details-btn:hover img {
|
|
||||||
filter: invert(0.8) sepia(1) saturate(5) hue-rotate(175deg); /* Make icon blue on hover */
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracks-btn:hover img {
|
|
||||||
filter: invert(0.8) sepia(1) saturate(5) hue-rotate(90deg); /* Make icon green on hover */
|
|
||||||
}
|
|
||||||
@@ -1,825 +0,0 @@
|
|||||||
/* ---------------------- */
|
|
||||||
/* DOWNLOAD QUEUE STYLES */
|
|
||||||
/* ---------------------- */
|
|
||||||
|
|
||||||
/* Container for the download queue sidebar */
|
|
||||||
#downloadQueue {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
right: -350px; /* Hidden offscreen by default */
|
|
||||||
width: 350px;
|
|
||||||
height: 100vh;
|
|
||||||
background: #181818;
|
|
||||||
padding: 20px;
|
|
||||||
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
z-index: 1001;
|
|
||||||
/* Remove overflow-y here to delegate scrolling to the queue items container */
|
|
||||||
box-shadow: -20px 0 30px rgba(0, 0, 0, 0.4);
|
|
||||||
|
|
||||||
/* Added for flex layout */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* When active, the sidebar slides into view */
|
|
||||||
#downloadQueue.active {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header inside the queue sidebar */
|
|
||||||
.sidebar-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header h2 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Queue subtitle with statistics */
|
|
||||||
.queue-subtitle {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 5px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #b3b3b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-stat {
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-stat-active {
|
|
||||||
color: #4a90e2;
|
|
||||||
background-color: rgba(74, 144, 226, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-stat-completed {
|
|
||||||
color: #1DB954;
|
|
||||||
background-color: rgba(29, 185, 84, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-stat-error {
|
|
||||||
color: #ff5555;
|
|
||||||
background-color: rgba(255, 85, 85, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Refresh queue button */
|
|
||||||
#refreshQueueBtn {
|
|
||||||
background: #2a2a2a;
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.3s ease, transform 0.2s ease;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#refreshQueueBtn:hover {
|
|
||||||
background: #333;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
#refreshQueueBtn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
#refreshQueueBtn.refreshing {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Artist queue message */
|
|
||||||
.queue-artist-message {
|
|
||||||
background: #2a2a2a;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
color: #fff;
|
|
||||||
text-align: center;
|
|
||||||
border-left: 4px solid #4a90e2;
|
|
||||||
animation: pulse 1.5s infinite;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% { opacity: 0.8; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
100% { opacity: 0.8; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cancel all button styling */
|
|
||||||
#cancelAllBtn {
|
|
||||||
background: #8b0000; /* Dark blood red */
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.3s ease, transform 0.2s ease;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#cancelAllBtn:hover {
|
|
||||||
background: #a30000; /* Slightly lighter red on hover */
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
#cancelAllBtn:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Close button for the queue sidebar */
|
|
||||||
.close-btn {
|
|
||||||
background: #2a2a2a;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s ease, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:hover {
|
|
||||||
background-color: #333;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Container for all queue items */
|
|
||||||
#queueItems {
|
|
||||||
/* Allow the container to fill all available space in the sidebar */
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 5px; /* Add slight padding for scrollbar */
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #1DB954 rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar styles */
|
|
||||||
#queueItems::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#queueItems::-webkit-scrollbar-track {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#queueItems::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #1DB954;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Each download queue item */
|
|
||||||
.queue-item {
|
|
||||||
background: #2a2a2a;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
position: relative;
|
|
||||||
border-left: 4px solid transparent;
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation only for newly added items */
|
|
||||||
.queue-item-new {
|
|
||||||
animation: fadeIn 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(5px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-item:hover {
|
|
||||||
background-color: #333;
|
|
||||||
transform: translateY(-5px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Title text in a queue item */
|
|
||||||
.queue-item .title {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Type indicator (e.g. track, album) */
|
|
||||||
.queue-item .type {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #1DB954;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.7px;
|
|
||||||
font-weight: 600;
|
|
||||||
background-color: rgba(29, 185, 84, 0.1);
|
|
||||||
padding: 3px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: inline-block;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Album type - for better visual distinction */
|
|
||||||
.queue-item .type.album {
|
|
||||||
color: #4a90e2;
|
|
||||||
background-color: rgba(74, 144, 226, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track type */
|
|
||||||
.queue-item .type.track {
|
|
||||||
color: #1DB954;
|
|
||||||
background-color: rgba(29, 185, 84, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Playlist type */
|
|
||||||
.queue-item .type.playlist {
|
|
||||||
color: #e67e22;
|
|
||||||
background-color: rgba(230, 126, 34, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Log text for status messages */
|
|
||||||
.queue-item .log {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #b3b3b3;
|
|
||||||
line-height: 1.4;
|
|
||||||
font-family: 'SF Mono', Menlo, monospace;
|
|
||||||
padding: 8px 0;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optional state indicators for each queue item */
|
|
||||||
.queue-item--complete,
|
|
||||||
.queue-item.download-success {
|
|
||||||
border-left-color: #1DB954;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-item--error {
|
|
||||||
border-left-color: #ff5555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-item--processing {
|
|
||||||
border-left-color: #4a90e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress bar for downloads */
|
|
||||||
.status-bar {
|
|
||||||
height: 3px;
|
|
||||||
background: #1DB954;
|
|
||||||
width: 0;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
margin-top: 8px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Overall progress container for albums and playlists */
|
|
||||||
.overall-progress-container {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
position: relative; /* Positioning context for z-index */
|
|
||||||
z-index: 2; /* Ensure overall progress appears above track progress */
|
|
||||||
}
|
|
||||||
|
|
||||||
.overall-progress-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #b3b3b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overall-progress-label {
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overall-progress-count {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1DB954;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overall-progress-bar-container {
|
|
||||||
height: 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overall-progress-bar {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, #4a90e2, #7a67ee); /* Changed to blue-purple gradient */
|
|
||||||
width: 0;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overall-progress-bar.complete {
|
|
||||||
background: #4a90e2; /* Changed to solid blue for completed overall progress */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track progress bar container */
|
|
||||||
.track-progress-bar-container {
|
|
||||||
height: 4px;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1; /* Ensure it's below the overall progress */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Track progress bar */
|
|
||||||
.track-progress-bar {
|
|
||||||
height: 100%;
|
|
||||||
background: #1DB954; /* Keep green for track-level progress */
|
|
||||||
width: 0;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
box-shadow: 0 0 3px rgba(29, 185, 84, 0.5); /* Add subtle glow to differentiate */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Complete state for track progress */
|
|
||||||
/* Real-time progress style */
|
|
||||||
.track-progress-bar.real-time {
|
|
||||||
background: #1DB954; /* Vivid green for real-time progress */
|
|
||||||
background: #1DB954;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pulsing animation for indeterminate progress */
|
|
||||||
.track-progress-bar.progress-pulse {
|
|
||||||
background: linear-gradient(90deg, #1DB954 0%, #2cd267 50%, #1DB954 100%); /* Keep in green family */
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: progress-pulse-slide 1.5s ease infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes progress-pulse-slide {
|
|
||||||
0% { background-position: 0% 50%; }
|
|
||||||
50% { background-position: 100% 50%; }
|
|
||||||
100% { background-position: 0% 50%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress percentage text */
|
|
||||||
.progress-percent {
|
|
||||||
text-align: right;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #1DB954;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optional status message colors (if using state classes) */
|
|
||||||
.log--success {
|
|
||||||
color: #1DB954 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log--error {
|
|
||||||
color: #ff5555 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log--warning {
|
|
||||||
color: #ffaa00 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log--info {
|
|
||||||
color: #4a90e2 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loader animations for real-time progress */
|
|
||||||
@keyframes progress-pulse {
|
|
||||||
0% { opacity: 0.5; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
100% { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-indicator {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 8px;
|
|
||||||
animation: progress-pulse 1.5s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading spinner style */
|
|
||||||
.loading-spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-radius: 50%;
|
|
||||||
border-top-color: #1DB954;
|
|
||||||
animation: spin 1s ease-in-out infinite;
|
|
||||||
margin-right: 6px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner.small {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-width: 1px;
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cancel button inside each queue item */
|
|
||||||
.cancel-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 5px;
|
|
||||||
outline: none;
|
|
||||||
margin-top: 10px;
|
|
||||||
/* Optionally constrain the overall size */
|
|
||||||
max-width: 24px;
|
|
||||||
max-height: 24px;
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn img {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
filter: invert(1);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn:hover img {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancel-btn:active img {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Group header for multiple albums from same artist */
|
|
||||||
.queue-group-header {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #b3b3b3;
|
|
||||||
margin: 15px 0 10px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-group-header span {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-group-header span::before {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #1DB954;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------- */
|
|
||||||
/* FOOTER & "SHOW MORE" BUTTON */
|
|
||||||
/* ------------------------------- */
|
|
||||||
|
|
||||||
#queueFooter {
|
|
||||||
text-align: center;
|
|
||||||
padding-top: 15px;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#queueFooter button {
|
|
||||||
background: #1DB954;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 18px;
|
|
||||||
border-radius: 20px;
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#queueFooter button:hover {
|
|
||||||
background: #17a448;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#queueFooter button:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------- */
|
|
||||||
/* ERROR BUTTONS STYLES */
|
|
||||||
/* -------------------------- */
|
|
||||||
|
|
||||||
/* Container for error action buttons */
|
|
||||||
.error-buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ----------------------------- */
|
|
||||||
/* DOWNLOAD SUMMARY ICONS */
|
|
||||||
/* ----------------------------- */
|
|
||||||
|
|
||||||
/* Base styles for all summary icons */
|
|
||||||
.summary-icon {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-right: 4px;
|
|
||||||
margin-top: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Download summary formatting */
|
|
||||||
.download-summary {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-line {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-line span {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Specific icon background colors */
|
|
||||||
.summary-line span:nth-child(2) {
|
|
||||||
background: rgba(29, 185, 84, 0.1); /* Success background */
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-line span:nth-child(3) {
|
|
||||||
background: rgba(230, 126, 34, 0.1); /* Skip background */
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-line span:nth-child(4) {
|
|
||||||
background: rgba(255, 85, 85, 0.1); /* Failed background */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Failed tracks list styling */
|
|
||||||
.failed-tracks-title {
|
|
||||||
color: #ff5555;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 10px 0 5px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.failed-tracks-list {
|
|
||||||
list-style-type: none;
|
|
||||||
padding-left: 10px;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #b3b3b3;
|
|
||||||
max-height: 100px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.failed-tracks-list li {
|
|
||||||
padding: 3px 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.failed-tracks-list li::before {
|
|
||||||
content: "•";
|
|
||||||
color: #ff5555;
|
|
||||||
position: absolute;
|
|
||||||
left: -10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base styles for error buttons */
|
|
||||||
.error-buttons button {
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover state for all error buttons */
|
|
||||||
.error-buttons button:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-buttons button:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Specific styles for the Close (X) error button */
|
|
||||||
.close-error-btn {
|
|
||||||
background-color: #333;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-error-btn:hover {
|
|
||||||
background-color: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Specific styles for the Retry button */
|
|
||||||
.retry-btn {
|
|
||||||
background-color: #ff5555;
|
|
||||||
color: #fff;
|
|
||||||
padding: 6px 15px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry-btn:hover {
|
|
||||||
background-color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty queue state */
|
|
||||||
.queue-empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 200px;
|
|
||||||
color: #b3b3b3;
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-empty img {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-empty p {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error notification in queue */
|
|
||||||
.queue-error {
|
|
||||||
background-color: rgba(192, 57, 43, 0.1);
|
|
||||||
color: #ff5555;
|
|
||||||
padding: 10px 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
font-size: 14px;
|
|
||||||
border-left: 3px solid #ff5555;
|
|
||||||
animation: fadeIn 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error state styling */
|
|
||||||
.queue-item.error {
|
|
||||||
border-left: 4px solid #ff5555;
|
|
||||||
background-color: rgba(255, 85, 85, 0.05);
|
|
||||||
transition: none !important; /* Remove all transitions */
|
|
||||||
transform: none !important; /* Prevent any transform */
|
|
||||||
position: relative !important; /* Keep normal positioning */
|
|
||||||
left: 0 !important; /* Prevent any left movement */
|
|
||||||
right: 0 !important; /* Prevent any right movement */
|
|
||||||
top: 0 !important; /* Prevent any top movement */
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-item.error:hover {
|
|
||||||
background-color: rgba(255, 85, 85, 0.1);
|
|
||||||
transform: none !important; /* Force disable any transform */
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2) !important; /* Keep original shadow */
|
|
||||||
position: relative !important; /* Force normal positioning */
|
|
||||||
left: 0 !important; /* Prevent any left movement */
|
|
||||||
right: 0 !important; /* Prevent any right movement */
|
|
||||||
top: 0 !important; /* Prevent any top movement */
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
color: #ff5555;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------- */
|
|
||||||
/* MOBILE RESPONSIVE ADJUSTMENTS */
|
|
||||||
/* ------------------------------- */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
/* Make the sidebar full width on mobile */
|
|
||||||
#downloadQueue {
|
|
||||||
width: 100%;
|
|
||||||
right: -100%; /* Off-screen fully */
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* When active, the sidebar slides into view from full width */
|
|
||||||
#downloadQueue.active {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Adjust header and title for smaller screens */
|
|
||||||
.sidebar-header {
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header h2 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reduce the size of the close buttons */
|
|
||||||
.close-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Adjust queue items padding */
|
|
||||||
.queue-item {
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure text remains legible on smaller screens */
|
|
||||||
.queue-item .log,
|
|
||||||
.queue-item .type {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#cancelAllBtn {
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-buttons {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-error-btn {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry-btn {
|
|
||||||
padding: 6px 12px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Download History</title>
|
|
||||||
<!-- Link to global stylesheets first -->
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/base.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
|
|
||||||
<!-- Link to page-specific stylesheet -->
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/history/history.css') }}">
|
|
||||||
<!-- Helper function for image errors, if not already in base.css or loaded globally -->
|
|
||||||
<script>
|
|
||||||
function handleImageError(img) {
|
|
||||||
img.onerror = null; // Prevent infinite loop if placeholder also fails
|
|
||||||
img.src = "{{ url_for('static', filename='images/placeholder.jpg') }}";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1 id="history-title">Download History</h1>
|
|
||||||
|
|
||||||
<div class="filters">
|
|
||||||
<label for="status-filter">Status:</label>
|
|
||||||
<select id="status-filter">
|
|
||||||
<option value="">All</option>
|
|
||||||
<option value="COMPLETED">Completed</option>
|
|
||||||
<option value="ERROR">Error</option>
|
|
||||||
<option value="CANCELLED">Cancelled</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="type-filter">Type:</label>
|
|
||||||
<select id="type-filter">
|
|
||||||
<option value="">All</option>
|
|
||||||
<option value="track">Track</option>
|
|
||||||
<option value="album">Album</option>
|
|
||||||
<option value="playlist">Playlist</option>
|
|
||||||
<option value="artist">Artist</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="track-filter">Track Status:</label>
|
|
||||||
<select id="track-filter">
|
|
||||||
<option value="">All</option>
|
|
||||||
<option value="SUCCESSFUL">Successful</option>
|
|
||||||
<option value="SKIPPED">Skipped</option>
|
|
||||||
<option value="FAILED">Failed</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div class="checkbox-filter">
|
|
||||||
<input type="checkbox" id="hide-child-tracks" />
|
|
||||||
<label for="hide-child-tracks">Hide Individual Tracks</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th data-sort="item_name">Name</th>
|
|
||||||
<th data-sort="item_artist">Artist</th>
|
|
||||||
<th data-sort="download_type">Type/Status</th>
|
|
||||||
<th data-sort="service_used">Service</th>
|
|
||||||
<th data-sort="quality_profile">Quality</th>
|
|
||||||
<th data-sort="status_final">Status</th>
|
|
||||||
<th data-sort="timestamp_added">Date Added</th>
|
|
||||||
<th data-sort="timestamp_completed">Date Completed/Ended</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="history-table-body">
|
|
||||||
<!-- Rows will be inserted here by JavaScript -->
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div class="pagination">
|
|
||||||
<button id="prev-page" disabled>Previous</button>
|
|
||||||
<span id="page-info">Page 1 of 1</span>
|
|
||||||
<button id="next-page" disabled>Next</button>
|
|
||||||
<select id="limit-select">
|
|
||||||
<option value="10">10 per page</option>
|
|
||||||
<option value="25" selected>25 per page</option>
|
|
||||||
<option value="50">50 per page</option>
|
|
||||||
<option value="100">100 per page</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fixed floating buttons for home and queue -->
|
|
||||||
<a href="/" class="btn-icon home-btn floating-icon" aria-label="Return to home" title="Go to Home">
|
|
||||||
<img src="{{ url_for('static', filename='images/home.svg') }}" alt="Home" onerror="handleImageError(this)"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Link to the new TypeScript file (compiled to JS) -->
|
|
||||||
<script type="module" src="{{ url_for('static', filename='js/history.js') }}"></script>
|
|
||||||
<!-- Queue icon, assuming queue.js handles its own initialization if included -->
|
|
||||||
<!-- You might want to include queue.js here if the queue icon is desired on this page -->
|
|
||||||
<!-- <script type="module" src="{{ url_for('static', filename='js/queue.js') }}"></script> -->
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2017",
|
|
||||||
"module": "ES2020",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"outDir": "./static/js",
|
|
||||||
"rootDir": "./src/js"
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/js/**/*.ts",
|
|
||||||
"src/js/album.ts",
|
|
||||||
"src/js/artist.ts",
|
|
||||||
"src/js/config.ts",
|
|
||||||
"src/js/main.ts",
|
|
||||||
"src/js/playlist.ts",
|
|
||||||
"src/js/queue.ts",
|
|
||||||
"src/js/track.ts"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user