Skip to content

feat: add unified torrent management page (MyClient) under Overview#1167

Merged
Rhilip merged 43 commits into
masterfrom
copilot/add-unified-torrent-management
May 15, 2026
Merged

feat: add unified torrent management page (MyClient) under Overview#1167
Rhilip merged 43 commits into
masterfrom
copilot/add-unified-torrent-management

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 9, 2026

  • Resolve merge conflicts (branch was already up-to-date with master)
  • Add downloaderInstance cache in src/entries/offscreen/utils/download.ts:
    • Cache is a Map<downloaderId, { configKey, instance }> keyed by downloader ID
    • configKey is a JSON hash of { id, type, address, username, password, timeout } — the connection-relevant fields
    • Cache hit: same downloaderId + same configKey → reuse existing instance (no re-instantiation)
    • Cache miss: config changed → create new instance and update cache
    • Changed return type of getDownloaderInstance from false | DownloaderInstance to DownloaderInstance | null for cleaner typing
    • Updated downloadTorrentToRemote to use cached getDownloaderInstance instead of calling getDownloader directly

Summary by Sourcery

Add a unified MyClient overview page for managing torrents across download clients and wire it to cached downloader instances and new client control messages.

New Features:

  • Introduce a MyClient overview route and UI to list, search, and control torrents across all configured download clients.
  • Add dialogs for client status, pushing new torrents to downloaders, and confirming torrent deletions with optional data removal.
  • Expose message handlers and config options to fetch client torrents and control them (pause, resume, delete) from the UI.
  • Extend downloader clients with the ability to fetch tracker lists for torrents.

Bug Fixes:

  • Fix SentToDownloader template replacement when torrent.site is missing to avoid accessing metadata for undefined sites.

Enhancements:

  • Introduce a downloader instance cache to reuse client connections when configuration is unchanged and adopt it across status/version and download flows.
  • Adjust qBittorrent, Transmission, and Deluge integrations to support tracker retrieval and modern WebAPI / RPC expectations, including POST-based torrent actions and correct parameter names.
  • Unify styling and behavior for status buttons and delete dialogs, and add table behavior defaults for the MyClient view.

Copilot AI requested review from Copilot and removed request for Copilot April 9, 2026 11:14
Copilot AI requested review from Copilot and removed request for Copilot April 9, 2026 11:16
Copilot AI changed the title [WIP] Add unified management feature for multiple torrent clients feat: add unified torrent management page (MyClient) under Overview Apr 9, 2026
Copilot AI requested a review from Rhilip April 9, 2026 11:19
Copilot AI review requested due to automatic review settings April 9, 2026 13:17
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a unified “My BtClient” (MyClient) Overview page to manage torrents across multiple configured download clients from a single aggregated table, backed by new offscreen message handlers.

Changes:

  • Introduces Overview/MyClient UI with an aggregated v-data-table and per-row / bulk actions.
  • Adds new messaging API (getClientTorrents, pause/resume/deleteClientTorrent) and offscreen handlers delegating to AbstractBittorrentClient.
  • Registers the new route, adds default table behavior config, and adds i18n strings for the new page.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/entries/options/views/Overview/MyClient/Index.vue New unified torrent management UI (table, filters, actions, delete dialog integration).
src/entries/offscreen/utils/download.ts Adds message handlers and a helper to resolve downloader instances, enabling torrent operations.
src/entries/messages.ts Extends the typed message protocol for new MyClient operations.
src/entries/options/plugins/router.ts Registers /my-client under Overview navigation.
src/entries/options/stores/config.ts Adds default tableBehavior settings for the MyClient table.
src/entries/shared/types/storages/config.ts Updates UiTableBehaviorKey typing to include MyClient.
src/locales/en.json Adds MyClient i18n strings (table headers, actions, states, empty text).
src/locales/zh_CN.json Adds MyClient i18n strings (table headers, actions, states, empty text) and minor formatting change.
Comments suppressed due to low confidence (1)

src/entries/shared/types/storages/config.ts:15

  • UiTableBehaviorKey still includes | string, which makes the whole union effectively just string. Adding the literal "MyClient" here doesn't improve type-safety (and won’t meaningfully remove the need for casts) because any string is already allowed. If the intent is to enforce a fixed set of table keys, remove | string; otherwise consider dropping the extra literals to avoid giving a false sense of strictness.
type UiTableBehaviorKey = "SetSite" | "SearchEntity" | "MyData" | "DownloadHistory" | "MyClient" | string;
interface UiTableBehaviorItem<T = string> {
  itemsPerPage?: number;
  columns?: T[];
  sortBy?: { key: T; order: "asc" | "desc" }[];

Comment on lines +38 to +54
const activeDownloaderIds = computed(() =>
selectedDownloaderIds.value.length > 0
? selectedDownloaderIds.value
: enabledDownloaders.value.map((d) => d.id),
);

const filteredTorrents = computed(() => {
if (!searchText.value) return torrents.value;
const q = searchText.value.toLowerCase();
return torrents.value.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
t.infoHash.toLowerCase().includes(q) ||
(t.label ?? "").toLowerCase().includes(q) ||
t.savePath.toLowerCase().includes(q),
);
});
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The downloader filter chips currently don't actually filter the displayed torrents: filteredTorrents only applies searchText, and toggling selectedDownloaderIds won't change the table until the user manually clicks Refresh. Also, when selectedDownloaderIds is empty, activeDownloaderIds treats that as “all enabled” (so the UI shows no chips selected but all clients included). To match the PR description (“toggle which clients are included”), either (a) filter torrents by selectedDownloaderIds in a computed used by the table, or (b) watch selectedDownloaderIds and reload, and initialize the chip group to show all selected by default.

Copilot uses AI. Check for mistakes.
Comment on lines +257 to +264
<v-progress-circular
:model-value="item.progress"
:size="36"
:width="3"
:color="item.isCompleted ? 'green' : 'blue'"
>
<span class="text-caption">{{ item.progress.toFixed(0) }}%</span>
</v-progress-circular>
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI assumes CTorrent.progress is a 0–100 percentage (v-progress-circular :model-value="item.progress" and toFixed(0) + '%'). At least one built-in client returns a 0–1 fraction (e.g., synologyDownloadStation.ts sets progress: download / task.size), which will render as ~0–1% here. Consider normalizing/clamping progress in this view (e.g., treat values <= 1 as a fraction and convert to percent) to keep the unified table consistent across clients.

Copilot uses AI. Check for mistakes.
Comment on lines +308 to +310
<template #item.dateAdded="{ item }">
<span class="text-no-wrap text-caption">{{ formatDate(item.dateAdded * 1000) }}</span>
</template>
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some downloader implementations legitimately return dateAdded: 0 (e.g., Aria2 uses 0 because it can't provide an added time). Rendering formatDate(item.dateAdded * 1000) will show the Unix epoch (1970-01-01) which is misleading. Consider rendering a placeholder (e.g., '-') when dateAdded <= 0 (or when the value is missing/invalid).

Copilot uses AI. Check for mistakes.
Copilot AI requested review from Copilot and removed request for Copilot April 9, 2026 13:28
Copilot AI requested review from Copilot and removed request for Copilot April 9, 2026 14:03
Copilot AI requested review from Copilot and removed request for Copilot April 12, 2026 07:24
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 28 out of 28 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

src/packages/downloader/entity/uTorrent.ts:14

  • CustomPathDescription is imported but not used anywhere in this file, which will fail builds when noUnusedLocals/lint is enabled. Remove the import or use it in the metadata description if intended.
import {
  AbstractBittorrentClient,
  CAddTorrentOptions,
  CustomPathDescription,
  CTorrent,
  TorrentClientConfig,
  TorrentClientMetaData,
  CTorrentState,
  CAddTorrentResult,
} from "../types";

Comment on lines +77 to +88
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);
}
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-refresh timers are keyed per-downloader, but there’s no mechanism to cancel timers for downloaders that become inactive when selectedDownloaderIds changes (filter toggled in ClientStatusDialog). As a result, refresh callbacks can keep running for excluded downloaders, doing unnecessary background requests and re-adding their torrents. Consider checking activeDownloaderIds inside the timeout callback (and/or watching the filter to stop/reschedule timers) and clearing timers for IDs that are no longer active.

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +55
function clearDownloaderTimer(id: string) {
const tid = refreshTimers.get(id);
if (tid) {
clearTimeout(tid);
refreshTimers.delete(id);
}
}
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearDownloaderTimer only clears when tid is truthy. Since setTimeout can theoretically return 0 in some environments, a safer check is tid !== undefined (or tid != null) to ensure timers are always cleared.

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +37
startAutoRefresh,
stopAutoRefresh,
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useClientRefresh() destructuring includes startAutoRefresh and stopAutoRefresh, but they are never used in this component. With noUnusedLocals/lint enabled this will cause a build error. Remove them from the destructuring (or use them).

Suggested change
startAutoRefresh,
stopAutoRefresh,

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 28 out of 28 changed files in this pull request and generated 7 comments.

import {
AbstractBittorrentClient,
CAddTorrentOptions,
CustomPathDescription,
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CustomPathDescription is imported but never used in this file, which will fail lint/typecheck. Please remove the import or use it where intended.

Suggested change
CustomPathDescription,

Copilot uses AI. Check for mistakes.
Comment on lines +163 to +170
<v-btn
v-else
:title="t('MyClient.autoRefresh.stopDownloader')"
color="amber"
size="small"
icon="mdi-stop"
variant="text"
@click.stop="() => suspendedDownloader(d.id)"
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

t('MyClient.autoRefresh.stopDownloader') is referenced here, but this i18n key does not exist in either locale file added/updated in this PR. Add MyClient.autoRefresh.stopDownloader to src/locales/en.json and src/locales/zh_CN.json (or change to an existing key).

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +60
function suspendedDownloader(id: string) {
suspendedDownloaders.value.add(id);
}
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suspendedDownloader() only adds the id to suspendedDownloaders, but does not clear any already-scheduled refresh timer for that downloader. This means a “stopped” downloader can still refresh once more. Consider also calling clearDownloaderTimer(id) (available from useClientRefresh()) when suspending.

Copilot uses AI. Check for mistakes.
}

function torrentKey(torrent: CTorrent) {
return String(torrent.id) + torrent.clientId;
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

torrentKey() concatenates torrent.id and torrent.clientId without a delimiter, which can collide (e.g. id="12" + clientId="3" vs id="1" + clientId="23"). Use a stable separator (like ${clientId}:${id}) to avoid deleting/acting on the wrong torrent.

Suggested change
return String(torrent.id) + torrent.clientId;
return `${torrent.clientId}:${String(torrent.id)}`;

Copilot uses AI. Check for mistakes.
Comment on lines +80 to +85
{
path: "/my-client",
name: "MyClient",
meta: { icon: "mdi-download-network" },
component: () => import("../views/Overview/MyClient/Index.vue"),
},
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description only mentions an axios bump, but this change set adds a new MyClient page/route, downloader messaging APIs, and multiple UI/config changes. Please update the PR title/description to match the actual scope (or split into separate PRs) so reviewers can assess risk appropriately.

Copilot uses AI. Check for mistakes.
const results = await Promise.allSettled(
torrents.map((t) => sendMessage("pauseClientTorrent", { downloaderId: t.clientId, id: t.id })),
);
const succeeded = results.filter((r) => r.status === "fulfilled").length;
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

succeeded is counted as the number of fulfilled promises, but pauseClientTorrent resolves to a boolean and may fulfill with false on failure. This will over-report successes in the snackbar. Count only fulfilled results whose value is truthy (and optionally surface failures).

Suggested change
const succeeded = results.filter((r) => r.status === "fulfilled").length;
const succeeded = results.filter((r) => r.status === "fulfilled" && Boolean(r.value)).length;

Copilot uses AI. Check for mistakes.
Comment on lines +164 to +169
const results = await Promise.allSettled(
torrents.map((t) => sendMessage("resumeClientTorrent", { downloaderId: t.clientId, id: t.id })),
);
const succeeded = results.filter((r) => r.status === "fulfilled").length;
runtimeStore.showSnakebar(t("MyClient.action.resumeSelectedSuccess", { count: succeeded }), { color: "success" });
const affectedIds = [...new Set(torrents.map((t) => t.clientId))];
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as pause: resumeClientTorrent returns a boolean, but the snackbar counts all fulfilled promises as successes even when the value is false. Count only truthy fulfilled values (and optionally report failed ids).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

src/packages/downloader/entity/uTorrent.ts:13

  • CustomPathDescription is imported but not used anywhere in this file. Remove the unused import to avoid dead code and keep imports minimal.
import {
  AbstractBittorrentClient,
  CAddTorrentOptions,
  CustomPathDescription,
  CTorrent,
  TorrentClientConfig,
  TorrentClientMetaData,
  CTorrentState,
  CAddTorrentResult,

Comment thread src/packages/downloader/entity/qBittorrent.ts Outdated
Comment thread src/locales/en.json
"stop": "Stop auto-refresh",
"clientSuspended": "{name} failed 3 times in a row. Auto-refresh suspended. Click the chip to resume.",
"suspendedTip": "This client has been suspended due to 3 consecutive failures. Click the alert icon to resume.",
"resumeDownloader": "Resume auto-refresh for this downloader",
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MyClient.autoRefresh.stopDownloader is used in the UI (ClientStatusDialog.vue), but this locale section doesn't define a stopDownloader string. Add the missing key so the stop button title renders correctly.

Suggested change
"resumeDownloader": "Resume auto-refresh for this downloader",
"resumeDownloader": "Resume auto-refresh for this downloader",
"stopDownloader": "Stop auto-refresh for this downloader",

Copilot uses AI. Check for mistakes.
Comment thread src/locales/zh_CN.json
"stop": "停止自动刷新",
"clientSuspended": "{name} 刷新失败 3 次,已暂停自动刷新。点击下载器标签可恢复。",
"suspendedTip": "该下载器已因连续失败 3 次而被暂停。点击感叹号图标可恢复。",
"resumeDownloader": "恢复该下载器的自动刷新",
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MyClient.autoRefresh.stopDownloader is referenced by the UI (ClientStatusDialog.vue), but this locale section doesn't define a stopDownloader string. Add the missing key so the stop button title renders correctly.

Suggested change
"resumeDownloader": "恢复该下载器的自动刷新",
"resumeDownloader": "恢复该下载器的自动刷新",
"stopDownloader": "停止该下载器的自动刷新",

Copilot uses AI. Check for mistakes.
@Rhilip
Copy link
Copy Markdown
Collaborator

Rhilip commented May 8, 2026

@copilot resolve the merge conflicts in this pull request

…x review issues

Co-authored-by: Rhilip <13842140+Rhilip@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 8, 2026

@copilot resolve the merge conflicts in this pull request

Done in 124a949. Merged the latest master (10 new commits, one actual conflict in qBittorrent.ts's pause/resume methods) and addressed outstanding review feedback at the same time:

  • Conflict (qBittorrent.ts): kept our POST body approach while incorporating master's qBit 5.0+ endpoint detection (/torrents/stop//torrents/start)
  • torrentKey(): now uses ${clientId}:${id} to prevent key collisions
  • pauseTorrents/resumeTorrents: count only fulfilled && Boolean(r.value) as successes
  • clearDownloaderTimer: check tid !== undefined instead of if (tid)
  • suspendedDownloader(): now also calls clearDownloaderTimer(id) to cancel any pending refresh immediately
  • i18n: added missing MyClient.autoRefresh.stopDownloader to en.json and zh_CN.json
  • uTorrent.ts: removed unused CustomPathDescription import

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 4 comments.

Comment on lines +427 to +438
let rawTrackers: Array<{ url: string; tier: number }>;
if (typeof torrent === "object" && Array.isArray(torrent.raw?.trackers)) {
rawTrackers = torrent.raw.trackers;
} else {
const hash = torrent.infoHash || (torrent.id as string);
const result = await this.request<Record<string, Required<DelugeRawTorrent>>>("core.get_torrents_status", [
{ hash },
["trackers"],
]);

const torrentData = Object.values(result)[0];
rawTrackers = torrentData?.trackers;
Comment on lines +80 to +83
if (globalRefreshInterval.value <= 0) return;

clearDownloaderTimer(id);
const tid = window.setTimeout(async () => {
"totalSize",
"leftUntilDone",
"labels",
"trackers",
"label",
"state",
"total_size",
"trackers",
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 8, 2026

Reviewer's Guide

Adds a unified "MyClient" torrent management page under Overview, introduces a cached downloader instance helper used by multiple offscreen message handlers, extends downloader entities with tracker retrieval and correct POST usage for some qBittorrent APIs, and wires new config, messaging, and UI utilities to support cross-client torrent listing/control and bulk operations.

Sequence diagram for MyClient torrent listing and control

sequenceDiagram
  actor User
  participant MyClientIndex as MyClient_IndexVue
  participant Messages as sendMessage
  participant Offscreen as offscreen_download_ts
  participant Downloader as DownloaderInstance

  User->>MyClientIndex: Open MyClient route
  MyClientIndex->>Messages: getClientTorrents(downloaderId)
  Messages->>Offscreen: getClientTorrents(downloaderId)
  Offscreen->>Offscreen: getDownloaderInstance(downloaderId)
  alt cache hit
    Offscreen-->>Offscreen: return cached DownloaderInstance
  else cache miss or config changed
    Offscreen->>Offscreen: getDownloaderConfig(downloaderId)
    Offscreen->>Downloader: new Downloader(config)
    Offscreen-->>Offscreen: cache {configKey, instance}
  end
  Offscreen->>Downloader: getAllTorrents()
  Downloader-->>Offscreen: CTorrent[]
  Offscreen-->>Messages: CTorrent[]
  Messages-->>MyClientIndex: CTorrent[]
  MyClientIndex-->>User: Render unified torrent table

  User->>MyClientIndex: Pause or resume selected torrents
  MyClientIndex->>Messages: pauseClientTorrent or resumeClientTorrent
  Messages->>Offscreen: pauseClientTorrent or resumeClientTorrent
  Offscreen->>Offscreen: getDownloaderInstance(downloaderId)
  Offscreen->>Downloader: pauseTorrent or resumeTorrent
  Downloader-->>Offscreen: boolean
  Offscreen-->>Messages: boolean
  Messages-->>MyClientIndex: boolean
  MyClientIndex-->>User: Update row state and snackbar

  User->>MyClientIndex: Delete selected torrents
  MyClientIndex->>Messages: deleteClientTorrent
  Messages->>Offscreen: deleteClientTorrent
  Offscreen->>Offscreen: getDownloaderInstance(downloaderId)
  Offscreen->>Downloader: removeTorrent(id, removeData)
  Downloader-->>Offscreen: boolean
  Offscreen-->>Messages: boolean
  Messages-->>MyClientIndex: boolean
  MyClientIndex-->>User: Refresh list after delete
Loading

Class diagram for downloader abstraction and tracker retrieval changes

classDiagram
  class AbstractBittorrentClient {
    <<abstract>>
    +DownloaderBaseConfig config
    +login() Promise~boolean~
    +ping() Promise~void~
    +getAllTorrents() Promise~CTorrent[]~
    +pauseTorrent(id any) Promise~boolean~
    +resumeTorrent(id any) Promise~boolean~
    +removeTorrent(id any, removeData boolean) Promise~boolean~
    +getTorrentTrackers(torrent CTorrent) Promise~string[]~
  }

  class QBittorrent {
    +config TorrentClientConfig
    +request~T~(path string, config any) Promise~AxiosResponse~
    +pauseTorrent(hashes string|string[]|"all") Promise~boolean~
    +resumeTorrent(hashes string|string[]|"all") Promise~boolean~
    +removeTorrent(hashes string|string[]|"all", removeData boolean) Promise~boolean~
    +getTorrentTrackers(torrent CTorrent) Promise~string[]~
  }

  class Transmission {
    +request~T~(method TransmissionRequestMethod, args any) Promise~AxiosResponse~
    +pauseTorrent(id any) Promise~boolean~
    +resumeTorrent(id any) Promise~boolean~
    +removeTorrent(id any, removeData boolean) Promise~boolean~
    +getTorrentTrackers(torrent CTorrent) Promise~string[]~
    -rawTorrent_trackers trackers
  }

  class Deluge {
    +request~T~(method string, params any[]) Promise~T~
    +pauseTorrent(id any) Promise~boolean~
    +resumeTorrent(id any) Promise~boolean~
    +removeTorrent(id any, removeData boolean) Promise~boolean~
    +getTorrentTrackers(torrent CTorrent) Promise~string[]~
    -DelugeRawTorrent_trackers trackers?
  }

  class Aria2 {
    +getAllTorrents() Promise~CTorrent[]~
    +pauseTorrent(id any) Promise~boolean~
    +resumeTorrent(id any) Promise~boolean~
    +removeTorrent(id any, removeData boolean) Promise~boolean~
    +getTorrentTrackers(torrent CTorrent) Promise~string[]~
  }

  class Flood {
    +getAllTorrents() Promise~CTorrent[]~
    +pauseTorrent(id any) Promise~boolean~
    +resumeTorrent(id any) Promise~boolean~
    +removeTorrent(id any, removeData boolean) Promise~boolean~
    +getTorrentTrackers(torrent CTorrent) Promise~string[]~
  }

  class RuTorrent {
    +getAllTorrents() Promise~CTorrent[]~
    +pauseTorrent(id any) Promise~boolean~
    +resumeTorrent(id any) Promise~boolean~
    +removeTorrent(id any, removeData boolean) Promise~boolean~
    +getTorrentTrackers(torrent CTorrent) Promise~string[]~
  }

  class SynologyDownloadStation {
    +getAllTorrents() Promise~CTorrent[]~
    +pauseTorrent(id any) Promise~boolean~
    +resumeTorrent(id any) Promise~boolean~
    +removeTorrent(id any, removeData boolean) Promise~boolean~
    +getTorrentTrackers(torrent CTorrent) Promise~string[]~
  }

  class UTorrent {
    +getAllTorrents() Promise~CTorrent[]~
    +pauseTorrent(id any) Promise~boolean~
    +resumeTorrent(id any) Promise~boolean~
    +removeTorrent(id any, removeData boolean) Promise~boolean~
    +getTorrentTrackers(torrent CTorrent) Promise~string[]~
  }

  AbstractBittorrentClient <|-- QBittorrent
  AbstractBittorrentClient <|-- Transmission
  AbstractBittorrentClient <|-- Deluge
  AbstractBittorrentClient <|-- Aria2
  AbstractBittorrentClient <|-- Flood
  AbstractBittorrentClient <|-- RuTorrent
  AbstractBittorrentClient <|-- SynologyDownloadStation
  AbstractBittorrentClient <|-- UTorrent

  class DownloaderInstanceCache {
    -downloaderInstanceCache Map~string, CacheEntry~
    -CacheEntry configKey string
    -CacheEntry instance DownloaderInstance
    +getDownloaderConfigKey(config IDownloaderMetadata) string
    +getDownloaderInstance(downloaderId string) Promise~DownloaderInstance|null~
  }

  DownloaderInstanceCache ..> AbstractBittorrentClient
Loading

Class diagram for MyClient UI components and shared refresh utilities

classDiagram
  class MyClientIndexVue {
    +loading Ref~boolean~
    +tableSelected Ref~CTorrent[]~
    +searchText Ref~string~
    +showDeleteDialog Ref~boolean~
    +showPushToDownloaderDialog Ref~boolean~
    +showRawDialog Ref~boolean~
    +showClientStatusDialog Ref~boolean~
    +rawTorrent Ref~CTorrent|null~
    +allTorrents ComputedRef~CTorrent[]~
    +filteredTorrents ComputedRef~CTorrent[]~
    +fullTableHeader ComputedRef~DataTableHeader[]~
    +tableHeader ComputedRef~DataTableHeader[]~
    +loadTorrents() Promise~void~
    +pauseTorrents(list CTorrent[]) Promise~void~
    +resumeTorrents(list CTorrent[]) Promise~void~
    +openDeleteDialog(list CTorrent[]) void
    +confirmDeleteTorrent(torrentKey string, removeData boolean) Promise~void~
    +openRawDialog(item CTorrent) void
    +clientName(clientId string) string
    +clientIcon(clientId string) string|undefined
    +torrentKey(torrent CTorrent) string
  }

  class ClientStatusDialogVue {
    +showDialog ModelRef~boolean~
    +clientStatuses Ref~Record~string,TorrentClientStatus~~
    +clientVersions Ref~Record~string,string~~
    +clientLoading Ref~Record~string,boolean~~
    +isDownloaderActive(id string) boolean
    +toggleDownloaderFilter(id string) void
    +torrentCountFor(id string) number
    +formatSizeOrDash(v number) string
    +fetchStatusFor(id string) Promise~void~
    +fetchAll() Promise~void~
  }

  class PushToDownloaderDialogVue {
    +showDialog ModelRef~boolean~
    +inputMode Ref~TInputMode~
    +urlInput Ref~string~
    +torrentFiles Ref~File[]~
    +showSentToDownloaderDialog Ref~boolean~
    +pendingTorrentItems Ref~ITorrent[]~
    +cleanStatus() void
    +submit() Promise~void~
  }

  class MyClientDeleteDialogVue {
    +showDialog ModelRef~boolean~
    +toDeleteIds string[]
    +confirmDelete(id string, removeData boolean) Promise~void~
    +removeData Ref~boolean~
    +wrappedConfirmDelete(id string) Promise~void~
  }

  class TorrentStateTdVue {
    +item CTorrent
    -stateDisplay Record~CTorrentState,StateDisplay~
  }

  class MyClientSharedState {
    <<module>>
    +torrents Ref~Record~string,CTorrent[]~~
    +selectedDownloaderIds Ref~string[]~
    +suspendedDownloaders Ref~Set~string~~
    +globalRefreshInterval Ref~number~
    +autoRefreshRunning Ref~boolean~
  }

  class UseClientRefresh {
    +enabledDownloaders ComputedRef~DownloaderMetadata[]~
    +activeDownloaderIds ComputedRef~string[]~
    +loadSingleDownloader(id string) Promise~void~
    +clearDownloaderTimer(id string) void
    +scheduleDownloaderRefresh(id string) void
    +stopAllTimers() void
    +resetRefreshState() void
    +resumeDownloaderRefresh(id string) void
    +startAutoRefresh() void
    +stopAutoRefresh() void
    +toggleAutoRefresh() void
  }

  MyClientIndexVue --> MyClientSharedState : uses
  ClientStatusDialogVue --> MyClientSharedState : uses
  PushToDownloaderDialogVue --> SentToDownloaderDialogVue : opens
  MyClientIndexVue --> MyClientDeleteDialogVue : composes
  MyClientIndexVue --> TorrentStateTdVue : renders
  MyClientIndexVue --> UseClientRefresh : calls
  ClientStatusDialogVue --> UseClientRefresh : calls

  class SentToDownloaderDialogVue {
    +torrentItems ITorrent[]
  }

  class DownloaderConfigStore {
    +saveDownloadHistory boolean
    +startupAutoFetchDownloaderStatus boolean
    +initDownloaderTorrentOnEnter boolean
    +saveLastDownloader boolean
    +allowDirectSendToClient boolean
    +localDownloadMethod TLocalDownloadMethod
    +ignoreSiteDownloadIntervalWhenLocalDownload boolean
  }

  class UiTableBehaviorMyClient {
    +itemsPerPage number
    +columns string[]
    +sortBy any
  }

  MyClientIndexVue --> DownloaderConfigStore : reads
  MyClientIndexVue --> UiTableBehaviorMyClient : reads/writes
Loading

File-Level Changes

Change Details Files
Add cached downloader instance helper and reuse it across offscreen downloader message handlers including new torrent control endpoints.
  • Introduce DownloaderInstance type alias and a Map-based downloaderInstanceCache keyed by downloaderId and a JSONified connection config key.
  • Add getDownloaderInstance that returns an existing instance when config is unchanged or re-instantiates and updates cache when connection-relevant fields differ, returning null when config is missing.
  • Refactor getDownloaderVersion and getDownloaderStatus handlers to use getDownloaderInstance instead of constructing downloaders directly.
  • Add offscreen message handlers getClientTorrents, deleteClientTorrent, pauseClientTorrent, and resumeClientTorrent that delegate to the cached downloader instance.
  • Update downloadTorrentToRemote to use getDownloaderInstance and early-return if the instance cannot be created.
src/entries/offscreen/utils/download.ts
src/entries/messages.ts
Align qBittorrent, Transmission, Deluge and other downloader entities with a unified getTorrentTrackers API and correct HTTP semantics for some qBittorrent torrent operations.
  • In qBittorrent entity, ensure POST requests set application/x-www-form-urlencoded when method is post and change pause/remove/resume torrent operations to send form-encoded body via POST to the appropriate endpoints, mapping removeData to deleteFiles.
  • Add getTorrentTrackers implementation for qBittorrent that prefers trackers parsed from magnet_uri and falls back to /torrents/trackers when needed.
  • Extend Transmission and Deluge raw torrent types and requested fields to include trackers, and implement getTorrentTrackers that returns announce URLs, with RPC fallback when trackers are not present in the cached raw torrent.
  • Add stub getTorrentTrackers implementations that return empty arrays for Aria2, Flood, ruTorrent, SynologyDownloadStation, and uTorrent.
  • Add abstract getTorrentTrackers(torrent: CTorrent) method to AbstractBittorrentClient and ensure all concrete clients implement it.
src/packages/downloader/entity/qBittorrent.ts
src/packages/downloader/entity/Transmission.ts
src/packages/downloader/entity/Deluge.ts
src/packages/downloader/entity/Aria2.ts
src/packages/downloader/entity/Flood.ts
src/packages/downloader/entity/ruTorrent.ts
src/packages/downloader/entity/synologyDownloadStation.ts
src/packages/downloader/entity/uTorrent.ts
src/packages/downloader/types.ts
Introduce the Overview/MyClient page with cross-client torrent listing, filtering, auto-refresh, client status dialog, push-to-downloader helper, and delete-with-data dialog.
  • Create MyClient/Index.vue which renders a data table of CTorrent items aggregated across active downloaders, with search, column selection stored in config, per-row actions (pause, resume, delete, view raw JSON), selection-based bulk actions, and toolbar controls for manual refresh and global auto-refresh.
  • Implement MyClient/utils.ts composable that holds shared torrent state, selected downloader filters, suspended downloader tracking, global refresh interval, and per-client retry/auto-refresh scheduling using offscreen messages to reload torrents.
  • Add ClientStatusDialog.vue to show per-client status/versions, open-address buttons, and per-downloader pause/resume of auto-refresh tied into the shared utils state.
  • Add PushToDownloaderDialog.vue to accept either URLs or .torrent files, normalize them into ITorrent items, and forward them to the existing SentToDownloaderDialog flow for sending to a downloader.
  • Add MyClient-specific DeleteDialog.vue wrapper that extends the base DeleteDialog with a "remove data" checkbox and passes that flag into the per-torrent delete handler.
  • Add TorrentStateTd.vue to render CTorrentState as localized colored chips with icons for use in the MyClient table.
src/entries/options/views/Overview/MyClient/Index.vue
src/entries/options/views/Overview/MyClient/utils.ts
src/entries/options/views/Overview/MyClient/ClientStatusDialog.vue
src/entries/options/views/Overview/MyClient/PushToDownloaderDialog.vue
src/entries/options/views/Overview/MyClient/DeleteDialog.vue
src/entries/options/views/Overview/MyClient/TorrentStateTd.vue
Wire the new MyClient page into routing, config, settings, and shared UI utilities.
  • Register the /my-client route under Overview with icon mdi-download-network and component Overview/MyClient/Index.vue.
  • Extend UiTableBehaviorKey and config schema to support a MyClient table, including default itemsPerPage, visible columns, and sortBy behavior; add initDownloaderTorrentOnEnter flag under download configuration and expose it as a switch in DownloadWindow.vue below the new MyClient section label.
  • Initialize default MyClient table behavior and initDownloaderTorrentOnEnter in the config Pinia store.
  • Extend the main messages protocol map with getClientTorrents, deleteClientTorrent, pauseClientTorrent, and resumeClientTorrent signatures returning CTorrent arrays and booleans as appropriate.
  • Adjust SentToDownloaderDialog utils to only reference torrent.site/torrent.siteName when site is present, avoiding undefined site lookups.
src/entries/options/plugins/router.ts
src/entries/shared/types/storages/config.ts
src/entries/options/views/Settings/SetBase/DownloadWindow.vue
src/entries/options/stores/config.ts
src/entries/messages.ts
src/entries/options/components/SentToDownloaderDialog/utils.ts
Refine shared dialog and styling components to support the new MyClient flows and consolidate status button styling.
  • Enhance the generic DeleteDialog component to accept an append-text slot inside the card body and add a divider above actions, enabling MyClient to inject the "remove data" checkbox.
  • Move the status icon spacing rule into a reusable .status-btn class in options main.scss and apply it to the Overview search status button; remove the old id-based rule and id attribute from the button.
  • Minorly tweak SearchEntity status button markup (class order, icon sizing) to use the shared .status-btn styling.
  • Remove the unused ClientStatusSpan-based status column from the SetDownloader table since MyClient now owns client status display.
src/entries/options/components/DeleteDialog.vue
src/entries/options/main.scss
src/entries/options/views/Overview/SearchEntity/Index.vue
src/entries/options/views/Settings/SetDownloader/Index.vue
src/entries/options/views/Settings/SetDownloader/ClientStatusSpan.vue

Assessment against linked issues

Issue Objective Addressed Explanation
#1166 Add a unified UI page to view and manage torrents from multiple download clients (e.g., qBittorrent, Transmission, etc.) in one place.
#1166 Support cross‑client operations on the same torrent (identify identical torrents via hash/filename and perform actions like deleting a specific hash across all clients). The new MyClient page and related APIs operate per downloaderId and per torrent id only. There is no logic to correlate torrents across different clients by hash/filename, nor to propagate a delete/pause/resume of one torrent to matching torrents on other clients.
#1166 Integrate unified torrent management with media libraries (e.g., Emby) so that deleting torrents can also delete the corresponding media directory. The PR does not add any Emby/media-server linkage or filesystem operations tied to torrent deletion. Changes are limited to downloader clients, UI for MyClient, and related messaging.

Possibly linked issues

  • #: The PR implements a unified multi-client torrent management page with search/pause/resume/delete across clients, matching the requested feature.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've reviewed this pull request using the Sourcery rules engine

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 2 comments.

Comment thread src/packages/downloader/entity/qBittorrent.ts
Comment on lines +36 to +41
v-model="configStore.download.startupAutoFetchDownloaderStatus"
:label="t('SetBase.download.startupAutoFetchDownloaderStatus')"
color="success"
hide-details
/>
<v-switch
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 12 comments.

Comments suppressed due to low confidence (1)

src/entries/options/views/Settings/SetDownloader/ClientStatusSpan.vue:1

  • IDownloaderMetadata.autoFlushStatus is preserved in the storage schema (see src/entries/shared/types/storages/metadata.ts:57) but the only producer/consumer of this field – ClientStatusSpan.vue – is being removed in this PR. No code path now reads or writes autoFlushStatus, so the field becomes dead metadata that will linger in users' stored configuration. Consider either removing the schema field (with a migration) or wiring the new MyClient page to persist globalRefreshInterval into it so the previous per-downloader setting is migrated forward.

}
}

function suspendedDownloader(id: string) {
return true;
}

async getTorrentTrackers(_torrent: string | CTorrent): Promise<string[]> {
Comment on lines +52 to +65
export async function getDownloaderInstance(downloaderId: string): Promise<DownloaderInstance | null> {
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;
}
Comment on lines +57 to +75
async function loadSingleDownloader(id: string): Promise<void> {
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 },
);
}
}
}
Comment on lines +389 to +436
<template #item.progress="{ item }">
<v-progress-circular
:model-value="item.progress"
:size="36"
:width="3"
:color="item.isCompleted ? 'green' : 'blue'"
>
<span class="text-caption">{{ item.progress.toFixed(0) }}%</span>
</v-progress-circular>
</template>

<!-- state column -->
<template #item.state="{ item }">
<TorrentStateTd :item="item" />
</template>

<!-- upload speed -->
<template #item.uploadSpeed="{ item }">
<span v-if="item.uploadSpeed > 0" class="text-no-wrap text-green-darken-2">
{{ formatSize(item.uploadSpeed) }}/s
</span>
<span v-else class="text-grey">-</span>
</template>

<!-- download speed -->
<template #item.downloadSpeed="{ item }">
<span v-if="item.downloadSpeed > 0" class="text-no-wrap text-blue-darken-2">
{{ formatSize(item.downloadSpeed) }}/s
</span>
<span v-else class="text-grey">-</span>
</template>

<!-- total uploaded -->
<template #item.totalUploaded="{ item }">
<span class="text-no-wrap text-green-darken-2">{{ formatSize(item.totalUploaded) }}</span>
</template>

<!-- total downloaded -->
<template #item.totalDownloaded="{ item }">
<span class="text-no-wrap text-blue-darken-2">{{ formatSize(item.totalDownloaded) }}</span>
</template>

<!-- ratio column -->
<template #item.ratio="{ item }">
<span :class="item.ratio >= 1 ? 'text-green' : 'text-red'">
{{ item.ratio.toFixed(2) }}
</span>
</template>
Comment on lines 512 to 519
async removeTorrent(hashes: string | string[] | "all", removeData: boolean = false): Promise<boolean> {
const params = {
const data = {
hashes: hashes === "all" ? "all" : normalizePieces(hashes),
removeData,
deleteFiles: removeData,
};
await this.request("/torrents/delete", { params });
await this.request("/torrents/delete", { method: "post", data });
return true;
}
Comment on lines +65 to +70
if (torrentItems.length === 0) return;

pendingTorrentItems.value = torrentItems;
showDialog.value = false;
showSentToDownloaderDialog.value = true;
}
Comment on lines +479 to +480
if (Array.isArray(torrent.raw.trackers)) {
trackers = torrent.raw.trackers;
Comment on lines +450 to +466
<v-btn-group class="table-action" density="compact" variant="plain">
<v-btn
v-if="item.state === CTorrentState.downloading || item.state === CTorrentState.seeding"
:title="t('MyClient.action.pause')"
color="warning"
icon="mdi-pause"
size="small"
@click="() => pauseTorrents([item])"
/>
<v-btn
v-else-if="item.state === CTorrentState.paused || item.state === CTorrentState.error"
:title="t('MyClient.action.resume')"
color="success"
icon="mdi-play"
size="small"
@click="() => resumeTorrents([item])"
/>
Comment on lines +151 to +171
async function pauseTorrents(torrents: CTorrent[]) {
if (torrents.length === 0) return;
const results = await Promise.allSettled(
torrents.map((t) => sendMessage("pauseClientTorrent", { downloaderId: t.clientId, id: t.id })),
);
const succeeded = results.filter((r) => r.status === "fulfilled" && Boolean(r.value)).length;
runtimeStore.showSnakebar(t("MyClient.action.pauseSelectedSuccess", { count: succeeded }), { color: "success" });
const affectedIds = [...new Set(torrents.map((t) => t.clientId))];
await Promise.allSettled(affectedIds.map(loadSingleDownloader));
}

async function resumeTorrents(torrents: CTorrent[]) {
if (torrents.length === 0) return;
const results = await Promise.allSettled(
torrents.map((t) => sendMessage("resumeClientTorrent", { downloaderId: t.clientId, id: t.id })),
);
const succeeded = results.filter((r) => r.status === "fulfilled" && Boolean(r.value)).length;
runtimeStore.showSnakebar(t("MyClient.action.resumeSelectedSuccess", { count: succeeded }), { color: "success" });
const affectedIds = [...new Set(torrents.map((t) => t.clientId))];
await Promise.allSettled(affectedIds.map(loadSingleDownloader));
}
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 13 comments.

}
}

function suspendedDownloader(id: string) {
</script>

<template>
<v-dialog v-model="showDialog" max-width="800" scrollable @afterEnter="onEnter()">
Comment on lines +300 to +323
<v-combobox
v-model="(configStore.tableBehavior['MyClient'] as any).columns"
:items="fullTableHeader"
:return-object="false"
chips
class="table-header-filter-clear ml-1"
density="compact"
hide-details
item-value="key"
max-width="200"
multiple
prepend-inner-icon="mdi-filter-cog"
:title="t('MyClient.columnSelector')"
@update:model-value="(v) => configStore.updateTableBehavior('MyClient', 'columns', v)"
>
<template #chip="{ item, index }">
<v-chip v-if="index === 0">
<span>{{ item.title }}</span>
</v-chip>
<span v-if="index === 1" class="grey--text caption">
(+{{ (configStore.tableBehavior["MyClient"] as any).columns!.length - 1 }})
</span>
</template>
</v-combobox>
Comment on lines +45 to +65
const downloaderInstanceCache = new Map<string, { configKey: string; instance: DownloaderInstance }>();

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<DownloaderInstance | null> {
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;
}
return true;
}

async getTorrentTrackers(_torrent: string | CTorrent): Promise<string[]> {
Comment on lines +57 to +75
async function loadSingleDownloader(id: string): Promise<void> {
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 },
);
}
}
}
Comment on lines +432 to +437
const result = await this.request<Record<string, Required<DelugeRawTorrent>>>("core.get_torrents_status", [
{ hash },
["trackers"],
]);

const torrentData = Object.values(result)[0];
Comment thread src/locales/en.json
Comment on lines +1188 to +1189
"pauseSelected": "Pause",
"resumeSelected": "Resume",
Comment on lines +424 to +425
<span :class="item.ratio >= 1 ? 'text-green' : 'text-red'">
{{ item.ratio.toFixed(2) }}
Comment on lines 260 to +262
download: {
saveDownloadHistory: true,
startupAutoFetchDownloaderStatus: false,
initDownloaderTorrentOnEnter: false,
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

希望添加多客户端种子统一管理功能:相同种子查找,删除,甚至改名,移动目录等

3 participants