diff --git a/src/entries/messages.ts b/src/entries/messages.ts index cef7523b5..6a72ccf9d 100644 --- a/src/entries/messages.ts +++ b/src/entries/messages.ts @@ -11,7 +11,7 @@ import type { import type { ISocialInformation, TSupportSocialSite$1 } from "@ptd/social"; import type { IMediaServerId, IMediaServerSearchOptions, IMediaServerSearchResult } from "@ptd/mediaServer"; import type { IBackupData, IBackupFileInfo } from "@ptd/backupServer"; -import type { TorrentClientStatus } from "@ptd/downloader"; +import type { CTorrent, TorrentClientStatus } from "@ptd/downloader"; // 可序列化的种子信息,用于辅种检测 export interface ITorrentInfoForVerification { @@ -113,6 +113,11 @@ interface ProtocolMap extends TMessageMap { getTorrentDownloadLink(torrent: ITorrent): string; getTorrentInfoForVerification(torrent: ITorrent): ITorrentInfoForVerification; + getClientTorrents(downloaderId: string): CTorrent[]; + deleteClientTorrent(data: { downloaderId: string; id: any; removeData?: boolean }): boolean; + pauseClientTorrent(data: { downloaderId: string; id: any }): boolean; + resumeClientTorrent(data: { downloaderId: string; id: any }): boolean; + downloadTorrent(data: IDownloadTorrentOption): IDownloadTorrentResult; getDownloadHistory(): ITorrentDownloadMetadata[]; diff --git a/src/entries/offscreen/utils/download.ts b/src/entries/offscreen/utils/download.ts index 8b3f7863c..e332fe36e 100644 --- a/src/entries/offscreen/utils/download.ts +++ b/src/entries/offscreen/utils/download.ts @@ -6,6 +6,7 @@ import { isEmpty } from "es-toolkit/compat"; import { getDownloader, getRemoteTorrentFile, + type CTorrent, type CAddTorrentOptions, type TorrentClientStatus, } from "@ptd/downloader"; @@ -39,6 +40,30 @@ export async function getDownloaderConfig(downloaderId: string) { return metadataStore?.downloaders?.[downloaderId] ?? ({} as IDownloaderMetadata); } +type DownloaderInstance = Awaited>; + +const downloaderInstanceCache = new Map(); + +function getDownloaderConfigKey(config: IDownloaderMetadata): string { + const { id, type, address, username, password, timeout } = config; + return JSON.stringify({ id, type, address, username, password, timeout }); +} + +export async function getDownloaderInstance(downloaderId: string): Promise { + const downloaderConfig = await getDownloaderConfig(downloaderId); + if (!downloaderConfig.id) return null; + + const configKey = getDownloaderConfigKey(downloaderConfig); + const cached = downloaderInstanceCache.get(downloaderId); + if (cached && cached.configKey === configKey) { + return cached.instance; + } + + const instance = await getDownloader(downloaderConfig); + downloaderInstanceCache.set(downloaderId, { configKey, instance }); + return instance; +} + onMessage("getDownloaderConfig", async ({ data: downloaderId }) => await getDownloaderConfig(downloaderId)); onMessage("getDownloaderList", async () => { @@ -56,9 +81,8 @@ onMessage("getDownloaderList", async () => { onMessage("getDownloaderVersion", async ({ data: downloaderId }) => { let downloaderVersion = "unknown"; - const downloaderConfig = await getDownloaderConfig(downloaderId); - if (downloaderConfig.id) { - const downloaderInstance = await getDownloader(downloaderConfig); + const downloaderInstance = await getDownloaderInstance(downloaderId); + if (downloaderInstance) { downloaderVersion = await downloaderInstance.getClientVersion(); } @@ -68,9 +92,8 @@ onMessage("getDownloaderVersion", async ({ data: downloaderId }) => { onMessage("getDownloaderStatus", async ({ data: downloaderId }) => { let downloaderStatus: TorrentClientStatus = { dlSpeed: 0, upSpeed: 0, dlData: 0, upData: 0 }; - const downloaderConfig = await getDownloaderConfig(downloaderId); - if (downloaderConfig.id) { - const downloaderInstance = await getDownloader(downloaderConfig); + const downloaderInstance = await getDownloaderInstance(downloaderId); + if (downloaderInstance) { downloaderStatus = await downloaderInstance.getClientStatus(); } @@ -108,6 +131,44 @@ export async function getTorrentInfoForVerification(torrent: ITorrent) { onMessage("getTorrentInfoForVerification", async ({ data: torrent }) => await getTorrentInfoForVerification(torrent)); +onMessage("getClientTorrents", async ({ data: downloaderId }) => { + let downloaderTorrents: CTorrent[] = []; + const downloaderInstance = await getDownloaderInstance(downloaderId); + if (downloaderInstance) { + downloaderTorrents = await downloaderInstance.getAllTorrents(); + } + return downloaderTorrents; +}); + +onMessage("deleteClientTorrent", async ({ data: { downloaderId, id, removeData } }) => { + let deleteStatus: boolean = false; + const downloaderInstance = await getDownloaderInstance(downloaderId); + if (downloaderInstance) { + deleteStatus = await downloaderInstance.removeTorrent(id, removeData ?? false); + } + return deleteStatus; +}); + +onMessage("pauseClientTorrent", async ({ data: { downloaderId, id } }) => { + let pauseStatus: boolean = false; + const downloaderInstance = await getDownloaderInstance(downloaderId); + if (downloaderInstance) { + pauseStatus = await downloaderInstance.pauseTorrent(id); + } + + return pauseStatus; +}); + +onMessage("resumeClientTorrent", async ({ data: { downloaderId, id } }) => { + let resumeStatus: boolean = false; + const downloaderInstance = await getDownloaderInstance(downloaderId); + if (downloaderInstance) { + resumeStatus = await downloaderInstance.resumeTorrent(id); + } + + return resumeStatus; +}); + function buildDownloadHistory(downloadOption: IDownloadTorrentOption): ITorrentDownloadMetadata { const { torrent = {}, downloaderId = "local" } = downloadOption; return { @@ -298,7 +359,8 @@ async function downloadTorrentToRemote( const downloaderConfig = await getDownloaderConfig(downloaderId); if (downloaderConfig.id && downloaderConfig.enabled) { - const downloaderInstance = await getDownloader(downloaderConfig); + const downloaderInstance = await getDownloaderInstance(downloaderId); + if (!downloaderInstance) return downloadStatus; if (addTorrentOptions.localDownload) { addTorrentOptions.localDownloadOption = downloadRequestConfig; } diff --git a/src/entries/options/components/DeleteDialog.vue b/src/entries/options/components/DeleteDialog.vue index 34df5d493..32d229f36 100644 --- a/src/entries/options/components/DeleteDialog.vue +++ b/src/entries/options/components/DeleteDialog.vue @@ -37,10 +37,14 @@ async function dialogEnter() { {{ t("common.dialog.title.confirmAction") }} - + {{ t("common.dialog.deleteText", [toDeleteIds!.length]) }} + + + + diff --git a/src/entries/options/components/SentToDownloaderDialog/utils.ts b/src/entries/options/components/SentToDownloaderDialog/utils.ts index 2d0362db4..82e000025 100644 --- a/src/entries/options/components/SentToDownloaderDialog/utils.ts +++ b/src/entries/options/components/SentToDownloaderDialog/utils.ts @@ -54,12 +54,15 @@ export function sendTorrentToDownloader( const replaceMap: Record = { "torrent.title": torrent.title ?? "", "torrent.subTitle": torrent.subTitle ?? "", - "torrent.site": torrent.site, - "torrent.siteName": await metadataStore.getSiteName(torrent.site), "torrent.category": (torrent.category as string) ?? "", ...baseReplaceMap, }; + if (torrent.site) { + replaceMap["torrent.site"] = torrent.site; + replaceMap["torrent.siteName"] = await metadataStore.getSiteName(torrent.site); + } + for (const key of ["savePath", "label"] as (keyof typeof realAddTorrentOptions)[]) { if (realAddTorrentOptions[key]) { if (realAddTorrentOptions[key] === "") { diff --git a/src/entries/options/main.scss b/src/entries/options/main.scss index 28edf03aa..5c295faa8 100644 --- a/src/entries/options/main.scss +++ b/src/entries/options/main.scss @@ -50,3 +50,9 @@ a:not(:hover) { .list-item-half-spacer > .v-list-item__prepend > .v-icon ~ .v-list-item__spacer { width: 12px; } + +.status-btn { + i.v-icon + i.v-icon { + margin-left: 4px; + } +} diff --git a/src/entries/options/plugins/router.ts b/src/entries/options/plugins/router.ts index bef5b13ce..a213c663c 100644 --- a/src/entries/options/plugins/router.ts +++ b/src/entries/options/plugins/router.ts @@ -77,6 +77,12 @@ export const routes: RouteRecordRaw[] = [ meta: { icon: "mdi-play-network" }, component: () => import("../views/Overview/MediaServerEntity/Index.vue"), }, + { + path: "/my-client", + name: "MyClient", + meta: { icon: "mdi-download-network" }, + component: () => import("../views/Overview/MyClient/Index.vue"), + }, { path: "/download-history", name: "DownloadHistory", diff --git a/src/entries/options/stores/config.ts b/src/entries/options/stores/config.ts index 1a8deecde..ae4a7143e 100644 --- a/src/entries/options/stores/config.ts +++ b/src/entries/options/stores/config.ts @@ -139,6 +139,22 @@ export const useConfigStore = defineStore("config", { itemsPerPage: 10, sortBy: [{ key: "enabled", order: "desc" }], }, + MyClient: { + itemsPerPage: 25, + columns: [ + "clientId", + "name", + "totalSize", + "progress", + "state", + "ratio", + "uploadSpeed", + "downloadSpeed", + "dateAdded", + "action", + ], + sortBy: [{ key: "dateAdded", order: "desc" }], + }, SetSearchSolution: { itemsPerPage: 10, }, @@ -243,7 +259,7 @@ export const useConfigStore = defineStore("config", { download: { saveDownloadHistory: true, - startupAutoFetchDownloaderStatus: false, + initDownloaderTorrentOnEnter: false, saveLastDownloader: false, allowDirectSendToClient: false, localDownloadMethod: "browser", diff --git a/src/entries/options/views/Overview/MyClient/ClientStatusDialog.vue b/src/entries/options/views/Overview/MyClient/ClientStatusDialog.vue new file mode 100644 index 000000000..59087c32e --- /dev/null +++ b/src/entries/options/views/Overview/MyClient/ClientStatusDialog.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/src/entries/options/views/Overview/MyClient/DeleteDialog.vue b/src/entries/options/views/Overview/MyClient/DeleteDialog.vue new file mode 100644 index 000000000..f38bfae9e --- /dev/null +++ b/src/entries/options/views/Overview/MyClient/DeleteDialog.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/src/entries/options/views/Overview/MyClient/Index.vue b/src/entries/options/views/Overview/MyClient/Index.vue new file mode 100644 index 000000000..3e5de88e4 --- /dev/null +++ b/src/entries/options/views/Overview/MyClient/Index.vue @@ -0,0 +1,513 @@ + + + + + diff --git a/src/entries/options/views/Overview/MyClient/PushToDownloaderDialog.vue b/src/entries/options/views/Overview/MyClient/PushToDownloaderDialog.vue new file mode 100644 index 000000000..08df5d9e8 --- /dev/null +++ b/src/entries/options/views/Overview/MyClient/PushToDownloaderDialog.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/src/entries/options/views/Overview/MyClient/TorrentStateTd.vue b/src/entries/options/views/Overview/MyClient/TorrentStateTd.vue new file mode 100644 index 000000000..a1467b99c --- /dev/null +++ b/src/entries/options/views/Overview/MyClient/TorrentStateTd.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/entries/options/views/Overview/MyClient/utils.ts b/src/entries/options/views/Overview/MyClient/utils.ts new file mode 100644 index 000000000..f6b61c653 --- /dev/null +++ b/src/entries/options/views/Overview/MyClient/utils.ts @@ -0,0 +1,145 @@ +import { computed, ref } from "vue"; + +import type { CTorrent } from "@ptd/downloader"; +import { sendMessage } from "@/messages.ts"; +import { useMetadataStore } from "@/options/stores/metadata.ts"; +import { useRuntimeStore } from "@/options/stores/runtime.ts"; +import { useI18n } from "vue-i18n"; + +// ── module-level shared state ───────────────────────────────────────────── + +/** Loaded torrent map keyed by clientId, shared between Index.vue and ClientStatusDialog.vue. */ +export const torrents = ref>({}); + +/** Which downloader IDs are selected in the torrent filter (empty = all). */ +export const selectedDownloaderIds = ref([]); + +/** Downloaders whose auto-refresh has been suspended due to ≥3 consecutive failures. */ +export const suspendedDownloaders = ref(new Set()); + +/** Global auto-refresh interval in seconds (0 = off). */ +export const globalRefreshInterval = ref(0); + +/** Whether auto-refresh is currently running. */ +export const autoRefreshRunning = ref(false); + +// private – not reactive, managed by the composable only +const failCounts = new Map(); +const refreshTimers = new Map(); + +// ── composable ──────────────────────────────────────────────────────────── + +/** + * Composable providing auto-refresh logic for the MyClient page. + * All state is module-level and shared across component instances. + */ +export function useClientRefresh() { + const { t } = useI18n(); + const metadataStore = useMetadataStore(); + const runtimeStore = useRuntimeStore(); + + const enabledDownloaders = computed(() => metadataStore.getEnabledDownloaders); + + const activeDownloaderIds = computed(() => + selectedDownloaderIds.value.length > 0 + ? selectedDownloaderIds.value + : enabledDownloaders.value.map((d) => d.id), + ); + + function clearDownloaderTimer(id: string) { + const tid = refreshTimers.get(id); + if (tid !== undefined) { + clearTimeout(tid); + refreshTimers.delete(id); + } + } + + async function loadSingleDownloader(id: string): Promise { + try { + const result = await sendMessage("getClientTorrents", id); + torrents.value = { ...torrents.value, [id]: result }; + failCounts.set(id, 0); + } catch { + const prev = failCounts.get(id) ?? 0; + const next = prev + 1; + failCounts.set(id, next); + if (next >= 3) { + suspendedDownloaders.value.add(id); + clearDownloaderTimer(id); + runtimeStore.showSnakebar( + t("MyClient.autoRefresh.clientSuspended", { name: metadataStore.downloaders[id]?.name ?? id }), + { color: "error", timeout: 8000 }, + ); + } + } + } + + function scheduleDownloaderRefresh(id: string) { + if (!autoRefreshRunning.value) return; + if (suspendedDownloaders.value.has(id)) return; + if (globalRefreshInterval.value <= 0) return; + + clearDownloaderTimer(id); + const tid = window.setTimeout(async () => { + await loadSingleDownloader(id); + scheduleDownloaderRefresh(id); + }, globalRefreshInterval.value * 1000); + refreshTimers.set(id, tid); + } + + function stopAllTimers() { + for (const id of refreshTimers.keys()) { + clearDownloaderTimer(id); + } + autoRefreshRunning.value = false; + } + + /** Reset failure-tracking and suspended state (call before a manual full reload). */ + function resetRefreshState() { + suspendedDownloaders.value = new Set(); + failCounts.clear(); + } + + function resumeDownloaderRefresh(id: string) { + suspendedDownloaders.value.delete(id); + failCounts.set(id, 0); + if (autoRefreshRunning.value) { + scheduleDownloaderRefresh(id); + } + } + + function startAutoRefresh() { + if (globalRefreshInterval.value <= 0) return; + autoRefreshRunning.value = true; + for (const id of activeDownloaderIds.value) { + scheduleDownloaderRefresh(id); + } + } + + function stopAutoRefresh() { + stopAllTimers(); + resetRefreshState(); + } + + function toggleAutoRefresh() { + if (autoRefreshRunning.value) { + stopAutoRefresh(); + } else { + startAutoRefresh(); + } + } + + return { + enabledDownloaders, + activeDownloaderIds, + loadSingleDownloader, + clearDownloaderTimer, + scheduleDownloaderRefresh, + stopAllTimers, + resetRefreshState, + resumeDownloaderRefresh, + startAutoRefresh, + stopAutoRefresh, + toggleAutoRefresh, + }; +} diff --git a/src/entries/options/views/Overview/SearchEntity/Index.vue b/src/entries/options/views/Overview/SearchEntity/Index.vue index 610cb9b46..72810ea58 100644 --- a/src/entries/options/views/Overview/SearchEntity/Index.vue +++ b/src/entries/options/views/Overview/SearchEntity/Index.vue @@ -181,21 +181,20 @@ function cancelSearchQueue() { @@ -440,10 +439,4 @@ function cancelSearchQueue() { padding: 0 8px; } } - -#ptd-search-entity-status { - i.v-icon + i.v-icon { - margin-left: 4px; - } -} diff --git a/src/entries/options/views/Settings/SetBase/DownloadWindow.vue b/src/entries/options/views/Settings/SetBase/DownloadWindow.vue index ce7b4730d..94ef6d875 100644 --- a/src/entries/options/views/Settings/SetBase/DownloadWindow.vue +++ b/src/entries/options/views/Settings/SetBase/DownloadWindow.vue @@ -26,9 +26,15 @@ async function clearLastDownloader(v: boolean) { false-icon="mdi-alert-octagon" hide-details /> + + + + + + {{ t("SetBase.download.myClientTitle") }} diff --git a/src/entries/options/views/Settings/SetDownloader/ClientStatusSpan.vue b/src/entries/options/views/Settings/SetDownloader/ClientStatusSpan.vue deleted file mode 100644 index 5061e6f56..000000000 --- a/src/entries/options/views/Settings/SetDownloader/ClientStatusSpan.vue +++ /dev/null @@ -1,146 +0,0 @@ - - - - - diff --git a/src/entries/options/views/Settings/SetDownloader/Index.vue b/src/entries/options/views/Settings/SetDownloader/Index.vue index 6ed4e1a71..71aa26b56 100644 --- a/src/entries/options/views/Settings/SetDownloader/Index.vue +++ b/src/entries/options/views/Settings/SetDownloader/Index.vue @@ -19,7 +19,6 @@ import DefaultDownloaderEditDialog from "./DefaultDownloaderEditDialog.vue"; import DeleteDialog from "@/options/components/DeleteDialog.vue"; import NavButton from "@/options/components/NavButton.vue"; -import ClientStatusSpan from "@/options/views/Settings/SetDownloader/ClientStatusSpan.vue"; const { t } = useI18n(); const metadataStore = useMetadataStore(); @@ -47,7 +46,6 @@ const fullTableHeader = [ { title: t("SetDownloader.common.name"), key: "name", align: "start" }, { title: t("SetDownloader.common.address"), key: "address", align: "start" }, { title: t("common.username"), key: "username", align: "start" }, - { title: t("SetDownloader.common.status"), key: "status", align: "end", sortable: false }, { title: t("SetDownloader.index.table.enabled"), key: "enabled", align: "center" }, { title: t("SetDownloader.index.table.autodl"), key: "feature.DefaultAutoStart", align: "center" }, { title: t("common.action"), key: "action", sortable: false }, @@ -161,9 +159,9 @@ async function confirmDeleteDownloader(downloaderId: TDownloaderKey) { - {{ - t("SetDownloader.index.table.downloaderCategory") - }} + + {{ t("SetDownloader.index.table.downloaderCategory") }} + - -