diff --git a/src/client/components/ComicDetail/AirDCPPBundles.tsx b/src/client/components/ComicDetail/AirDCPPBundles.tsx index 66c47f1..a588963 100644 --- a/src/client/components/ComicDetail/AirDCPPBundles.tsx +++ b/src/client/components/ComicDetail/AirDCPPBundles.tsx @@ -3,7 +3,7 @@ import prettyBytes from "pretty-bytes"; import dayjs from "dayjs"; import ellipsize from "ellipsize"; import { map } from "lodash"; - +import {DownloadProgressTick} from "./DownloadProgressTick" export const AirDCPPBundles = (props) => { return (
@@ -38,6 +38,8 @@ export const AirDCPPBundles = (props) => { {dayjs .unix(bundle.time_finished) .format("h:mm on ddd, D MMM, YYYY")} + {/* Download progress */} + {bundle.id} diff --git a/src/client/components/ComicDetail/DownloadProgressTick.tsx b/src/client/components/ComicDetail/DownloadProgressTick.tsx index b4d69b3..8f7bd24 100644 --- a/src/client/components/ComicDetail/DownloadProgressTick.tsx +++ b/src/client/components/ComicDetail/DownloadProgressTick.tsx @@ -1,32 +1,132 @@ import prettyBytes from "pretty-bytes"; -import React, { ReactElement } from "react"; +import React, { ReactElement, useEffect, useRef, useState } from "react"; +import { useStore } from "../../store"; +import type { Socket } from "socket.io-client"; + +/** + * @typedef {Object} DownloadProgressTickProps + * @property {string} bundleId - The bundle ID to filter ticks by (as string) + */ +interface DownloadProgressTickProps { + bundleId: string; +} + +/** + * Shape of the download tick data received over the socket. + * + * @typedef DownloadTickData + * @property {number} id - Unique download ID + * @property {string} name - File name (e.g. "movie.mkv") + * @property {number} downloaded_bytes - Bytes downloaded so far + * @property {number} size - Total size in bytes + * @property {number} speed - Current download speed (bytes/sec) + * @property {number} seconds_left - Estimated seconds remaining + * @property {{ id: string; str: string; completed: boolean; downloaded: boolean; failed: boolean; hook_error: any }} status + * - Status object (e.g. `{ id: "queued", str: "Running (15.1%)", ... }`) + * @property {{ online: number; total: number; str: string }} sources + * - Peer count (e.g. `{ online: 1, total: 1, str: "1/1 online" }`) + * @property {string} target - Download destination (e.g. "/Downloads/movie.mkv") + */ +interface DownloadTickData { + id: number; + name: string; + downloaded_bytes: number; + size: number; + speed: number; + seconds_left: number; + status: { + id: string; + str: string; + completed: boolean; + downloaded: boolean; + failed: boolean; + hook_error: any; + }; + sources: { + online: number; + total: number; + str: string; + }; + target: string; +} + +export const DownloadProgressTick: React.FC = ({ + bundleId, +}): ReactElement | null => { + const socketRef = useRef(); + const [tick, setTick] = useState(null); + useEffect(() => { + const socket = useStore.getState().getSocket("manual"); + socketRef.current = socket; + + socket.emit("call", "socket.listenFileProgress", { + namespace: "/manual", + config: { + protocol: `ws`, + hostname: `192.168.1.119:5600`, + username: `admin`, + password: `password`, + }, + }); + + /** + * Handler for each "downloadTick" event. + * Only update state if event.id matches bundleId. + * + * @param {DownloadTickData} data - Payload from the server + */ + const onDownloadTick = (data: DownloadTickData) => { + // Compare numeric data.id to string bundleId + console.log(data.id); + console.log(`bundleId is ${bundleId}`) + if (data.id === parseInt(bundleId, 10)) { + setTick(data); + } + }; + + socket.on("downloadTick", onDownloadTick); + return () => { + socket.off("downloadTick", onDownloadTick); + }; + }, [socketRef, bundleId]); + + if (!tick) { + return null; + } + + // Compute human-readable values and percentages + const downloaded = prettyBytes(tick.downloaded_bytes); + const total = prettyBytes(tick.size); + const percent = tick.size > 0 + ? Math.round((tick.downloaded_bytes / tick.size) * 100) + : 0; + const speed = prettyBytes(tick.speed) + "/s"; + const minutesLeft = Math.round(tick.seconds_left / 60); -export const DownloadProgressTick = (props): ReactElement => { return ( -
-

{props.data.name}

-
- - {prettyBytes(props.data.downloaded_bytes)} of{" "} - {prettyBytes(props.data.size)}{" "} - - - {(parseInt(props.data.downloaded_bytes) / parseInt(props.data.size)) * - 100} - % - -
-
-

{prettyBytes(props.data.speed)} per second.

- Time left: - {Math.round(parseInt(props.data.seconds_left) / 60)} +
+ {/* File name */} +
{tick.name}
+ + {/* Downloaded vs Total */} +
+ {downloaded} of {total}
-
{props.data.target}
+ {/* Progress bar */} +
+
+
+
{percent}% complete
+ + {/* Speed and Time Left */} +
+ Speed: {speed} + Time left: {minutesLeft} min +
); }; diff --git a/src/client/components/ComicDetail/DownloadsPanel.tsx b/src/client/components/ComicDetail/DownloadsPanel.tsx index 2998d02..e478321 100644 --- a/src/client/components/ComicDetail/DownloadsPanel.tsx +++ b/src/client/components/ComicDetail/DownloadsPanel.tsx @@ -1,5 +1,4 @@ -import React, { useEffect, useContext, ReactElement, useState } from "react"; -import { RootState } from "threetwo-ui-typings"; +import React, { useEffect, ReactElement, useState, useMemo } from "react"; import { isEmpty, isNil, isUndefined, map } from "lodash"; import { AirDCPPBundles } from "./AirDCPPBundles"; import { TorrentDownloads } from "./TorrentDownloads"; @@ -9,47 +8,73 @@ import { LIBRARY_SERVICE_BASE_URI, QBITTORRENT_SERVICE_BASE_URI, TORRENT_JOB_SERVICE_BASE_URI, - SOCKET_BASE_URI, } from "../../constants/endpoints"; import { useStore } from "../../store"; import { useShallow } from "zustand/react/shallow"; import { useParams } from "react-router-dom"; -interface IDownloadsPanelProps { - key: number; +export interface TorrentDetails { + infoHash: string; + progress: number; + downloadSpeed?: number; + uploadSpeed?: number; } -export const DownloadsPanel = ( - props: IDownloadsPanelProps, -): ReactElement | null => { +/** + * DownloadsPanel displays two tabs of download information for a specific comic: + * - DC++ (AirDCPP) bundles + * - Torrent downloads + * It also listens for real-time torrent updates via a WebSocket. + * + * @component + * @returns {ReactElement | null} The rendered DownloadsPanel or null if no socket is available. + */ +export const DownloadsPanel = (): ReactElement | null => { const { comicObjectId } = useParams<{ comicObjectId: string }>(); const [infoHashes, setInfoHashes] = useState([]); - const [torrentDetails, setTorrentDetails] = useState([]); - const [activeTab, setActiveTab] = useState("directconnect"); - const { socketIOInstance } = useStore( - useShallow((state: any) => ({ - socketIOInstance: state.socketIOInstance, - })), + const [torrentDetails, setTorrentDetails] = useState([]); + const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">( + "directconnect", ); - // React to torrent progress data sent over websockets - socketIOInstance.on("AS_TORRENT_DATA", (data) => { - const torrents = data.torrents - .flatMap(({ _id, details }) => { - if (_id === comicObjectId) { - return details; - } - }) - .filter((item) => item !== undefined); - setTorrentDetails(torrents); - }); + const { socketIOInstance } = useStore( + useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })), + ); /** - * Query to fetch AirDC++ download bundles for a given comic resource Id - * @param {string} {comicObjectId} - A mongo id that identifies a comic document + * Registers socket listeners on mount and cleans up on unmount. */ + useEffect(() => { + if (!socketIOInstance) return; + + /** + * Handler for incoming torrent data events. + * Merges new entries or updates existing ones by infoHash. + * + * @param {TorrentDetails} data - Payload from the socket event. + */ + const handleTorrentData = (data: TorrentDetails) => { + setTorrentDetails((prev) => { + const idx = prev.findIndex((t) => t.infoHash === data.infoHash); + if (idx === -1) { + return [...prev, data]; + } + const next = [...prev]; + next[idx] = { ...next[idx], ...data }; + return next; + }); + }; + + socketIOInstance.on("AS_TORRENT_DATA", handleTorrentData); + + return () => { + socketIOInstance.off("AS_TORRENT_DATA", handleTorrentData); + }; + }, [socketIOInstance]); + + // ————— DC++ Bundles (via REST) ————— const { data: bundles } = useQuery({ - queryKey: ["bundles"], + queryKey: ["bundles", comicObjectId], queryFn: async () => await axios({ url: `${LIBRARY_SERVICE_BASE_URI}/getBundles`, @@ -64,77 +89,66 @@ export const DownloadsPanel = ( }, }, }), - enabled: activeTab !== "" && activeTab === "directconnect", }); - // Call the scheduled job for fetching torrent data - // triggered by the active tab been set to "torrents" - const { data: torrentData } = useQuery({ - queryFn: () => - axios({ - url: `${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`, - method: "GET", - params: { - trigger: activeTab, - }, - }), - queryKey: [activeTab], - enabled: activeTab !== "" && activeTab === "torrents", + // ————— Torrent Jobs (via REST) ————— + const { data: rawJobs = [] } = useQuery({ + queryKey: ["torrents", comicObjectId], + queryFn: async () => { + const { data } = await axios.get( + `${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`, + { params: { trigger: activeTab } }, + ); + return Array.isArray(data) ? data : []; + }, + initialData: [], + enabled: activeTab === "torrents", }); - console.log(bundles); + + // Only when rawJobs changes *and* activeTab === "torrents" should we update infoHashes: + useEffect(() => { + if (activeTab !== "torrents") return; + setInfoHashes(rawJobs.map((j: any) => j.infoHash)); + }, [activeTab]); + return ( -
-
-
- + <> +
+ - -
- -
- +
+ {activeTab === "torrents" ? ( + + ) : !isNil(bundles?.data) && bundles.data.length > 0 ? ( + + ) : ( +

No DC++ bundles found.

+ )}
- - {activeTab === "torrents" ? ( - - ) : null} - {!isNil(bundles?.data) && bundles?.data.length !== 0 ? ( - - ) : ( - "nutin" - )} -
+ ); }; + export default DownloadsPanel;