Merge pull request #816 from mentalblank/magnet-enrichment
Tracker Enrichment
This commit is contained in:
commit
32fe929f48
8 changed files with 1086 additions and 10 deletions
|
|
@ -74,7 +74,15 @@ Supports the following parameters:
|
|||
[DisplayName("Copy added torrent files")]
|
||||
[Description("When a torrent file or magnet is added, create a copy in this directory.")]
|
||||
public String? CopyAddedTorrents { get; set; } = null;
|
||||
|
||||
|
||||
[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? TrackerEnrichmentList { get; set; } = null;
|
||||
|
||||
[DisplayName("Tracker enrichment cache expiration")]
|
||||
[Description("The time in minutes to cache the tracker list. Set to 0 to disable caching.")]
|
||||
public Int32 TrackerEnrichmentCacheExpiration { get; set; } = 60;
|
||||
|
||||
[DisplayName("Disable update notifications")]
|
||||
[Description("Ignore update notifications. You will still be notified if the version you are running has a security vulnerability.")]
|
||||
public Boolean DisableUpdateNotifications { get; set; } = false;
|
||||
|
|
|
|||
622
server/RdtClient.Service.Test/Services/EnricherTest.cs
Normal file
622
server/RdtClient.Service.Test/Services/EnricherTest.cs
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
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:0123456789abcdef0123456789abcdef01234567&dn=TestFile&tr=http%3A%2F%2Ftracker1.com%2Fannounce&tr=http%3A%2F%2Ftracker2.com%2Fannounce";
|
||||
|
||||
// Helper methods for creating BEncodedDictionary objects for torrents
|
||||
private static BEncodedDictionary CreateStandardTorrentDict(String announceUrl, List<String>? announceListTier = null)
|
||||
{
|
||||
var torrentDict = new BEncodedDictionary
|
||||
{
|
||||
["announce"] = new BEncodedString(announceUrl)
|
||||
};
|
||||
|
||||
var finalAnnounceList = new BEncodedList();
|
||||
|
||||
if (announceListTier != null)
|
||||
{
|
||||
var tier = new BEncodedList();
|
||||
|
||||
foreach (var tracker in announceListTier)
|
||||
{
|
||||
tier.Add(new BEncodedString(tracker));
|
||||
}
|
||||
|
||||
finalAnnounceList.Add(tier);
|
||||
}
|
||||
else
|
||||
{
|
||||
finalAnnounceList.Add(new BEncodedList
|
||||
{
|
||||
new BEncodedString(announceUrl)
|
||||
});
|
||||
}
|
||||
|
||||
torrentDict["announce-list"] = finalAnnounceList;
|
||||
|
||||
if (!torrentDict.ContainsKey("info"))
|
||||
{
|
||||
torrentDict["info"] = new BEncodedDictionary();
|
||||
}
|
||||
|
||||
return torrentDict;
|
||||
}
|
||||
|
||||
private static BEncodedDictionary CreateTorrentDictWithOnlyAnnounce(String announceUrl)
|
||||
{
|
||||
return new BEncodedDictionary
|
||||
{
|
||||
["announce"] = new BEncodedString(announceUrl),
|
||||
["info"] = new BEncodedDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
private static BEncodedDictionary CreateTorrentDictWithNoTrackers()
|
||||
{
|
||||
return new BEncodedDictionary
|
||||
{
|
||||
["info"] = new BEncodedDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
private static BEncodedDictionary CreateTorrentDictWithComplexAnnounceList(String primaryAnnounceUrl, List<List<String>> announceListTiers)
|
||||
{
|
||||
var torrentDict = new BEncodedDictionary
|
||||
{
|
||||
["announce"] = new BEncodedString(primaryAnnounceUrl)
|
||||
};
|
||||
|
||||
var bencodedAnnounceList = new BEncodedList();
|
||||
|
||||
foreach (var tier in announceListTiers)
|
||||
{
|
||||
var bencodedTier = new BEncodedList();
|
||||
|
||||
foreach (var tracker in tier)
|
||||
{
|
||||
bencodedTier.Add(new BEncodedString(tracker));
|
||||
}
|
||||
|
||||
bencodedAnnounceList.Add(bencodedTier);
|
||||
}
|
||||
|
||||
torrentDict["announce-list"] = bencodedAnnounceList;
|
||||
|
||||
if (!torrentDict.ContainsKey("info"))
|
||||
{
|
||||
torrentDict["info"] = new BEncodedDictionary();
|
||||
}
|
||||
|
||||
return torrentDict;
|
||||
}
|
||||
|
||||
private void SetupTrackerListGrabber(String[] trackerList)
|
||||
{
|
||||
_trackerListGrabberMock
|
||||
.Setup(t => t.GetTrackers())
|
||||
.ReturnsAsync(trackerList)
|
||||
.Verifiable();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichMagnetLink_WhenNoTrackers_ReturnsOriginal()
|
||||
{
|
||||
// Arrange
|
||||
SetupTrackerListGrabber([]);
|
||||
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var enriched = await enricher.EnrichMagnetLink(TestMagnetLink);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TestMagnetLink, enriched);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("magnet:?xt=urn:btih:HASH",
|
||||
"magnet:?xt=urn:btih:HASH&tr=http%3A%2F%2Fnew-tracker.com%2Fannounce",
|
||||
"http://new-tracker.com/announce")]
|
||||
[InlineData("magnet:?xt=urn:btih:HASH",
|
||||
"magnet:?xt=urn:btih:HASH&tr=http%3A%2F%2Ftracker1.com%2Fannounce&tr=http%3A%2F%2Ftracker2.com%2Fannounce",
|
||||
"http://tracker1.com/announce",
|
||||
"http://tracker2.com/announce")]
|
||||
[InlineData("magnet:?xt=urn:btih:HASH", "magnet:?xt=urn:btih:HASH&tr=udp%3A%2F%2Ftracker1", "udp://tracker1")]
|
||||
[InlineData("magnet:?xt=urn:btih:HASH", "magnet:?xt=urn:btih:HASH&tr=udp%3A%2F%2Ftracker1&tr=udp%3A%2F%2Ftracker2", "udp://tracker1", "udp://tracker2")]
|
||||
[InlineData("magnet:?xt=urn:btih:HASH", "magnet:?xt=urn:btih:HASH")]
|
||||
[InlineData("magnet:?",
|
||||
"magnet:?tr=udp%3A%2F%2Ftracker1",
|
||||
"udp://tracker1")]
|
||||
[InlineData("magnet:?",
|
||||
"magnet:?tr=udp%3A%2F%2Ftracker1&tr=udp%3A%2F%2Ftracker2",
|
||||
"udp://tracker1",
|
||||
"udp://tracker2")]
|
||||
[InlineData("magnet:?",
|
||||
"magnet:?")]
|
||||
public async Task EnrichMagnetLink_WhenTrackersProvided_AddsTrackers(String magnetLink, String expectedResult, params String[] newTrackers)
|
||||
{
|
||||
// Arrange
|
||||
SetupTrackerListGrabber(newTrackers);
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var enriched = await enricher.EnrichMagnetLink(magnetLink);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedResult, enriched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichMagnetLink_WhenDuplicatesProvided_AddsUniqueTrackers()
|
||||
{
|
||||
// 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_WhenGrabberFails_ThrowsException()
|
||||
{
|
||||
// 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 EnrichMagnetLink_WhenTrackersAdded_PreservesParameters()
|
||||
{
|
||||
// Arrange
|
||||
var magnetLink = "magnet:?xt=urn:btih:HASH&dn=MyFile&tr=udp%3A%2F%2Fexisting";
|
||||
|
||||
var newTrackers = new[]
|
||||
{
|
||||
"udp://new1", "udp://existing"
|
||||
};
|
||||
|
||||
SetupTrackerListGrabber(newTrackers);
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var result = await enricher.EnrichMagnetLink(magnetLink);
|
||||
|
||||
// Assert
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(new Uri(result).Query);
|
||||
Assert.Equal("urn:btih:HASH", queryParams["xt"]);
|
||||
Assert.Equal("MyFile", queryParams["dn"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichMagnetLink_WhenTrackersAdded_IncludesAllTrackers()
|
||||
{
|
||||
// Arrange
|
||||
var magnetLink = "magnet:?xt=urn:btih:HASH&dn=MyFile&tr=udp%3A%2F%2Fexisting";
|
||||
|
||||
var newTrackers = new[]
|
||||
{
|
||||
"udp://new1", "udp://existing"
|
||||
};
|
||||
|
||||
SetupTrackerListGrabber(newTrackers);
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var result = await enricher.EnrichMagnetLink(magnetLink);
|
||||
|
||||
// Assert
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(new Uri(result).Query);
|
||||
var trValues = queryParams.GetValues("tr")?.ToList() ?? new List<String>();
|
||||
|
||||
Assert.Contains("udp://existing", trValues);
|
||||
Assert.Contains("udp://new1", trValues);
|
||||
Assert.Equal(2, trValues.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenTrackersAdded_CreatesAnnounceKey()
|
||||
{
|
||||
// Arrange
|
||||
var originalTracker = "http://tracker1.com/announce";
|
||||
var newTracker = "http://new-tracker.com/announce";
|
||||
var torrentDict = CreateStandardTorrentDict(originalTracker);
|
||||
var originalTorrentBytes = torrentDict.Encode();
|
||||
SetupTrackerListGrabber([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"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenTrackersAdded_CreatesAnnounceListKey()
|
||||
{
|
||||
// Arrange
|
||||
var originalTracker = "http://tracker1.com/announce";
|
||||
var newTracker = "http://new-tracker.com/announce";
|
||||
var torrentDict = CreateStandardTorrentDict(originalTracker);
|
||||
var originalTorrentBytes = torrentDict.Encode();
|
||||
SetupTrackerListGrabber([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-list"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenTrackersAdded_IncludesAllTrackers()
|
||||
{
|
||||
// Arrange
|
||||
var originalTracker = "http://tracker1.com/announce";
|
||||
var newTracker = "http://new-tracker.com/announce";
|
||||
var torrentDict = CreateStandardTorrentDict(originalTracker);
|
||||
var originalTorrentBytes = torrentDict.Encode();
|
||||
SetupTrackerListGrabber([newTracker]); // Grabber returns newTracker
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var enrichedBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes);
|
||||
var enrichedDict = BEncodedValue.Decode<BEncodedDictionary>(enrichedBytes);
|
||||
|
||||
// Assert
|
||||
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);
|
||||
Assert.Equal(2, flattened.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenTrackersAdded_SetsPrimaryAnnounce()
|
||||
{
|
||||
// Arrange
|
||||
var originalTracker = "http://tracker1.com/announce";
|
||||
var newTracker = "http://new-tracker.com/announce";
|
||||
var torrentDict = CreateStandardTorrentDict(originalTracker);
|
||||
var originalTorrentBytes = torrentDict.Encode();
|
||||
SetupTrackerListGrabber([newTracker]);
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var enrichedBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes);
|
||||
var enrichedDict = BEncodedValue.Decode<BEncodedDictionary>(enrichedBytes);
|
||||
|
||||
// Assert
|
||||
var announceList = (BEncodedList)enrichedDict["announce-list"];
|
||||
var flattened = announceList.Cast<BEncodedList>().SelectMany(l => l.Cast<BEncodedString>().Select(s => s.Text)).ToList();
|
||||
var announce = ((BEncodedString)enrichedDict["announce"]).Text;
|
||||
Assert.Equal(flattened.First(), announce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenDuplicatesProvided_AddsUniqueTrackers()
|
||||
{
|
||||
// Arrange
|
||||
var originalTracker = "http://tracker1.com/announce";
|
||||
var duplicateTracker = "http://tracker1.com/announce";
|
||||
|
||||
var torrentDict = CreateStandardTorrentDict(originalTracker);
|
||||
var originalTorrentBytes = torrentDict.Encode();
|
||||
|
||||
SetupTrackerListGrabber([duplicateTracker]);
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var enrichedBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes);
|
||||
var enrichedDict = BEncodedValue.Decode<BEncodedDictionary>(enrichedBytes);
|
||||
|
||||
// Assert
|
||||
var announceList = (BEncodedList)enrichedDict["announce-list"];
|
||||
var flattened = announceList.Cast<BEncodedList>().SelectMany(l => l.Cast<BEncodedString>().Select(s => s.Text)).ToList();
|
||||
|
||||
Assert.Single(flattened);
|
||||
Assert.Contains(originalTracker, flattened);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenNoAnnounceList_CreatesAnnounceListKey()
|
||||
{
|
||||
// Arrange
|
||||
var originalTracker = "http://tracker1.com/announce";
|
||||
var newTracker = "http://new-tracker.com/announce";
|
||||
var torrentDict = CreateTorrentDictWithOnlyAnnounce(originalTracker);
|
||||
var originalTorrentBytes = torrentDict.Encode();
|
||||
SetupTrackerListGrabber([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-list"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenNoAnnounceList_AddsOriginalTracker()
|
||||
{
|
||||
// Arrange
|
||||
var originalTracker = "http://tracker1.com/announce";
|
||||
var newTracker = "http://new-tracker.com/announce";
|
||||
var torrentDict = CreateTorrentDictWithOnlyAnnounce(originalTracker);
|
||||
var originalTorrentBytes = torrentDict.Encode();
|
||||
SetupTrackerListGrabber([newTracker]);
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var enrichedBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes);
|
||||
var enrichedDict = BEncodedValue.Decode<BEncodedDictionary>(enrichedBytes);
|
||||
|
||||
// Assert
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenNoAnnounceList_AddsGrabbedTrackers()
|
||||
{
|
||||
// Arrange
|
||||
var originalTracker = "http://tracker1.com/announce";
|
||||
var newTracker = "http://new-tracker.com/announce";
|
||||
var torrentDict = CreateTorrentDictWithOnlyAnnounce(originalTracker);
|
||||
var originalTorrentBytes = torrentDict.Encode();
|
||||
SetupTrackerListGrabber([newTracker]);
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var enrichedBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes);
|
||||
var enrichedDict = BEncodedValue.Decode<BEncodedDictionary>(enrichedBytes);
|
||||
|
||||
// Assert
|
||||
var announceList = (BEncodedList)enrichedDict["announce-list"];
|
||||
var flattened = announceList.Cast<BEncodedList>().SelectMany(l => l.Cast<BEncodedString>().Select(s => s.Text)).ToList();
|
||||
Assert.Contains(newTracker, flattened);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenGrabberFails_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
_trackerListGrabberMock
|
||||
.Setup(t => t.GetTrackers())
|
||||
.ThrowsAsync(new InvalidOperationException("Unable to fetch tracker list for enrichment."));
|
||||
|
||||
var torrentDict = CreateStandardTorrentDict("http://tracker1.com/announce");
|
||||
var originalTorrentBytes = torrentDict.Encode();
|
||||
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => enricher.EnrichTorrentBytes(originalTorrentBytes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenNoNewTrackers_ReturnsOriginalBytes()
|
||||
{
|
||||
// Arrange
|
||||
var torrentDict = CreateStandardTorrentDict("http://existing.tracker/announce");
|
||||
var originalTorrentBytes = torrentDict.Encode();
|
||||
|
||||
SetupTrackerListGrabber([]);
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var resultBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(originalTorrentBytes, resultBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenNoTrackers_ReturnsOriginalBytes()
|
||||
{
|
||||
// Arrange
|
||||
var torrentDict = CreateTorrentDictWithNoTrackers();
|
||||
var originalTorrentBytes = torrentDict.Encode();
|
||||
|
||||
SetupTrackerListGrabber([]); // Grabber returns no new trackers
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var resultBytes = await enricher.EnrichTorrentBytes(originalTorrentBytes);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(originalTorrentBytes, resultBytes);
|
||||
|
||||
var decodedResult = BEncodedValue.Decode<BEncodedDictionary>(resultBytes);
|
||||
Assert.False(decodedResult.ContainsKey("announce"));
|
||||
Assert.False(decodedResult.ContainsKey("announce-list"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenNullInput_ThrowsArgumentException()
|
||||
{
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => enricher.EnrichTorrentBytes(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenEmptyInput_ThrowsArgumentException()
|
||||
{
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => enricher.EnrichTorrentBytes([]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenInvalidInput_ThrowsInvalidOperationException()
|
||||
{
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
var notTorrent = new Byte[]
|
||||
{
|
||||
1, 2, 3, 4, 5
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => enricher.EnrichTorrentBytes(notTorrent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenNoAnnounceFields_CreatesAnnounceField()
|
||||
{
|
||||
// Arrange
|
||||
var torrentDict = CreateTorrentDictWithNoTrackers();
|
||||
var originalBytes = torrentDict.Encode();
|
||||
|
||||
var newTrackers = new[]
|
||||
{
|
||||
"udp://tracker1", "udp://tracker2"
|
||||
};
|
||||
|
||||
SetupTrackerListGrabber(newTrackers);
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var enrichedBytes = await enricher.EnrichTorrentBytes(originalBytes);
|
||||
var enrichedDict = BEncodedValue.Decode<BEncodedDictionary>(enrichedBytes);
|
||||
|
||||
// Assert
|
||||
Assert.True(enrichedDict.ContainsKey("announce"));
|
||||
Assert.Equal(newTrackers[0], ((BEncodedString)enrichedDict["announce"]).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenNoAnnounceFields_CreatesAnnounceListKey()
|
||||
{
|
||||
// Arrange
|
||||
var torrentDict = CreateTorrentDictWithNoTrackers();
|
||||
var originalBytes = torrentDict.Encode();
|
||||
|
||||
var newTrackers = new[]
|
||||
{
|
||||
"udp://tracker1", "udp://tracker2"
|
||||
};
|
||||
|
||||
SetupTrackerListGrabber(newTrackers);
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var enrichedBytes = await enricher.EnrichTorrentBytes(originalBytes);
|
||||
var enrichedDict = BEncodedValue.Decode<BEncodedDictionary>(enrichedBytes);
|
||||
|
||||
// Assert
|
||||
Assert.True(enrichedDict.ContainsKey("announce-list"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenNoAnnounceFields_AddsGrabbedTrackers()
|
||||
{
|
||||
// Arrange
|
||||
var torrentDict = CreateTorrentDictWithNoTrackers();
|
||||
var originalBytes = torrentDict.Encode();
|
||||
|
||||
var newTrackers = new[]
|
||||
{
|
||||
"udp://tracker1", "udp://tracker2"
|
||||
};
|
||||
|
||||
SetupTrackerListGrabber(newTrackers);
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var enrichedBytes = await enricher.EnrichTorrentBytes(originalBytes);
|
||||
var enrichedDict = BEncodedValue.Decode<BEncodedDictionary>(enrichedBytes);
|
||||
|
||||
// Assert
|
||||
var announceList = (BEncodedList)enrichedDict["announce-list"];
|
||||
Assert.Equal(newTrackers.Length, announceList.Count);
|
||||
|
||||
Assert.Collection(announceList,
|
||||
tier => Assert.Equal(newTrackers[0], ((BEncodedString)((BEncodedList)tier)[0]).Text),
|
||||
tier => Assert.Equal(newTrackers[1], ((BEncodedString)((BEncodedList)tier)[0]).Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichTorrentBytes_WhenExistingTrackers_MergesNewTrackers()
|
||||
{
|
||||
// Arrange
|
||||
var primaryAnnounceUrl = "http://existing.com/announce";
|
||||
|
||||
var announceListTiers = new List<List<String>>
|
||||
{
|
||||
new()
|
||||
{
|
||||
"http://tier1.com"
|
||||
},
|
||||
new()
|
||||
{
|
||||
primaryAnnounceUrl
|
||||
}
|
||||
};
|
||||
|
||||
var torrentDict = CreateTorrentDictWithComplexAnnounceList(primaryAnnounceUrl, announceListTiers);
|
||||
var originalBytes = torrentDict.Encode();
|
||||
|
||||
var newTrackersFromGrabber = new[]
|
||||
{
|
||||
"udp://new1", "http://existing.com/announce"
|
||||
};
|
||||
|
||||
SetupTrackerListGrabber(newTrackersFromGrabber);
|
||||
|
||||
var enricher = new Enricher(_loggerMock.Object, _trackerListGrabberMock.Object);
|
||||
|
||||
// Act
|
||||
var enrichedBytes = await enricher.EnrichTorrentBytes(originalBytes);
|
||||
var enrichedDict = BEncodedValue.Decode<BEncodedDictionary>(enrichedBytes);
|
||||
|
||||
// Assert
|
||||
var finalAnnounceList = ((BEncodedList)enrichedDict["announce-list"])
|
||||
.Cast<BEncodedList>()
|
||||
.SelectMany(tier => tier.Cast<BEncodedString>().Select(t => t.Text))
|
||||
.ToList();
|
||||
|
||||
Assert.Contains("http://tier1.com", finalAnnounceList);
|
||||
Assert.Contains("http://existing.com/announce", finalAnnounceList);
|
||||
Assert.Contains("udp://new1", finalAnnounceList);
|
||||
Assert.Equal(3, finalAnnounceList.Count);
|
||||
Assert.Contains(((BEncodedString)enrichedDict["announce"]).Text, finalAnnounceList);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,11 +20,13 @@ class Mocks
|
|||
public readonly Mock<ILogger<TorrentsService>> TorrentsLoggerMock;
|
||||
public readonly Mock<IDownloads> DownloadsMock;
|
||||
public readonly Mock<ITorrentData> TorrentDataMock;
|
||||
public readonly Mock<IEnricher> EnricherMock;
|
||||
|
||||
public Mocks()
|
||||
{
|
||||
TorrentDataMock = new();
|
||||
DownloadsMock = new();
|
||||
EnricherMock = new();
|
||||
|
||||
TorrentsLoggerMock = new();
|
||||
|
||||
|
|
@ -58,7 +60,7 @@ public class TorrentsTest
|
|||
}
|
||||
];
|
||||
|
||||
return new ()
|
||||
return new()
|
||||
{
|
||||
{
|
||||
torrent,
|
||||
|
|
@ -102,6 +104,7 @@ public class TorrentsTest
|
|||
mocks.DownloadsMock.Object,
|
||||
mocks.ProcessFactoryMock.Object,
|
||||
fileSystemMock,
|
||||
mocks.EnricherMock.Object,
|
||||
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
|
||||
null!,
|
||||
null!,
|
||||
|
|
@ -167,6 +170,7 @@ public class TorrentsTest
|
|||
mocks.DownloadsMock.Object,
|
||||
mocks.ProcessFactoryMock.Object,
|
||||
fileSystemMock,
|
||||
mocks.EnricherMock.Object,
|
||||
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
|
||||
null!,
|
||||
null!,
|
||||
|
|
@ -214,6 +218,7 @@ public class TorrentsTest
|
|||
mocks.DownloadsMock.Object,
|
||||
mocks.ProcessFactoryMock.Object,
|
||||
fileSystemMock,
|
||||
mocks.EnricherMock.Object,
|
||||
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
|
||||
null!,
|
||||
null!,
|
||||
|
|
@ -280,6 +285,7 @@ public class TorrentsTest
|
|||
mocks.DownloadsMock.Object,
|
||||
mocks.ProcessFactoryMock.Object,
|
||||
fileSystemMock,
|
||||
mocks.EnricherMock.Object,
|
||||
null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine
|
||||
null!,
|
||||
null!,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ public static class DiConfig
|
|||
|
||||
public static void RegisterRdtServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddMemoryCache();
|
||||
|
||||
services.AddSingleton<IAllDebridNetClientFactory, AllDebridNetClientFactory>();
|
||||
services.AddScoped<AllDebridTorrentClient>();
|
||||
|
||||
|
|
@ -38,6 +40,8 @@ public static class DiConfig
|
|||
services.AddScoped<DebridLinkClient>();
|
||||
|
||||
services.AddSingleton<IDownloadableFileFilter, DownloadableFileFilter>();
|
||||
services.AddSingleton<ITrackerListGrabber, TrackerListGrabber>();
|
||||
services.AddSingleton<IEnricher, Enricher>();
|
||||
|
||||
services.AddSingleton<IAuthorizationHandler, AuthSettingHandler>();
|
||||
|
||||
|
|
|
|||
226
server/RdtClient.Service/Services/Enricher.cs
Normal file
226
server/RdtClient.Service/Services/Enricher.cs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
using System.Text;
|
||||
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 sealed 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 trackers to. Is not modified</param>
|
||||
/// <returns>Magnet link with additional trackers</returns>
|
||||
public async Task<String> EnrichMagnetLink(String magnetLink)
|
||||
{
|
||||
var newTrackers = await trackerListGrabber.GetTrackers().ConfigureAwait(false);
|
||||
|
||||
if (newTrackers.Length == 0)
|
||||
{
|
||||
logger.LogWarning("No new trackers were retrieved.");
|
||||
|
||||
return magnetLink;
|
||||
}
|
||||
|
||||
var qmIdx = magnetLink.IndexOf('?');
|
||||
|
||||
if (qmIdx == -1 || qmIdx == magnetLink.Length - 1)
|
||||
{
|
||||
var sb = new StringBuilder(magnetLink);
|
||||
|
||||
if (qmIdx == -1)
|
||||
{
|
||||
sb.Append('?');
|
||||
}
|
||||
|
||||
for (var i = 0; i < newTrackers.Length; i++)
|
||||
{
|
||||
if (i > 0 || (qmIdx != magnetLink.Length - 1 && qmIdx != -1))
|
||||
{
|
||||
sb.Append('&');
|
||||
}
|
||||
|
||||
sb.Append("tr=").Append(Uri.EscapeDataString(newTrackers[i]));
|
||||
}
|
||||
|
||||
logger.LogInformation("Added {NewTrackersCount} new trackers to a magnet link with no initial query string. Total trackers: {TotalTrackersCount}.",
|
||||
newTrackers.Length,
|
||||
newTrackers.Length);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
var schemePart = magnetLink[..qmIdx];
|
||||
var queryPart = magnetLink[(qmIdx + 1)..];
|
||||
|
||||
var queryKVs = HttpUtility.ParseQueryString(queryPart);
|
||||
|
||||
var paramDict = new Dictionary<String, List<String>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (String key in queryKVs)
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!paramDict.ContainsKey(key))
|
||||
{
|
||||
paramDict[key] = new List<String>();
|
||||
}
|
||||
|
||||
paramDict[key].AddRange(queryKVs.GetValues(key) ?? []);
|
||||
}
|
||||
|
||||
var existingTrackers = paramDict.TryGetValue("tr", out var value)
|
||||
? new HashSet<String>(value, StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<String>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
paramDict.Remove("tr");
|
||||
|
||||
var newUniqueTrackers = newTrackers.Where(t => !existingTrackers.Contains(t)).ToList();
|
||||
|
||||
foreach (var tr in newUniqueTrackers)
|
||||
{
|
||||
existingTrackers.Add(tr);
|
||||
}
|
||||
|
||||
var outParams = new List<String>();
|
||||
|
||||
foreach (var kv in paramDict)
|
||||
{
|
||||
foreach (var v in kv.Value)
|
||||
{
|
||||
if (kv.Key == "xt" || kv.Key == "dn")
|
||||
{
|
||||
outParams.Add($"{kv.Key}={v}");
|
||||
}
|
||||
else
|
||||
{
|
||||
outParams.Add($"{kv.Key}={Uri.EscapeDataString(v)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var tr in existingTrackers)
|
||||
{
|
||||
outParams.Add($"tr={Uri.EscapeDataString(tr)}");
|
||||
}
|
||||
|
||||
var finalMagnet = schemePart + "?" + String.Join("&", outParams);
|
||||
|
||||
logger.LogInformation("Added {NewTrackersCount} new trackers to the magnet link. Total trackers: {TotalTrackersCount}.",
|
||||
newUniqueTrackers.Count,
|
||||
existingTrackers.Count);
|
||||
|
||||
return finalMagnet;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (torrentBytes == null || torrentBytes.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Torrent bytes cannot be null or empty.", nameof(torrentBytes));
|
||||
}
|
||||
|
||||
BEncodedDictionary torrentDict;
|
||||
|
||||
try
|
||||
{
|
||||
torrentDict = BEncodedValue.Decode<BEncodedDictionary>(torrentBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to decode torrent bytes.");
|
||||
|
||||
throw new InvalidOperationException("Invalid torrent file format.", ex);
|
||||
}
|
||||
|
||||
var newTrackers = await trackerListGrabber.GetTrackers().ConfigureAwait(false);
|
||||
|
||||
if (newTrackers.Length == 0)
|
||||
{
|
||||
logger.LogWarning("No new trackers were retrieved.");
|
||||
|
||||
return torrentBytes;
|
||||
}
|
||||
|
||||
var seenTrackers = new HashSet<String>(StringComparer.OrdinalIgnoreCase);
|
||||
var allTrackers = new List<String>();
|
||||
|
||||
if (torrentDict.TryGetValue("announce-list", out var alc) && alc is BEncodedList alList)
|
||||
{
|
||||
foreach (var tier in alList.OfType<BEncodedList>())
|
||||
{
|
||||
foreach (var s in tier.OfType<BEncodedString>())
|
||||
{
|
||||
if (seenTrackers.Add(s.Text))
|
||||
{
|
||||
allTrackers.Add(s.Text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (torrentDict.TryGetValue("announce", out var announceValue) && announceValue is BEncodedString announceStr)
|
||||
{
|
||||
if (seenTrackers.Add(announceStr.Text))
|
||||
{
|
||||
allTrackers.Add(announceStr.Text);
|
||||
}
|
||||
}
|
||||
|
||||
var addedTrackersCount = 0;
|
||||
|
||||
foreach (var tracker in newTrackers)
|
||||
{
|
||||
if (seenTrackers.Add(tracker))
|
||||
{
|
||||
allTrackers.Add(tracker);
|
||||
addedTrackersCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var dedupedAnnounceList = new BEncodedList();
|
||||
|
||||
foreach (var tracker in allTrackers)
|
||||
{
|
||||
dedupedAnnounceList.Add(new BEncodedList
|
||||
{
|
||||
new BEncodedString(tracker)
|
||||
});
|
||||
}
|
||||
|
||||
torrentDict["announce-list"] = dedupedAnnounceList;
|
||||
|
||||
if (allTrackers.Count > 0)
|
||||
{
|
||||
torrentDict["announce"] = new BEncodedString(allTrackers[0]);
|
||||
}
|
||||
else
|
||||
{
|
||||
return torrentBytes;
|
||||
}
|
||||
|
||||
logger.LogInformation("Added {NewTrackersCount} new trackers to the torrent. Total trackers: {TotalTrackersCount}.",
|
||||
addedTrackersCount,
|
||||
allTrackers.Count);
|
||||
|
||||
return torrentDict.Encode();
|
||||
}
|
||||
}
|
||||
6
server/RdtClient.Service/Services/ITrackerListGrabber.cs
Normal file
6
server/RdtClient.Service/Services/ITrackerListGrabber.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace RdtClient.Service.Services;
|
||||
|
||||
public interface ITrackerListGrabber
|
||||
{
|
||||
Task<String[]> GetTrackers();
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ using System.IO.Abstractions;
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Web;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MonoTorrent;
|
||||
using RdtClient.Data.Data;
|
||||
|
|
@ -24,6 +25,7 @@ public class Torrents(
|
|||
IDownloads downloads,
|
||||
IProcessFactory processFactory,
|
||||
IFileSystem fileSystem,
|
||||
IEnricher enricher,
|
||||
AllDebridTorrentClient allDebridTorrentClient,
|
||||
PremiumizeTorrentClient premiumizeTorrentClient,
|
||||
RealDebridTorrentClient realDebridTorrentClient,
|
||||
|
|
@ -109,8 +111,8 @@ public class Torrents(
|
|||
|
||||
public async Task<Torrent> AddMagnetToDebridQueue(String magnetLink, Torrent torrent)
|
||||
{
|
||||
var enriched = await enricher.EnrichMagnetLink(magnetLink);
|
||||
MagnetLink magnet;
|
||||
|
||||
try
|
||||
{
|
||||
magnet = MagnetLink.Parse(magnetLink);
|
||||
|
|
@ -125,11 +127,9 @@ public class Torrents(
|
|||
torrent.RdName = magnet.Name;
|
||||
|
||||
var hash = magnet.InfoHashes.V1OrV2.ToHex();
|
||||
|
||||
var newTorrent = await AddQueued(hash, magnetLink, false, torrent);
|
||||
var newTorrent = await AddQueued(hash, enriched, false, torrent);
|
||||
|
||||
Log($"Adding {hash} (magnet link) to queue", newTorrent);
|
||||
|
||||
await CopyAddedTorrent(magnet.Name!, magnetLink);
|
||||
|
||||
return newTorrent;
|
||||
|
|
@ -139,8 +139,20 @@ public class Torrents(
|
|||
{
|
||||
MonoTorrent.Torrent monoTorrent;
|
||||
|
||||
var fileAsBase64 = Convert.ToBase64String(bytes);
|
||||
logger.LogDebug($"bytes {bytes}");
|
||||
var enriched = await enricher.EnrichTorrentBytes(bytes);
|
||||
|
||||
String fileAsBase64;
|
||||
|
||||
if (enriched.SequenceEqual(bytes))
|
||||
{
|
||||
fileAsBase64 = Convert.ToBase64String(bytes);
|
||||
logger.LogDebug($"bytes {bytes}");
|
||||
}
|
||||
else
|
||||
{
|
||||
fileAsBase64 = Convert.ToBase64String(enriched);
|
||||
logger.LogDebug($"enriched bytes {enriched}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
|
@ -224,7 +236,7 @@ public class Torrents(
|
|||
{
|
||||
throw new("Torrent has no torrent file or magnet link");
|
||||
}
|
||||
|
||||
|
||||
logger.LogDebug("Adding {hash} to debrid provider {torrentInfo}", torrent.Hash, torrent.ToLog());
|
||||
|
||||
await RealDebridUpdateLock.WaitAsync();
|
||||
|
|
@ -435,7 +447,7 @@ public class Torrents(
|
|||
{
|
||||
var download = await downloads.GetById(downloadId) ?? throw new($"Download with ID {downloadId} not found");
|
||||
|
||||
Log($"Unrestricting link", download, download.Torrent);
|
||||
Log("Unrestricting link", download, download.Torrent);
|
||||
|
||||
var unrestrictedLink = await TorrentClient.Unrestrict(download.Path);
|
||||
|
||||
|
|
|
|||
192
server/RdtClient.Service/Services/TrackerListGrabber.cs
Normal file
192
server/RdtClient.Service/Services/TrackerListGrabber.cs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Reflection;
|
||||
|
||||
namespace RdtClient.Service.Services;
|
||||
|
||||
public class TrackerListGrabber(IHttpClientFactory httpClientFactory, IMemoryCache memoryCache, ILogger<TrackerListGrabber> logger) : ITrackerListGrabber
|
||||
{
|
||||
private const String CacheKey = "TrackerList";
|
||||
|
||||
private Int32? _lastExpirationMinutes;
|
||||
|
||||
private static readonly SemaphoreSlim Semaphore = new(1, 1);
|
||||
|
||||
public async Task<String[]> GetTrackers()
|
||||
{
|
||||
var trackerUrlList = Settings.Get.General.TrackerEnrichmentList;
|
||||
|
||||
if (String.IsNullOrWhiteSpace(trackerUrlList))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(trackerUrlList, UriKind.Absolute, out var trackerUri) ||
|
||||
(trackerUri.Scheme != Uri.UriSchemeHttp && trackerUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
logger.LogWarning("Invalid tracker list URL format: {Url}", trackerUrlList);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
var currentExpiration = Settings.Get.General.TrackerEnrichmentCacheExpiration;
|
||||
var useCache = currentExpiration > 0;
|
||||
|
||||
if (!useCache)
|
||||
{
|
||||
memoryCache.Remove(CacheKey);
|
||||
_lastExpirationMinutes = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_lastExpirationMinutes is not null && currentExpiration != _lastExpirationMinutes)
|
||||
{
|
||||
logger.LogDebug("Tracker list cache timeout changed, invalidating cache.");
|
||||
memoryCache.Remove(CacheKey);
|
||||
}
|
||||
|
||||
_lastExpirationMinutes = currentExpiration;
|
||||
|
||||
if (memoryCache.TryGetValue(CacheKey, out String[]? cachedTrackers) && cachedTrackers is { Length: > 0 })
|
||||
{
|
||||
logger.LogDebug("Using cached tracker list.");
|
||||
|
||||
return cachedTrackers;
|
||||
}
|
||||
}
|
||||
|
||||
await Semaphore.WaitAsync().ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (useCache)
|
||||
{
|
||||
if (memoryCache.TryGetValue(CacheKey, out String[]? cachedTrackers) && cachedTrackers is { Length: > 0 })
|
||||
{
|
||||
logger.LogDebug("Using cached tracker list (after lock).");
|
||||
|
||||
return cachedTrackers;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug("Tracker cache miss or cache disabled. Fetching tracker list.");
|
||||
|
||||
var trackers = await FetchAndParseTrackersAsync(trackerUri).ConfigureAwait(false);
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
memoryCache.Set(CacheKey,
|
||||
trackers,
|
||||
new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(currentExpiration)
|
||||
});
|
||||
}
|
||||
|
||||
return trackers;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
logger.LogError(ex, "Fetching tracker list was canceled (timeout or cancellation).");
|
||||
|
||||
throw new TaskCanceledException("Fetching tracker list was canceled due to timeout or cancellation.", ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unable to fetch tracker list.");
|
||||
|
||||
throw new Exception("Unable to fetch tracker list for enrichment.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<String[]> FetchAndParseTrackersAsync(Uri trackerUri)
|
||||
{
|
||||
logger.LogDebug("Fetching tracker list from URL: {TrackerUrl}", trackerUri);
|
||||
|
||||
var httpClient = httpClientFactory.CreateClient();
|
||||
|
||||
var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString();
|
||||
|
||||
var currentVersion = version != null && version.LastIndexOf('.') > 0
|
||||
? $"v{version[..version.LastIndexOf('.')]}"
|
||||
: "";
|
||||
|
||||
httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("RdtClient", currentVersion));
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
var token = cts.Token;
|
||||
var response = await httpClient.GetAsync(trackerUri, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
|
||||
|
||||
using (response)
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var contentStream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
|
||||
using var reader = new StreamReader(contentStream);
|
||||
var result = await reader.ReadToEndAsync(token).ConfigureAwait(false);
|
||||
|
||||
String[] trackers;
|
||||
|
||||
try
|
||||
{
|
||||
var trackerRejectionCount = 0;
|
||||
|
||||
trackers = result
|
||||
.Split([
|
||||
"\r\n", "\n"
|
||||
],
|
||||
StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(line => !String.IsNullOrWhiteSpace(line) && !line.TrimStart().StartsWith('#'))
|
||||
.Select(t => t.EndsWith("/") ? t.TrimEnd('/') : t)
|
||||
.Select(t => t.Trim())
|
||||
.Where(t =>
|
||||
{
|
||||
if (!Uri.TryCreate(t, UriKind.Absolute, out var uri))
|
||||
{
|
||||
logger.LogDebug("Rejected tracker: {TrackerUrl} - Reason: Invalid format or unsupported scheme.", t);
|
||||
trackerRejectionCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
var isIpv6 = uri.Host.StartsWith('[') && uri.Host.Contains(']');
|
||||
|
||||
var valid = ((isIpv6 && uri.Host.All(c => Char.IsLetterOrDigit(c) || c == '.' || c == ':' || c == '[' || c == ']')) ||
|
||||
(!isIpv6 && uri.Host.All(c => Char.IsLetterOrDigit(c) || c == '.' || c == '-' || c == '_'))) &&
|
||||
(uri.Scheme == Uri.UriSchemeHttp ||
|
||||
uri.Scheme == Uri.UriSchemeHttps ||
|
||||
uri.Scheme.Equals("udp", StringComparison.OrdinalIgnoreCase) ||
|
||||
uri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase)) &&
|
||||
!t.Contains("..") &&
|
||||
!t.Contains("\\") &&
|
||||
!t.Any(Char.IsControl) &&
|
||||
uri.Host.Length > 0;
|
||||
|
||||
if (!valid)
|
||||
{
|
||||
logger.LogDebug("Enrichment tracker rejected: {TrackerUrl} - Reason: Invalid format or unsupported scheme.", t);
|
||||
trackerRejectionCount++;
|
||||
}
|
||||
|
||||
return valid;
|
||||
})
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
logger.LogInformation("{TrackerRejectionCount} trackers were rejected during enrichment.", trackerRejectionCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
{
|
||||
logger.LogError(ex, "Error parsing tracker list response.");
|
||||
|
||||
throw new InvalidOperationException("Failed to parse tracker list response.", ex);
|
||||
}
|
||||
|
||||
return trackers;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue