diff --git a/spotizerr-ui/src/routes/history.tsx b/spotizerr-ui/src/routes/history.tsx index 9b585ef..ffef6b9 100644 --- a/spotizerr-ui/src/routes/history.tsx +++ b/spotizerr-ui/src/routes/history.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState, useMemo, useCallback } from "react"; import apiClient from "../lib/api-client"; import { toast } from "sonner"; import { @@ -15,6 +15,7 @@ type HistoryEntry = { task_id: string; item_name: string; item_artist: string; + item_url?: string; download_type: "track" | "album" | "playlist" | "artist"; service_used: string; quality_profile: string; @@ -30,6 +31,50 @@ type HistoryEntry = { total_failed?: number; }; +const STATUS_CLASS: Record = { + COMPLETED: "text-green-500", + ERROR: "text-red-500", + CANCELLED: "text-gray-500", + SKIPPED: "text-yellow-500", +}; + +const QUALITY_MAP: Record> = { + spotify: { + NORMAL: "OGG 96k", + HIGH: "OGG 160k", + VERY_HIGH: "OGG 320k", + }, + deezer: { + MP3_128: "MP3 128k", + MP3_320: "MP3 320k", + FLAC: "FLAC (Hi-Res)", + }, +}; + +const getDownloadSource = (entry: HistoryEntry): "Spotify" | "Deezer" | "Unknown" => { + const url = entry.item_url?.toLowerCase() || ""; + const service = entry.service_used?.toLowerCase() || ""; + if (url.includes("spotify.com")) return "Spotify"; + if (url.includes("deezer.com")) return "Deezer"; + if (service.includes("spotify")) return "Spotify"; + if (service.includes("deezer")) return "Deezer"; + return "Unknown"; +}; + +const formatQuality = (entry: HistoryEntry): string => { + const sourceName = getDownloadSource(entry).toLowerCase(); + const profile = entry.quality_profile || "N/A"; + const sourceQuality = sourceName !== "unknown" ? QUALITY_MAP[sourceName]?.[profile] || profile : profile; + let qualityDisplay = sourceQuality; + if (entry.convert_to && entry.convert_to !== "None") { + qualityDisplay += ` → ${entry.convert_to.toUpperCase()}`; + if (entry.bitrate && entry.bitrate !== "None") { + qualityDisplay += ` ${entry.bitrate}`; + } + } + return qualityDisplay; +}; + // --- Column Definitions --- const columnHelper = createColumnHelper(); @@ -49,14 +94,23 @@ export const History = () => { const [statusFilter, setStatusFilter] = useState(""); const [typeFilter, setTypeFilter] = useState(""); const [trackStatusFilter, setTrackStatusFilter] = useState(""); - const [hideChildTracks, setHideChildTracks] = useState(true); + const [showChildTracks, setShowChildTracks] = useState(false); const [parentTaskId, setParentTaskId] = useState(null); + const [parentTask, setParentTask] = useState(null); const pagination = useMemo(() => ({ pageIndex, pageSize }), [pageIndex, pageSize]); - const viewTracksForParent = (taskId: string) => { - setParentTaskId(taskId); - }; + const viewTracksForParent = useCallback( + (parentEntry: HistoryEntry) => { + setPagination({ pageIndex: 0, pageSize }); + setParentTaskId(parentEntry.task_id); + setParentTask(parentEntry); + setStatusFilter(""); + setTypeFilter(""); + setTrackStatusFilter(""); + }, + [pageSize], + ); const columns = useMemo( () => [ @@ -64,7 +118,7 @@ export const History = () => { header: "Name", cell: (info) => info.row.original.parent_task_id ? ( - └─ {info.getValue()} + └─ {info.getValue()} ) : ( {info.getValue()} ), @@ -72,105 +126,83 @@ export const History = () => { columnHelper.accessor("item_artist", { header: "Artist" }), columnHelper.accessor("download_type", { header: "Type", - cell: (info) => { - const entry = info.row.original; - if (entry.parent_task_id && entry.track_status) { - const statusClass = { - SUCCESSFUL: "text-green-500", - SKIPPED: "text-yellow-500", - FAILED: "text-red-500", - }[entry.track_status]; - return ( - {entry.track_status.toLowerCase()} - ); - } - return {info.getValue()}; - }, + cell: (info) => {info.getValue()}, }), columnHelper.accessor("quality_profile", { header: "Quality", - cell: (info) => { - const entry = info.row.original; - let qualityDisplay = entry.quality_profile || "N/A"; - - if (entry.convert_to && entry.convert_to !== "None") { - qualityDisplay = `${entry.convert_to.toUpperCase()}`; - if (entry.bitrate && entry.bitrate !== "None") { - qualityDisplay += ` ${entry.bitrate}k`; - } - qualityDisplay += ` (${entry.quality_profile || "Original"})`; - } else if (entry.bitrate && entry.bitrate !== "None") { - qualityDisplay = `${entry.bitrate}k (${entry.quality_profile || "Profile"})`; - } - return qualityDisplay; - }, + cell: (info) => formatQuality(info.row.original), }), columnHelper.accessor("status_final", { header: "Status", cell: (info) => { - const status = info.getValue(); - const statusClass = { - COMPLETED: "text-green-500", - ERROR: "text-red-500", - CANCELLED: "text-gray-500", - SKIPPED: "text-yellow-500", - }[status]; + const entry = info.row.original; + const status = entry.parent_task_id ? entry.track_status : entry.status_final; + const statusKey = (status || "").toUpperCase(); + const statusClass = + { + COMPLETED: "text-green-500", + SUCCESSFUL: "text-green-500", + ERROR: "text-red-500", + FAILED: "text-red-500", + CANCELLED: "text-gray-500", + SKIPPED: "text-yellow-500", + }[statusKey] || "text-gray-500"; + return {status}; }, }), + columnHelper.accessor("item_url", { + id: "source", + header: parentTaskId ? "Download Source" : "Search Source", + cell: (info) => getDownloadSource(info.row.original), + }), columnHelper.accessor("timestamp_completed", { header: "Date Completed", cell: (info) => new Date(info.getValue() * 1000).toLocaleString(), }), - columnHelper.accessor("error_message", { - header: "Details", - cell: (info) => - info.getValue() ? ( - - ) : null, - }), - columnHelper.display({ - id: "actions", - header: "Actions", - cell: ({ row }) => { - const entry = row.original; - if (!entry.parent_task_id && (entry.download_type === "album" || entry.download_type === "playlist")) { - const hasChildren = - (entry.total_successful ?? 0) > 0 || (entry.total_skipped ?? 0) > 0 || (entry.total_failed ?? 0) > 0; - if (hasChildren) { - return ( -
- - - {entry.total_successful ?? 0} /{" "} - {entry.total_skipped ?? 0} /{" "} - {entry.total_failed ?? 0} - -
- ); - } - } - return null; - }, - }), + ...(!parentTaskId + ? [ + columnHelper.display({ + id: "actions", + header: "Actions", + cell: ({ row }) => { + const entry = row.original; + if (!entry.parent_task_id && (entry.download_type === "album" || entry.download_type === "playlist")) { + const hasChildren = + (entry.total_successful ?? 0) > 0 || + (entry.total_skipped ?? 0) > 0 || + (entry.total_failed ?? 0) > 0; + if (hasChildren) { + return ( +
+ + + {entry.total_successful ?? 0} /{" "} + {entry.total_skipped ?? 0} /{" "} + {entry.total_failed ?? 0} + +
+ ); + } + } + return null; + }, + }), + ] + : []), ], - [], + [viewTracksForParent, parentTaskId], ); useEffect(() => { const fetchHistory = async () => { setIsLoading(true); + setData([]); try { const params = new URLSearchParams({ limit: `${pageSize}`, @@ -181,15 +213,57 @@ export const History = () => { if (statusFilter) params.append("status_final", statusFilter); if (typeFilter) params.append("download_type", typeFilter); if (trackStatusFilter) params.append("track_status", trackStatusFilter); - if (hideChildTracks) params.append("hide_child_tracks", "true"); + if (!parentTaskId && !showChildTracks) { + params.append("hide_child_tracks", "true"); + } if (parentTaskId) params.append("parent_task_id", parentTaskId); const response = await apiClient.get<{ entries: HistoryEntry[]; total_count: number; }>(`/history?${params.toString()}`); - setData(response.data.entries); - setTotalEntries(response.data.total_count); + + const originalEntries = response.data.entries; + let processedEntries = originalEntries; + + // If including child tracks in the main history, group them with their parents + if (showChildTracks && !parentTaskId) { + const parents = originalEntries.filter((e) => !e.parent_task_id); + const childrenByParentId = originalEntries + .filter((e) => e.parent_task_id) + .reduce( + (acc, child) => { + const parentId = child.parent_task_id!; + if (!acc[parentId]) { + acc[parentId] = []; + } + acc[parentId].push(child); + return acc; + }, + {} as Record, + ); + + const groupedEntries: HistoryEntry[] = []; + parents.forEach((parent) => { + groupedEntries.push(parent); + const children = childrenByParentId[parent.task_id]; + if (children) { + groupedEntries.push(...children); + } + }); + processedEntries = groupedEntries; + } + + // If viewing child tracks for a specific parent, filter out the parent entry from the list + const finalEntries = parentTaskId + ? processedEntries.filter((entry) => entry.task_id !== parentTaskId) + : processedEntries; + + setData(finalEntries); + + // Adjust total count to reflect filtered entries for accurate pagination + const numFiltered = originalEntries.length - finalEntries.length; + setTotalEntries(response.data.total_count - numFiltered); } catch { toast.error("Failed to load history."); } finally { @@ -197,7 +271,7 @@ export const History = () => { } }; fetchHistory(); - }, [pageIndex, pageSize, sorting, statusFilter, typeFilter, trackStatusFilter, hideChildTracks, parentTaskId]); + }, [pageIndex, pageSize, sorting, statusFilter, typeFilter, trackStatusFilter, showChildTracks, parentTaskId]); const table = useReactTable({ data, @@ -216,62 +290,105 @@ export const History = () => { setStatusFilter(""); setTypeFilter(""); setTrackStatusFilter(""); - setHideChildTracks(true); + setShowChildTracks(false); }; const viewParentTask = () => { + setPagination({ pageIndex: 0, pageSize }); setParentTaskId(null); + setParentTask(null); clearFilters(); }; return (
-

Download History

- {parentTaskId && ( - + {parentTaskId && parentTask ? ( +
+ +
+
+
+

{parentTask.item_name}

+

{parentTask.item_artist}

+
+ + {parentTask.download_type} + +
+
+
+
+ {parentTask.status_final} +
+

+ Quality: + {formatQuality(parentTask)} +

+

+ Completed: + {new Date(parentTask.timestamp_completed * 1000).toLocaleString()} +

+
+
+
+

Tracks

+
+ ) : ( +

Download History

)} {/* Filter Controls */} -
- - - - -
+ {!parentTaskId && ( +
+ + + + +
+ )} {/* Table */}
@@ -316,12 +433,17 @@ export const History = () => { !row.original.parent_task_id && (row.original.download_type === "album" || row.original.download_type === "playlist"); const isChild = !!row.original.parent_task_id; - const rowClass = isParent ? "bg-gray-800 font-semibold" : isChild ? "bg-gray-900" : ""; + let rowClass = "hover:bg-muted/50"; + if (isParent) { + rowClass += " bg-muted/50 font-semibold hover:bg-muted"; + } else if (isChild) { + rowClass += " border-t border-dashed border-muted-foreground/20"; + } return ( - + {row.getVisibleCells().map((cell) => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))}