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.")]
public String? CopyAddedTorrents { get; set; } = null;
[DisplayName("Magnet enrichment list")]
[Description("Optional. Specify the URL of a tracker list file to be appended to magnet links.")]
[DisplayName("Tracker enrichment list")]
[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;
[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<IDownloads> DownloadsMock;
public readonly Mock<ITorrentData> TorrentDataMock;
public readonly Mock<ITrackerListGrabber> TrackerListGrabberMock;
public readonly Mock<IEnricher> EnricherMock;
public Mocks()
{
TorrentDataMock = new();
DownloadsMock = new();
TrackerListGrabberMock = new();
EnricherMock = new();
TorrentsLoggerMock = new();
@ -104,7 +104,7 @@ public class TorrentsTest
mocks.DownloadsMock.Object,
mocks.ProcessFactoryMock.Object,
fileSystemMock,
mocks.TrackerListGrabberMock.Object,
mocks.EnricherMock.Object,
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
null!,
null!,
@ -170,7 +170,7 @@ public class TorrentsTest
mocks.DownloadsMock.Object,
mocks.ProcessFactoryMock.Object,
fileSystemMock,
mocks.TrackerListGrabberMock.Object,
mocks.EnricherMock.Object,
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
null!,
null!,
@ -218,7 +218,7 @@ public class TorrentsTest
mocks.DownloadsMock.Object,
mocks.ProcessFactoryMock.Object,
fileSystemMock,
mocks.TrackerListGrabberMock.Object,
mocks.EnricherMock.Object,
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
null!,
null!,
@ -285,7 +285,7 @@ public class TorrentsTest
mocks.DownloadsMock.Object,
mocks.ProcessFactoryMock.Object,
fileSystemMock,
mocks.TrackerListGrabberMock.Object,
mocks.EnricherMock.Object,
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
null!,
null!,

View file

@ -39,6 +39,7 @@ public static class DiConfig
services.AddSingleton<IDownloadableFileFilter, DownloadableFileFilter>();
services.AddSingleton<ITrackerListGrabber, TrackerListGrabber>();
services.AddSingleton<IEnricher, Enricher>();
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,
IProcessFactory processFactory,
IFileSystem fileSystem,
ITrackerListGrabber trackerListGrabber,
IEnricher enricher,
AllDebridTorrentClient allDebridTorrentClient,
PremiumizeTorrentClient premiumizeTorrentClient,
RealDebridTorrentClient realDebridTorrentClient,
@ -33,7 +33,7 @@ public class Torrents(
TorBoxTorrentClient torBoxTorrentClient)
{
private static readonly SemaphoreSlim RealDebridUpdateLock = new(1, 1);
private readonly ITrackerListGrabber _trackerListGrabber = trackerListGrabber;
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
ReferenceHandler = ReferenceHandler.IgnoreCycles
@ -109,45 +109,9 @@ public class Torrents(
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)
{
var enriched = await EnrichMagnetLink(magnetLink);
var enriched = await enricher.EnrichMagnetLink(magnetLink);
MagnetLink magnet;
try
{
@ -166,7 +130,7 @@ public class Torrents(
var newTorrent = await AddQueued(hash, enriched, false, torrent);
Log($"Adding {hash} (magnet link) to queue", newTorrent);
await CopyAddedTorrent(magnet.Name!, enriched);
await CopyAddedTorrent(magnet.Name!, magnetLink);
return newTorrent;
}
@ -178,9 +142,12 @@ public class Torrents(
var fileAsBase64 = Convert.ToBase64String(bytes);
logger.LogDebug($"bytes {bytes}");
var enriched = await enricher.EnrichTorrentBytes(bytes);
logger.LogDebug($"enriched bytes {enriched}");
try
{
monoTorrent = await MonoTorrent.Torrent.LoadAsync(bytes);
monoTorrent = await MonoTorrent.Torrent.LoadAsync(enriched);
}
catch (Exception ex)
{

View file

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