⬇️ Fixing AirDC++ download integration
This commit is contained in:
@@ -3,7 +3,7 @@ import prettyBytes from "pretty-bytes";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import ellipsize from "ellipsize";
|
import ellipsize from "ellipsize";
|
||||||
import { map } from "lodash";
|
import { map } from "lodash";
|
||||||
|
import {DownloadProgressTick} from "./DownloadProgressTick"
|
||||||
export const AirDCPPBundles = (props) => {
|
export const AirDCPPBundles = (props) => {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
|
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
|
||||||
@@ -38,6 +38,8 @@ export const AirDCPPBundles = (props) => {
|
|||||||
{dayjs
|
{dayjs
|
||||||
.unix(bundle.time_finished)
|
.unix(bundle.time_finished)
|
||||||
.format("h:mm on ddd, D MMM, YYYY")}
|
.format("h:mm on ddd, D MMM, YYYY")}
|
||||||
|
{/* Download progress */}
|
||||||
|
<DownloadProgressTick bundleId={bundle.id} />
|
||||||
</td>
|
</td>
|
||||||
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
|
||||||
<span className="tag is-warning">{bundle.id}</span>
|
<span className="tag is-warning">{bundle.id}</span>
|
||||||
|
|||||||
@@ -1,32 +1,132 @@
|
|||||||
import prettyBytes from "pretty-bytes";
|
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<DownloadProgressTickProps> = ({
|
||||||
|
bundleId,
|
||||||
|
}): ReactElement | null => {
|
||||||
|
const socketRef = useRef<Socket>();
|
||||||
|
const [tick, setTick] = useState<DownloadTickData | null>(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 (
|
return (
|
||||||
<div>
|
<div className="mt-2 p-2 border rounded-md bg-white shadow-sm">
|
||||||
<h4 className="is-size-5">{props.data.name}</h4>
|
{/* File name */}
|
||||||
<div>
|
<h5 className="text-md font-medium truncate">{tick.name}</h5>
|
||||||
<span className="is-size-4 has-text-weight-semibold">
|
|
||||||
{prettyBytes(props.data.downloaded_bytes)} of{" "}
|
{/* Downloaded vs Total */}
|
||||||
{prettyBytes(props.data.size)}{" "}
|
<div className="mt-1 flex items-center space-x-2">
|
||||||
</span>
|
<span className="text-sm text-gray-700">{downloaded} of {total}</span>
|
||||||
<progress
|
|
||||||
className="progress is-small is-success"
|
|
||||||
value={props.data.downloaded_bytes}
|
|
||||||
max={props.data.size}
|
|
||||||
>
|
|
||||||
{(parseInt(props.data.downloaded_bytes) / parseInt(props.data.size)) *
|
|
||||||
100}
|
|
||||||
%
|
|
||||||
</progress>
|
|
||||||
</div>
|
|
||||||
<div className="is-size-6 mt-1 mb-2">
|
|
||||||
<p>{prettyBytes(props.data.speed)} per second.</p>
|
|
||||||
Time left:
|
|
||||||
{Math.round(parseInt(props.data.seconds_left) / 60)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>{props.data.target}</div>
|
{/* Progress bar */}
|
||||||
|
<div className="relative mt-2 h-2 bg-gray-200 rounded overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 bg-green-500"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-600">{percent}% complete</div>
|
||||||
|
|
||||||
|
{/* Speed and Time Left */}
|
||||||
|
<div className="mt-2 flex space-x-4 text-sm text-gray-600">
|
||||||
|
<span>Speed: {speed}</span>
|
||||||
|
<span>Time left: {minutesLeft} min</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useEffect, useContext, ReactElement, useState } from "react";
|
import React, { useEffect, ReactElement, useState, useMemo } from "react";
|
||||||
import { RootState } from "threetwo-ui-typings";
|
|
||||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||||
import { AirDCPPBundles } from "./AirDCPPBundles";
|
import { AirDCPPBundles } from "./AirDCPPBundles";
|
||||||
import { TorrentDownloads } from "./TorrentDownloads";
|
import { TorrentDownloads } from "./TorrentDownloads";
|
||||||
@@ -9,47 +8,73 @@ import {
|
|||||||
LIBRARY_SERVICE_BASE_URI,
|
LIBRARY_SERVICE_BASE_URI,
|
||||||
QBITTORRENT_SERVICE_BASE_URI,
|
QBITTORRENT_SERVICE_BASE_URI,
|
||||||
TORRENT_JOB_SERVICE_BASE_URI,
|
TORRENT_JOB_SERVICE_BASE_URI,
|
||||||
SOCKET_BASE_URI,
|
|
||||||
} from "../../constants/endpoints";
|
} from "../../constants/endpoints";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
interface IDownloadsPanelProps {
|
export interface TorrentDetails {
|
||||||
key: number;
|
infoHash: string;
|
||||||
|
progress: number;
|
||||||
|
downloadSpeed?: number;
|
||||||
|
uploadSpeed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadsPanel = (
|
/**
|
||||||
props: IDownloadsPanelProps,
|
* DownloadsPanel displays two tabs of download information for a specific comic:
|
||||||
): ReactElement | null => {
|
* - 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 { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||||
const [infoHashes, setInfoHashes] = useState<string[]>([]);
|
const [infoHashes, setInfoHashes] = useState<string[]>([]);
|
||||||
const [torrentDetails, setTorrentDetails] = useState([]);
|
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState("directconnect");
|
const [activeTab, setActiveTab] = useState<"directconnect" | "torrents">(
|
||||||
const { socketIOInstance } = useStore(
|
"directconnect",
|
||||||
useShallow((state: any) => ({
|
|
||||||
socketIOInstance: state.socketIOInstance,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// React to torrent progress data sent over websockets
|
const { socketIOInstance } = useStore(
|
||||||
socketIOInstance.on("AS_TORRENT_DATA", (data) => {
|
useShallow((state: any) => ({ socketIOInstance: state.socketIOInstance })),
|
||||||
const torrents = data.torrents
|
);
|
||||||
.flatMap(({ _id, details }) => {
|
|
||||||
if (_id === comicObjectId) {
|
|
||||||
return details;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((item) => item !== undefined);
|
|
||||||
setTorrentDetails(torrents);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query to fetch AirDC++ download bundles for a given comic resource Id
|
* Registers socket listeners on mount and cleans up on unmount.
|
||||||
* @param {string} {comicObjectId} - A mongo id that identifies a comic document
|
|
||||||
*/
|
*/
|
||||||
|
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({
|
const { data: bundles } = useQuery({
|
||||||
queryKey: ["bundles"],
|
queryKey: ["bundles", comicObjectId],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
await axios({
|
await axios({
|
||||||
url: `${LIBRARY_SERVICE_BASE_URI}/getBundles`,
|
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
|
// ————— Torrent Jobs (via REST) —————
|
||||||
// triggered by the active tab been set to "torrents"
|
const { data: rawJobs = [] } = useQuery<any[]>({
|
||||||
const { data: torrentData } = useQuery({
|
queryKey: ["torrents", comicObjectId],
|
||||||
queryFn: () =>
|
queryFn: async () => {
|
||||||
axios({
|
const { data } = await axios.get(
|
||||||
url: `${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
|
`${TORRENT_JOB_SERVICE_BASE_URI}/getTorrentData`,
|
||||||
method: "GET",
|
{ params: { trigger: activeTab } },
|
||||||
params: {
|
);
|
||||||
trigger: activeTab,
|
return Array.isArray(data) ? data : [];
|
||||||
},
|
},
|
||||||
}),
|
initialData: [],
|
||||||
queryKey: [activeTab],
|
enabled: activeTab === "torrents",
|
||||||
enabled: activeTab !== "" && 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 (
|
return (
|
||||||
<div className="columns is-multiline">
|
<>
|
||||||
<div>
|
<div className="mt-5 mb-3">
|
||||||
<div className="sm:hidden">
|
<nav className="flex space-x-2">
|
||||||
<label htmlFor="Download Type" className="sr-only">
|
<button
|
||||||
Download Type
|
onClick={() => setActiveTab("directconnect")}
|
||||||
</label>
|
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "directconnect"
|
||||||
|
? "bg-green-500 text-white"
|
||||||
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
DC++
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab("torrents")}
|
||||||
|
className={`px-4 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
activeTab === "torrents"
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Torrents
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<select id="Tab" className="w-full rounded-md border-gray-200">
|
<div className="mt-4">
|
||||||
<option>DC++ Downloads</option>
|
{activeTab === "torrents" ? (
|
||||||
<option>Torrents</option>
|
<TorrentDownloads data={torrentDetails} />
|
||||||
</select>
|
) : !isNil(bundles?.data) && bundles.data.length > 0 ? (
|
||||||
</div>
|
<AirDCPPBundles data={bundles.data} />
|
||||||
|
) : (
|
||||||
<div className="hidden sm:block">
|
<p>No DC++ bundles found.</p>
|
||||||
<nav className="flex gap-6" aria-label="Tabs">
|
)}
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
|
|
||||||
activeTab === "directconnect"
|
|
||||||
? "bg-slate-200 dark:text-slate-200 dark:bg-slate-400 text-slate-800"
|
|
||||||
: "dark:text-slate-400 text-slate-800"
|
|
||||||
}`}
|
|
||||||
aria-current="page"
|
|
||||||
onClick={() => setActiveTab("directconnect")}
|
|
||||||
>
|
|
||||||
DC++ Downloads
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className={`shrink-0 rounded-lg p-2 text-sm font-medium hover:bg-gray-50 hover:text-gray-700 ${
|
|
||||||
activeTab === "torrents"
|
|
||||||
? "bg-slate-200 text-slate-800"
|
|
||||||
: "dark:text-slate-400 text-slate-800"
|
|
||||||
}`}
|
|
||||||
onClick={() => setActiveTab("torrents")}
|
|
||||||
>
|
|
||||||
Torrents
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
{activeTab === "torrents" ? (
|
|
||||||
<TorrentDownloads data={torrentDetails} />
|
|
||||||
) : null}
|
|
||||||
{!isNil(bundles?.data) && bundles?.data.length !== 0 ? (
|
|
||||||
<AirDCPPBundles data={bundles.data} />
|
|
||||||
) : (
|
|
||||||
"nutin"
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DownloadsPanel;
|
export default DownloadsPanel;
|
||||||
|
|||||||
Reference in New Issue
Block a user