Add Transmission label option for unlinked downloads (#626)

This commit is contained in:
Flaminel
2026-06-13 23:09:26 +03:00
committed by GitHub
parent 28f22f1085
commit 1cc068c2ab
4 changed files with 172 additions and 4 deletions

View File

@@ -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<TransmissionServiceFixtu
result.ShouldNotBeNull();
result.ShouldBeEmpty();
}
[Fact]
public void ExcludesAlreadyLabeled_WhenUseTag()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
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<Domain.Entities.ITorrentItemWrapper>
{
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<TransmissionServiceFixtu
.TorrentSetLocationAsync(Arg.Is<long[]>(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<Domain.Entities.ITorrentItemWrapper>
{
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<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
await _fixture.ClientWrapper.Received(1)
.TorrentSetAsync(Arg.Is<TorrentSettings>(s =>
s.Ids.Contains(123L)
&& s.Labels.Contains("existing")
&& s.Labels.Contains("unlinked")));
await _fixture.ClientWrapper.DidNotReceive()
.TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
}
[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<Domain.Entities.ITorrentItemWrapper>
{
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<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
await _fixture.ClientWrapper.Received(1)
.TorrentSetAsync(Arg.Is<TorrentSettings>(s =>
s.Ids.Contains(123L)
&& s.Labels.Length == 1
&& s.Labels.Contains("UNLINKED")));
}
[Fact]
public async Task HasHardlinks_SkipsTorrent()
{

View File

@@ -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,
});
}
}

View File

@@ -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()) {
<app-toggle label="Use Tag Instead" [checked]="client.unlinkedConfig?.useTag ?? false"
@if (isTagFilterableClient()) {
<app-toggle [label]="isSelectedClientTransmission() ? 'Use Label Instead' : 'Use Tag Instead'" [checked]="client.unlinkedConfig?.useTag ?? false"
(checkedChange)="updateUnlinkedField('useTag', $event)"
hint="When enabled, uses a tag instead of category (qBittorrent only)"
[hint]="isSelectedClientTransmission() ? 'When enabled, adds a label instead of changing the category' : 'When enabled, adds a tag instead of changing the category'"
helpKey="download-cleaner:unlinkedUseTag" />
}

View File

@@ -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.
</ConfigSection>