Expand enrichment to torrent files

This commit is contained in:
MentalBlank 2025-05-20 03:27:41 +10:00
parent 5de0bac9d5
commit 80a8a1020c
7 changed files with 266 additions and 53 deletions

View file

@ -75,8 +75,8 @@ Supports the following parameters:
[Description("When a torrent file or magnet is added, create a copy in this directory.")] [Description("When a torrent file or magnet is added, create a copy in this directory.")]
public String? CopyAddedTorrents { get; set; } = null; public String? CopyAddedTorrents { get; set; } = null;
[DisplayName("Magnet enrichment list")] [DisplayName("Tracker enrichment list")]
[Description("Optional. Specify the URL of a tracker list file to be appended to magnet links.")] [Description("Optional. Specify the URL of a tracker list file to be appended to magnet links and torrent files.")]
public String? MagnetTrackerEnrichment { get; set; } = null; public String? MagnetTrackerEnrichment { get; set; } = null;
[DisplayName("Disable update notifications")] [DisplayName("Disable update notifications")]

View file

@ -0,0 +1,134 @@
using Microsoft.Extensions.Logging;
using Moq;
using RdtClient.Service.Services;
using MonoTorrent.BEncoding;
namespace RdtClient.Service.Test.Services;
public class EnricherTest : IDisposable
{
private readonly MockRepository _mockRepository;
private readonly Mock<ILogger<Enricher>> _loggerMock;
private readonly Mock<ITrackerListGrabber> _trackerListGrabberMock;
public EnricherTest()
{
_mockRepository = new MockRepository(MockBehavior.Strict);
_loggerMock = _mockRepository.Create<ILogger<Enricher>>(MockBehavior.Loose);
_trackerListGrabberMock = _mockRepository.Create<ITrackerListGrabber>();
}
public void Dispose()
{
_mockRepository.VerifyAll();
}
private const String TestMagnetLink = "magnet:?xt=urn:btih:1234567890123456789012345678901234567890&dn=TestFile&tr=http%3A%2F%2Ftracker1.com%2Fannounce&tr=http%3A%2F%2Ftracker2.com%2Fannounce";
[Fact]
public async Task EnrichMagnetLink_AddsNoTrackers_WhenNoTrackersFromTrackerGrabber()
{
// Arrange
SetupTrackerListGrabber([]);
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
// Act
var enriched = await enricher.EnrichMagnetLink(TestMagnetLink);
// Assert
Assert.Equal(TestMagnetLink, enriched);
}
[Fact]
public async Task EnrichMagnetLink_AddsTrackers_WhenTrackersFromTrackerGrabber()
{
// Arrange
SetupTrackerListGrabber(["http://new-tracker.com/announce"]);
var Enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
// Act
var enriched = await Enricher.EnrichMagnetLink(TestMagnetLink);
// Assert
Assert.Equal(TestMagnetLink + $"&tr={Uri.EscapeDataString("http://new-tracker.com/announce")}", enriched);
}
[Fact]
public async Task EnrichMagnetLink_DoesNotAddDuplicateTrackers_WhenTrackersFromTrackerGrabberAlreadyPresent()
{
// Arrange
SetupTrackerListGrabber(["http://new-tracker.com/announce", "http://tracker1.com/announce"]);
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
// Act
var enriched = await enricher.EnrichMagnetLink(TestMagnetLink);
// Assert
Assert.Equal(TestMagnetLink + $"&tr={Uri.EscapeDataString("http://new-tracker.com/announce")}", enriched);
}
[Fact]
public async Task EnrichMagnetLink_Throws_WhenTrackerGrabberThrows()
{
// Arrange
_trackerListGrabberMock
.Setup(t => t.GetTrackers())
.ThrowsAsync(new InvalidOperationException("Unable to fetch tracker list for enrichment."));
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => enricher.EnrichMagnetLink(TestMagnetLink));
}
[Fact]
public async Task EnrichTorrentBytes_AddsTrackers_WhenTrackersFromTrackerGrabber()
{
// Arrange
var originalTracker = "http://tracker1.com/announce";
var newTracker = "http://new-tracker.com/announce";
var torrentDict = new BEncodedDictionary
{
["announce"] = new BEncodedString(originalTracker),
["announce-list"] = new BEncodedList
{
new BEncodedList { new BEncodedString(originalTracker) }
}
};
var originalTorrentBytes = torrentDict.Encode();
SetupTrackerListGrabber(new[] { newTracker });
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
// Act
var enrichedBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes);
var enrichedDict = BEncodedValue.Decode<BEncodedDictionary>(enrichedBytes);
// Assert
Assert.True(enrichedDict.ContainsKey("announce"));
Assert.True(enrichedDict.ContainsKey("announce-list"));
var announceList = (BEncodedList)enrichedDict["announce-list"];
var flattened = announceList.Cast<BEncodedList>().SelectMany(l => l.Cast<BEncodedString>().Select(s => s.Text)).ToList();
Assert.Contains(originalTracker, flattened);
Assert.Contains(newTracker, flattened);
var announce = ((BEncodedString)enrichedDict["announce"]).Text;
Assert.Equal(flattened.First(), announce);
}
private void SetupTrackerListGrabber(String[] trackerList)
{
_trackerListGrabberMock
.Setup(t => t.GetTrackers())
.ReturnsAsync(trackerList)
.Verifiable();
}
}

View file

@ -20,13 +20,13 @@ class Mocks
public readonly Mock<ILogger<TorrentsService>> TorrentsLoggerMock; public readonly Mock<ILogger<TorrentsService>> TorrentsLoggerMock;
public readonly Mock<IDownloads> DownloadsMock; public readonly Mock<IDownloads> DownloadsMock;
public readonly Mock<ITorrentData> TorrentDataMock; public readonly Mock<ITorrentData> TorrentDataMock;
public readonly Mock<ITrackerListGrabber> TrackerListGrabberMock; public readonly Mock<IEnricher> EnricherMock;
public Mocks() public Mocks()
{ {
TorrentDataMock = new(); TorrentDataMock = new();
DownloadsMock = new(); DownloadsMock = new();
TrackerListGrabberMock = new(); EnricherMock = new();
TorrentsLoggerMock = new(); TorrentsLoggerMock = new();
@ -104,7 +104,7 @@ public class TorrentsTest
mocks.DownloadsMock.Object, mocks.DownloadsMock.Object,
mocks.ProcessFactoryMock.Object, mocks.ProcessFactoryMock.Object,
fileSystemMock, fileSystemMock,
mocks.TrackerListGrabberMock.Object, mocks.EnricherMock.Object,
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
null!, null!,
null!, null!,
@ -170,7 +170,7 @@ public class TorrentsTest
mocks.DownloadsMock.Object, mocks.DownloadsMock.Object,
mocks.ProcessFactoryMock.Object, mocks.ProcessFactoryMock.Object,
fileSystemMock, fileSystemMock,
mocks.TrackerListGrabberMock.Object, mocks.EnricherMock.Object,
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
null!, null!,
null!, null!,
@ -218,7 +218,7 @@ public class TorrentsTest
mocks.DownloadsMock.Object, mocks.DownloadsMock.Object,
mocks.ProcessFactoryMock.Object, mocks.ProcessFactoryMock.Object,
fileSystemMock, fileSystemMock,
mocks.TrackerListGrabberMock.Object, mocks.EnricherMock.Object,
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
null!, null!,
null!, null!,
@ -285,7 +285,7 @@ public class TorrentsTest
mocks.DownloadsMock.Object, mocks.DownloadsMock.Object,
mocks.ProcessFactoryMock.Object, mocks.ProcessFactoryMock.Object,
fileSystemMock, fileSystemMock,
mocks.TrackerListGrabberMock.Object, mocks.EnricherMock.Object,
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
null!, null!,
null!, null!,

View file

@ -39,6 +39,7 @@ public static class DiConfig
services.AddSingleton<IDownloadableFileFilter, DownloadableFileFilter>(); services.AddSingleton<IDownloadableFileFilter, DownloadableFileFilter>();
services.AddSingleton<ITrackerListGrabber, TrackerListGrabber>(); services.AddSingleton<ITrackerListGrabber, TrackerListGrabber>();
services.AddSingleton<IEnricher, Enricher>();
services.AddSingleton<IAuthorizationHandler, AuthSettingHandler>(); services.AddSingleton<IAuthorizationHandler, AuthSettingHandler>();

View file

@ -0,0 +1,111 @@
using System.Web;
using Microsoft.Extensions.Logging;
using MonoTorrent.BEncoding;
namespace RdtClient.Service.Services;
public interface IEnricher
{
Task<String> EnrichMagnetLink(String magnetLink);
Task<Byte[]> EnrichTorrentBytes(Byte[] torrentBytes);
}
/// <summary>
/// Enriches magnet links and torrents by adding trackers from the tracker list grabber.
/// </summary>
public class Enricher(ILogger<Enricher> logger, ITrackerListGrabber trackerListGrabber) : IEnricher
{
/// <summary>
/// Add trackers from the tracker list grabber to the magnet link.
/// </summary>
/// <param name="magnetLink">Magnet link to add trackres to. Is not modified</param>
/// <returns>Magnet link with additional trackers</returns>
public async Task<String> EnrichMagnetLink(String magnetLink)
{
var newTrackers = await trackerListGrabber.GetTrackers();
var uri = new Uri(magnetLink);
var query = HttpUtility.ParseQueryString(uri.Query);
var existingTrackers = query.GetValues("tr") ?? [];
var allTrackers = existingTrackers.Concat(newTrackers).Distinct(StringComparer.OrdinalIgnoreCase);
var trackerQuery = String.Join("&tr=", allTrackers.Select(Uri.EscapeDataString));
if (!String.IsNullOrEmpty(trackerQuery))
{
trackerQuery = "&tr=" + trackerQuery;
}
var baseWithoutTrackers = magnetLink.Split("&tr=")[0];
var separator = baseWithoutTrackers.Contains('?') ? "&" : "?";
var newUri = baseWithoutTrackers + separator + trackerQuery.TrimStart('&');
try
{
_ = new Uri(newUri);
}
catch (UriFormatException ex)
{
logger.LogWarning(ex, "Failed to enrich magnet link: {newUri}", newUri);
throw new InvalidOperationException($"Failed to enrich magnet link: {newUri}", ex);
}
return newUri;
}
/// <summary>
/// Add trackers from the tracker list grabber to the .torrent file bytes.
/// </summary>
/// <param name="torrentBytes">Torrent file bytes to add trackers to. Is not modified</param>
/// <returns>Torrent file bytes with additional trackers</returns>
public async Task<Byte[]> EnrichTorrentBytes(Byte[] torrentBytes)
{
var newTrackers = await trackerListGrabber.GetTrackers();
if (torrentBytes == null) throw new ArgumentNullException(nameof(torrentBytes));
var torrentDict = BEncodedValue.Decode<BEncodedDictionary>(torrentBytes);
if (!torrentDict.TryGetValue("announce-list", out var announceListValue) || announceListValue is not BEncodedList announceList)
{
announceList = new BEncodedList();
if (torrentDict.TryGetValue("announce", out var announceValue) && announceValue is BEncodedString announceStr)
{
announceList.Add(new BEncodedList { announceStr });
}
}
var existingTrackers = new HashSet<String>(StringComparer.OrdinalIgnoreCase);
foreach (var tier in announceList)
{
if (tier is BEncodedList tierList)
{
foreach (var tracker in tierList)
{
if (tracker is BEncodedString trackerStr)
{
existingTrackers.Add(trackerStr.Text);
}
}
}
}
foreach (var tracker in newTrackers)
{
if (!existingTrackers.Contains(tracker))
{
announceList.Add(new BEncodedList { new BEncodedString(tracker) });
existingTrackers.Add(tracker);
}
}
torrentDict["announce-list"] = announceList;
if (announceList.Count > 0 && announceList[0] is BEncodedList firstTier && firstTier.Count > 0 && firstTier[0] is BEncodedString firstTracker)
{
torrentDict["announce"] = firstTracker;
}
return torrentDict.Encode();
}
}

View file

@ -25,7 +25,7 @@ public class Torrents(
IDownloads downloads, IDownloads downloads,
IProcessFactory processFactory, IProcessFactory processFactory,
IFileSystem fileSystem, IFileSystem fileSystem,
ITrackerListGrabber trackerListGrabber, IEnricher enricher,
AllDebridTorrentClient allDebridTorrentClient, AllDebridTorrentClient allDebridTorrentClient,
PremiumizeTorrentClient premiumizeTorrentClient, PremiumizeTorrentClient premiumizeTorrentClient,
RealDebridTorrentClient realDebridTorrentClient, RealDebridTorrentClient realDebridTorrentClient,
@ -33,7 +33,7 @@ public class Torrents(
TorBoxTorrentClient torBoxTorrentClient) TorBoxTorrentClient torBoxTorrentClient)
{ {
private static readonly SemaphoreSlim RealDebridUpdateLock = new(1, 1); private static readonly SemaphoreSlim RealDebridUpdateLock = new(1, 1);
private readonly ITrackerListGrabber _trackerListGrabber = trackerListGrabber;
private static readonly JsonSerializerOptions JsonSerializerOptions = new() private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{ {
ReferenceHandler = ReferenceHandler.IgnoreCycles ReferenceHandler = ReferenceHandler.IgnoreCycles
@ -109,45 +109,9 @@ public class Torrents(
await torrentData.UpdateCategory(torrent.TorrentId, category); await torrentData.UpdateCategory(torrent.TorrentId, category);
} }
private async Task<String> EnrichMagnetLink(String magnetLink)
{
if (String.IsNullOrWhiteSpace(Settings.Get.General.MagnetTrackerEnrichment))
{
return magnetLink;
}
try
{
var newTrackers = await _trackerListGrabber.GetTrackers();
var uri = new Uri(magnetLink);
var query = HttpUtility.ParseQueryString(uri.Query);
var existingTrackers = query.GetValues("tr") ?? [];
var allTrackers = existingTrackers.Concat(newTrackers).Distinct(StringComparer.OrdinalIgnoreCase);
var trackerQuery = String.Join("&tr=", allTrackers.Select(Uri.EscapeDataString));
if (!String.IsNullOrEmpty(trackerQuery))
{
trackerQuery = "&tr=" + trackerQuery;
}
var baseWithoutTrackers = magnetLink.Split(["&tr="], StringSplitOptions.None)[0];
var separator = baseWithoutTrackers.Contains('?') ? "&" : "?";
return baseWithoutTrackers + separator + trackerQuery.TrimStart('&');
}
catch (Exception ex)
{
logger.LogError(ex, "{Message}, trying to enrich {Magnet}", ex.Message, magnetLink);
return magnetLink;
}
}
public async Task<Torrent> AddMagnetToDebridQueue(String magnetLink, Torrent torrent) public async Task<Torrent> AddMagnetToDebridQueue(String magnetLink, Torrent torrent)
{ {
var enriched = await EnrichMagnetLink(magnetLink); var enriched = await enricher.EnrichMagnetLink(magnetLink);
MagnetLink magnet; MagnetLink magnet;
try try
{ {
@ -166,7 +130,7 @@ public class Torrents(
var newTorrent = await AddQueued(hash, enriched, false, torrent); var newTorrent = await AddQueued(hash, enriched, false, torrent);
Log($"Adding {hash} (magnet link) to queue", newTorrent); Log($"Adding {hash} (magnet link) to queue", newTorrent);
await CopyAddedTorrent(magnet.Name!, enriched); await CopyAddedTorrent(magnet.Name!, magnetLink);
return newTorrent; return newTorrent;
} }
@ -178,9 +142,12 @@ public class Torrents(
var fileAsBase64 = Convert.ToBase64String(bytes); var fileAsBase64 = Convert.ToBase64String(bytes);
logger.LogDebug($"bytes {bytes}"); logger.LogDebug($"bytes {bytes}");
var enriched = await enricher.EnrichTorrentBytes(bytes);
logger.LogDebug($"enriched bytes {enriched}");
try try
{ {
monoTorrent = await MonoTorrent.Torrent.LoadAsync(bytes); monoTorrent = await MonoTorrent.Torrent.LoadAsync(enriched);
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -12,9 +12,9 @@ public class TrackerListGrabber : ITrackerListGrabber
public async Task<String[]> GetTrackers() public async Task<String[]> GetTrackers()
{ {
var trackerUrlList = Settings.Get.General.MagnetTrackerEnrichment; var trackerUrlList = Settings.Get.General.MagnetTrackerEnrichment;
if (string.IsNullOrWhiteSpace(trackerUrlList)) if (String.IsNullOrWhiteSpace(trackerUrlList))
{ {
return Array.Empty<String>(); return [];
} }
try try
@ -26,9 +26,9 @@ public class TrackerListGrabber : ITrackerListGrabber
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray(); .ToArray();
} }
catch catch (Exception ex)
{ {
return Array.Empty<String>(); throw new InvalidOperationException("Unable to fetch tracker list for enrichment.", ex);
} }
} }
} }