feat: add unified torrent management page (MyClient) under Overview#1167
Conversation
Agent-Logs-Url: https://github.com/pt-plugins/PT-depiler/sessions/c9f44fb7-e62c-4922-a360-7bca711c815c Co-authored-by: Rhilip <13842140+Rhilip@users.noreply.github.com>
…t, improve types Agent-Logs-Url: https://github.com/pt-plugins/PT-depiler/sessions/c9f44fb7-e62c-4922-a360-7bca711c815c Co-authored-by: Rhilip <13842140+Rhilip@users.noreply.github.com>
…oved readability and maintainability
There was a problem hiding this comment.
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/MyClientUI with an aggregatedv-data-tableand per-row / bulk actions. - Adds new messaging API (
getClientTorrents,pause/resume/deleteClientTorrent) and offscreen handlers delegating toAbstractBittorrentClient. - 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
UiTableBehaviorKeystill includes| string, which makes the whole union effectively juststring. 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" }[];
| 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), | ||
| ); | ||
| }); |
There was a problem hiding this comment.
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.
| <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> |
There was a problem hiding this comment.
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.
| <template #item.dateAdded="{ item }"> | ||
| <span class="text-no-wrap text-caption">{{ formatDate(item.dateAdded * 1000) }}</span> | ||
| </template> |
There was a problem hiding this comment.
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).
…nt page Agent-Logs-Url: https://github.com/pt-plugins/PT-depiler/sessions/c5eb9812-36f3-476e-9394-22724da92316 Co-authored-by: Rhilip <13842140+Rhilip@users.noreply.github.com>
…rect Set mutation Agent-Logs-Url: https://github.com/pt-plugins/PT-depiler/sessions/c5eb9812-36f3-476e-9394-22724da92316 Co-authored-by: Rhilip <13842140+Rhilip@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pt-plugins/PT-depiler/sessions/59712124-8a50-4d9c-864b-373df3e68c70 Co-authored-by: Rhilip <13842140+Rhilip@users.noreply.github.com>
Agent-Logs-Url: https://github.com/pt-plugins/PT-depiler/sessions/57eae5b5-d667-4c7c-8911-f3ca3f77219d Co-authored-by: Rhilip <13842140+Rhilip@users.noreply.github.com>
There was a problem hiding this comment.
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
CustomPathDescriptionis imported but not used anywhere in this file, which will fail builds whennoUnusedLocals/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";
| 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); | ||
| } |
There was a problem hiding this comment.
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.
| function clearDownloaderTimer(id: string) { | ||
| const tid = refreshTimers.get(id); | ||
| if (tid) { | ||
| clearTimeout(tid); | ||
| refreshTimers.delete(id); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| startAutoRefresh, | ||
| stopAutoRefresh, |
There was a problem hiding this comment.
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).
| startAutoRefresh, | |
| stopAutoRefresh, |
… suspend functionality
… CTorrent[]> Agent-Logs-Url: https://github.com/pt-plugins/PT-depiler/sessions/2f56a0c6-0de3-4385-ac75-6ed48bec0afa Co-authored-by: Rhilip <13842140+Rhilip@users.noreply.github.com>
…-torrent-management
| import { | ||
| AbstractBittorrentClient, | ||
| CAddTorrentOptions, | ||
| CustomPathDescription, |
There was a problem hiding this comment.
CustomPathDescription is imported but never used in this file, which will fail lint/typecheck. Please remove the import or use it where intended.
| CustomPathDescription, |
| <v-btn | ||
| v-else | ||
| :title="t('MyClient.autoRefresh.stopDownloader')" | ||
| color="amber" | ||
| size="small" | ||
| icon="mdi-stop" | ||
| variant="text" | ||
| @click.stop="() => suspendedDownloader(d.id)" |
There was a problem hiding this comment.
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).
| function suspendedDownloader(id: string) { | ||
| suspendedDownloaders.value.add(id); | ||
| } |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| function torrentKey(torrent: CTorrent) { | ||
| return String(torrent.id) + torrent.clientId; |
There was a problem hiding this comment.
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.
| return String(torrent.id) + torrent.clientId; | |
| return `${torrent.clientId}:${String(torrent.id)}`; |
| { | ||
| path: "/my-client", | ||
| name: "MyClient", | ||
| meta: { icon: "mdi-download-network" }, | ||
| component: () => import("../views/Overview/MyClient/Index.vue"), | ||
| }, |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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).
| const succeeded = results.filter((r) => r.status === "fulfilled").length; | |
| const succeeded = results.filter((r) => r.status === "fulfilled" && Boolean(r.value)).length; |
| 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))]; |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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
CustomPathDescriptionis 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,
| "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", |
There was a problem hiding this comment.
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.
| "resumeDownloader": "Resume auto-refresh for this downloader", | |
| "resumeDownloader": "Resume auto-refresh for this downloader", | |
| "stopDownloader": "Stop auto-refresh for this downloader", |
| "stop": "停止自动刷新", | ||
| "clientSuspended": "{name} 刷新失败 3 次,已暂停自动刷新。点击下载器标签可恢复。", | ||
| "suspendedTip": "该下载器已因连续失败 3 次而被暂停。点击感叹号图标可恢复。", | ||
| "resumeDownloader": "恢复该下载器的自动刷新", |
There was a problem hiding this comment.
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.
| "resumeDownloader": "恢复该下载器的自动刷新", | |
| "resumeDownloader": "恢复该下载器的自动刷新", | |
| "stopDownloader": "停止该下载器的自动刷新", |
|
@copilot resolve the merge conflicts in this pull request |
…x review issues Co-authored-by: Rhilip <13842140+Rhilip@users.noreply.github.com>
Done in 124a949. Merged the latest master (10 new commits, one actual conflict in
|
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
| 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; |
| if (globalRefreshInterval.value <= 0) return; | ||
|
|
||
| clearDownloaderTimer(id); | ||
| const tid = window.setTimeout(async () => { |
| "totalSize", | ||
| "leftUntilDone", | ||
| "labels", | ||
| "trackers", |
| "label", | ||
| "state", | ||
| "total_size", | ||
| "trackers", |
Agent-Logs-Url: https://github.com/pt-plugins/PT-depiler/sessions/0121aeb3-aa84-4903-af09-b506e949f2df Co-authored-by: Rhilip <13842140+Rhilip@users.noreply.github.com>
Reviewer's GuideAdds 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 controlsequenceDiagram
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
Class diagram for downloader abstraction and tracker retrieval changesclassDiagram
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
Class diagram for MyClient UI components and shared refresh utilitiesclassDiagram
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
File-Level Changes
Assessment against linked issues
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
| v-model="configStore.download.startupAutoFetchDownloaderStatus" | ||
| :label="t('SetBase.download.startupAutoFetchDownloaderStatus')" | ||
| color="success" | ||
| hide-details | ||
| /> | ||
| <v-switch |
There was a problem hiding this comment.
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.autoFlushStatusis preserved in the storage schema (seesrc/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 writesautoFlushStatus, 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 persistglobalRefreshIntervalinto it so the previous per-downloader setting is migrated forward.
| } | ||
| } | ||
|
|
||
| function suspendedDownloader(id: string) { |
| return true; | ||
| } | ||
|
|
||
| async getTorrentTrackers(_torrent: string | CTorrent): Promise<string[]> { |
| 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; | ||
| } |
| 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 }, | ||
| ); | ||
| } | ||
| } | ||
| } |
| <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> |
| 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; | ||
| } |
| if (torrentItems.length === 0) return; | ||
|
|
||
| pendingTorrentItems.value = torrentItems; | ||
| showDialog.value = false; | ||
| showSentToDownloaderDialog.value = true; | ||
| } |
| if (Array.isArray(torrent.raw.trackers)) { | ||
| trackers = torrent.raw.trackers; |
| <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])" | ||
| /> |
| 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)); | ||
| } |
…idating downloader calls
| } | ||
| } | ||
|
|
||
| function suspendedDownloader(id: string) { |
| </script> | ||
|
|
||
| <template> | ||
| <v-dialog v-model="showDialog" max-width="800" scrollable @afterEnter="onEnter()"> |
| <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> |
| 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[]> { |
| 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 }, | ||
| ); | ||
| } | ||
| } | ||
| } |
| const result = await this.request<Record<string, Required<DelugeRawTorrent>>>("core.get_torrents_status", [ | ||
| { hash }, | ||
| ["trackers"], | ||
| ]); | ||
|
|
||
| const torrentData = Object.values(result)[0]; |
| "pauseSelected": "Pause", | ||
| "resumeSelected": "Resume", |
| <span :class="item.ratio >= 1 ? 'text-green' : 'text-red'"> | ||
| {{ item.ratio.toFixed(2) }} |
| download: { | ||
| saveDownloadHistory: true, | ||
| startupAutoFetchDownloaderStatus: false, | ||
| initDownloaderTorrentOnEnter: false, |
downloaderInstancecache insrc/entries/offscreen/utils/download.ts:Map<downloaderId, { configKey, instance }>keyed by downloader IDconfigKeyis a JSON hash of{ id, type, address, username, password, timeout }— the connection-relevant fieldsdownloaderId+ sameconfigKey→ reuse existing instance (no re-instantiation)getDownloaderInstancefromfalse | DownloaderInstancetoDownloaderInstance | nullfor cleaner typingdownloadTorrentToRemoteto use cachedgetDownloaderInstanceinstead of callinggetDownloaderdirectlySummary 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:
Bug Fixes:
Enhancements: