From 35e14b1ee85ddf0d2c9c557a65f49ebd6ab91e1c Mon Sep 17 00:00:00 2001 From: lklynet Date: Mon, 23 Feb 2026 21:25:27 -0500 Subject: [PATCH] fix(requests): improve download status detection and add re-search feature - Fix detection of failed downloads by checking multiple fields for failure indicators - Mark stale grabbed items (15+ minutes old) as failed to prevent indefinite processing - Add re-search button for failed requests to trigger manual search - Optimize requests endpoint with caching and lazy fetching of album/artist details - Fix frontend polling to only fetch status for active albums instead of all --- backend/routes/library/handlers/downloads.js | 80 +++++- backend/routes/requests.js | 261 +++++++++++-------- frontend/src/pages/RequestsPage.jsx | 147 +++++++++-- 3 files changed, 352 insertions(+), 136 deletions(-) diff --git a/backend/routes/library/handlers/downloads.js b/backend/routes/library/handlers/downloads.js index d086746..f425aa8 100644 --- a/backend/routes/library/handlers/downloads.js +++ b/backend/routes/library/handlers/downloads.js @@ -7,6 +7,8 @@ import { } from "../../../middleware/requirePermission.js"; import { hasPermission } from "../../../middleware/auth.js"; +const STALE_GRABBED_MS = 15 * 60 * 1000; + export default function registerDownloads(router) { router.post( "/downloads/album", @@ -260,6 +262,21 @@ export default function registerDownloads(router) { } } + const latestHistoryByAlbumId = new Map(); + for (const h of historyItems) { + if (h?.albumId == null) continue; + const historyTime = new Date( + h?.date || h?.eventDate || 0, + ).getTime(); + const existing = latestHistoryByAlbumId.get(h.albumId); + if (!existing || historyTime > existing.historyTime) { + latestHistoryByAlbumId.set(h.albumId, { + history: h, + historyTime, + }); + } + } + for (const albumId of albumIdArray) { if (!albumId || albumId === "undefined" || albumId === "null") continue; @@ -346,9 +363,9 @@ export default function registerDownloads(router) { continue; } - const recentHistory = historyItems.find( - (h) => h.albumId === lidarrAlbumId, - ); + const historyEntry = latestHistoryByAlbumId.get(lidarrAlbumId); + const recentHistory = historyEntry?.history; + const historyTime = historyEntry?.historyTime ?? 0; if (recentHistory) { const eventType = String( @@ -363,6 +380,22 @@ export default function registerDownloads(router) { const errorMessage = String( data?.errorMessage || "", ).toLowerCase(); + const sourceTitle = String( + recentHistory?.sourceTitle || "", + ).toLowerCase(); + const dataString = JSON.stringify(data).toLowerCase(); + const isGrabbed = + eventType.includes("grabbed") || + sourceTitle.includes("grabbed") || + dataString.includes("grabbed"); + const isFailedDownload = + eventType.includes("fail") || + statusMessages.includes("fail") || + statusMessages.includes("error") || + errorMessage.includes("fail") || + errorMessage.includes("error") || + sourceTitle.includes("fail") || + dataString.includes("fail"); const isFailedImport = eventType === "albumimportincomplete" || eventType.includes("incomplete") || @@ -375,10 +408,13 @@ export default function registerDownloads(router) { eventType.includes("import") && !isFailedImport && eventType !== "albumimportincomplete"; + const isStaleGrabbed = + isGrabbed && + Date.now() - historyTime > STALE_GRABBED_MS; statuses[albumId] = { status: isComplete ? "added" - : isFailedImport + : isFailedImport || isFailedDownload || isStaleGrabbed ? "failed" : "processing", updatedAt: new Date().toISOString(), @@ -461,8 +497,15 @@ export default function registerDownloads(router) { const historyByAlbumId = new Map(); for (const h of historyItems) { if (h?.albumId == null) continue; - if (!historyByAlbumId.has(h.albumId)) { - historyByAlbumId.set(h.albumId, h); + const historyTime = new Date( + h?.date || h?.eventDate || 0, + ).getTime(); + const existing = historyByAlbumId.get(h.albumId); + if (!existing || historyTime > existing.historyTime) { + historyByAlbumId.set(h.albumId, { + history: h, + historyTime, + }); } } @@ -546,7 +589,9 @@ export default function registerDownloads(router) { continue; } - const recentHistory = historyByAlbumId.get(lidarrAlbumId); + const historyEntry = historyByAlbumId.get(lidarrAlbumId); + const recentHistory = historyEntry?.history; + const historyTime = historyEntry?.historyTime ?? 0; if (recentHistory) { const eventType = String( @@ -561,6 +606,22 @@ export default function registerDownloads(router) { const errorMessage = String( data?.errorMessage || "", ).toLowerCase(); + const sourceTitle = String( + recentHistory?.sourceTitle || "", + ).toLowerCase(); + const dataString = JSON.stringify(data).toLowerCase(); + const isGrabbed = + eventType.includes("grabbed") || + sourceTitle.includes("grabbed") || + dataString.includes("grabbed"); + const isFailedDownload = + eventType.includes("fail") || + statusMessages.includes("fail") || + statusMessages.includes("error") || + errorMessage.includes("fail") || + errorMessage.includes("error") || + sourceTitle.includes("fail") || + dataString.includes("fail"); const isFailedImport = eventType === "albumimportincomplete" || eventType.includes("incomplete") || @@ -573,6 +634,9 @@ export default function registerDownloads(router) { eventType.includes("import") && !isFailedImport && eventType !== "albumimportincomplete"; + const isStaleGrabbed = + isGrabbed && + Date.now() - historyTime > STALE_GRABBED_MS; const historyDate = new Date( recentHistory.date || recentHistory.eventDate || 0, ); @@ -582,7 +646,7 @@ export default function registerDownloads(router) { allStatuses[String(lidarrAlbumId)] = { status: isComplete ? "added" - : isFailedImport + : isFailedImport || isFailedDownload || isStaleGrabbed ? "failed" : "processing", updatedAt: new Date().toISOString(), diff --git a/backend/routes/requests.js b/backend/routes/requests.js index 760be33..4eee6e9 100644 --- a/backend/routes/requests.js +++ b/backend/routes/requests.js @@ -4,6 +4,10 @@ import { noCache } from "../middleware/cache.js"; const router = express.Router(); const dismissedAlbumIds = new Set(); +const REQUESTS_CACHE_MS = 15000; +const STALE_GRABBED_MS = 15 * 60 * 1000; +let lastRequestsResponse = null; +let lastRequestsAt = 0; const toIso = (value) => { if (!value) return new Date().toISOString(); @@ -23,83 +27,36 @@ router.get("/", noCache, async (req, res) => { return res.json([]); } - const [queue, history, artists, albums] = await Promise.all([ + const now = Date.now(); + if (lastRequestsResponse && now - lastRequestsAt < REQUESTS_CACHE_MS) { + return res.json(lastRequestsResponse); + } + + const [queue, history] = await Promise.all([ lidarrClient.getQueue().catch(() => []), lidarrClient.getHistory(1, 200).catch(() => ({ records: [] })), - lidarrClient.request("/artist").catch(() => []), - lidarrClient.request("/album").catch(() => []), ]); - const artistById = new Map( - (Array.isArray(artists) ? artists : []).map((a) => [ - a.id, - { - id: a.id, - artistName: a.artistName, - foreignArtistId: a.foreignArtistId || a.mbid || null, - }, - ]), - ); - - const albumById = new Map( - (Array.isArray(albums) ? albums : []).map((album) => [album.id, album]), - ); - - const normalizePercent = (value) => { - if (value === undefined || value === null) return 0; - const raw = Number(value); - if (Number.isNaN(raw)) return 0; - if (raw > 1 && raw <= 100) return Math.round(raw); - if (raw >= 0 && raw <= 1) return Math.round(raw * 100); - if (raw > 100) return Math.min(100, Math.round(raw / 10)); - return 0; - }; - - const isAlbumAvailable = (album) => { - if (!album) return false; - const stats = album.statistics || {}; - const percent = normalizePercent(stats.percentOfTracks); - const size = Number(stats.sizeOnDisk || 0); - return percent >= 100 || size > 0; - }; - const requestsByAlbumId = new Map(); const queueItems = Array.isArray(queue) ? queue : queue?.records || []; + const queueByAlbumId = new Map(); for (const item of queueItems) { const albumId = item?.albumId ?? item?.album?.id; if (albumId == null) continue; + queueByAlbumId.set(String(albumId), item); + } - const artistId = item?.artistId ?? item?.artist?.id ?? item?.album?.artistId; - const artistInfo = artistId != null ? artistById.get(artistId) : null; + for (const item of queueItems) { + const albumId = item?.albumId ?? item?.album?.id; + if (albumId == null) continue; const albumName = item?.album?.title || item?.title || "Album"; - const artistName = - item?.artist?.artistName || artistInfo?.artistName || "Artist"; + const artistName = item?.artist?.artistName || "Artist"; let artistMbid = null; - if (artistId && artistById.has(artistId)) { - artistMbid = artistById.get(artistId).foreignArtistId || null; - } - - if (!artistMbid) { - artistMbid = item?.artist?.foreignArtistId || null; - } - - if (!artistMbid && artistInfo) { - artistMbid = artistInfo.foreignArtistId || null; - } - - if (!artistMbid && artistId) { - try { - const { libraryManager } = await import("../services/libraryManager.js"); - const libraryArtist = await libraryManager.getArtistById(artistId); - if (libraryArtist) { - artistMbid = libraryArtist.foreignArtistId || libraryArtist.mbid || null; - } - } catch {} - } + artistMbid = item?.artist?.foreignArtistId || null; const queueStatus = String(item.status || "").toLowerCase(); const title = String(item.title || "").toLowerCase(); @@ -133,7 +90,7 @@ router.get("/", noCache, async (req, res) => { albumId: String(albumId), albumMbid: item?.album?.foreignAlbumId || null, albumName, - artistId: artistId != null ? String(artistId) : null, + artistId: item?.artist?.id != null ? String(item.artist.id) : null, artistMbid, artistName, status, @@ -151,43 +108,32 @@ router.get("/", noCache, async (req, res) => { ? history : []; + const latestHistoryByAlbum = new Map(); for (const record of historyRecords) { const albumId = record?.albumId; if (albumId == null) continue; + const recordTime = new Date( + record?.date || record?.eventDate || 0, + ).getTime(); + const existing = latestHistoryByAlbum.get(String(albumId)); + if (!existing || recordTime > existing.recordTime) { + latestHistoryByAlbum.set(String(albumId), { + record, + recordTime, + }); + } + } + for (const [albumId, { record, recordTime }] of latestHistoryByAlbum) { const existing = requestsByAlbumId.get(String(albumId)); if (existing) continue; - const artistId = record?.artistId; - const artistInfo = artistId != null ? artistById.get(artistId) : null; - const albumName = record?.album?.title || record?.sourceTitle || "Album"; - const artistName = - record?.artist?.artistName || artistInfo?.artistName || "Artist"; + const artistName = record?.artist?.artistName || "Artist"; let artistMbid = null; - if (artistId && artistById.has(artistId)) { - artistMbid = artistById.get(artistId).foreignArtistId || null; - } - - if (!artistMbid) { - artistMbid = record?.artist?.foreignArtistId || null; - } - - if (!artistMbid && artistInfo) { - artistMbid = artistInfo.foreignArtistId || null; - } - - if (!artistMbid && artistId) { - try { - const { libraryManager } = await import("../services/libraryManager.js"); - const libraryArtist = await libraryManager.getArtistById(artistId); - if (libraryArtist) { - artistMbid = libraryArtist.foreignArtistId || libraryArtist.mbid || null; - } - } catch {} - } + artistMbid = record?.artist?.foreignArtistId || null; const eventType = String(record?.eventType || "").toLowerCase(); const data = record?.data || {}; @@ -197,6 +143,19 @@ router.get("/", noCache, async (req, res) => { const errorMessage = String(data?.errorMessage || "").toLowerCase(); const sourceTitle = String(record?.sourceTitle || "").toLowerCase(); const dataString = JSON.stringify(data).toLowerCase(); + const hasQueue = queueByAlbumId.has(String(albumId)); + const isGrabbed = + eventType.includes("grabbed") || + sourceTitle.includes("grabbed") || + dataString.includes("grabbed"); + const isFailedDownload = + eventType.includes("fail") || + statusMessages.includes("fail") || + statusMessages.includes("error") || + errorMessage.includes("fail") || + errorMessage.includes("error") || + sourceTitle.includes("fail") || + dataString.includes("fail"); const isFailedImport = eventType === "albumimportincomplete" || @@ -211,15 +170,17 @@ router.get("/", noCache, async (req, res) => { dataString.includes("import fail"); const isSuccessfulImport = eventType.includes("import") && !isFailedImport && eventType !== "albumimportincomplete"; - const lidarrAlbum = albumById.get(albumId); - const isCompleteInLibrary = isAlbumAvailable(lidarrAlbum); - const status = isCompleteInLibrary - ? "available" + const isStaleGrabbed = + isGrabbed && !hasQueue && Date.now() - recordTime > STALE_GRABBED_MS; + const status = hasQueue + ? "processing" : isSuccessfulImport - ? "available" - : isFailedImport - ? "failed" - : "processing"; + ? "available" + : isFailedImport || isFailedDownload || isStaleGrabbed + ? "failed" + : isGrabbed + ? "processing" + : "processing"; requestsByAlbumId.set(String(albumId), { id: `lidarr-history-${record.id ?? albumId}`, @@ -227,11 +188,11 @@ router.get("/", noCache, async (req, res) => { albumId: String(albumId), albumMbid: record?.album?.foreignAlbumId || null, albumName, - artistId: artistId != null ? String(artistId) : null, + artistId: record?.artistId != null ? String(record.artistId) : null, artistMbid, artistName, status, - requestedAt: toIso(record?.date), + requestedAt: toIso(record?.date || record?.eventDate), mbid: artistMbid, name: albumName, image: null, @@ -239,15 +200,6 @@ router.get("/", noCache, async (req, res) => { }); } - for (const request of requestsByAlbumId.values()) { - if (request.inQueue) continue; - if (request.status === "available") continue; - const lidarrAlbum = albumById.get(parseInt(request.albumId, 10)); - if (isAlbumAvailable(lidarrAlbum)) { - request.status = "available"; - } - } - let sorted = [...requestsByAlbumId.values()].sort( (a, b) => new Date(b.requestedAt) - new Date(a.requestedAt), ); @@ -255,6 +207,101 @@ router.get("/", noCache, async (req, res) => { (r) => !r.albumId || !dismissedAlbumIds.has(String(r.albumId)), ); + const isPlaceholder = (value, fallback) => { + if (!value) return true; + const normalized = String(value).trim().toLowerCase(); + return normalized === String(fallback).trim().toLowerCase(); + }; + + const missingAlbumIds = new Set(); + const missingArtistIds = new Set(); + + for (const request of sorted) { + if (request.albumId) { + if ( + !request.albumMbid || + isPlaceholder(request.albumName, "Album") || + !request.artistId + ) { + missingAlbumIds.add(String(request.albumId)); + } + } + if (request.artistId) { + if ( + !request.artistMbid || + isPlaceholder(request.artistName, "Artist") + ) { + missingArtistIds.add(String(request.artistId)); + } + } + } + + const albumDetailsById = new Map(); + const artistDetailsById = new Map(); + + if (missingAlbumIds.size > 0) { + const albumIds = Array.from(missingAlbumIds); + const albums = await Promise.all( + albumIds.map((id) => lidarrClient.getAlbum(id).catch(() => null)), + ); + for (let i = 0; i < albumIds.length; i++) { + if (albums[i]) { + albumDetailsById.set(String(albumIds[i]), albums[i]); + if (albums[i]?.artistId != null) { + missingArtistIds.add(String(albums[i].artistId)); + } + } + } + } + + if (missingArtistIds.size > 0) { + const artistIds = Array.from(missingArtistIds); + const artists = await Promise.all( + artistIds.map((id) => lidarrClient.getArtist(id).catch(() => null)), + ); + for (let i = 0; i < artistIds.length; i++) { + if (artists[i]) { + artistDetailsById.set(String(artistIds[i]), artists[i]); + } + } + } + + if (albumDetailsById.size > 0 || artistDetailsById.size > 0) { + sorted = sorted.map((request) => { + const enriched = { ...request }; + if (enriched.albumId && albumDetailsById.has(String(enriched.albumId))) { + const album = albumDetailsById.get(String(enriched.albumId)); + if (album) { + if (!enriched.albumMbid && album.foreignAlbumId) { + enriched.albumMbid = album.foreignAlbumId; + } + if (isPlaceholder(enriched.albumName, "Album") && album.title) { + enriched.albumName = album.title; + enriched.name = album.title; + } + if (!enriched.artistId && album.artistId != null) { + enriched.artistId = String(album.artistId); + } + } + } + if (enriched.artistId && artistDetailsById.has(String(enriched.artistId))) { + const artist = artistDetailsById.get(String(enriched.artistId)); + if (artist) { + if (isPlaceholder(enriched.artistName, "Artist") && artist.artistName) { + enriched.artistName = artist.artistName; + } + if (!enriched.artistMbid && artist.foreignArtistId) { + enriched.artistMbid = artist.foreignArtistId; + enriched.mbid = artist.foreignArtistId; + } + } + } + return enriched; + }); + } + + lastRequestsResponse = sorted; + lastRequestsAt = Date.now(); res.json(sorted); } catch (error) { res.status(500).json({ error: "Failed to fetch requests" }); diff --git a/frontend/src/pages/RequestsPage.jsx b/frontend/src/pages/RequestsPage.jsx index 60f423c..a28b675 100644 --- a/frontend/src/pages/RequestsPage.jsx +++ b/frontend/src/pages/RequestsPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { Loader, @@ -7,8 +7,14 @@ import { AlertCircle, X, Music, + RefreshCw, } from "lucide-react"; -import { getRequests, deleteRequest, getAllDownloadStatus } from "../utils/api"; +import { + getRequests, + deleteRequest, + getDownloadStatus, + triggerAlbumSearch, +} from "../utils/api"; import ArtistImage from "../components/ArtistImage"; import { useToast } from "../contexts/ToastContext"; @@ -17,10 +23,30 @@ function RequestsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [downloadStatuses, setDownloadStatuses] = useState({}); + const [reSearchingAlbumId, setReSearchingAlbumId] = useState(null); const navigate = useNavigate(); - const { showError } = useToast(); + const { showError, showSuccess } = useToast(); + const activeAlbumIdsRef = useRef([]); + + const activeAlbumIds = useMemo(() => { + return requests + .filter( + (request) => + request.albumId && + (request.inQueue || + (request.status && + request.status !== "available" && + request.status !== "failed")), + ) + .map((request) => String(request.albumId)); + }, [requests]); + + const activeAlbumIdsKey = useMemo(() => { + if (!activeAlbumIds.length) return ""; + return [...activeAlbumIds].sort().join(","); + }, [activeAlbumIds]); - const fetchRequests = async ({ silent = false } = {}) => { + const fetchRequests = useCallback(async ({ silent = false } = {}) => { if (!silent) { setLoading(true); } @@ -36,30 +62,38 @@ function RequestsPage() { setLoading(false); } } - }; + }, []); - useEffect(() => { - fetchRequests(); + const fetchActiveDownloadStatus = useCallback(async (albumIds) => { + const ids = Array.isArray(albumIds) + ? albumIds + : activeAlbumIdsRef.current; + if (!ids.length) { + setDownloadStatuses({}); + return; + } + try { + const statuses = await getDownloadStatus(ids); + setDownloadStatuses(statuses || {}); + } catch {} + }, []); - const pollDownloadStatus = async () => { - try { - const statuses = await getAllDownloadStatus(); - setDownloadStatuses(statuses); - } catch {} - }; + useEffect(() => { + activeAlbumIdsRef.current = activeAlbumIds; + }, [activeAlbumIds]); - pollDownloadStatus(); - const interval = setInterval(pollDownloadStatus, 15000); + useEffect(() => { + fetchRequests(); const handleFocus = () => { fetchRequests({ silent: true }); - pollDownloadStatus(); + fetchActiveDownloadStatus(); }; const handleVisibility = () => { if (document.visibilityState === "visible") { fetchRequests({ silent: true }); - pollDownloadStatus(); + fetchActiveDownloadStatus(); } }; @@ -67,11 +101,35 @@ function RequestsPage() { document.addEventListener("visibilitychange", handleVisibility); return () => { - clearInterval(interval); window.removeEventListener("focus", handleFocus); document.removeEventListener("visibilitychange", handleVisibility); }; - }, []); + }, [fetchRequests, fetchActiveDownloadStatus]); + + useEffect(() => { + const albumIds = activeAlbumIdsKey ? activeAlbumIdsKey.split(",") : []; + if (!albumIds.length) { + setDownloadStatuses({}); + return; + } + + let cancelled = false; + const pollDownloadStatus = async () => { + try { + const statuses = await getDownloadStatus(albumIds); + if (!cancelled) { + setDownloadStatuses(statuses || {}); + } + } catch {} + }; + + pollDownloadStatus(); + const interval = setInterval(pollDownloadStatus, 15000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [activeAlbumIdsKey]); useEffect(() => { const hasActive = requests.some( @@ -84,7 +142,7 @@ function RequestsPage() { fetchRequests({ silent: true }); }, intervalMs); return () => clearInterval(interval); - }, [requests]); + }, [requests, fetchRequests]); const handleStopDownload = async (request) => { if (!request.inQueue || !request.albumId) return; @@ -98,6 +156,29 @@ function RequestsPage() { } }; + const handleReSearchRequest = async (request) => { + if (!request?.albumId) return; + const albumId = String(request.albumId); + setReSearchingAlbumId(albumId); + try { + setDownloadStatuses((prev) => ({ + ...prev, + [albumId]: { status: "searching" }, + })); + await triggerAlbumSearch(request.albumId); + showSuccess("Search triggered for album"); + fetchActiveDownloadStatus([albumId]); + } catch (err) { + showError( + `Failed to re-search album: ${ + err.response?.data?.message || err.message + }`, + ); + } finally { + setReSearchingAlbumId(null); + } + }; + const getStatusBadge = (request) => { const albumStatus = request.albumId ? downloadStatuses[String(request.albumId)] @@ -253,6 +334,14 @@ function RequestsPage() { const artistMbid = isAlbum ? request.artistMbid : request.mbid; const hasValidMbid = artistMbid && artistMbid !== "null" && artistMbid !== "undefined"; + const albumStatus = request.albumId + ? downloadStatuses[String(request.albumId)] + : null; + const isFailed = + albumStatus?.status === "failed" || request.status === "failed"; + const isReSearching = + request.albumId && + String(request.albumId) === reSearchingAlbumId; return (
-
+
{getStatusBadge(request)} + {isFailed && request.albumId && ( + + )}