Compare commits

...

4 Commits

Author SHA1 Message Date
Flaminel
baf6a8c2f4 Add option to set failed import strikes per arr (#135) 2025-05-09 01:17:16 +03:00
Flaminel
cd345afc54 Fix logs when using qBit tag instead of category (#134) 2025-05-08 22:50:14 +03:00
Flaminel
246ec4d6eb Add option to set a tag instead of changing the category for unlinked downloads (#133) 2025-05-08 21:51:08 +03:00
Flaminel
569eeae181 Fix hardlinks on ARM64 (#130) 2025-05-07 21:44:49 +03:00
22 changed files with 165 additions and 26 deletions

View File

@@ -4,9 +4,11 @@ on:
jobs:
build:
uses: flmorg/universal-workflows/.github/workflows/dotnet.build.app.yml@main
uses: flmorg/universal-workflows-testing/.github/workflows/dotnet.build.app.yml@main
with:
dockerRepository: flaminel/cleanuperr
githubContext: ${{ toJSON(github) }}
outputName: cleanuperr
selfContained: false
baseImage: 9.0-bookworm-slim
secrets: inherit

View File

@@ -1,4 +1,5 @@
using Common.Configuration.ContentBlocker;
using Microsoft.Extensions.Configuration;
namespace Common.Configuration.Arr;
@@ -7,6 +8,9 @@ public abstract record ArrConfig
public required bool Enabled { get; init; }
public Block Block { get; init; } = new();
[ConfigurationKeyName("IMPORT_FAILED_MAX_STRIKES")]
public short ImportFailedMaxStrikes { get; init; } = -1;
public required List<ArrInstance> Instances { get; init; }
}

View File

@@ -20,6 +20,9 @@ public sealed record DownloadCleanerConfig : IJobConfig, IIgnoredDownloadsConfig
[ConfigurationKeyName("UNLINKED_TARGET_CATEGORY")]
public string UnlinkedTargetCategory { get; init; } = "cleanuperr-unlinked";
[ConfigurationKeyName("UNLINKED_USE_TAG")]
public bool UnlinkedUseTag { get; init; }
[ConfigurationKeyName("UNLINKED_IGNORED_ROOT_DIR")]
public string UnlinkedIgnoredRootDir { get; init; } = string.Empty;

View File

@@ -57,6 +57,7 @@
}
],
"UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked",
"UNLINKED_USE_TAG": false,
"UNLINKED_IGNORED_ROOT_DIR": "",
"UNLINKED_CATEGORIES": [
"tv-sonarr",
@@ -84,6 +85,7 @@
},
"Sonarr": {
"Enabled": true,
"IMPORT_FAILED_MAX_STRIKES": -1,
"SearchType": "Episode",
"Block": {
"Type": "blacklist",
@@ -98,6 +100,7 @@
},
"Radarr": {
"Enabled": true,
"IMPORT_FAILED_MAX_STRIKES": -1,
"Block": {
"Type": "blacklist",
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist"
@@ -111,6 +114,7 @@
},
"Lidarr": {
"Enabled": true,
"IMPORT_FAILED_MAX_STRIKES": -1,
"Block": {
"Type": "blacklist",
"Path": "https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist"

View File

@@ -47,6 +47,7 @@
"DELETE_PRIVATE": false,
"CATEGORIES": [],
"UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked",
"UNLINKED_USE_TAG": false,
"UNLINKED_IGNORED_ROOT_DIR": "",
"UNLINKED_CATEGORIES": [],
"IGNORED_DOWNLOADS_PATH": ""
@@ -71,6 +72,7 @@
},
"Sonarr": {
"Enabled": false,
"IMPORT_FAILED_MAX_STRIKES": -1,
"SearchType": "Episode",
"Block": {
"Type": "blacklist",
@@ -85,6 +87,7 @@
},
"Radarr": {
"Enabled": false,
"IMPORT_FAILED_MAX_STRIKES": -1,
"Block": {
"Type": "blacklist",
"Path": ""
@@ -98,6 +101,7 @@
},
"Lidarr": {
"Enabled": false,
"IMPORT_FAILED_MAX_STRIKES": -1,
"Block": {
"Type": "blacklist",
"Path": ""

View File

@@ -18,7 +18,7 @@
<PackageReference Include="MassTransit" Version="8.3.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" />
<PackageReference Include="Quartz" Version="3.13.1" />
<PackageReference Include="Scrutor" Version="6.0.1" />
</ItemGroup>

View File

@@ -73,7 +73,7 @@ public abstract class ArrClient : IArrClient
return queueResponse;
}
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload)
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes)
{
if (_queueCleanerConfig.ImportFailedIgnorePrivate && isPrivateDownload)
{
@@ -102,11 +102,19 @@ public abstract class ArrClient : IArrClient
_logger.LogDebug("skip failed import check | contains ignored pattern | {name}", record.Title);
return false;
}
if (arrMaxStrikes is 0)
{
_logger.LogDebug("skip failed import check | arr max strikes is 0 | {name}", record.Title);
return false;
}
ushort maxStrikes = arrMaxStrikes > 0 ? (ushort)arrMaxStrikes : _queueCleanerConfig.ImportFailedMaxStrikes;
return await _striker.StrikeAndCheckLimit(
record.DownloadId,
record.Title,
_queueCleanerConfig.ImportFailedMaxStrikes,
maxStrikes,
StrikeType.ImportFailed
);
}

View File

@@ -9,7 +9,7 @@ public interface IArrClient
{
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page);
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload);
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);

View File

@@ -75,7 +75,7 @@ public sealed class ContentBlocker : GenericHandler
await base.ExecuteAsync();
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config)
{
IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsProvider.GetIgnoredDownloads();

View File

@@ -86,9 +86,12 @@ public sealed class DownloadCleaner : GenericHandler
{
if (!_hardLinkCategoryCreated)
{
_logger.LogDebug("creating category {cat}", _config.UnlinkedTargetCategory);
await _downloadService.CreateCategoryAsync(_config.UnlinkedTargetCategory);
if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.QBittorrent && !_config.UnlinkedUseTag)
{
_logger.LogDebug("creating category {cat}", _config.UnlinkedTargetCategory);
await _downloadService.CreateCategoryAsync(_config.UnlinkedTargetCategory);
}
_hardLinkCategoryCreated = true;
}
@@ -124,7 +127,7 @@ public sealed class DownloadCleaner : GenericHandler
_logger.LogTrace("finished cleaning downloads");
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config)
{
using var _ = LogContext.PushProperty("InstanceName", instanceType.ToString());

View File

@@ -251,6 +251,15 @@ public class QBitService : DownloadService, IQBitService
?.Cast<TorrentInfo>()
.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.Where(x =>
{
if (_downloadCleanerConfig.UnlinkedUseTag)
{
return !x.Tags.Any(tag => tag.Equals(_downloadCleanerConfig.UnlinkedTargetCategory, StringComparison.InvariantCultureIgnoreCase));
}
return true;
})
.Cast<object>()
.ToList();
@@ -436,12 +445,18 @@ public class QBitService : DownloadService, IQBitService
}
await _dryRunInterceptor.InterceptAsync(ChangeCategory, download.Hash, _downloadCleanerConfig.UnlinkedTargetCategory);
if (_downloadCleanerConfig.UnlinkedUseTag)
{
_logger.LogInformation("tag added for {name}", download.Name);
}
else
{
_logger.LogInformation("category changed for {name}", download.Name);
download.Category = _downloadCleanerConfig.UnlinkedTargetCategory;
}
_logger.LogInformation("category changed for {name}", download.Name);
await _notifier.NotifyCategoryChanged(download.Category, _downloadCleanerConfig.UnlinkedTargetCategory);
download.Category = _downloadCleanerConfig.UnlinkedTargetCategory;
await _notifier.NotifyCategoryChanged(download.Category, _downloadCleanerConfig.UnlinkedTargetCategory, _downloadCleanerConfig.UnlinkedUseTag);
}
}
@@ -467,6 +482,12 @@ public class QBitService : DownloadService, IQBitService
[DryRunSafeguard]
protected virtual async Task ChangeCategory(string hash, string newCategory)
{
if (_downloadCleanerConfig.UnlinkedUseTag)
{
await _client.AddTorrentTagAsync([hash], newCategory);
return;
}
await _client.SetTorrentCategoryAsync([hash], newCategory);
}

View File

@@ -67,7 +67,7 @@ public abstract class GenericHandler : IHandler, IDisposable
_downloadService.Dispose();
}
protected abstract Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType);
protected abstract Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config);
protected async Task ProcessArrConfigAsync(ArrConfig config, InstanceType instanceType, bool throwOnFailure = false)
{
@@ -80,7 +80,7 @@ public abstract class GenericHandler : IHandler, IDisposable
{
try
{
await ProcessInstanceAsync(arrInstance, instanceType);
await ProcessInstanceAsync(arrInstance, instanceType, config);
}
catch (Exception exception)
{

View File

@@ -10,5 +10,5 @@ public interface INotificationPublisher
Task NotifyDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason);
Task NotifyCategoryChanged(string oldCategory, string newCategory);
Task NotifyCategoryChanged(string oldCategory, string newCategory, bool isTag = false);
}

View File

@@ -123,21 +123,29 @@ public class NotificationPublisher : INotificationPublisher
}
}
public virtual async Task NotifyCategoryChanged(string oldCategory, string newCategory)
public virtual async Task NotifyCategoryChanged(string oldCategory, string newCategory, bool isTag = false)
{
CategoryChangedNotification notification = new()
{
Title = "Category changed",
Title = isTag? "Tag added" : "Category changed",
Description = ContextProvider.Get<string>("downloadName"),
Fields =
[
new() { Title = "Hash", Text = ContextProvider.Get<string>("hash").ToLowerInvariant() },
new() { Title = "Old category", Text = oldCategory },
new() { Title = "New category", Text = newCategory }
new() { Title = "Hash", Text = ContextProvider.Get<string>("hash").ToLowerInvariant() }
],
Level = NotificationLevel.Important
};
if (isTag)
{
notification.Fields.Add(new() { Title = "Tag", Text = newCategory });
}
else
{
notification.Fields.Add(new() { Title = "Old category", Text = oldCategory });
notification.Fields.Add(new() { Title = "New category", Text = newCategory });
}
await NotifyInternal(notification);
}

View File

@@ -49,7 +49,7 @@ public sealed class QueueCleaner : GenericHandler
_ignoredDownloadsProvider = ignoredDownloadsProvider;
}
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType)
protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config)
{
IReadOnlyList<string> ignoredDownloads = await _ignoredDownloadsProvider.GetIgnoredDownloads();
@@ -108,7 +108,7 @@ public sealed class QueueCleaner : GenericHandler
}
// failed import check
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate);
bool shouldRemoveFromArr = await arrClient.ShouldRemoveFromQueue(instanceType, record, downloadCheckResult.IsPrivate, config.ImportFailedMaxStrikes);
DeleteReason deleteReason = downloadCheckResult.ShouldRemove ? downloadCheckResult.DeleteReason : DeleteReason.ImportFailed;
if (!shouldRemoveFromArr && !downloadCheckResult.ShouldRemove)

View File

@@ -221,15 +221,18 @@ services:
- DOWNLOADCLEANER__ENABLED=true
- DOWNLOADCLEANER__IGNORED_DOWNLOADS_PATH=/ignored
- DOWNLOADCLEANER__DELETE_PRIVATE=false
- DOWNLOADCLEANER__CATEGORIES__0__NAME=tv-sonarr
- DOWNLOADCLEANER__CATEGORIES__0__MAX_RATIO=-1
- DOWNLOADCLEANER__CATEGORIES__0__MIN_SEED_TIME=0
- DOWNLOADCLEANER__CATEGORIES__0__MAX_SEED_TIME=99999
- DOWNLOADCLEANER__CATEGORIES__1__NAME=nohardlink
- DOWNLOADCLEANER__CATEGORIES__1__NAME=cleanuperr-unlinked
- DOWNLOADCLEANER__CATEGORIES__1__MAX_RATIO=-1
- DOWNLOADCLEANER__CATEGORIES__1__MIN_SEED_TIME=0
- DOWNLOADCLEANER__CATEGORIES__1__MAX_SEED_TIME=99999
- DOWNLOADCLEANER__UNLINKED_TARGET_CATEGORY=cleanuperr-unlinked
- DOWNLOADCLEANER__UNLINKED_USE_TAG=false
- DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR=/downloads
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__0=tv-sonarr
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__1=radarr
@@ -249,6 +252,7 @@ services:
# - TRANSMISSION__PASSWORD=testing
- SONARR__ENABLED=true
- SONARR__IMPORT_FAILED_MAX_STRIKES=-1
- SONARR__SEARCHTYPE=Episode
- SONARR__BLOCK__TYPE=blacklist
- SONARR__BLOCK__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist
@@ -256,12 +260,14 @@ services:
- SONARR__INSTANCES__0__APIKEY=425d1e713f0c405cbbf359ac0502c1f4
- RADARR__ENABLED=true
- RADARR__IMPORT_FAILED_MAX_STRIKES=-1
- RADARR__BLOCK__TYPE=blacklist
- RADARR__BLOCK__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist
- RADARR__INSTANCES__0__URL=http://radarr:7878
- RADARR__INSTANCES__0__APIKEY=8b7454f668e54c5b8f44f56f93969761
- LIDARR__ENABLED=true
- LIDARR__IMPORT_FAILED_MAX_STRIKES=-1
- LIDARR__BLOCK__TYPE=blacklist
- LIDARR__BLOCK__PATH=https://raw.githubusercontent.com/flmorg/cleanuperr/refs/heads/main/blacklist # TODO
- LIDARR__INSTANCES__0__URL=http://lidarr:8686

View File

@@ -91,6 +91,7 @@ services:
# change category for downloads with no hardlinks
- DOWNLOADCLEANER__UNLINKED_TARGET_CATEGORY=cleanuperr-unlinked
- DOWNLOADCLEANER__UNLINKED_USE_TAG=false
- DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR=/downloads
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__0=tv-sonarr
- DOWNLOADCLEANER__UNLINKED_CATEGORIES__1=radarr
@@ -117,6 +118,7 @@ services:
# - TRANSMISSION__PASSWORD=testing
- SONARR__ENABLED=true
- SONARR__IMPORT_FAILED_MAX_STRIKES=-1
- SONARR__SEARCHTYPE=Episode
- SONARR__BLOCK__TYPE=blacklist
- SONARR__BLOCK__PATH=https://example.com/path/to/file.txt
@@ -126,6 +128,7 @@ services:
- SONARR__INSTANCES__1__APIKEY=secret2
- RADARR__ENABLED=true
- RADARR__IMPORT_FAILED_MAX_STRIKES=-1
- RADARR__BLOCK__TYPE=blacklist
- RADARR__BLOCK__PATH=https://example.com/path/to/file.txt
- RADARR__INSTANCES__0__URL=http://localhost:7878
@@ -134,6 +137,7 @@ services:
- RADARR__INSTANCES__1__APIKEY=secret4
- LIDARR__ENABLED=true
- LIDARR__IMPORT_FAILED_MAX_STRIKES=-1
- LIDARR__BLOCK__TYPE=blacklist
- LIDARR__BLOCK__PATH=https://example.com/path/to/file.txt
- LIDARR__INSTANCES__0__URL=http://radarr:8686

View File

@@ -78,6 +78,7 @@ import { Note } from '@site/src/components/Admonition';
}
],
"UNLINKED_TARGET_CATEGORY": "cleanuperr-unlinked",
"DOWNLOADCLEANER__UNLINKED_USE_TAG": false,
"UNLINKED_IGNORED_ROOT_DIR": "/downloads",
"UNLINKED_CATEGORIES": [
"tv-sonarr",
@@ -105,6 +106,7 @@ import { Note } from '@site/src/components/Admonition';
},
"Sonarr": {
"Enabled": true,
"IMPORT_FAILED_MAX_STRIKES=-1
"SearchType": "Episode",
"Block": {
"Type": "blacklist",
@@ -123,6 +125,7 @@ import { Note } from '@site/src/components/Admonition';
},
"Radarr": {
"Enabled": true,
"IMPORT_FAILED_MAX_STRIKES": -1,
"Block": {
"Type": "blacklist",
"Path": "https://example.com/path/to/file.txt"
@@ -140,6 +143,7 @@ import { Note } from '@site/src/components/Admonition';
},
"Lidarr": {
"Enabled": true,
"IMPORT_FAILED_MAX_STRIKES": -1,
"Block": {
"Type": "blacklist",
"Path": "https://example.com/path/to/file.txt"

View File

@@ -12,6 +12,24 @@ const settings: EnvVarProps[] = [
required: false,
acceptedValues: ["true", "false"],
},
{
name: "LIDARR__IMPORT_FAILED_MAX_STRIKES",
description: [
"Number of strikes before removing a failed import. Set to `0` to never remove failed imports.",
"A strike is given when an item fails to be imported."
],
type: "integer number",
defaultValue: "-1",
required: false,
notes: [
"If the value is a positive number, it overwrites the values of [QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES](/cleanuperr/docs/configuration/queue-cleaner/import-failed?QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES).",
"`0` means to never remove failed imports.",
"If not set to `0` or a negative number, the minimum value is `3`.",
],
warnings: [
"The value is not restricted to be a certain positive number. Use a low value (e.g. `1`) at your own risk."
]
},
{
name: "LIDARR__BLOCK__TYPE",
description: [

View File

@@ -12,6 +12,24 @@ const settings: EnvVarProps[] = [
required: false,
acceptedValues: ["true", "false"],
},
{
name: "RADARR__IMPORT_FAILED_MAX_STRIKES",
description: [
"Number of strikes before removing a failed import. Set to `0` to never remove failed imports.",
"A strike is given when an item fails to be imported."
],
type: "integer number",
defaultValue: "-1",
required: false,
notes: [
"If the value is a positive number, it overwrites the values of [QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES](/cleanuperr/docs/configuration/queue-cleaner/import-failed?QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES).",
"`0` means to never remove failed imports.",
"If not set to `0` or a negative number, the minimum value is `3`.",
],
warnings: [
"The value is not restricted to be a certain positive number. Use a low value (e.g. `1`) at your own risk."
]
},
{
name: "RADARR__BLOCK__TYPE",
description: [

View File

@@ -12,6 +12,24 @@ const settings: EnvVarProps[] = [
required: false,
acceptedValues: ["true", "false"],
},
{
name: "SONARR__IMPORT_FAILED_MAX_STRIKES",
description: [
"Number of strikes before removing a failed import. Set to `0` to never remove failed imports.",
"A strike is given when an item fails to be imported."
],
type: "integer number",
defaultValue: "-1",
required: false,
notes: [
"If the value is a positive number, it overwrites the values of [QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES](/cleanuperr/docs/configuration/queue-cleaner/import-failed?QUEUECLEANER__IMPORT_FAILED_MAX_STRIKES).",
"`0` means to never remove failed imports.",
"If not set to `0` or a negative number, the minimum value is `3`.",
],
warnings: [
"The value is not restricted to be a certain positive number. Use a low value (e.g. `1`) at your own risk."
]
},
{
name: "SONARR__BLOCK__TYPE",
description: [

View File

@@ -11,6 +11,20 @@ const settings: EnvVarProps[] = [
defaultValue: "cleanuperr-unlinked",
required: false,
},
{
name: "DOWNLOADCLEANER__UNLINKED_USE_TAG",
description: [
"If set to true, a tag will be set instead of changing the category.",
],
type: "boolean",
defaultValue: "false",
required: false,
acceptedValues: ["true", "false"],
notes: [
"Works only for qBittorrent.",
],
},
{
name: "DOWNLOADCLEANER__UNLINKED_IGNORED_ROOT_DIR",
description: [