Add dead torrent handling (#627)

This commit is contained in:
Flaminel
2026-06-14 02:14:10 +03:00
committed by GitHub
parent 1cc068c2ab
commit 7aa3224f4d
54 changed files with 4207 additions and 19 deletions

View File

@@ -5,6 +5,7 @@ import {
DownloadCleanerConfig,
SeedingRule,
UnlinkedConfigModel,
DeadTorrentConfigModel,
OrphanedFilesConfig,
} from '@shared/models/download-cleaner-config.model';
@@ -42,12 +43,17 @@ export class DownloadCleanerApi {
}
// Unlinked config
updateUnlinkedConfig(clientId: string, config: Partial<UnlinkedConfigModel>): Observable<void> {
updateUnlinkedConfig(clientId: string, config: UnlinkedConfigModel): Observable<void> {
return this.http.put<void>(`/api/unlinked-config/${clientId}`, config);
}
// Dead torrent config
updateDeadTorrentConfig(clientId: string, config: DeadTorrentConfigModel): Observable<void> {
return this.http.put<void>(`/api/dead-torrent-config/${clientId}`, config);
}
// Per-client orphaned files config
updateOrphanedFilesConfig(clientId: string, config: Partial<OrphanedFilesConfig>): Observable<OrphanedFilesConfig> {
updateOrphanedFilesConfig(clientId: string, config: OrphanedFilesConfig): Observable<OrphanedFilesConfig> {
return this.http.put<OrphanedFilesConfig>(`/api/orphaned-files-config/${clientId}`, config);
}
}

View File

@@ -90,6 +90,11 @@ export class DocumentationService {
'downloadDirectoryTarget': 'download-directory-source-and-local-directory-target',
'unlinkedIgnoredRootDir': 'ignored-root-directory',
'unlinkedCategories': 'unlinked-categories',
'deadTorrentEnabled': 'enable-dead-torrent-handling',
'deadTorrentTargetCategory': 'dead-torrent-target-category',
'deadTorrentUseTag': 'dead-torrent-use-tag',
'deadTorrentStrikes': 'dead-torrent-strikes',
'deadTorrentCategories': 'dead-torrent-categories',
'orphanedFilesEnabled': 'enabled-per-client',
'orphanedFilesScanDirectories': 'scan-directories',
'orphanedFilesOrphanedDirectory': 'orphaned-directory',

View File

@@ -201,6 +201,48 @@
</div>
</app-accordion>
@if (isDeadTorrentCapableClient()) {
<app-accordion header="Dead Torrents" subtitle="Triage torrents with no seeders" [(expanded)]="deadTorrentExpanded">
<div class="form-stack">
<app-toggle label="Enabled" [checked]="client.deadTorrentConfig?.enabled ?? false"
(checkedChange)="updateDeadTorrentField('enabled', $event)"
hint="When enabled, torrents reporting no seeders (or whose tracker is unreachable) for a number of consecutive runs are moved to a target category/tag"
helpKey="download-cleaner:deadTorrentEnabled" />
@if (client.deadTorrentConfig?.enabled) {
<app-input label="Target Category" placeholder="cleanuparr-dead" [value]="client.deadTorrentConfig?.targetCategory ?? ''"
(valueChange)="updateDeadTorrentField('targetCategory', $event)"
hint="Category/tag dead torrents are moved to. Create a seeding rule for this category to control what happens next."
helpKey="download-cleaner:deadTorrentTargetCategory" />
@if (isTagFilterableClient()) {
<app-toggle [label]="isSelectedClientTransmission() ? 'Use Label Instead' : 'Use Tag Instead'" [checked]="client.deadTorrentConfig?.useTag ?? false"
(checkedChange)="updateDeadTorrentField('useTag', $event)"
[hint]="isSelectedClientTransmission() ? 'When enabled, adds a label instead of changing the category' : 'When enabled, uses a tag instead of category'"
helpKey="download-cleaner:deadTorrentUseTag" />
}
<app-number-input label="Strikes" [value]="client.deadTorrentConfig?.maxStrikes ?? null" [min]="3" [step]="1"
(valueChange)="updateDeadTorrentField('maxStrikes', $event ?? 0)"
[error]="deadTorrentStrikesError()"
hint="Consecutive runs with no seeders before moving the torrent (minimum 3). Set this high enough to ride out tracker downtime — e.g. with an hourly cleaner, 168 ≈ 1 week. Strikes reset once seeders are found again."
helpKey="download-cleaner:deadTorrentStrikes" />
<div class="form-divider"></div>
<app-chip-input label="Categories" placeholder="Add category..."
[items]="client.deadTorrentConfig?.categories ?? []"
(itemsChange)="updateDeadTorrentField('categories', $event)"
hint="Categories to scan for dead torrents"
[error]="deadTorrentCategoriesError()"
helpKey="download-cleaner:deadTorrentCategories" />
}
<div class="form-actions">
<app-button variant="primary" [glowing]="deadTorrentDirty()" [loading]="deadTorrentSaving()" [disabled]="deadTorrentSaving() || deadTorrentSaved() || !deadTorrentDirty() || !!deadTorrentCategoriesError() || !!deadTorrentStrikesError()" (clicked)="saveDeadTorrentConfig()">
{{ deadTorrentSaved() ? 'Saved!' : 'Save' }}
</app-button>
</div>
</div>
</app-accordion>
}
<app-accordion header="Orphaned Files" subtitle="Move files not associated with any active torrent" [(expanded)]="orphanedFilesExpanded">
<div class="form-stack">
<app-toggle

View File

@@ -15,8 +15,8 @@ import { ToastService } from '@core/services/toast.service';
import { ConfirmService } from '@core/services/confirm.service';
import {
DownloadCleanerConfig, SeedingRule, ClientCleanerConfig, UnlinkedConfigModel,
OrphanedFilesConfig,
createDefaultUnlinkedConfig, createDefaultOrphanedFilesConfig,
DeadTorrentConfigModel, OrphanedFilesConfig,
createDefaultUnlinkedConfig, createDefaultDeadTorrentConfig, createDefaultOrphanedFilesConfig,
} from '@shared/models/download-cleaner-config.model';
import { ScheduleOptions } from '@shared/models/queue-cleaner-config.model';
import { ScheduleUnit, TorrentPrivacyType, DownloadClientTypeName } from '@shared/models/enums';
@@ -73,10 +73,13 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
readonly saved = signal(false);
readonly unlinkedSaving = signal(false);
readonly unlinkedSaved = signal(false);
readonly deadTorrentSaving = signal(false);
readonly deadTorrentSaved = signal(false);
readonly orphanedFilesSaving = signal(false);
readonly orphanedFilesSaved = signal(false);
readonly rulesReloading = signal(false);
private readonly unlinkedSnapshots = signal<Record<string, string>>({});
private readonly deadTorrentSnapshots = signal<Record<string, string>>({});
// Global settings
readonly enabled = signal(false);
@@ -125,8 +128,18 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|| typeName === DownloadClientTypeName.uTorrent;
});
// Dead torrent detection needs a seeder count; rTorrent does not report one.
readonly isDeadTorrentCapableClient = computed(() => {
const typeName = this.selectedClient()?.downloadClientTypeName;
return typeName === DownloadClientTypeName.qBittorrent
|| typeName === DownloadClientTypeName.Deluge
|| typeName === DownloadClientTypeName.Transmission
|| typeName === DownloadClientTypeName.uTorrent;
});
readonly seedingRulesExpanded = signal(false);
readonly unlinkedExpanded = signal(false);
readonly deadTorrentExpanded = signal(false);
readonly orphanedFilesExpanded = signal(false);
// Seeding rule modal
@@ -212,6 +225,28 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
return undefined;
});
readonly deadTorrentCategoriesError = computed(() => {
const client = this.selectedClient();
if (!client?.deadTorrentConfig?.enabled) {
return undefined;
}
if ((client.deadTorrentConfig.categories ?? []).length === 0) {
return 'At least one category is required';
}
return undefined;
});
readonly deadTorrentStrikesError = computed(() => {
const client = this.selectedClient();
if (!client?.deadTorrentConfig?.enabled) {
return undefined;
}
if ((client.deadTorrentConfig.maxStrikes ?? 0) < 3) {
return 'Strikes must be at least 3';
}
return undefined;
});
readonly orphanedFilesScanDirsError = computed(() => {
const client = this.selectedClient();
if (!client?.orphanedFilesConfig?.enabled) {
@@ -244,6 +279,16 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
return saved !== JSON.stringify(client.unlinkedConfig);
});
readonly deadTorrentDirty = computed(() => {
const client = this.selectedClient();
if (!client) {
return false;
}
const saved = this.deadTorrentSnapshots()[client.downloadClientId]
?? JSON.stringify(createDefaultDeadTorrentConfig());
return saved !== JSON.stringify(client.deadTorrentConfig);
});
readonly orphanedFilesDirty = computed(() => {
const client = this.selectedClient();
if (!client) {
@@ -292,6 +337,7 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
...c,
seedingRules: c.seedingRules ?? [],
unlinkedConfig: c.unlinkedConfig ?? createDefaultUnlinkedConfig(),
deadTorrentConfig: c.deadTorrentConfig ?? createDefaultDeadTorrentConfig(),
orphanedFilesConfig: c.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig(),
})));
@@ -300,12 +346,15 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
}
const unlinkedSnapshots: Record<string, string> = {};
const deadTorrentSnapshots: Record<string, string> = {};
const orphanedFilesSnapshots: Record<string, string> = {};
for (const c of dc.clients ?? []) {
unlinkedSnapshots[c.downloadClientId] = JSON.stringify(c.unlinkedConfig ?? createDefaultUnlinkedConfig());
deadTorrentSnapshots[c.downloadClientId] = JSON.stringify(c.deadTorrentConfig ?? createDefaultDeadTorrentConfig());
orphanedFilesSnapshots[c.downloadClientId] = JSON.stringify(c.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig());
}
this.unlinkedSnapshots.set(unlinkedSnapshots);
this.deadTorrentSnapshots.set(deadTorrentSnapshots);
this.orphanedFilesSnapshots.set(orphanedFilesSnapshots);
this.loader.stop();
@@ -462,7 +511,7 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
}
async onClientChange(newClientId: unknown): Promise<void> {
if (this.unlinkedDirty() || this.orphanedFilesDirty()) {
if (this.unlinkedDirty() || this.deadTorrentDirty() || this.orphanedFilesDirty()) {
const confirmed = await this.confirm.confirm({
title: 'Unsaved Changes',
message: 'You have unsaved changes for this client. Discard them?',
@@ -472,10 +521,32 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
if (!confirmed) {
return;
}
const currentId = this.selectedClientId();
if (currentId) {
this.restoreClientEditsFromSnapshot(currentId);
}
}
this.selectedClientId.set(newClientId as string | null);
}
/** Reverts the client's unlinked/dead-torrent/orphaned edits back to their saved snapshots. */
private restoreClientEditsFromSnapshot(clientId: string): void {
this.clientConfigs.update(configs => configs.map(c => {
if (c.downloadClientId !== clientId) {
return c;
}
const unlinked = this.unlinkedSnapshots()[clientId];
const deadTorrent = this.deadTorrentSnapshots()[clientId];
const orphaned = this.orphanedFilesSnapshots()[clientId];
return {
...c,
unlinkedConfig: unlinked ? JSON.parse(unlinked) : c.unlinkedConfig,
deadTorrentConfig: deadTorrent ? JSON.parse(deadTorrent) : c.deadTorrentConfig,
orphanedFilesConfig: orphaned ? JSON.parse(orphaned) : c.orphanedFilesConfig,
};
}));
}
// --- Unlinked config ---
updateUnlinkedField<K extends keyof UnlinkedConfigModel>(field: K, value: UnlinkedConfigModel[K]): void {
@@ -514,6 +585,44 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
});
}
// --- Dead torrent per-client config ---
updateDeadTorrentField<K extends keyof DeadTorrentConfigModel>(field: K, value: DeadTorrentConfigModel[K]): void {
this.updateSelectedClient(client => ({
...client,
deadTorrentConfig: {
...(client.deadTorrentConfig ?? createDefaultDeadTorrentConfig()),
[field]: value,
},
}));
}
saveDeadTorrentConfig(): void {
const clientId = this.selectedClientId();
const client = this.selectedClient();
if (!clientId || !client?.deadTorrentConfig) {
return;
}
this.deadTorrentSaving.set(true);
this.api.updateDeadTorrentConfig(clientId, client.deadTorrentConfig).subscribe({
next: () => {
this.toast.success('Dead torrent config saved');
this.deadTorrentSaving.set(false);
this.deadTorrentSaved.set(true);
setTimeout(() => this.deadTorrentSaved.set(false), 1500);
this.deadTorrentSnapshots.update(s => ({
...s,
[clientId]: JSON.stringify(client.deadTorrentConfig),
}));
},
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save dead torrent config');
this.deadTorrentSaving.set(false);
},
});
}
// --- Orphaned files per-client config ---
updateOrphanedFilesField<K extends keyof OrphanedFilesConfig>(field: K, value: OrphanedFilesConfig[K]): void {
@@ -615,6 +724,6 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
});
hasPendingChanges(): boolean {
return this.dirty() || this.unlinkedDirty() || this.orphanedFilesDirty();
return this.dirty() || this.unlinkedDirty() || this.deadTorrentDirty() || this.orphanedFilesDirty();
}
}

View File

@@ -24,6 +24,14 @@ export interface UnlinkedConfigModel {
categories: string[];
}
export interface DeadTorrentConfigModel {
enabled: boolean;
targetCategory: string;
useTag: boolean;
maxStrikes: number;
categories: string[];
}
export interface OrphanedFilesConfig {
enabled: boolean;
scanDirectories: string[];
@@ -40,6 +48,7 @@ export interface ClientCleanerConfig {
downloadClientTypeName: string;
seedingRules: SeedingRule[];
unlinkedConfig: UnlinkedConfigModel | null;
deadTorrentConfig: DeadTorrentConfigModel | null;
orphanedFilesConfig: OrphanedFilesConfig | null;
}
@@ -78,6 +87,16 @@ export function createDefaultUnlinkedConfig(): UnlinkedConfigModel {
};
}
export function createDefaultDeadTorrentConfig(): DeadTorrentConfigModel {
return {
enabled: false,
targetCategory: 'cleanuparr-dead',
useTag: false,
maxStrikes: 0,
categories: [],
};
}
export function createDefaultOrphanedFilesConfig(): OrphanedFilesConfig {
return {
enabled: false,