mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-06-23 21:19:44 -04:00
Add dead torrent handling (#627)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user