diff --git a/package.json b/package.json index 9226b1e..cccc7a0 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@floating-ui/react-dom": "^2.1.7", "@fortawesome/fontawesome-free": "^7.2.0", "@popperjs/core": "^2.11.8", -"@tanstack/react-query": "^5.90.21", + "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.3", "@types/mime-types": "^3.0.1", "@types/react-router-dom": "^5.3.3", diff --git a/src/client/components/Dashboard/RecentlyImported.tsx b/src/client/components/Dashboard/RecentlyImported.tsx index 86581a3..cc6176d 100644 --- a/src/client/components/Dashboard/RecentlyImported.tsx +++ b/src/client/components/Dashboard/RecentlyImported.tsx @@ -63,7 +63,10 @@ export const RecentlyImported = ( !isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation); const hasComicInfo = !isNil(comicInfo) && !isEmpty(comicInfo); - const cardState = (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported"; + const isMissingFile = isNil(rawFileDetails); + const cardState = isMissingFile + ? "missing" + : (hasComicInfo || isComicVineMetadataAvailable) ? "scraped" : "imported"; return (
{ - const [socketReconnectTrigger, setSocketReconnectTrigger] = useState(0); const [importError, setImportError] = useState(null); + const queryClient = useQueryClient(); const { importJobQueue, getSocket, disconnectSocket } = useStore( useShallow((state) => ({ importJobQueue: state.importJobQueue, @@ -45,26 +45,53 @@ export const Import = (): ReactElement => { const importSession = useImportSessionStatus(); const hasActiveSession = importSession.isActive; + const wasComplete = useRef(false); + // React to importSession.isComplete rather than socket events — more reliable + // since it's derived from the actual GraphQL state, not a raw socket event. + useEffect(() => { + if (importSession.isComplete && !wasComplete.current) { + wasComplete.current = true; + // Small delay so the backend has time to commit job result stats + setTimeout(() => { + // Invalidate the cache to force a fresh fetch of job result statistics + queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] }); + refetch(); + }, 1500); + importJobQueue.setStatus("drained"); + } else if (!importSession.isComplete) { + wasComplete.current = false; + } + }, [importSession.isComplete, refetch, importJobQueue, queryClient]); + + // Listen to socket events to update Past Imports table in real-time useEffect(() => { const socket = getSocket("/"); - const handleQueueDrained = () => refetch(); - const handleCoverExtracted = () => refetch(); - const handleSessionCompleted = () => { - refetch(); - importJobQueue.setStatus("drained"); + + const handleImportCompleted = () => { + console.log("[Import] IMPORT_SESSION_COMPLETED event - refreshing Past Imports"); + // Small delay to ensure backend has committed the job results + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] }); + }, 1500); }; + const handleQueueDrained = () => { + console.log("[Import] LS_IMPORT_QUEUE_DRAINED event - refreshing Past Imports"); + // Small delay to ensure backend has committed the job results + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["GetJobResultStatistics"] }); + }, 1500); + }; + + socket.on("IMPORT_SESSION_COMPLETED", handleImportCompleted); socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained); - socket.on("LS_COVER_EXTRACTED", handleCoverExtracted); - socket.on("IMPORT_SESSION_COMPLETED", handleSessionCompleted); return () => { + socket.off("IMPORT_SESSION_COMPLETED", handleImportCompleted); socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained); - socket.off("LS_COVER_EXTRACTED", handleCoverExtracted); - socket.off("IMPORT_SESSION_COMPLETED", handleSessionCompleted); }; - }, [getSocket, refetch, importJobQueue, socketReconnectTrigger]); + }, [getSocket, queryClient]); /** * Handles force re-import - re-imports all files to fix indexing issues @@ -89,7 +116,6 @@ export const Import = (): ReactElement => { disconnectSocket("/"); setTimeout(() => { getSocket("/"); - setSocketReconnectTrigger(prev => prev + 1); setTimeout(() => { forceReImport(); }, 500); diff --git a/src/client/components/Import/RealTimeImportStats.tsx b/src/client/components/Import/RealTimeImportStats.tsx index be2be24..0e946ac 100644 --- a/src/client/components/Import/RealTimeImportStats.tsx +++ b/src/client/components/Import/RealTimeImportStats.tsx @@ -1,4 +1,5 @@ import { ReactElement, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import { useGetImportStatisticsQuery, @@ -37,7 +38,7 @@ export const RealTimeImportStats = (): ReactElement => { // File list for the detail panel — only fetched when there are missing files const { data: missingComicsData } = useGetWantedComicsQuery( { - paginationOptions: { limit: 5, page: 1 }, + paginationOptions: { limit: 3, page: 1 }, predicate: { "importStatus.isRawFileMissing": true }, }, { @@ -49,6 +50,18 @@ export const RealTimeImportStats = (): ReactElement => { const missingDocs = missingComicsData?.getComicBooks?.docs ?? []; + const getMissingComicLabel = (comic: any): string => { + const series = + comic.canonicalMetadata?.series?.value ?? + comic.inferredMetadata?.issue?.name; + const issueNum = + comic.canonicalMetadata?.issueNumber?.value ?? + comic.inferredMetadata?.issue?.number; + if (series && issueNum) return `${series} #${issueNum}`; + if (series) return series; + return comic.rawFileDetails?.name ?? comic.id; + }; + const importSession = useImportSessionStatus(); const { mutate: startIncrementalImport, isPending: isStartingImport } = useStartIncrementalImportMutation({ @@ -66,13 +79,6 @@ export const RealTimeImportStats = (): ReactElement => { const hasNewFiles = stats && stats.newFiles > 0; const missingCount = stats?.missingFiles ?? 0; - // Mark queue drained when session completes — LS_LIBRARY_STATISTICS handles the refetch - useEffect(() => { - if (importSession.isComplete && importSession.status === "completed") { - importJobQueue.setStatus("drained"); - } - }, [importSession.isComplete, importSession.status, importJobQueue]); - // LS_LIBRARY_STATISTICS fires after every filesystem change and every import job completion. // Invalidating GetImportStatistics covers: total files, imported, new files, and missing count. // Invalidating GetWantedComics refreshes the missing file name list in the detail panel. @@ -139,13 +145,13 @@ export const RealTimeImportStats = (): ReactElement => { // Determine what to show in each card based on current phase const sessionStats = importSession.stats; - const hasSessionStats = (importSession.isActive || importSession.isComplete) && sessionStats !== null; + const hasSessionStats = importSession.isActive && sessionStats !== null; const totalFiles = stats.totalLocalFiles; const importedCount = hasSessionStats ? sessionStats!.filesSucceeded : stats.alreadyImported; const failedCount = hasSessionStats ? sessionStats!.filesFailed : 0; - const showProgressBar = importSession.isActive || importSession.isComplete; + const showProgressBar = importSession.isActive; const showFailedCard = hasSessionStats && failedCount > 0; const showMissingCard = missingCount > 0; @@ -267,18 +273,25 @@ export const RealTimeImportStats = (): ReactElement => {

{missingDocs.length > 0 && ( )} + + + View all in Library +
diff --git a/src/client/components/Library/Library.tsx b/src/client/components/Library/Library.tsx index 2ef3f2f..08c7cc2 100644 --- a/src/client/components/Library/Library.tsx +++ b/src/client/components/Library/Library.tsx @@ -1,6 +1,5 @@ -import React, { useMemo, ReactElement, useState, useEffect } from "react"; -import PropTypes from "prop-types"; -import { useNavigate } from "react-router-dom"; +import React, { useMemo, ReactElement, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { isEmpty, isNil, isUndefined } from "lodash"; import MetadataPanel from "../shared/MetadataPanel"; import T2Table from "../shared/T2Table"; @@ -12,79 +11,130 @@ import { useQueryClient, } from "@tanstack/react-query"; import axios from "axios"; -import { format, fromUnixTime, parseISO } from "date-fns"; +import { format, parseISO } from "date-fns"; +import { useGetWantedComicsQuery } from "../../graphql/generated"; + +type FilterOption = "all" | "missingFiles"; + +interface SearchQuery { + query: Record; + pagination: { size: number; from: number }; + type: string; + trigger: string; +} + +const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [ + { value: "all", label: "All Comics" }, + { value: "missingFiles", label: "Missing Files" }, +]; /** - * Component that tabulates the contents of the user's ThreeTwo Library. - * - * @component - * @example - * + * Library page component. Displays a paginated, searchable table of all comics + * in the collection, with an optional filter for comics with missing raw files. */ export const Library = (): ReactElement => { - // Default page state - // offset: 0 - const [offset, setOffset] = useState(0); - const [searchQuery, setSearchQuery] = useState({ + const [searchParams] = useSearchParams(); + const initialFilter = (searchParams.get("filter") as FilterOption) ?? "all"; + + const [activeFilter, setActiveFilter] = useState(initialFilter); + const [searchQuery, setSearchQuery] = useState({ query: {}, - pagination: { - size: 25, - from: offset, - }, + pagination: { size: 25, from: 0 }, type: "all", trigger: "libraryPage", }); + const queryClient = useQueryClient(); - /** - * Method that queries the Elasticsearch index "comics" for issues specified by the query - * @param searchQuery - A searchQuery object that contains the search term, type, and pagination params. - */ - const fetchIssues = async (searchQuery) => { - const { pagination, query, type } = searchQuery; + /** Fetches a page of issues from the search API. */ + const fetchIssues = async (q: SearchQuery) => { + const { pagination, query, type } = q; return await axios({ method: "POST", url: "http://localhost:3000/api/search/searchIssue", - data: { - query, - pagination, - type, - }, + data: { query, pagination, type }, }); }; - const searchIssues = (e) => { + const { data, isPlaceholderData } = useQuery({ + queryKey: ["comics", searchQuery], + queryFn: () => fetchIssues(searchQuery), + placeholderData: keepPreviousData, + enabled: activeFilter === "all", + }); + + const { data: missingFilesData, isLoading: isMissingLoading } = useGetWantedComicsQuery( + { + paginationOptions: { limit: 25, page: 1 }, + predicate: { "importStatus.isRawFileMissing": true }, + }, + { enabled: activeFilter === "missingFiles" }, + ); + + const { data: missingIdsData } = useGetWantedComicsQuery( + { + paginationOptions: { limit: 1000, page: 1 }, + predicate: { "importStatus.isRawFileMissing": true }, + }, + { enabled: activeFilter === "all" }, + ); + + /** Set of comic IDs whose raw files are missing, used to highlight rows in the main table. */ + const missingIdSet = useMemo( + () => new Set((missingIdsData?.getComicBooks?.docs ?? []).map((doc: any) => doc.id)), + [missingIdsData], + ); + + const searchResults = data?.data; + const navigate = useNavigate(); + + const navigateToComicDetail = (row: any) => navigate(`/comic/details/${row.original._id}`); + const navigateToMissingComicDetail = (row: any) => navigate(`/comic/details/${row.original.id}`); + + /** Triggers a search by volume name and resets pagination. */ + const searchIssues = (e: any) => { queryClient.invalidateQueries({ queryKey: ["comics"] }); setSearchQuery({ - query: { - volumeName: e.search, - }, - pagination: { - size: 15, - from: 0, - }, + query: { volumeName: e.search }, + pagination: { size: 15, from: 0 }, type: "volumeName", trigger: "libraryPage", }); }; - const { data, isLoading, isError, isPlaceholderData } = useQuery({ - queryKey: ["comics", offset, searchQuery], - queryFn: () => fetchIssues(searchQuery), - placeholderData: keepPreviousData, - }); - - const searchResults = data?.data; - // Programmatically navigate to comic detail - const navigate = useNavigate(); - const navigateToComicDetail = (row) => { - navigate(`/comic/details/${row.original._id}`); + /** Advances to the next page of results. */ + const nextPage = (pageIndex: number, pageSize: number) => { + if (!isPlaceholderData) { + queryClient.invalidateQueries({ queryKey: ["comics"] }); + setSearchQuery({ + query: {}, + pagination: { size: 15, from: pageSize * pageIndex + 1 }, + type: "all", + trigger: "libraryPage", + }); + } }; - const ComicInfoXML = (value) => { - return value.data ? ( + /** Goes back to the previous page of results. */ + const previousPage = (pageIndex: number, pageSize: number) => { + let from = 0; + if (pageIndex === 2) { + from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2); + } else { + from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1); + } + queryClient.invalidateQueries({ queryKey: ["comics"] }); + setSearchQuery({ + query: {}, + pagination: { size: 15, from }, + type: "all", + trigger: "libraryPage", + }); + }; + + const ComicInfoXML = (value: any) => + value.data ? (
- {/* Series Name */} @@ -94,7 +144,6 @@ export const Library = (): ReactElement => {
- {/* Pages */} @@ -103,7 +152,6 @@ export const Library = (): ReactElement => { Pages: {value.data.pagecount[0]} - {/* Issue number */} @@ -117,30 +165,62 @@ export const Library = (): ReactElement => {
) : null; - }; + + const missingFilesColumns = useMemo( + () => [ + { + header: "Missing Files", + columns: [ + { + header: "Status", + id: "missingStatus", + cell: () => ( +
+ + + MISSING + +
+ ), + }, + { + header: "Comic", + id: "missingComic", + minWidth: 250, + accessorFn: (row: any) => row, + cell: (info: any) => , + }, + ], + }, + ], + [], + ); const columns = useMemo( () => [ { header: "Comic Metadata", - footer: 1, columns: [ { header: "File Details", id: "fileDetails", minWidth: 250, accessorKey: "_source", - cell: (info) => { - return ; + cell: (info: any) => { + const source = info.getValue(); + return ( + + ); }, }, { header: "ComicInfo.xml", accessorKey: "_source.sourcedMetadata.comicInfo", - cell: (info) => - !isEmpty(info.getValue()) ? ( - - ) : null, + cell: (info: any) => + !isEmpty(info.getValue()) ? : null, }, ], }, @@ -150,19 +230,18 @@ export const Library = (): ReactElement => { { header: "Date of Import", accessorKey: "_source.createdAt", - cell: (info) => { - return !isNil(info.getValue()) ? ( + cell: (info: any) => + !isNil(info.getValue()) ? (
-

{format(parseISO(info.getValue()), "dd MMMM, yyyy")}

+

{format(parseISO(info.getValue()), "dd MMMM, yyyy")}

{format(parseISO(info.getValue()), "h aaaa")}
- ) : null; - }, + ) : null, }, { header: "Downloads", accessorKey: "_source.acquisition", - cell: (info) => ( + cell: (info: any) => (
@@ -172,7 +251,6 @@ export const Library = (): ReactElement => { DC++: {info.getValue().directconnect.downloads.length} - @@ -187,130 +265,95 @@ export const Library = (): ReactElement => { ], }, ], - [], + [missingIdSet], ); - /** - * Pagination control that fetches the next x (pageSize) items - * based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index - * @param {number} pageIndex - * @param {number} pageSize - * @returns void - * - **/ - const nextPage = (pageIndex: number, pageSize: number) => { - if (!isPlaceholderData) { - queryClient.invalidateQueries({ queryKey: ["comics"] }); - setSearchQuery({ - query: {}, - pagination: { - size: 15, - from: pageSize * pageIndex + 1, - }, - type: "all", - trigger: "libraryPage", - }); - // setOffset(pageSize * pageIndex + 1); - } - }; - - /** - * Pagination control that fetches the previous x (pageSize) items - * based on the y (pageIndex) offset from the ThreeTwo Elasticsearch index - * @param {number} pageIndex - * @param {number} pageSize - * @returns void - **/ - const previousPage = (pageIndex: number, pageSize: number) => { - let from = 0; - if (pageIndex === 2) { - from = (pageIndex - 1) * pageSize + 2 - (pageSize + 2); - } else { - from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1); - } - queryClient.invalidateQueries({ queryKey: ["comics"] }); - setSearchQuery({ - query: {}, - pagination: { - size: 15, - from, - }, - type: "all", - trigger: "libraryPage", - }); - // setOffset(from); - }; - - // ImportStatus.propTypes = { - // value: PropTypes.bool.isRequired, - // }; - return ( -
-
-
-
-
-
-

- Library -

- -

- Browse your comic book collection. -

-
-
-
-
- {!isUndefined(searchResults?.hits) ? ( -
-
- - searchIssues(e)} /> - -
-
- ) : ( -
-
-
-

- No comics were found in the library, Elasticsearch reports no - indices. Try importing a few comics into the library and come - back. -

-
-
-
-
-                {!isUndefined(searchResults?.data?.meta?.body) ? (
-                  

- {JSON.stringify( - searchResults?.data.meta.body.error.root_cause, - null, - 4, - )} -

- ) : null} -
-
-
- )} -
+ const FilterDropdown = () => ( +
+ +
); + + const isMissingFilter = activeFilter === "missingFiles"; + + return ( +
+
+
+
+
+

+ Library +

+

+ Browse your comic book collection. +

+
+
+
+
+ + {isMissingFilter ? ( +
+ {isMissingLoading ? ( +
Loading...
+ ) : ( + "bg-card-missing/40"} + paginationHandlers={{ nextPage: () => {}, previousPage: () => {} }} + > + + + )} +
+ ) : !isUndefined(searchResults?.hits) ? ( +
+ +
+ + searchIssues(e)} /> +
+
+
+ ) : ( +
+
+
+

+ No comics were found in the library, Elasticsearch reports no indices. Try + importing a few comics into the library and come back. +

+
+
+ +
+ )} +
+ ); }; export default Library; diff --git a/src/client/components/shared/Carda.tsx b/src/client/components/shared/Carda.tsx index c86a2f9..381238c 100644 --- a/src/client/components/shared/Carda.tsx +++ b/src/client/components/shared/Carda.tsx @@ -10,7 +10,7 @@ interface ICardProps { children?: PropTypes.ReactNodeLike; borderColorClass?: string; backgroundColor?: string; - cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported"; + cardState?: "wanted" | "delete" | "scraped" | "uncompressed" | "imported" | "missing"; onClick?: (event: React.MouseEvent) => void; cardContainerStyle?: React.CSSProperties; imageStyle?: React.CSSProperties; @@ -28,6 +28,8 @@ const getCardStateClass = (cardState?: string): string => { return "bg-card-uncompressed"; case "imported": return "bg-card-imported"; + case "missing": + return "bg-card-missing"; default: return ""; } diff --git a/src/client/components/shared/MetadataPanel.tsx b/src/client/components/shared/MetadataPanel.tsx index 7fd24ab..dba1092 100644 --- a/src/client/components/shared/MetadataPanel.tsx +++ b/src/client/components/shared/MetadataPanel.tsx @@ -8,14 +8,17 @@ import { determineCoverFile } from "../../shared/utils/metadata.utils"; import { find, isUndefined } from "lodash"; interface IMetadatPanelProps { - value: any; - children: any; - imageStyle: any; - titleStyle: any; - tagsStyle: any; - containerStyle: any; + data: any; + value?: any; + children?: any; + imageStyle?: any; + titleStyle?: any; + tagsStyle?: any; + containerStyle?: any; + isMissing?: boolean; } export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => { + const { isMissing = false } = props; const { rawFileDetails, inferredMetadata, @@ -31,8 +34,11 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => { { name: "rawFileDetails", content: () => ( -
-
+
+
+ {isMissing && ( + + )}

{issueName}

@@ -58,26 +64,28 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => { )}
{/* File extension */} - - - + {rawFileDetails.mimeType && ( + + + + + + {rawFileDetails.mimeType} + - - - {rawFileDetails.mimeType} - - + )} {/* size */} - - - + {rawFileDetails.fileSize != null && ( + + + + + + {prettyBytes(rawFileDetails.fileSize)} + - - - {prettyBytes(rawFileDetails.fileSize)} - - + )} {/* Uncompressed version available? */} {rawFileDetails.archive?.uncompressed && ( @@ -177,10 +185,10 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => { const metadataPanel = find(metadataContentPanel, { name: objectReference, }); - return (
+ { imageStyle={props.imageStyle} />
-
{metadataPanel.content()}
+
{metadataPanel?.content()}
); }; diff --git a/src/client/components/shared/T2Table.tsx b/src/client/components/shared/T2Table.tsx index d84edfe..61d3a5e 100644 --- a/src/client/components/shared/T2Table.tsx +++ b/src/client/components/shared/T2Table.tsx @@ -17,6 +17,7 @@ interface T2TableProps { previousPage?(...args: unknown[]): unknown; }; rowClickHandler?(...args: unknown[]): unknown; + getRowClassName?(row: any): string; children?: any; } @@ -27,6 +28,7 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => { paginationHandlers: { nextPage, previousPage }, totalPages, rowClickHandler, + getRowClassName, } = tableOptions; const [{ pageIndex, pageSize }, setPagination] = useState({ @@ -140,7 +142,7 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => { rowClickHandler(row)} - className="border-b border-gray-200 dark:border-slate-700 hover:bg-slate-100/30 dark:hover:bg-slate-700/20 transition-colors cursor-pointer" + className={`border-b border-gray-200 dark:border-slate-700 hover:bg-slate-100/30 dark:hover:bg-slate-700/20 transition-colors cursor-pointer ${getRowClassName ? getRowClassName(row) : ""}`} > {row.getVisibleCells().map((cell) => ( diff --git a/src/client/hooks/useImportSessionStatus.ts b/src/client/hooks/useImportSessionStatus.ts index 7540577..56dd7a5 100644 --- a/src/client/hooks/useImportSessionStatus.ts +++ b/src/client/hooks/useImportSessionStatus.ts @@ -60,13 +60,22 @@ export const useImportSessionStatus = (): ImportSessionState => { // Track if we've received completion events const completionEventReceived = useRef(false); const queueDrainedEventReceived = useRef(false); + // Only true if IMPORT_SESSION_STARTED fired in this browser session. + // Prevents a stale "running" DB session from showing as active on hard refresh. + const sessionStartedEventReceived = useRef(false); - // Query active import session - NO POLLING, only refetch on Socket.IO events + // Query active import session - polls every 3s as a fallback when a session is + // active (e.g. tab re-opened mid-import and socket events were missed) const { data: sessionData, refetch } = useGetActiveImportSessionQuery( {}, { - refetchOnWindowFocus: false, - refetchInterval: false, // NO POLLING + refetchOnWindowFocus: true, + refetchInterval: (query) => { + const s = (query.state.data as any)?.getActiveImportSession; + return s?.status === "running" || s?.status === "active" || s?.status === "processing" + ? 3000 + : false; + }, } ); @@ -152,12 +161,18 @@ export const useImportSessionStatus = (): ImportSessionState => { // Case 3: Check if session is actually running/active if (status === "running" || status === "active" || status === "processing") { - // Check if there's actual progress happening - const hasProgress = stats.filesProcessed > 0 || stats.filesSucceeded > 0; const hasQueuedWork = stats.filesQueued > 0 && stats.filesProcessed < stats.filesQueued; - - // Only treat as active if there's progress OR it just started - if (hasProgress && hasQueuedWork) { + // Only treat as "just started" if the started event fired in this browser session. + // Prevents a stale DB session from showing a 0% progress bar on hard refresh. + const justStarted = stats.filesQueued === 0 && stats.filesProcessed === 0 && sessionStartedEventReceived.current; + + // No in-session event AND no actual progress → stale unclosed session from a previous run. + // Covers the case where the backend stores filesQueued but never updates filesProcessed/filesSucceeded. + const likelyStale = !sessionStartedEventReceived.current + && stats.filesProcessed === 0 + && stats.filesSucceeded === 0; + + if ((hasQueuedWork || justStarted) && !likelyStale) { return { status: "running", sessionId, @@ -172,8 +187,8 @@ export const useImportSessionStatus = (): ImportSessionState => { isActive: true, }; } else { - // Session says "running" but no progress - likely stuck/stale - console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stuck (status: "${status}", processed: ${stats.filesProcessed}, succeeded: ${stats.filesSucceeded}, queued: ${stats.filesQueued}) - treating as idle`); + // Session says "running" but all files processed — likely a stale session + console.warn(`[useImportSessionStatus] Session "${sessionId}" appears stale (status: "${status}", processed: ${stats.filesProcessed}, queued: ${stats.filesQueued}) - treating as idle`); return { status: "idle", sessionId: null, @@ -247,6 +262,7 @@ export const useImportSessionStatus = (): ImportSessionState => { // Reset completion flags when new session starts completionEventReceived.current = false; queueDrainedEventReceived.current = false; + sessionStartedEventReceived.current = true; refetch(); }; diff --git a/src/client/shared/utils/metadata.utils.ts b/src/client/shared/utils/metadata.utils.ts index 70ac4f5..10b4e26 100644 --- a/src/client/shared/utils/metadata.utils.ts +++ b/src/client/shared/utils/metadata.utils.ts @@ -44,21 +44,23 @@ export const determineCoverFile = (data): any => { }; // comicvine if (!isEmpty(data.comicvine)) { - coverFile.comicvine.url = data?.comicvine?.image.small_url; + coverFile.comicvine.url = data?.comicvine?.image?.small_url; coverFile.comicvine.issueName = data.comicvine?.name; coverFile.comicvine.publisher = data.comicvine?.publisher?.name; } // rawFileDetails - if (!isEmpty(data.rawFileDetails)) { + if (!isEmpty(data.rawFileDetails) && data.rawFileDetails.cover?.filePath) { const encodedFilePath = encodeURI( `${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`, ); coverFile.rawFile.url = escapePoundSymbol(encodedFilePath); coverFile.rawFile.issueName = data.rawFileDetails.name; + } else if (!isEmpty(data.rawFileDetails)) { + coverFile.rawFile.issueName = data.rawFileDetails.name; } // wanted - if (!isUndefined(data.locg)) { + if (!isNil(data.locg)) { coverFile.locg.url = data.locg.cover; coverFile.locg.issueName = data.locg.name; coverFile.locg.publisher = data.locg.publisher; @@ -66,14 +68,15 @@ export const determineCoverFile = (data): any => { const result = filter(coverFile, (item) => item.url !== ""); - if (result.length > 1) { + if (result.length >= 1) { const highestPriorityCoverFile = minBy(result, (item) => item.priority); if (!isUndefined(highestPriorityCoverFile)) { return highestPriorityCoverFile; } - } else { - return result[0]; } + + // No cover URL available — return rawFile entry so the name is still shown + return coverFile.rawFile; }; export const determineExternalMetadata = ( @@ -85,8 +88,8 @@ export const determineExternalMetadata = ( case "comicvine": return { coverURL: - source.comicvine?.image.small_url || - source.comicvine.volumeInformation?.image.small_url, + source.comicvine?.image?.small_url || + source.comicvine?.volumeInformation?.image?.small_url, issue: source.comicvine.name, icon: "cvlogo.svg", }; diff --git a/tailwind.config.ts b/tailwind.config.ts index d707640..58ad7fc 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -13,6 +13,7 @@ module.exports = { scraped: "#b8edbc", uncompressed: "#FFF3E0", imported: "#d8dab0", + missing: "#fee2e2", }, }, },