From 1cc068c2ab1ae00858debee34f211ac0b80afdc3 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sat, 13 Jun 2026 23:09:26 +0300 Subject: [PATCH] Add Transmission label option for unlinked downloads (#626) --- .../TransmissionServiceDCTests.cs | 131 ++++++++++++++++++ .../Transmission/TransmissionServiceDC.cs | 37 +++++ .../download-cleaner.component.html | 6 +- .../configuration/download-cleaner/index.mdx | 2 +- 4 files changed, 172 insertions(+), 4 deletions(-) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs index e8e43831..916c78a9 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs @@ -1,6 +1,7 @@ using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using NSubstitute; +using Transmission.API.RPC.Arguments; using Transmission.API.RPC.Entity; using Shouldly; using Xunit; @@ -364,6 +365,50 @@ public class TransmissionServiceDCTests : IClassFixture + { + new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies", Labels = ["unlinked"] }), + new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/movies", Labels = [] }) + }; + + // Act + var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, + new UnlinkedConfig { Categories = ["movies"], TargetCategory = "unlinked", UseTag = true }); + + // Assert + result.ShouldNotBeNull(); + result.ShouldHaveSingleItem(); + result[0].Hash.ShouldBe("hash2"); + } + + [Fact] + public void ExcludesAlreadyLabeled_WhenUseTag_CaseInsensitive() + { + // Arrange + var sut = _fixture.CreateSut(); + + var downloads = new List + { + new TransmissionItemWrapper(new TorrentInfo { HashString = "hash1", DownloadDir = "/downloads/movies", Labels = ["UNLINKED"] }), + new TransmissionItemWrapper(new TorrentInfo { HashString = "hash2", DownloadDir = "/downloads/movies", Labels = [] }) + }; + + // Act + var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, + new UnlinkedConfig { Categories = ["movies"], TargetCategory = "unlinked", UseTag = true }); + + // Assert + result.ShouldNotBeNull(); + result.ShouldHaveSingleItem(); + result[0].Hash.ShouldBe("hash2"); + } } public class CreateCategoryAsync_Tests : TransmissionServiceDCTests @@ -671,6 +716,92 @@ public class TransmissionServiceDCTests : IClassFixture(ids => ids.Contains(123)), expectedNewLocation, true); } + [Fact] + public async Task UseTag_SetsLabel_AndDoesNotChangeLocation() + { + // Arrange + var sut = _fixture.CreateSut(); + + var unlinkedConfig = new UnlinkedConfig + { + Id = Guid.NewGuid(), + TargetCategory = "unlinked", + UseTag = true + }; + + var downloads = new List + { + new TransmissionItemWrapper(new TorrentInfo + { + Id = 123, + HashString = "hash1", + Name = "Test", + DownloadDir = Path.Combine("downloads", "movies"), + Labels = ["existing"], + Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } }, + FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } } + }) + }; + + _fixture.HardLinkFileService + .GetHardLinkCount(Arg.Any(), Arg.Any()) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); + + // Assert + await _fixture.ClientWrapper.Received(1) + .TorrentSetAsync(Arg.Is(s => + s.Ids.Contains(123L) + && s.Labels.Contains("existing") + && s.Labels.Contains("unlinked"))); + await _fixture.ClientWrapper.DidNotReceive() + .TorrentSetLocationAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UseTag_DoesNotDuplicateLabel_WhenAlreadyPresentWithDifferentCase() + { + // Arrange + var sut = _fixture.CreateSut(); + + var unlinkedConfig = new UnlinkedConfig + { + Id = Guid.NewGuid(), + TargetCategory = "unlinked", + UseTag = true + }; + + var downloads = new List + { + new TransmissionItemWrapper(new TorrentInfo + { + Id = 123, + HashString = "hash1", + Name = "Test", + DownloadDir = Path.Combine("downloads", "movies"), + Labels = ["UNLINKED"], + Files = new[] { new TransmissionTorrentFiles { Name = "file1.mkv" } }, + FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } } + }) + }; + + _fixture.HardLinkFileService + .GetHardLinkCount(Arg.Any(), Arg.Any()) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); + + // Assert + await _fixture.ClientWrapper.Received(1) + .TorrentSetAsync(Arg.Is(s => + s.Ids.Contains(123L) + && s.Labels.Length == 1 + && s.Labels.Contains("UNLINKED"))); + } + [Fact] public async Task HasHardlinks_SkipsTorrent() { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs index e7ff8aac..42a8c3a5 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs @@ -4,6 +4,7 @@ using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Cleanuparr.Shared.Helpers; using Microsoft.Extensions.Logging; +using Transmission.API.RPC.Arguments; using Transmission.API.RPC.Entity; namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; @@ -43,6 +44,16 @@ public partial class TransmissionService return downloads ?.Where(x => !string.IsNullOrEmpty(x.Hash)) .Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) + .Where(x => + { + if (unlinkedConfig.UseTag) + { + return !x.Tags.Any(tag => + tag.Equals(unlinkedConfig.TargetCategory, StringComparison.InvariantCultureIgnoreCase)); + } + + return true; + }) .ToList(); } @@ -128,6 +139,23 @@ public partial class TransmissionService } string currentCategory = torrent.Category ?? string.Empty; + + if (unlinkedConfig.UseTag) + { + string[] newLabels = torrent.Tags + .Append(unlinkedConfig.TargetCategory) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + await _dryRunInterceptor.InterceptAsync(() => ChangeLabels(torrent.Info.Id, newLabels)); + + _logger.LogInformation("label added for {name}", torrent.Name); + + await _eventPublisher.PublishCategoryChanged(currentCategory, unlinkedConfig.TargetCategory, isTag: true); + + continue; + } + string newLocation = torrent.Info.GetNewLocationByAppend(unlinkedConfig.TargetCategory); await _dryRunInterceptor.InterceptAsync(() => ChangeDownloadLocation(torrent.Info.Id, newLocation)); @@ -144,4 +172,13 @@ public partial class TransmissionService { await _client.TorrentSetLocationAsync([downloadId], newLocation, true); } + + protected virtual async Task ChangeLabels(long downloadId, string[] labels) + { + await _client.TorrentSetAsync(new TorrentSettings + { + Ids = [downloadId], + Labels = labels, + }); + } } diff --git a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html index 327beb82..3716aabe 100644 --- a/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html +++ b/code/frontend/src/app/features/settings/download-cleaner/download-cleaner.component.html @@ -172,10 +172,10 @@ (valueChange)="updateUnlinkedField('targetCategory', $event)" hint="Category to move unlinked downloads to. You have to create a seeding rule for this category if you want to remove the downloads." helpKey="download-cleaner:unlinkedTargetCategory" /> - @if (isSelectedClientQBittorrent()) { - } diff --git a/docs/docs/configuration/download-cleaner/index.mdx b/docs/docs/configuration/download-cleaner/index.mdx index b1d2ac8d..37a38f8c 100644 --- a/docs/docs/configuration/download-cleaner/index.mdx +++ b/docs/docs/configuration/download-cleaner/index.mdx @@ -359,7 +359,7 @@ Category to move unlinked downloads to. You must create a seeding rule for this title="Use Tag" > -When enabled, uses a tag instead of category for marking unlinked downloads (qBittorrent only). +When enabled, marks unlinked downloads with a tag instead of changing their category, preserving the original category. Supported by **qBittorrent** (tags) and **Transmission** (labels); not available for other clients.