From ea66419f339e7ab0998ee293e732ae9e9c339617 Mon Sep 17 00:00:00 2001 From: Rishi Ghan Date: Tue, 24 Feb 2026 13:39:50 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A0=20changed=20import=20to=20account?= =?UTF-8?q?=20for=20graphql?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ComicDetail/AcquisitionPanel.tsx | 338 +++++++++++++----- .../components/Dashboard/WantedComicsList.tsx | 200 ++++++----- src/client/components/Import/Import.tsx | 96 ++++- src/client/components/shared/Carda.tsx | 2 +- src/client/store/index.ts | 12 +- 5 files changed, 457 insertions(+), 191 deletions(-) diff --git a/src/client/components/ComicDetail/AcquisitionPanel.tsx b/src/client/components/ComicDetail/AcquisitionPanel.tsx index 657e0ef..bf7afc5 100644 --- a/src/client/components/ComicDetail/AcquisitionPanel.tsx +++ b/src/client/components/ComicDetail/AcquisitionPanel.tsx @@ -5,8 +5,7 @@ import React, { useRef, useState, } from "react"; -import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings"; -import { RootState, SearchInstance } from "threetwo-ui-typings"; +import { SearchQuery, PriorityEnum, SearchResponse, SearchInstance } from "threetwo-ui-typings"; import ellipsize from "ellipsize"; import { Form, Field } from "react-final-form"; import { difference } from "../../shared/utils/object.utils"; @@ -15,44 +14,104 @@ import { useStore } from "../../store"; import { useShallow } from "zustand/react/shallow"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import axios from "axios"; -import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints"; +import { AIRDCPP_SERVICE_BASE_URI, SETTINGS_SERVICE_BASE_URI } from "../../constants/endpoints"; import type { Socket } from "socket.io-client"; interface IAcquisitionPanelProps { query: any; - comicObjectId: any; + comicObjectId: string; comicObject: any; settings: any; } +interface AirDCPPConfig { + protocol: string; + hostname: string; + username: string; + password: string; +} + +interface SearchResult { + id: string; + name: string; + type: { + id: string; + str: string; + }; + size: number; + slots: { + total: number; + free: number; + }; + users: { + user: { + nicks: string; + flags: string[]; + }; + }; + dupe?: any; +} + +interface SearchInstanceData { + id: number; + owner: string; + expires_in: number; +} + +interface SearchInfo { + query: { + pattern: string; + extensions: string[]; + file_type: string; + }; +} + +interface Hub { + hub_url: string; + identity: { + name: string; + }; + value: string; +} + +interface SearchFormValues { + issueName: string; +} + export const AcquisitionPanel = ( props: IAcquisitionPanelProps, ): ReactElement => { const socketRef = useRef(); const queryClient = useQueryClient(); + const searchTimeoutRef = useRef(null); - const [dcppQuery, setDcppQuery] = useState({}); - const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]); + const [dcppQuery, setDcppQuery] = useState(null); + const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]); const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false); - const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({}); - const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({}); + const [isSearching, setIsSearching] = useState(false); + const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState(null); + const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState(null); + const [searchError, setSearchError] = useState(null); const { comicObjectId } = props; const issueName = props.query.issue.name || ""; const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " "); + // Search timeout duration in milliseconds (30 seconds) + const SEARCH_TIMEOUT_MS = 30000; + useEffect(() => { const socket = useStore.getState().getSocket("manual"); socketRef.current = socket; // --- Handlers --- - const handleResultAdded = ({ result }: any) => { + const handleResultAdded = ({ result }: { result: SearchResult }) => { setAirDCPPSearchResults((prev) => prev.some((r) => r.id === result.id) ? prev : [...prev, result], ); }; - const handleResultUpdated = ({ result }: any) => { + const handleResultUpdated = ({ result }: { result: SearchResult }) => { setAirDCPPSearchResults((prev) => { const idx = prev.findIndex((r) => r.id === result.id); if (idx === -1) return prev; @@ -63,45 +122,86 @@ export const AcquisitionPanel = ( }); }; - const handleSearchInitiated = (data: any) => { + const handleSearchInitiated = (data: { instance: SearchInstanceData }) => { setAirDCPPSearchInstance(data.instance); + setIsSearching(true); + setSearchError(null); + + // Clear any existing timeout + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + // Set a timeout to stop searching after SEARCH_TIMEOUT_MS + searchTimeoutRef.current = setTimeout(() => { + setIsSearching(false); + console.log(`Search timeout reached after ${SEARCH_TIMEOUT_MS / 1000} seconds`); + }, SEARCH_TIMEOUT_MS); }; - const handleSearchesSent = (data: any) => { + const handleSearchesSent = (data: { searchInfo: SearchInfo }) => { setAirDCPPSearchInfo(data.searchInfo); }; + const handleSearchError = (error: { message: string }) => { + setSearchError(error.message || "Search failed"); + setIsSearching(false); + + // Clear timeout on error + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + searchTimeoutRef.current = null; + } + }; + + const handleSearchCompleted = () => { + setIsSearching(false); + + // Clear timeout when search completes + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + searchTimeoutRef.current = null; + } + }; + // --- Subscribe once --- socket.on("searchResultAdded", handleResultAdded); socket.on("searchResultUpdated", handleResultUpdated); socket.on("searchInitiated", handleSearchInitiated); socket.on("searchesSent", handleSearchesSent); + socket.on("searchError", handleSearchError); + socket.on("searchCompleted", handleSearchCompleted); return () => { socket.off("searchResultAdded", handleResultAdded); socket.off("searchResultUpdated", handleResultUpdated); socket.off("searchInitiated", handleSearchInitiated); socket.off("searchesSent", handleSearchesSent); - // if you want to fully close the socket: - // useStore.getState().disconnectSocket("/manual"); + socket.off("searchError", handleSearchError); + socket.off("searchCompleted", handleSearchCompleted); + + // Clean up timeout on unmount + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } }; - }, []); + }, [SEARCH_TIMEOUT_MS]); const { data: settings, - isLoading, - isError, + isLoading: isLoadingSettings, + isError: isSettingsError, } = useQuery({ queryKey: ["settings"], queryFn: async () => await axios({ - url: "http://localhost:3000/api/settings/getAllSettings", + url: `${SETTINGS_SERVICE_BASE_URI}/getAllSettings`, method: "GET", }), }); - const { data: hubs } = useQuery({ - queryKey: ["hubs"], + const { data: hubs, isLoading: isLoadingHubs } = useQuery({ + queryKey: ["hubs", settings?.data.directConnect?.client?.host], queryFn: async () => await axios({ url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`, @@ -110,45 +210,72 @@ export const AcquisitionPanel = ( host: settings?.data.directConnect?.client?.host, }, }), - enabled: !isEmpty(settings?.data.directConnect?.client?.host), + enabled: !!settings?.data?.directConnect?.client?.host, }); + // Get AirDC++ config from settings + const airDCPPConfig: AirDCPPConfig | null = settings?.data?.directConnect?.client + ? { + protocol: settings.data.directConnect.client.protocol || "ws", + hostname: typeof settings.data.directConnect.client.host === 'string' + ? settings.data.directConnect.client.host + : `${settings.data.directConnect.client.host?.hostname || 'localhost'}:${settings.data.directConnect.client.host?.port || '5600'}`, + username: settings.data.directConnect.client.username || "admin", + password: settings.data.directConnect.client.password || "password", + } + : null; + useEffect(() => { - const dcppSearchQuery = { - query: { - pattern: `${sanitizedIssueName.replace(/#/g, "")}`, - extensions: ["cbz", "cbr", "cb7"], - }, - hub_urls: map(hubs?.data, (item) => item.value), - priority: 5, - }; - setDcppQuery(dcppSearchQuery); + if (hubs?.data && Array.isArray(hubs.data) && hubs.data.length > 0) { + const dcppSearchQuery = { + query: { + pattern: `${sanitizedIssueName.replace(/#/g, "")}`, + extensions: ["cbz", "cbr", "cb7"], + }, + hub_urls: map(hubs.data, (item) => item.value), + priority: 5, + }; + setDcppQuery(dcppSearchQuery as any); + } }, [hubs, sanitizedIssueName]); const search = async (searchData: any) => { + if (!airDCPPConfig) { + setSearchError("AirDC++ configuration not found in settings"); + return; + } + + if (!socketRef.current) { + setSearchError("Socket connection not available"); + return; + } + setAirDCPPSearchResults([]); - socketRef.current?.emit("call", "socket.search", { + setIsSearching(true); + setSearchError(null); + + socketRef.current.emit("call", "socket.search", { query: searchData, namespace: "/manual", - config: { - protocol: `ws`, - hostname: `192.168.1.119:5600`, - username: `admin`, - password: `password`, - }, + config: airDCPPConfig, }); }; const download = async ( - searchInstanceId: Number, - resultId: String, - comicObjectId: String, - name: String, - size: Number, - type: any, - config: any, + searchInstanceId: number, + resultId: string, + comicObjectId: string, + name: string, + size: number, + type: SearchResult["type"], + config: AirDCPPConfig, ): Promise => { - socketRef.current?.emit( + if (!socketRef.current) { + console.error("Socket connection not available"); + return; + } + + socketRef.current.emit( "call", "socket.download", { @@ -160,17 +287,27 @@ export const AcquisitionPanel = ( type, config, }, - (data: any) => console.log(data), + (data: any) => console.log("Download initiated:", data), ); }; - const getDCPPSearchResults = async (searchQuery) => { + const getDCPPSearchResults = async (searchQuery: SearchFormValues) => { + if (!searchQuery.issueName || searchQuery.issueName.trim() === "") { + setSearchError("Please enter a search term"); + return; + } + + if (!hubs?.data || !Array.isArray(hubs.data) || hubs.data.length === 0) { + setSearchError("No hubs configured"); + return; + } + const manualQuery = { query: { - pattern: `${searchQuery.issueName}`, + pattern: `${searchQuery.issueName.trim()}`, extensions: ["cbz", "cbr", "cb7"], }, - hub_urls: [hubs?.data[0].hub_url], + hub_urls: [hubs.data[0].hub_url], priority: 5, }; @@ -180,7 +317,12 @@ export const AcquisitionPanel = ( return ( <>
- {!isEmpty(hubs?.data) ? ( + {isLoadingSettings || isLoadingHubs ? ( +
+ + Loading configuration... +
+ ) : !isEmpty(hubs?.data) ? (
@@ -234,26 +387,36 @@ export const AcquisitionPanel = ( )} + + {/* Search Error Display */} + {searchError && ( +
+ Error: {searchError} +
+ )} {/* configured hub */} - {!isEmpty(hubs?.data) && ( + {!isEmpty(hubs?.data) && hubs?.data[0] && ( - {hubs && hubs?.data[0].hub_url} + {hubs.data[0].hub_url} )} {/* AirDC++ search instance details */} - {!isNil(airDCPPSearchInstance) && - !isEmpty(airDCPPSearchInfo) && - !isNil(hubs) && ( + {airDCPPSearchInstance && + airDCPPSearchInfo && + hubs?.data && (
- {hubs?.data.map((value, idx: string) => ( + {hubs.data.map((value: Hub, idx: number) => ( {value.identity.name} @@ -293,7 +456,7 @@ export const AcquisitionPanel = ( {/* AirDC++ results */}
- {!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? ( + {airDCPPSearchResults.length > 0 ? (
@@ -345,9 +508,9 @@ export const AcquisitionPanel = ( {users.user.nicks} - {users.user.flags.map((flag, idx) => ( + {users.user.flags.map((flag: string, flagIdx: number) => ( @@ -378,23 +541,21 @@ export const AcquisitionPanel = ( {/* ACTIONS */}
- ) : ( + ) : !isSearching ? (
+ ) : ( +
+ + Searching... +
)}
diff --git a/src/client/components/Dashboard/WantedComicsList.tsx b/src/client/components/Dashboard/WantedComicsList.tsx index 61ba8e1..a825acd 100644 --- a/src/client/components/Dashboard/WantedComicsList.tsx +++ b/src/client/components/Dashboard/WantedComicsList.tsx @@ -6,6 +6,7 @@ import { isEmpty, isNil, isUndefined, map } from "lodash"; import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils"; import { determineCoverFile } from "../../shared/utils/metadata.utils"; import Header from "../shared/Header"; +import useEmblaCarousel from "embla-carousel-react"; type WantedComicsListProps = { comics: any; @@ -16,107 +17,126 @@ export const WantedComicsList = ({ }: WantedComicsListProps): ReactElement => { const navigate = useNavigate(); + // embla carousel + const [emblaRef, emblaApi] = useEmblaCarousel({ + loop: false, + align: "start", + containScroll: "trimSnaps", + slidesToScroll: 1, + }); + return ( - <> +
-
- {map( - comics, - ({ - _id, - rawFileDetails, - sourcedMetadata: { comicvine, comicInfo, locg }, - wanted, - }) => { - const isComicBookMetadataAvailable = !isUndefined(comicvine); - const consolidatedComicMetadata = { - rawFileDetails, - comicvine, - comicInfo, - locg, - }; +
+
+
+ {map( + comics, + ( + { + _id, + rawFileDetails, + sourcedMetadata: { comicvine, comicInfo, locg }, + wanted, + }, + idx, + ) => { + const isComicBookMetadataAvailable = !isUndefined(comicvine); + const consolidatedComicMetadata = { + rawFileDetails, + comicvine, + comicInfo, + locg, + }; - const { - issueName, - url, - publisher = null, - } = determineCoverFile(consolidatedComicMetadata); - const titleElement = ( - - {ellipsize(issueName, 20)} -

{publisher}

- - ); - return ( - No Name} - > -
-
- {/* Issue type */} - {isComicBookMetadataAvailable && - !isNil(detectIssueTypes(comicvine.description)) ? ( -
- - - - + const { + issueName, + url, + publisher = null, + } = determineCoverFile(consolidatedComicMetadata); + const titleElement = ( + + {ellipsize(issueName, 20)} +

{publisher}

+ + ); + return ( +
+ No Name} + > +
+
+ {/* Issue type */} + {isComicBookMetadataAvailable && + !isNil(detectIssueTypes(comicvine.description)) ? ( +
+ + + + - - { - detectIssueTypes(comicvine.description) - .displayName - } - - + + { + detectIssueTypes(comicvine.description) + .displayName + } + + +
+ ) : null} + {/* issues marked as wanted, part of this volume */} + {wanted?.markEntireVolumeWanted ? ( +
sagla volume pahije
+ ) : ( +
+ + + + + + + {wanted.issues.length} + + +
+ )} +
+ {/* comicVine metadata presence */} + {isComicBookMetadataAvailable && ( + {"ComicVine + )} + {!isEmpty(locg) && ( + + )}
- ) : null} - {/* issues marked as wanted, part of this volume */} - {wanted?.markEntireVolumeWanted ? ( -
sagla volume pahije
- ) : ( -
- - - - - - - {wanted.issues.length} - - -
- )} +
- {/* comicVine metadata presence */} - {isComicBookMetadataAvailable && ( - {"ComicVine - )} - {!isEmpty(locg) && ( - - )} -
- - ); - }, - )} + ); + }, + )} +
+
- +
); }; diff --git a/src/client/components/Import/Import.tsx b/src/client/components/Import/Import.tsx index e801cb7..ed5f673 100644 --- a/src/client/components/Import/Import.tsx +++ b/src/client/components/Import/Import.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useCallback, useEffect } from "react"; +import React, { ReactElement, useCallback, useEffect, useRef } from "react"; import "react-loader-spinner/dist/loader/css/react-spinner-loader.css"; import { format } from "date-fns"; import Loader from "react-loader-spinner"; @@ -27,12 +27,21 @@ interface IProps { export const Import = (props: IProps): ReactElement => { const queryClient = useQueryClient(); - const { importJobQueue, socketIOInstance } = useStore( + const { importJobQueue, getSocket, setQueryClientRef } = useStore( useShallow((state) => ({ importJobQueue: state.importJobQueue, - socketIOInstance: state.socketIOInstance, + getSocket: state.getSocket, + setQueryClientRef: state.setQueryClientRef, })), ); + + const previousResultCountRef = useRef(0); + const pollingIntervalRef = useRef(null); + + // Set the queryClient reference in the store so socket events can use it + useEffect(() => { + setQueryClientRef({ current: queryClient }); + }, [queryClient, setQueryClientRef]); const sessionId = localStorage.getItem("sessionId"); const { mutate: initiateImport } = useMutation({ @@ -44,24 +53,91 @@ export const Import = (props: IProps): ReactElement => { }), }); - const { data, isError, isLoading } = useQuery({ + const { data, isError, isLoading, refetch } = useQuery({ queryKey: ["allImportJobResults"], - queryFn: async () => - await axios({ + queryFn: async () => { + const response = await axios({ method: "GET", url: "http://localhost:3000/api/jobqueue/getJobResultStatistics", - }), + params: { + _t: Date.now(), // Cache buster + }, + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache', + }, + }); + + // Track the result count + if (response.data?.length) { + previousResultCountRef.current = response.data.length; + } + + return response; + }, + refetchOnMount: true, + refetchOnWindowFocus: false, + staleTime: 0, // Always consider data stale + gcTime: 0, // Don't cache the data (replaces cacheTime in newer versions) + // Poll every 5 seconds when import is running + refetchInterval: importJobQueue.status === "running" || importJobQueue.status === "paused" ? 5000 : false, }); + // Listen for import queue drained event to refresh the table + useEffect(() => { + const socket = getSocket("/"); + + const handleQueueDrained = () => { + const initialCount = previousResultCountRef.current; + let attempts = 0; + const maxAttempts = 20; // Poll for up to 20 seconds + + // Clear any existing polling interval + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + } + + // Poll every second until we see new data or hit max attempts + pollingIntervalRef.current = setInterval(async () => { + attempts++; + + const result = await refetch(); + const newCount = result.data?.data?.length || 0; + + if (newCount > initialCount) { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + } else if (attempts >= maxAttempts) { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + } + }, 1000); + }; + + socket.on("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained); + + return () => { + socket.off("LS_IMPORT_QUEUE_DRAINED", handleQueueDrained); + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + } + }; + }, [getSocket, queryClient, refetch]); + const toggleQueue = (queueAction: string, queueStatus: string) => { - socketIOInstance.emit( + const socket = getSocket("/"); + socket.emit( "call", "socket.setQueueStatus", { queueAction, queueStatus, }, - (data) => console.log(data), + (data: any) => console.log(data), ); }; /** @@ -246,7 +322,7 @@ export const Import = (props: IProps): ReactElement => { - {data?.data.map((jobResult, id) => { + {data?.data.map((jobResult: any, id: number) => { return ( diff --git a/src/client/components/shared/Carda.tsx b/src/client/components/shared/Carda.tsx index 7648100..b53080d 100644 --- a/src/client/components/shared/Carda.tsx +++ b/src/client/components/shared/Carda.tsx @@ -87,7 +87,7 @@ const renderCard = (props: ICardProps): ReactElement => { Home {props.title ? ( diff --git a/src/client/store/index.ts b/src/client/store/index.ts index d688a8d..a025fe6 100644 --- a/src/client/store/index.ts +++ b/src/client/store/index.ts @@ -2,17 +2,16 @@ import { create } from "zustand"; import io, { Socket } from "socket.io-client"; import { SOCKET_BASE_URI } from "../constants/endpoints"; import { isNil } from "lodash"; -import { QueryClient } from "@tanstack/react-query"; import { toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.min.css"; -const queryClient = new QueryClient(); - // Type for global state interface StoreState { socketInstances: Record; getSocket: (namespace?: string) => Socket; disconnectSocket: (namespace: string) => void; + queryClientRef: { current: any } | null; + setQueryClientRef: (ref: any) => void; comicvine: { scrapingStatus: string; @@ -32,6 +31,8 @@ interface StoreState { export const useStore = create((set, get) => ({ socketInstances: {}, + queryClientRef: null, + setQueryClientRef: (ref: any) => set({ queryClientRef: ref }), getSocket: (namespace = "/") => { const fullNamespace = namespace === "/" ? "" : namespace; @@ -97,7 +98,10 @@ export const useStore = create((set, get) => ({ status: "drained", }, })); - queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] }); + const queryClientRef = get().queryClientRef; + if (queryClientRef?.current) { + queryClientRef.current.invalidateQueries({ queryKey: ["allImportJobResults"] }); + } }); socket.on("CV_SCRAPING_STATUS", (data) => {