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)}{" "}
-
-
-
-
- {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" ? (
-
- ) : null}
- {!isNil(bundles?.data) && bundles?.data.length !== 0 ? (
-
- ) : (
- "nutin"
- )}
-
+ >
);
};
+
export default DownloadsPanel;
|