diff --git a/server/RdtClient.Data/Data/ITorrentData.cs b/server/RdtClient.Data/Data/ITorrentData.cs new file mode 100644 index 0000000..4bbaa24 --- /dev/null +++ b/server/RdtClient.Data/Data/ITorrentData.cs @@ -0,0 +1,28 @@ +using RdtClient.Data.Enums; +using RdtClient.Data.Models.Data; + +namespace RdtClient.Data.Data; + +public interface ITorrentData +{ + Task> Get(); + Task GetById(Guid torrentId); + Task GetByHash(String hash); + + Task Add(String rdId, + String hash, + String? fileOrMagnetContents, + Boolean isFile, + DownloadClient downloadClient, + Torrent torrent); + + Task UpdateRdData(Torrent torrent); + Task Update(Torrent torrent); + Task UpdateCategory(Guid torrentId, String? category); + Task UpdateComplete(Guid torrentId, String? error, DateTimeOffset? datetime, Boolean retry); + Task UpdateFilesSelected(Guid torrentId, DateTimeOffset datetime); + Task UpdatePriority(Guid torrentId, Int32? priority); + Task UpdateRetry(Guid torrentId, DateTimeOffset? dateTime, Int32 retryCount); + Task UpdateError(Guid torrentId, String error); + Task Delete(Guid torrentId); +} diff --git a/server/RdtClient.Data/Data/TorrentData.cs b/server/RdtClient.Data/Data/TorrentData.cs index 34f43a3..4578348 100644 --- a/server/RdtClient.Data/Data/TorrentData.cs +++ b/server/RdtClient.Data/Data/TorrentData.cs @@ -4,7 +4,7 @@ using RdtClient.Data.Models.Data; namespace RdtClient.Data.Data; -public class TorrentData(DataContext dataContext) +public class TorrentData(DataContext dataContext) : ITorrentData { private static IList? _torrentCache; diff --git a/server/RdtClient.Data/DiConfig.cs b/server/RdtClient.Data/DiConfig.cs index 1432338..1a1b29e 100644 --- a/server/RdtClient.Data/DiConfig.cs +++ b/server/RdtClient.Data/DiConfig.cs @@ -19,7 +19,7 @@ public static class DiConfig services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); } } \ No newline at end of file diff --git a/server/RdtClient.Service.Test/Helpers/DownloadHelperTest.cs b/server/RdtClient.Service.Test/Helpers/DownloadHelperTest.cs new file mode 100644 index 0000000..b48f690 --- /dev/null +++ b/server/RdtClient.Service.Test/Helpers/DownloadHelperTest.cs @@ -0,0 +1,269 @@ +using System.IO.Abstractions.TestingHelpers; +using System.Text.Json; +using RdtClient.Data.Models.Data; +using RdtClient.Data.Models.TorrentClient; +using RdtClient.Service.Helpers; + +namespace RdtClient.Service.Test.Helpers; + +public class DownloadHelperTest +{ + [Fact] + public void GetDownloadPath_WithPath_WhenRdNameNull_ReturnsNull() + { + // Arrange + var download = new Download + { + Link = "https://fake.url/file.txt", + FileName = "file.txt" + }; + + var torrent = new Torrent + { + RdName = null + }; + + // Act + var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download); + + // Assert + Assert.Null(path); + } + + [Fact] + public void GetDownloadPath_WithoutPath_WhenRdNameNull_ReturnsNull() + { + // Arrange + var download = new Download + { + Link = "https://fake.url/file.txt", + FileName = "file.txt" + }; + + var torrent = new Torrent + { + RdName = null + }; + + // Act + var path = DownloadHelper.GetDownloadPath(torrent, download); + + // Assert + Assert.Null(path); + } + + [Fact] + public void GetDownloadPath_WithPath_WhenDownloadLinkNull_ReturnsNull() + { + // Arrange + var download = new Download + { + Link = null, + FileName = "file.txt" + }; + + var torrent = new Torrent + { + RdName = "Torrent Name" + }; + + // Act + var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download); + + // Assert + Assert.Null(path); + } + + [Fact] + public void GetDownloadPath_WithoutPath_WhenDownloadLinkNull_ReturnsNull() + { + // Arrange + var download = new Download + { + Link = null, + FileName = "file.txt" + }; + + var torrent = new Torrent + { + RdName = "Torrent Name" + }; + + // Act + var path = DownloadHelper.GetDownloadPath(torrent, download); + + // Assert + Assert.Null(path); + } + + [Fact] + public void GetDownloadPath_WithPath_WhenDownloadFileNameNull_UsesLinkToGuessFileName() + { + // Arrange + var download = new Download + { + Link = "https://fake.url/filename-from-link.txt", + FileName = null + }; + + var torrent = new Torrent + { + RdName = "Torrent Name" + }; + + var fileSystem = new MockFileSystem(); + + // Act + var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download, fileSystem); + + // Assert + var expectedPath = Path.Combine("/data/downloads", torrent.RdName, "filename-from-link.txt"); + Assert.Equal(expectedPath, path); + } + + [Fact] + public void GetDownloadPath_WithoutPath_WhenDownloadFileNameNull_UsesLinkToGuessFileName() + { + // Arrange + var download = new Download + { + Link = "https://fake.url/filename-from-link.txt", + FileName = null + }; + + var torrent = new Torrent + { + RdName = "Torrent Name" + }; + + // Act + var path = DownloadHelper.GetDownloadPath(torrent, download); + + // Assert + var expectedPath = Path.Combine(torrent.RdName, "filename-from-link.txt"); + Assert.Equal(expectedPath, path); + } + + [Fact] + public void GetDownloadPath_WithPath_WhenValid_CreatesDirectory() + { + // Arrange + var download = new Download + { + Link = "https://fake.url/file.txt", + FileName = "file.txt" + }; + + var torrent = new Torrent + { + RdName = "Torrent Name" + }; + + var fileSystem = new MockFileSystem(); + + // Act + var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download, fileSystem); + + // Assert + var expectedDirectoryPath = Path.Combine("/data/downloads", torrent.RdName); + Assert.True(fileSystem.Directory.Exists(expectedDirectoryPath)); + var expectedPath = Path.Combine(expectedDirectoryPath, download.FileName); + Assert.Equal(expectedPath, path); + } + + [Fact] + public void GetDownloadPath_WithPath_WhenFileInSubdirectories_ReturnsPathWithSubdirectories() + { + // Arrange + var download = new Download + { + Link = "https://fake.url/file.txt", + FileName = "file.txt" + }; + + var fileRelativePath = "inside/lots/of/subdirectories/file.txt"; + + IList files = + [ + new() + { + Path = fileRelativePath + } + ]; + + var torrent = new Torrent + { + RdName = "Torrent Name", + RdFiles = JsonSerializer.Serialize(files) + }; + + var fileSystem = new MockFileSystem(); + + // Act + var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download, fileSystem); + + // Assert + var expectedPath = Path.Combine("/data/downloads", torrent.RdName, fileRelativePath); + Assert.Equal(expectedPath, path); + } + + [Fact] + public void GetDownloadPath_WithoutPath_WhenFileInSubdirectories_ReturnsPathWithSubdirectories() + { + // Arrange + var download = new Download + { + Link = "https://fake.url/file.txt", + FileName = "file.txt" + }; + + var fileRelativePath = "inside/lots/of/subdirectories/file.txt"; + + IList files = + [ + new() + { + Path = fileRelativePath + } + ]; + + var torrent = new Torrent + { + RdName = "Torrent Name", + RdFiles = JsonSerializer.Serialize(files) + }; + + // Act + var path = DownloadHelper.GetDownloadPath(torrent, download); + + // Assert + var expectedPath = Path.Combine(torrent.RdName, fileRelativePath); + Assert.Equal(expectedPath, path); + } + + // This is probably a bug + [Fact] + public void GetDownloadPath_WithPath_WhenNoUriSegmentsOrFileName_ReturnsTorrentDirectory() + { + // Arrange + var download = new Download + { + Link = "https://fake.url", // HttpUtility.UrlDecode(new Uri("https://fake.url").Segments.Last()) == "/" + FileName = null + }; + + var torrent = new Torrent + { + RdName = "Torrent Name" + }; + + var fileSystem = new MockFileSystem(); + + // Act + var path = DownloadHelper.GetDownloadPath("/data/downloads", torrent, download, fileSystem); + + // Assert + var expectedPath = Path.Combine("/data/downloads", torrent.RdName); + Assert.Equal(expectedPath, path); + } +} diff --git a/server/RdtClient.Service.Test/RdtClient.Service.Test.csproj b/server/RdtClient.Service.Test/RdtClient.Service.Test.csproj new file mode 100644 index 0000000..c7fdadd --- /dev/null +++ b/server/RdtClient.Service.Test/RdtClient.Service.Test.csproj @@ -0,0 +1,31 @@ + + + + net9.0 + enable + enable + false + RdtClient.Service.Test + RdtClient.Service.Test + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs b/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs new file mode 100644 index 0000000..8f145e2 --- /dev/null +++ b/server/RdtClient.Service.Test/Services/Downloaders/DownloadStationDownloaderTest.cs @@ -0,0 +1,213 @@ +using Moq; +using RdtClient.Service.Helpers; +using RdtClient.Service.Services.Downloaders; +using Synology.Api.Client; +using Synology.Api.Client.Apis.DownloadStation; +using Synology.Api.Client.Apis.DownloadStation.Task.Models; + +namespace RdtClient.Service.Test.Services.Downloaders; + +class Mocks +{ + public readonly String Gid; + public readonly Mock SynologyClientMock = new(); + public readonly Mock TaskEndpointMock = new(); + + public Mocks(String gid = "123456") + { + Gid = gid; + var downloadStationApiMock = new Mock(); + downloadStationApiMock.Setup(a => a.TaskEndpoint()).Returns(TaskEndpointMock.Object); + SynologyClientMock.Setup(c => c.DownloadStationApi()).Returns(downloadStationApiMock.Object); + } +} + +class FakeDelayProvider : IDelayProvider +{ + public Task Delay(Int32 milliseconds) + { + return Task.CompletedTask; + } +} + +public class DownloadStationDownloaderTest +{ + [Fact] + public async Task Download_WhenRemotePathEmpty_Throws() + { + // Arrange + var synologyClientMock = new Mock(); + var gid = Guid.NewGuid(); + + var downloadStationDownloader = new DownloadStationDownloader(gid.ToString(), + "https://fake.url/file.txt", + "", + "/path/to/file.txt", + "download-path", + synologyClientMock.Object); + + // Act + var exception = await Assert.ThrowsAsync(downloadStationDownloader.Download); + + // Assert + Assert.Contains("invalid file path", exception.Message, StringComparison.OrdinalIgnoreCase); + synologyClientMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Download_WhenAlreadyAdded_Throws() + { + // Arrange + var mocks = new Mocks(); + mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ReturnsAsync(new DownloadStationTask()); + + var downloadStationDownloader = new DownloadStationDownloader(mocks.Gid, + "https://fake.url/file.txt", + "/path/on/remote/file.txt", + "/path/to/file.txt", + "download-path", + mocks.SynologyClientMock.Object); + + // Act + var exception = await Assert.ThrowsAsync(downloadStationDownloader.Download); + + // Assert + Assert.Contains("already been added", exception.Message, StringComparison.OrdinalIgnoreCase); + mocks.TaskEndpointMock.Verify(t => t.GetInfoAsync(mocks.Gid), Times.Once); + mocks.TaskEndpointMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Download_WhenAddedSuccessfully_ReturnsGid() + { + // Arrange + var mocks = new Mocks(); + mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ThrowsAsync(new Exception()); + + mocks.TaskEndpointMock.Setup(t => t.ListAsync()) + .ReturnsAsync(new DownloadStationTaskListResponse + { + Total = 0, + Offset = 0 + }); + + mocks.TaskEndpointMock.Setup(t => t.CreateAsync(It.IsAny())) + .ReturnsAsync(new DownloadStationTaskCreateResponse + { + TaskId = [mocks.Gid] + }); + + var downloadStationDownloader = new DownloadStationDownloader(mocks.Gid, + "https://fake.url/file.txt", + "/path/on/remote/file.txt", + "/path/to/file.txt", + "download-path", + mocks.SynologyClientMock.Object); + + // Act + var result = await downloadStationDownloader.Download(); + + // Assert + Assert.Equal(mocks.Gid, result); + mocks.TaskEndpointMock.Verify(t => t.GetInfoAsync(mocks.Gid), Times.Once); + mocks.TaskEndpointMock.Verify(t => t.ListAsync(), Times.Once); + mocks.TaskEndpointMock.Verify(t => t.CreateAsync(It.IsAny()), Times.Once); + + mocks.TaskEndpointMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Download_After5Tries_Throws() + { + var mocks = new Mocks(); + mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ThrowsAsync(new Exception()); + + var emptyListResponse = new DownloadStationTaskListResponse + { + Total = 0, + Offset = 0 + }; + + mocks.TaskEndpointMock.SetupSequence(t => t.ListAsync()) + .ReturnsAsync(emptyListResponse) + .ReturnsAsync(emptyListResponse) + .ReturnsAsync(emptyListResponse) + .ReturnsAsync(emptyListResponse) + .ReturnsAsync(emptyListResponse); + + mocks.TaskEndpointMock.SetupSequence(t => t.CreateAsync(It.IsAny())) + .ThrowsAsync(new Exception()) + .ThrowsAsync(new Exception()) + .ThrowsAsync(new Exception()) + .ThrowsAsync(new Exception()) + .ThrowsAsync(new Exception()); + + var downloadStationDownloader = new DownloadStationDownloader(mocks.Gid, + "https://fake.url/file.txt", + "/path/on/remote/file.txt", + "/path/to/file.txt", + "download-path", + mocks.SynologyClientMock.Object, + new FakeDelayProvider()); + + // Act + var exception = await Assert.ThrowsAsync(downloadStationDownloader.Download); + + // Assert + Assert.Contains("unable to download", exception.Message, StringComparison.OrdinalIgnoreCase); + mocks.TaskEndpointMock.Verify(t => t.GetInfoAsync(mocks.Gid), Times.Once); + mocks.TaskEndpointMock.Verify(t => t.ListAsync(), Times.Exactly(5)); + mocks.TaskEndpointMock.Verify(t => t.CreateAsync(It.IsAny()), Times.Exactly(5)); + + mocks.TaskEndpointMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Download_WhenSuccessfulAfter4Tries_ReturnsGid() + { + var mocks = new Mocks(); + mocks.TaskEndpointMock.Setup(t => t.GetInfoAsync(mocks.Gid)).ThrowsAsync(new Exception()); + + var emptyListResponse = new DownloadStationTaskListResponse + { + Total = 0, + Offset = 0 + }; + + mocks.TaskEndpointMock.SetupSequence(t => t.ListAsync()) + .ReturnsAsync(emptyListResponse) + .ReturnsAsync(emptyListResponse) + .ReturnsAsync(emptyListResponse) + .ReturnsAsync(emptyListResponse) + .ReturnsAsync(emptyListResponse); + + mocks.TaskEndpointMock.SetupSequence(t => t.CreateAsync(It.IsAny())) + .ThrowsAsync(new Exception()) + .ThrowsAsync(new Exception()) + .ThrowsAsync(new Exception()) + .ThrowsAsync(new Exception()) + .ReturnsAsync(new DownloadStationTaskCreateResponse + { + TaskId = [mocks.Gid] + }); + + var downloadStationDownloader = new DownloadStationDownloader(mocks.Gid, + "https://fake.url/file.txt", + "/path/on/remote/file.txt", + "/path/to/file.txt", + "download-path", + mocks.SynologyClientMock.Object, + new FakeDelayProvider()); + + // Act + var result = await downloadStationDownloader.Download(); + + // Assert + Assert.Equal(mocks.Gid, result); + mocks.TaskEndpointMock.Verify(t => t.GetInfoAsync(mocks.Gid), Times.Once); + mocks.TaskEndpointMock.Verify(t => t.ListAsync(), Times.Exactly(5)); + mocks.TaskEndpointMock.Verify(t => t.CreateAsync(It.IsAny()), Times.Exactly(5)); + + mocks.TaskEndpointMock.VerifyNoOtherCalls(); + } +} diff --git a/server/RdtClient.Service.Test/Services/TorrentClients/AllDebridTorrentClientTest.cs b/server/RdtClient.Service.Test/Services/TorrentClients/AllDebridTorrentClientTest.cs new file mode 100644 index 0000000..88ca92e --- /dev/null +++ b/server/RdtClient.Service.Test/Services/TorrentClients/AllDebridTorrentClientTest.cs @@ -0,0 +1,705 @@ +using AllDebridNET; +using Microsoft.Extensions.Logging; +using Moq; +using Newtonsoft.Json; +using RdtClient.Data.Enums; +using RdtClient.Data.Models.Data; +using RdtClient.Service.Services.TorrentClients; +using File = AllDebridNET.File; + +namespace RdtClient.Service.Test.Services.TorrentClients; + +public class AllDebridTorrentClientTest +{ + private static readonly Magnet Magnet1HalfDownloaded = new() + { + Id = 1, + Filename = "some-files", + Hash = "hash-1", + Status = "Downloading", + StatusCode = 1, + Downloaded = 50, + Size = 100, + Uploaded = 0, + Seeders = 1, + DownloadSpeed = 5, + UploadSpeed = 0, + UploadDate = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero).Second, + CompletionDate = 0 + }; + + private static readonly Magnet Magnet1Finished = new() + { + Id = Magnet1HalfDownloaded.Id, + Filename = Magnet1HalfDownloaded.Filename, + Hash = Magnet1HalfDownloaded.Hash, + Status = "Ready", + StatusCode = 4, + Size = Magnet1HalfDownloaded.Size, + UploadDate = Magnet1HalfDownloaded.UploadDate, + CompletionDate = new DateTimeOffset(2020, 1, 1, 1, 0, 0, TimeSpan.Zero).Second + }; + + private static readonly Magnet Magnet2QuarterDownloaded = new() + { + Id = 2, + Filename = "some-other-files", + Hash = "hash-2", + Status = "Downloading", + StatusCode = 1, + Downloaded = 100, + Size = 400, + Uploaded = 0, + Seeders = 1, + DownloadSpeed = 5, + UploadSpeed = 0, + UploadDate = new DateTimeOffset(2020, 1, 1, 0, 5, 0, TimeSpan.Zero).Second, + CompletionDate = 0 + }; + + private static readonly Magnet Magnet2Finished = new() + { + Id = Magnet2QuarterDownloaded.Id, + Filename = Magnet2QuarterDownloaded.Filename, + Hash = Magnet2QuarterDownloaded.Hash, + Status = "Ready", + StatusCode = 4, + Size = Magnet2QuarterDownloaded.Size, + UploadDate = Magnet2QuarterDownloaded.UploadDate, + CompletionDate = new DateTimeOffset(2020, 1, 1, 1, 5, 0, TimeSpan.Zero).Second + }; + + private static readonly List IncludeExcludeFiles = + [ + new() + { + FolderOrFileName = "include.txt", + Size = 1, + DownloadLink = "https://fake.url/include.txt" + }, + new() + { + FolderOrFileName = "exclude.txt", + Size = 1, + DownloadLink = "https://fake.url/exclude.txt" + }, + new() + { + FolderOrFileName = "include-folder", + SubNodes = + [ + new File + { + FolderOrFileName = "include-folder-exclude-file.txt", + Size = 1, + DownloadLink = "https://fake.url/include-folder-exclude-file.txt" + }, + new File + { + FolderOrFileName = "include-folder-include-file.txt", + Size = 1, + DownloadLink = "https://fake.url/include-folder-include-file.txt" + } + ] + }, + new() + { + FolderOrFileName = "exclude-folder", + SubNodes = + [ + new File + { + FolderOrFileName = "exclude-folder-exclude-file.txt", + Size = 1, + DownloadLink = "https://fake.url/exclude-folder-exclude-file.txt" + }, + new File + { + FolderOrFileName = "exclude-folder-include-file.txt", + Size = 1, + DownloadLink = "https://fake.url/exclude-folder-include-file.txt" + } + ] + } + ]; + + [Fact] + public async Task GetTorrents_WhenFullSyncNoTorrents_ReturnsEmptyList() + { + // Arrange + var mocks = new Mocks(); + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [], + Fullsync = true + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetTorrents(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetTorrents_WhenPartialSyncNoTorrents_ReturnsEmptyList() + { + // Arrange + var mocks = new Mocks(); + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [], + Fullsync = true + }) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 2, + Magnets = [], + Fullsync = false + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetTorrents(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetTorrents_WhenTorrentsFullSync_ReturnsOnlyTorrentsFromResponse() + { + // Arrange + var mocks = new Mocks(); + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [Magnet1Finished], + Fullsync = true + }) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 2, + Magnets = [Magnet2Finished], + Fullsync = true + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + + // `GetTorrents` returns a reference to `_cache` if the `StatusLiveAsync` has `Fullsync = false`, + // so when the second call `_cache.Add`s, it also affects `firstResult`. + // `.ToList()` clones it so it won't be changed by the second call + var firstResult = (await allDebridTorrentClient.GetTorrents()).ToList(); + var secondResult = await allDebridTorrentClient.GetTorrents(); + + // Assert + Assert.Equal(1, firstResult.Count); + Assert.Equal(Magnet1Finished.Id.ToString(), firstResult.First().Id); + Assert.Equal(1, secondResult.Count); + Assert.Equal(Magnet2Finished.Id.ToString(), secondResult.First().Id); + } + + [Fact] + public async Task GetTorrents_WhenPartialSyncResponseHasNewTorrent_ReturnsCachedAndNewTorrents() + { + var mocks = new Mocks(); + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [Magnet1Finished], + Fullsync = true + }) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 2, + Magnets = [Magnet2Finished], + Fullsync = false + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + + // `GetTorrents` returns a reference to `_cache` if the `StatusLiveAsync` has `Fullsync = false`, + // so when the second call `_cache.Add`s, it also affects `firstResult`. + // `.ToList()` clones it so it won't be changed by the second call + var firstResult = (await allDebridTorrentClient.GetTorrents()).ToList(); + var secondResult = await allDebridTorrentClient.GetTorrents(); + + // Assert + Assert.Equal(1, firstResult.Count); + Assert.Equal(Magnet1Finished.Id.ToString(), firstResult[0].Id); + Assert.Equal(2, secondResult.Count); + Assert.Contains(secondResult, t => t.Id == Magnet1Finished.Id.ToString()); + Assert.Contains(secondResult, t => t.Id == Magnet2Finished.Id.ToString()); + } + + [Fact] + public async Task GetTorrents_WhenPartialSyncResponseHasUpdate_ReturnsCachedAndUpdatedTorrents() + { + // Double check the fakes are as we expect + Assert.Equal(Magnet1Finished.Id, Magnet1HalfDownloaded.Id); + Assert.NotEqual(Magnet1Finished.Status, Magnet1HalfDownloaded.Status); + + // Arrange + var mocks = new Mocks(); + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [Magnet1HalfDownloaded, Magnet2Finished], + Fullsync = true + }) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 2, + Magnets = [Magnet1Finished], + Fullsync = false + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + + // `GetTorrents` returns a reference to `_cache` if the `StatusLiveAsync` has `Fullsync = false`, + // so when the second call `_cache.Add`s, it also affects `firstResult`. + // `.ToList()` clones it so it won't be changed by the second call + var firstResult = (await allDebridTorrentClient.GetTorrents()).ToList(); + var secondResult = await allDebridTorrentClient.GetTorrents(); + + // Assert + Assert.Equal(2, firstResult.Count); + Assert.Contains(firstResult, t => t.Id == Magnet1HalfDownloaded.Id.ToString() && t.Status == Magnet1HalfDownloaded.Status); + Assert.Contains(firstResult, t => t.Id == Magnet2Finished.Id.ToString()); + Assert.Equal(2, secondResult.Count); + Assert.Contains(secondResult, t => t.Id == Magnet1Finished.Id.ToString() && t.Status == Magnet1Finished.Status); + Assert.Contains(secondResult, t => t.Id == Magnet2Finished.Id.ToString()); + } + + public static IEnumerable DownloadingMagnetsWithProgress() + { + return [[Magnet1HalfDownloaded, 50], [Magnet2QuarterDownloaded, 25]]; + } + + [Theory] + [MemberData(nameof(DownloadingMagnetsWithProgress))] + public async Task Map_WhenTorrentDownloading_ComputesProgress(Magnet magnet, Int64 expectedProgress) + { + // Arrange + var mocks = new Mocks(); + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [magnet], + Fullsync = true + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + + // We have to use `GetTorrents` since `Map` is private + var result = await allDebridTorrentClient.GetTorrents(); + + // Assert + Assert.Equal(expectedProgress, result.First().Progress); + } + + [Fact] + public async Task UpdateData_WhenTorrentRdIdNull_ReturnsUnmodifiedTorrent() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = null, + Hash = "hash", + RdName = "rdName" + }; + + var serializedOriginal = JsonConvert.SerializeObject(torrent); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.UpdateData(torrent, null); + + // Assert + Assert.Equal(serializedOriginal, JsonConvert.SerializeObject(result)); + mocks.AllDebridClientFactoryMock.Verify(f => f.GetClient(), Times.Never); + } + + [Fact] + public async Task UpdateData_WhenTorrentClientTorrentNotProvided_FetchesFromAPIAndUpdatesTorrentAccordingly() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = Magnet1Finished.Id.ToString(), + Hash = Magnet1Finished.Hash!, + RdName = null, + RdSize = null, + RdStatus = null + }; + + mocks.AllDebridClientMock.Setup(c => c.Magnet.StatusAsync(torrent.RdId, It.IsAny())).ReturnsAsync(Magnet1Finished); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.UpdateData(torrent, null); + + // Assert + mocks.AllDebridClientMock.Verify(c => c.Magnet.StatusAsync(torrent.RdId, It.IsAny()), Times.Once); + Assert.Equal(Magnet1Finished.Filename, result.RdName); + Assert.Equal(Magnet1Finished.Size, result.RdSize); + Assert.Equal(Provider.AllDebrid, result.ClientKind); + Assert.Equal(TorrentStatus.Finished, result.RdStatus); + + // It mutates the original object: + Assert.Equal(torrent, result); + } + + [Fact] + public async Task UpdateData_WhenTorrentClientTorrentNotProvidedAndTorrentDeletedFromAD_UpdatesRdStatusRaw() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "123456", + Hash = "fake-hash-123456", + RdStatus = null, + RdStatusRaw = null + }; + + mocks.AllDebridClientMock.Setup(c => c.Magnet.StatusAsync(torrent.RdId, It.IsAny())) + .ThrowsAsync(new AllDebridException("Magnet not found", "MAGNET_INVALID_ID")); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.UpdateData(torrent, null); + + // Assert + Assert.Equal("deleted", result.RdStatusRaw); + Assert.Null(result.RdStatus); + + // It mutates the original object: + Assert.Equal(torrent, result); + } + + [Fact] + public async Task UpdateData_WhenTorrentClientTorrentIsProvided_DoesNotFetchFromApiAndUpdatesTorrentAccordingly() + { + // Double check fakes are as we expect + Assert.Equal(4, Magnet1Finished.StatusCode); + + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = Magnet1Finished.Id.ToString() + }; + + mocks.AllDebridClientMock.SetupSequence(c => c.Magnet.StatusLiveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MagnetStatusLiveResponse + { + Counter = 1, + Magnets = [Magnet2Finished], + Fullsync = true + }); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + var torrentClientTorrent = (await allDebridTorrentClient.GetTorrents()).First(); + + // Act + var result = await allDebridTorrentClient.UpdateData(torrent, torrentClientTorrent); + + // Assert + Assert.Equal(torrentClientTorrent.Filename, torrent.RdName); + Assert.Equal(torrentClientTorrent.Bytes, result.RdSize); + Assert.Equal(TorrentStatus.Finished, torrent.RdStatus); + Assert.Equal(Provider.AllDebrid, torrent.ClientKind); + + // It mutates the original object: + Assert.Equal(torrent, result); + } + + [Fact] + public async Task GetDownloadLinks_WhenTorrentRdIdNull_ReturnsNull() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = null + }; + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetDownloadLinks(torrent); + + // Assert + Assert.Null(result); + mocks.AllDebridClientFactoryMock.Verify(f => f.GetClient(), Times.Never); + } + + [Fact] + public async Task GetDownloadLinks_ByDefault_GetsLinksForAllFiles() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1" + }; + + List files = + [ + new() + { + FolderOrFileName = "file-1.txt", + Size = 50, + DownloadLink = "https://fake.url/file-1.txt" + }, + new() + { + FolderOrFileName = "file-2.txt", + Size = 150, + DownloadLink = "https://fake.url/file-2.txt" + } + ]; + + var expectedLinksSet = new HashSet(files.Select(n => n.DownloadLink)!); + mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(Int64.Parse(torrent.RdId), It.IsAny())).ReturnsAsync(files); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetDownloadLinks(torrent); + + // Assert + Assert.NotNull(result); + var linksSet = new HashSet(result); + Assert.Equal(expectedLinksSet, linksSet); + } + + [Fact] + public async Task GetDownloadLinks_WhenDownloadMinSizeSet_GetsLinksForOnlyFilesAboveThatSize() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + + // NB: this is in MB, the file sizes below are in B + DownloadMinSize = 100 + }; + + List files = + [ + new() + { + FolderOrFileName = "too-small.txt", + Size = (torrent.DownloadMinSize - 1) * 1024 * 1024, + DownloadLink = "https://fake.url/too-small.txt" + }, + new() + { + FolderOrFileName = "bigger-than-min.txt", + Size = (torrent.DownloadMinSize + 1) * 1024 * 1024, + DownloadLink = "https://fake.url/bigger-than-min.txt" + } + ]; + + var expectedLinksSet = new HashSet(files.Where(m => m.Size > torrent.DownloadMinSize * 1024 * 1024).Select(n => n.DownloadLink)!); + mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(Int64.Parse(torrent.RdId), It.IsAny())).ReturnsAsync(files); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetDownloadLinks(torrent); + + // Assert + Assert.NotNull(result); + var linksSet = new HashSet(result); + Assert.Equal(expectedLinksSet, linksSet); + } + + [Fact] + public async Task GetDownloadLinks_WhenIncludeRegexSet_GetsLinksForOnlyFilesMatchingRegex() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + IncludeRegex = "include" + }; + + var expectedLinksSet = new HashSet([ + "https://fake.url/include.txt", "https://fake.url/include-folder-exclude-file.txt", "https://fake.url/include-folder-include-file.txt", + "https://fake.url/exclude-folder-include-file.txt" + ]); + + mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(Int64.Parse(torrent.RdId), It.IsAny())).ReturnsAsync(IncludeExcludeFiles); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetDownloadLinks(torrent); + + // Assert + Assert.NotNull(result); + var linksSet = new HashSet(result); + Assert.Equal(expectedLinksSet, linksSet); + } + + [Fact] + public async Task GetDownloadLinks_WhenExcludeRegexSet_GetsLinksForOnlyFilesNotMatchingRegex() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + ExcludeRegex = "exclude" + }; + + var expectedLinksSet = new HashSet([ + "https://fake.url/include.txt", "https://fake.url/include-folder-include-file.txt" + ]); + + mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(Int64.Parse(torrent.RdId), It.IsAny())).ReturnsAsync(IncludeExcludeFiles); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetDownloadLinks(torrent); + + // Assert + Assert.NotNull(result); + var linksSet = new HashSet(result); + Assert.Equal(expectedLinksSet, linksSet); + } + + [Fact] + public async Task GetDownloadLinks_WhenIncludeAndExcludeRegexSet_GetsLinksForOnlyFilesMatchingIncludeRegexEvenIfNotMatchingExcludeRegex() + { + // Arrange + var mocks = new Mocks(); + + var torrentInclude = new Torrent + { + RdId = "1", + IncludeRegex = "include" + }; + + var torrentIncludeExclude = new Torrent + { + RdId = "2", + IncludeRegex = "include", + ExcludeRegex = "exclude" + }; + + mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(It.IsAny(), It.IsAny())).ReturnsAsync(IncludeExcludeFiles); + + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var includeOnlyResult = await allDebridTorrentClient.GetDownloadLinks(torrentInclude); + var includeExcludeResult = await allDebridTorrentClient.GetDownloadLinks(torrentIncludeExclude); + + // Assert + Assert.NotNull(includeOnlyResult); + Assert.NotNull(includeExcludeResult); + var includeOnlyLinksSet = new HashSet(includeOnlyResult); + var includeExcludeLinksSet = new HashSet(includeExcludeResult); + Assert.Equal(includeOnlyLinksSet, includeExcludeLinksSet); + } + + [Fact] + public async Task GetDownloadLinks_WhenAllFilesExcluded_ReturnsAllFiles() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + + // NB: this is in MB, the file sizes below are in B + DownloadMinSize = 100 + }; + + List files = + [ + new() + { + FolderOrFileName = "too-small-1.txt", + Size = (torrent.DownloadMinSize - 1) * 1024 * 1024, + DownloadLink = "https://fake.url/too-small-1.txt" + }, + new() + { + FolderOrFileName = "too-small-2.txt", + Size = (torrent.DownloadMinSize - 1) * 1024 * 1024, + DownloadLink = "https://fake.url/too-small-2.txt" + } + ]; + + var expectedLinksSet = new HashSet(files.Select(n => n.DownloadLink)!); + mocks.AllDebridClientMock.Setup(c => c.Magnet.FilesAsync(Int64.Parse(torrent.RdId), It.IsAny())).ReturnsAsync(files); + var allDebridTorrentClient = new AllDebridTorrentClient(mocks.LoggerMock.Object, mocks.AllDebridClientFactoryMock.Object); + + // Act + var result = await allDebridTorrentClient.GetDownloadLinks(torrent); + + // Assert + Assert.NotNull(result); + var linksSet = new HashSet(result); + Assert.Equal(expectedLinksSet, linksSet); + } + + private class Mocks + { + public readonly Mock AllDebridClientFactoryMock; + public readonly Mock AllDebridClientMock; + public readonly Mock> LoggerMock; + + public Mocks() + { + LoggerMock = new Mock>(); + AllDebridClientMock = new Mock(); + AllDebridClientFactoryMock = new Mock(); + AllDebridClientFactoryMock.Setup(f => f.GetClient()).Returns(AllDebridClientMock.Object); + } + } +} diff --git a/server/RdtClient.Service.Test/Services/TorrentsTest.cs b/server/RdtClient.Service.Test/Services/TorrentsTest.cs new file mode 100644 index 0000000..0e89b54 --- /dev/null +++ b/server/RdtClient.Service.Test/Services/TorrentsTest.cs @@ -0,0 +1,308 @@ +using System.Diagnostics; +using System.IO.Abstractions.TestingHelpers; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Moq; +using RdtClient.Data.Data; +using RdtClient.Data.Models.Data; +using RdtClient.Data.Models.Internal; +using RdtClient.Service.Services; +using RdtClient.Service.Wrappers; +using TorrentsService = RdtClient.Service.Services.Torrents; + +namespace RdtClient.Service.Test.Services; + +class Mocks +{ + public readonly Mock ProcessFactoryMock; + public readonly Mock ProcessMock; + public readonly Mock> TorrentsLoggerMock; + public readonly Mock DownloadsMock; + public readonly Mock TorrentDataMock; + + public Mocks() + { + TorrentDataMock = new(); + DownloadsMock = new(); + + TorrentsLoggerMock = new(); + + ProcessMock = new(); + ProcessStartInfo startInfo = new(); + ProcessMock.SetupProperty(p => p.StartInfo, startInfo); + ProcessFactoryMock = new(); + ProcessFactoryMock.Setup(p => p.NewProcess()).Returns(ProcessMock.Object); + } +} + +public class TorrentsTest +{ + public static IEnumerable TorrentAndDownload() + { + var torrent = new Torrent() + { + RdName = "TestTorrent", + Hash = "123ABC", + Category = "Movies", + RdSize = 100, + TorrentId = new Guid() + }; + + List downloads = + [ + new() + { + FileName = "file.txt", + TorrentId = torrent.TorrentId + } + ]; + + yield return new Object[] + { + torrent, downloads + }; + } + + [Theory] + [MemberData(nameof(TorrentAndDownload))] + public async Task RunTorrentComplete_WhenCommandSet_ShouldRunCommand(Torrent torrent, List downloads) + { + // Arrange + var settings = new DbSettings + { + General = new DbSettingsGeneral() + { + RunOnTorrentCompleteFileName = "/bin/echo", + RunOnTorrentCompleteArguments = "%N %L %F %R %D %C %Z %I" + } + }; + + var mocks = new Mocks(); + + mocks.TorrentDataMock.Setup(t => t.GetById(torrent.TorrentId)).Returns(Task.FromResult(torrent)); + mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads); + + var downloadPath = $"{settings.DownloadClient.DownloadPath}/{torrent.Category}"; + var torrentPath = $"{downloadPath}/{torrent.RdName}"; + var filePath = $"{torrentPath}/{downloads[0].FileName}"; + + var fileSystemMock = new MockFileSystem(new Dictionary + { + { + filePath, new MockFileData("Test file") + }, + }); + + var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object, + mocks.TorrentDataMock.Object, + mocks.DownloadsMock.Object, + mocks.ProcessFactoryMock.Object, + fileSystemMock, + null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine + null!, + null!, + null!, + null!); + + mocks.ProcessMock.Setup(p => p.WaitForExit(It.IsAny())).Returns(true); + + // Act + await torrents.RunTorrentComplete(torrent.TorrentId, settings); + + // Assert + Assert.Equal("/bin/echo", mocks.ProcessMock.Object.StartInfo.FileName); + + var expectedArgumentsSb = new StringBuilder(); + expectedArgumentsSb.Append($"\"{torrent.RdName}\""); + expectedArgumentsSb.Append($" \"{torrent.Category}\""); + expectedArgumentsSb.Append($" \"{filePath}\""); + expectedArgumentsSb.Append($" \"{downloadPath}\""); + expectedArgumentsSb.Append($" \"{torrentPath}\""); + expectedArgumentsSb.Append($" {downloads.Count.ToString()}"); + expectedArgumentsSb.Append($" {torrent.RdSize.ToString()}"); + expectedArgumentsSb.Append($" {torrent.Hash}"); + Assert.Equal(expectedArgumentsSb.ToString(), mocks.ProcessMock.Object.StartInfo.Arguments); + + mocks.ProcessMock.Verify(p => p.Start(), Times.Once); + } + + [Theory] + [MemberData(nameof(TorrentAndDownload))] + public async Task RunTorrentComplete_WhenCommandNotSet_ShouldNotRunCommand(Torrent torrent, List downloads) + { + // Arrange + var settings = new DbSettings() + { + General = new DbSettingsGeneral() + { + RunOnTorrentCompleteFileName = null + } + }; + + var mocks = new Mocks(); + + mocks.TorrentDataMock.Setup(t => t.GetById(torrent.TorrentId)).Returns(Task.FromResult(torrent)); + mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads); + + var downloadPath = $"{settings.DownloadClient.DownloadPath}/{torrent.Category}"; + var torrentPath = $"{downloadPath}/{torrent.RdName}"; + var filePath = $"{torrentPath}/{downloads[0].FileName}"; + + var fileSystemMock = new MockFileSystem(new Dictionary + { + { + filePath, new MockFileData("Test file") + }, + }); + + var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object, + mocks.TorrentDataMock.Object, + mocks.DownloadsMock.Object, + mocks.ProcessFactoryMock.Object, + fileSystemMock, + null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine + null!, + null!, + null!, + null!); + + //Act + await torrents.RunTorrentComplete(torrent.TorrentId, settings); + + //Assert + mocks.ProcessFactoryMock.VerifyNoOtherCalls(); + } + + [Theory] + [MemberData(nameof(TorrentAndDownload))] + public async Task RunTorrentComplete_WhenStdOut_Logs(Torrent torrent, List downloads) + { + // Arrange + var settings = new DbSettings() + { + General = new DbSettingsGeneral() + { + RunOnTorrentCompleteFileName = "/bin/echo" + } + }; + + var mocks = new Mocks(); + + mocks.TorrentDataMock.Setup(t => t.GetById(torrent.TorrentId)).Returns(Task.FromResult(torrent)); + mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads); + + var downloadPath = $"{settings.DownloadClient.DownloadPath}/{torrent.Category}"; + var torrentPath = $"{downloadPath}/{torrent.RdName}"; + var filePath = $"{torrentPath}/{downloads[0].FileName}"; + + var fileSystemMock = new MockFileSystem(new Dictionary + { + { + filePath, new MockFileData("Test file") + }, + }); + + var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object, + mocks.TorrentDataMock.Object, + mocks.DownloadsMock.Object, + mocks.ProcessFactoryMock.Object, + fileSystemMock, + null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine + null!, + null!, + null!, + null!); + + mocks.ProcessMock.Setup(p => p.WaitForExit(It.IsAny())) + .Callback(() => + { + mocks.ProcessMock.Raise(m => m.OutputDataReceived += null, this, "output-line 1"); + mocks.ProcessMock.Raise(m => m.OutputDataReceived += null, this, "output-line 2"); + mocks.ProcessMock.Raise(m => m.OutputDataReceived += null, this, "output-line 3"); + }) + .Returns(true); + + // Act + await torrents.RunTorrentComplete(torrent.TorrentId, settings); + + // Assert + mocks.ProcessMock.Verify(p => p.BeginOutputReadLine(), Times.Once); + + var messages = mocks.TorrentsLoggerMock.Invocations.Where(i => i.Method.Name == "Log").Select(i => i.Arguments[2].ToString()).Where(m => m != null).ToList(); + var exitedWithOutputMessages = messages.Where(m => Regex.IsMatch(m!, "exited with output")).ToList(); + Assert.NotNull(exitedWithOutputMessages); + Assert.Single(exitedWithOutputMessages); + var exitedWithOutputMessage = exitedWithOutputMessages.First(); + Assert.NotNull(exitedWithOutputMessage); + Assert.Matches("output-line 1", exitedWithOutputMessage); + Assert.Matches("output-line 2", exitedWithOutputMessage); + Assert.Matches("output-line 3", exitedWithOutputMessage); + } + + [Theory] + [MemberData(nameof(TorrentAndDownload))] + public async Task RunTorrentComplete_WhenStdErr_Logs(Torrent torrent, List downloads) + { + // Arrange + var settings = new DbSettings() + { + General = new DbSettingsGeneral() + { + RunOnTorrentCompleteFileName = "/bin/echo" + } + }; + + var mocks = new Mocks(); + + mocks.TorrentDataMock.Setup(t => t.GetById(torrent.TorrentId)).Returns(Task.FromResult(torrent)); + mocks.DownloadsMock.Setup(d => d.GetForTorrent(torrent.TorrentId)).ReturnsAsync(downloads); + + var downloadPath = $"{settings.DownloadClient.DownloadPath}/{torrent.Category}"; + var torrentPath = $"{downloadPath}/{torrent.RdName}"; + var filePath = $"{torrentPath}/{downloads[0].FileName}"; + + var fileSystemMock = new MockFileSystem(new Dictionary + { + { + filePath, new MockFileData("Test file") + }, + }); + + var torrents = new TorrentsService(mocks.TorrentsLoggerMock.Object, + mocks.TorrentDataMock.Object, + mocks.DownloadsMock.Object, + mocks.ProcessFactoryMock.Object, + fileSystemMock, + null!, // Torrent Clients are not used by `RunTorrentComplete`, this is fine + null!, + null!, + null!, + null!); + + mocks.ProcessMock.Setup(p => p.WaitForExit(It.IsAny())) + .Callback(() => + { + mocks.ProcessMock.Raise(m => m.ErrorDataReceived += null, this, "error-line 1"); + mocks.ProcessMock.Raise(m => m.ErrorDataReceived += null, this, "error-line 2"); + mocks.ProcessMock.Raise(m => m.ErrorDataReceived += null, this, "error-line 3"); + }) + .Returns(true); + + // Act + await torrents.RunTorrentComplete(torrent.TorrentId, settings); + + // Assert + mocks.ProcessMock.Verify(p => p.BeginErrorReadLine(), Times.Once); + + var messages = mocks.TorrentsLoggerMock.Invocations.Where(i => i.Method.Name == "Log").Select(i => i.Arguments[2].ToString()).Where(m => m != null).ToList(); + var exitedWithOutputMessages = messages.Where(m => Regex.IsMatch(m!, "exited with errors")).ToList(); + Assert.NotNull(exitedWithOutputMessages); + Assert.Single(exitedWithOutputMessages); + var exitedWithOutputMessage = exitedWithOutputMessages.First(); + Assert.NotNull(exitedWithOutputMessage); + Assert.Matches("error-line 1", exitedWithOutputMessage); + Assert.Matches("error-line 2", exitedWithOutputMessage); + Assert.Matches("error-line 3", exitedWithOutputMessage); + } +} diff --git a/server/RdtClient.Service/DiConfig.cs b/server/RdtClient.Service/DiConfig.cs index 71de2b4..6035b28 100644 --- a/server/RdtClient.Service/DiConfig.cs +++ b/server/RdtClient.Service/DiConfig.cs @@ -1,4 +1,5 @@ -using System.Net; +using System.IO.Abstractions; +using System.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; using Polly; @@ -7,17 +8,24 @@ using RdtClient.Service.BackgroundServices; using RdtClient.Service.Middleware; using RdtClient.Service.Services; using RdtClient.Service.Services.TorrentClients; +using RdtClient.Service.Wrappers; namespace RdtClient.Service; public static class DiConfig { public const String RD_CLIENT = "RdClient"; - + public static void RegisterRdtServices(this IServiceCollection services) { + services.AddSingleton(); services.AddScoped(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -30,7 +38,7 @@ public static class DiConfig services.AddScoped(); services.AddSingleton(); - + services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); @@ -50,4 +58,4 @@ public static class DiConfig services.AddHttpClient(RD_CLIENT) .AddPolicyHandler(retryPolicy); } -} \ No newline at end of file +} diff --git a/server/RdtClient.Service/Helpers/DownloadHelper.cs b/server/RdtClient.Service/Helpers/DownloadHelper.cs index 64067b0..3e2653d 100644 --- a/server/RdtClient.Service/Helpers/DownloadHelper.cs +++ b/server/RdtClient.Service/Helpers/DownloadHelper.cs @@ -1,11 +1,12 @@ -using RdtClient.Data.Models.Data; +using System.IO.Abstractions; +using RdtClient.Data.Models.Data; using System.Web; namespace RdtClient.Service.Helpers; public static class DownloadHelper { - public static String? GetDownloadPath(String downloadPath, Torrent torrent, Download download) + public static String? GetDownloadPath(String downloadPath, Torrent torrent, Download download, IFileSystem? fileSystem = null) { var fileUrl = download.Link; @@ -41,9 +42,11 @@ public static class DownloadHelper } } - if (!Directory.Exists(torrentPath)) + fileSystem ??= new FileSystem(); + + if (!fileSystem.Directory.Exists(torrentPath)) { - Directory.CreateDirectory(torrentPath); + fileSystem.Directory.CreateDirectory(torrentPath); } var filePath = Path.Combine(torrentPath, fileName); diff --git a/server/RdtClient.Service/Helpers/IDelayProvider.cs b/server/RdtClient.Service/Helpers/IDelayProvider.cs new file mode 100644 index 0000000..348a103 --- /dev/null +++ b/server/RdtClient.Service/Helpers/IDelayProvider.cs @@ -0,0 +1,6 @@ +namespace RdtClient.Service.Helpers; + +public interface IDelayProvider +{ + public Task Delay(Int32 milliseconds); +} diff --git a/server/RdtClient.Service/RdtClient.Service.csproj b/server/RdtClient.Service/RdtClient.Service.csproj index 2257d27..edb4d40 100644 --- a/server/RdtClient.Service/RdtClient.Service.csproj +++ b/server/RdtClient.Service/RdtClient.Service.csproj @@ -24,6 +24,7 @@ + diff --git a/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs b/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs index 2050b4c..1931499 100644 --- a/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs +++ b/server/RdtClient.Service/Services/Downloaders/DownloadStationDownloader.cs @@ -1,9 +1,18 @@ -using Serilog; +using RdtClient.Service.Helpers; +using Serilog; using Synology.Api.Client; using Synology.Api.Client.Apis.DownloadStation.Task.Models; namespace RdtClient.Service.Services.Downloaders; +class DelayProvider : IDelayProvider +{ + public Task Delay(Int32 delay) + { + return Task.Delay(delay); + } +} + public class DownloadStationDownloader : IDownloader { public event EventHandler? DownloadComplete; @@ -11,16 +20,17 @@ public class DownloadStationDownloader : IDownloader private const Int32 RetryCount = 5; - private readonly SynologyClient _synologyClient; + private readonly ISynologyClient _synologyClient; private readonly ILogger _logger; private readonly String _filePath; private readonly String _uri; private readonly String? _remotePath; + private readonly IDelayProvider _delayProvider; private String? _gid; - private DownloadStationDownloader(String? gid, String uri, String? remotePath, String filePath, String downloadPath, SynologyClient synologyClient) + public DownloadStationDownloader(String? gid, String uri, String? remotePath, String filePath, String downloadPath, ISynologyClient synologyClient, IDelayProvider? delayProvider = null) { _logger = Log.ForContext(); _logger.Debug($"Instantiated new DownloadStation Downloader for URI {uri} to filePath {filePath} and downloadPath {downloadPath} and GID {gid}"); @@ -30,6 +40,7 @@ public class DownloadStationDownloader : IDownloader _uri = uri; _remotePath = remotePath; _synologyClient = synologyClient; + _delayProvider = delayProvider ?? new DelayProvider(); } public static async Task Init(String? gid, String uri, String filePath, String downloadPath, String? category) @@ -149,13 +160,13 @@ public class DownloadStationDownloader : IDownloader retryCount++; _logger.Error($"Task not found in DownloadStation after creat Sucess. Retrying {retryCount}/{RetryCount}"); - await Task.Delay(retryCount * 1000); + await _delayProvider.Delay(retryCount * 1000); } catch (Exception e) { retryCount++; _logger.Error($"Error starting download: {e.Message}. Retrying {retryCount}/{RetryCount}"); - await Task.Delay(retryCount * 1000); + await _delayProvider.Delay(retryCount * 1000); } } diff --git a/server/RdtClient.Service/Services/Downloads.cs b/server/RdtClient.Service/Services/Downloads.cs index 020b36d..925c8e0 100644 --- a/server/RdtClient.Service/Services/Downloads.cs +++ b/server/RdtClient.Service/Services/Downloads.cs @@ -3,7 +3,7 @@ using Download = RdtClient.Data.Models.Data.Download; namespace RdtClient.Service.Services; -public class Downloads(DownloadData downloadData) +public class Downloads(DownloadData downloadData) : IDownloads { public async Task> GetForTorrent(Guid torrentId) { diff --git a/server/RdtClient.Service/Services/IDownloads.cs b/server/RdtClient.Service/Services/IDownloads.cs new file mode 100644 index 0000000..d0550b9 --- /dev/null +++ b/server/RdtClient.Service/Services/IDownloads.cs @@ -0,0 +1,24 @@ +using RdtClient.Data.Models.Data; + +namespace RdtClient.Service.Services; + +public interface IDownloads +{ + Task> GetForTorrent(Guid torrentId); + Task GetById(Guid downloadId); + Task Get(Guid torrentId, String path); + Task Add(Guid torrentId, String path); + Task UpdateUnrestrictedLink(Guid downloadId, String unrestrictedLink); + Task UpdateFileName(Guid downloadId, String fileName); + Task UpdateDownloadStarted(Guid downloadId, DateTimeOffset? dateTime); + Task UpdateDownloadFinished(Guid downloadId, DateTimeOffset? dateTime); + Task UpdateUnpackingQueued(Guid downloadId, DateTimeOffset? dateTime); + Task UpdateUnpackingStarted(Guid downloadId, DateTimeOffset? dateTime); + Task UpdateUnpackingFinished(Guid downloadId, DateTimeOffset? dateTime); + Task UpdateCompleted(Guid downloadId, DateTimeOffset? dateTime); + Task UpdateError(Guid downloadId, String? error); + Task UpdateRetryCount(Guid downloadId, Int32 retryCount); + Task UpdateRemoteId(Guid downloadId, String remoteId); + Task DeleteForTorrent(Guid torrentId); + Task Reset(Guid downloadId); +} diff --git a/server/RdtClient.Service/Services/TorrentClients/AllDebridTorrentClient.cs b/server/RdtClient.Service/Services/TorrentClients/AllDebridTorrentClient.cs index ed8249a..911d8d4 100644 --- a/server/RdtClient.Service/Services/TorrentClients/AllDebridTorrentClient.cs +++ b/server/RdtClient.Service/Services/TorrentClients/AllDebridTorrentClient.cs @@ -12,13 +12,14 @@ using Torrent = RdtClient.Data.Models.Data.Torrent; namespace RdtClient.Service.Services.TorrentClients; -public class AllDebridTorrentClient(ILogger logger, IHttpClientFactory httpClientFactory) : ITorrentClient +public interface IAllDebridNetClientFactory { - private static readonly Int64 SessionId = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - private static List _cache = []; - private static Int64 _sessionCounter = 0; + public IAllDebridNETClient GetClient(); +} - private AllDebridNETClient GetClient() +public class AllDebridNetClientFactory(ILogger logger, IHttpClientFactory httpClientFactory) : IAllDebridNetClientFactory +{ + public IAllDebridNETClient GetClient() { try { @@ -49,6 +50,13 @@ public class AllDebridTorrentClient(ILogger logger, IHtt throw; } } +} + +public class AllDebridTorrentClient(ILogger logger, IAllDebridNetClientFactory allDebridNetClientFactory) : ITorrentClient +{ + private static readonly Int64 SessionId = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + private static List _cache = []; + private static Int64 _sessionCounter = 0; private static TorrentClientTorrent Map(Magnet torrent) { @@ -77,7 +85,7 @@ public class AllDebridTorrentClient(ILogger logger, IHtt public async Task> GetTorrents() { - var results = await GetClient().Magnet.StatusLiveAsync(SessionId, _sessionCounter); + var results = await allDebridNetClientFactory.GetClient().Magnet.StatusLiveAsync(SessionId, _sessionCounter); _sessionCounter = results.Counter; @@ -106,7 +114,7 @@ public class AllDebridTorrentClient(ILogger logger, IHtt public async Task GetUser() { - var user = await GetClient().User.GetAsync() ?? throw new("Unable to get user"); + var user = await allDebridNetClientFactory.GetClient().User.GetAsync() ?? throw new("Unable to get user"); return new() { @@ -117,7 +125,7 @@ public class AllDebridTorrentClient(ILogger logger, IHtt public async Task AddMagnet(String magnetLink) { - var result = await GetClient().Magnet.UploadMagnetAsync(magnetLink); + var result = await allDebridNetClientFactory.GetClient().Magnet.UploadMagnetAsync(magnetLink); if (result?.Id == null) { @@ -131,7 +139,7 @@ public class AllDebridTorrentClient(ILogger logger, IHtt public async Task AddFile(Byte[] bytes) { - var result = await GetClient().Magnet.UploadFileAsync(bytes); + var result = await allDebridNetClientFactory.GetClient().Magnet.UploadFileAsync(bytes); if (result?.Id == null) { @@ -155,12 +163,12 @@ public class AllDebridTorrentClient(ILogger logger, IHtt public async Task Delete(String torrentId) { - await GetClient().Magnet.DeleteAsync(torrentId); + await allDebridNetClientFactory.GetClient().Magnet.DeleteAsync(torrentId); } public async Task Unrestrict(String link) { - var result = await GetClient().Links.DownloadLinkAsync(link); + var result = await allDebridNetClientFactory.GetClient().Links.DownloadLinkAsync(link); if (result.Link == null) { @@ -245,7 +253,7 @@ public class AllDebridTorrentClient(ILogger logger, IHtt return null; } - var allFiles = await GetClient().Magnet.FilesAsync(Int64.Parse(torrent.RdId)); + var allFiles = await allDebridNetClientFactory.GetClient().Magnet.FilesAsync(Int64.Parse(torrent.RdId)); var files = GetFiles(allFiles); @@ -323,7 +331,7 @@ public class AllDebridTorrentClient(ILogger logger, IHtt { Log($"{file.Path} ({file.Bytes}b) {file.DownloadLink}"); } - + return files.Where(m => m.DownloadLink != null).Select(m => m.DownloadLink!.ToString()).ToList(); } @@ -341,7 +349,7 @@ public class AllDebridTorrentClient(ILogger logger, IHtt private async Task GetInfo(String torrentId) { - var result = await GetClient().Magnet.StatusAsync(torrentId) ?? throw new($"Unable to find magnet with ID {torrentId}"); + var result = await allDebridNetClientFactory.GetClient().Magnet.StatusAsync(torrentId) ?? throw new($"Unable to find magnet with ID {torrentId}"); return Map(result); } diff --git a/server/RdtClient.Service/Services/Torrents.cs b/server/RdtClient.Service/Services/Torrents.cs index 0edb19f..3b2bb21 100644 --- a/server/RdtClient.Service/Services/Torrents.cs +++ b/server/RdtClient.Service/Services/Torrents.cs @@ -1,25 +1,29 @@ -using System.Diagnostics; -using System.Globalization; +using System.Globalization; +using System.IO.Abstractions; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using MonoTorrent; -using System.Text.Json.Serialization; using RdtClient.Data.Data; using RdtClient.Data.Enums; +using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Internal; using RdtClient.Data.Models.TorrentClient; using RdtClient.Service.BackgroundServices; using RdtClient.Service.Helpers; using RdtClient.Service.Services.TorrentClients; +using RdtClient.Service.Wrappers; using Torrent = RdtClient.Data.Models.Data.Torrent; namespace RdtClient.Service.Services; public class Torrents( ILogger logger, - TorrentData torrentData, - Downloads downloads, + ITorrentData torrentData, + IDownloads downloads, + IProcessFactory processFactory, + IFileSystem fileSystem, AllDebridTorrentClient allDebridTorrentClient, PremiumizeTorrentClient premiumizeTorrentClient, RealDebridTorrentClient realDebridTorrentClient, @@ -691,9 +695,11 @@ public class Torrents( await torrentData.Update(torrent); } - public async Task RunTorrentComplete(Guid torrentId) + public async Task RunTorrentComplete(Guid torrentId, DbSettings? settings = null) { - if (String.IsNullOrWhiteSpace(Settings.Get.General.RunOnTorrentCompleteFileName)) + settings ??= Settings.Get; + + if (String.IsNullOrWhiteSpace(settings.General.RunOnTorrentCompleteFileName)) { return; } @@ -702,8 +708,8 @@ public class Torrents( var downloadsForTorrent = await downloads.GetForTorrent(torrentId); - var fileName = Settings.Get.General.RunOnTorrentCompleteFileName; - var arguments = Settings.Get.General.RunOnTorrentCompleteArguments ?? ""; + var fileName = settings.General.RunOnTorrentCompleteFileName; + var arguments = settings.General.RunOnTorrentCompleteArguments ?? ""; Log($"Parsing external program {fileName} with arguments {arguments}", torrent); @@ -712,7 +718,7 @@ public class Torrents( var filePath = torrentPath; - var files = Directory.GetFiles(filePath); + var files = fileSystem.Directory.GetFiles(filePath); if (files.Length == 1) { @@ -733,7 +739,7 @@ public class Torrents( var errorSb = new StringBuilder(); var outputSb = new StringBuilder(); - using var process = new Process(); + using var process = processFactory.NewProcess(); process.StartInfo.FileName = fileName; process.StartInfo.Arguments = arguments; @@ -744,21 +750,21 @@ public class Torrents( process.OutputDataReceived += (_, data) => { - if (data.Data == null) + if (data == null) { return; } - outputSb.AppendLine(data.Data.Trim()); + outputSb.AppendLine(data.Trim()); }; process.ErrorDataReceived += (_, data) => { - if (data.Data == null) + if (data == null) { return; } - errorSb.AppendLine(data.Data.Trim()); + errorSb.AppendLine(data.Trim()); }; process.Start(); @@ -807,7 +813,7 @@ public class Torrents( } } - private void Log(String message, Data.Models.Data.Download? download, Torrent? torrent) + private void Log(String message, Download? download, Torrent? torrent) { if (download != null) { diff --git a/server/RdtClient.Service/Wrappers/IProcess.cs b/server/RdtClient.Service/Wrappers/IProcess.cs new file mode 100644 index 0000000..84f4cfe --- /dev/null +++ b/server/RdtClient.Service/Wrappers/IProcess.cs @@ -0,0 +1,16 @@ +using System.Diagnostics; + +namespace RdtClient.Service.Wrappers; + +public interface IProcess : IDisposable +{ + event EventHandler? OutputDataReceived; + event EventHandler? ErrorDataReceived; + + public ProcessStartInfo StartInfo { get; set; } + + void BeginOutputReadLine(); + void BeginErrorReadLine(); + Boolean WaitForExit(Int32 milliseconds); + void Start(); +} diff --git a/server/RdtClient.Service/Wrappers/IProcessFactory.cs b/server/RdtClient.Service/Wrappers/IProcessFactory.cs new file mode 100644 index 0000000..cb2b22a --- /dev/null +++ b/server/RdtClient.Service/Wrappers/IProcessFactory.cs @@ -0,0 +1,6 @@ +namespace RdtClient.Service.Wrappers; + +public interface IProcessFactory +{ + public IProcess NewProcess(); +} diff --git a/server/RdtClient.Service/Wrappers/Process.cs b/server/RdtClient.Service/Wrappers/Process.cs new file mode 100644 index 0000000..fa0e211 --- /dev/null +++ b/server/RdtClient.Service/Wrappers/Process.cs @@ -0,0 +1,44 @@ +using System.Diagnostics; + +namespace RdtClient.Service.Wrappers; + +public class Process : IProcess +{ + private readonly System.Diagnostics.Process _process = new(); + + public ProcessStartInfo StartInfo + { + get => _process.StartInfo; + set => _process.StartInfo = value; + } + + public event EventHandler? OutputDataReceived; + public event EventHandler? ErrorDataReceived; + + public void Dispose() + { + _process.Dispose(); + } + + public void BeginOutputReadLine() + { + _process.OutputDataReceived += (sender, args) => OutputDataReceived?.Invoke(sender, args.Data); + _process.BeginOutputReadLine(); + } + + public void BeginErrorReadLine() + { + _process.ErrorDataReceived += (sender, args) => ErrorDataReceived?.Invoke(sender, args.Data); + _process.BeginErrorReadLine(); + } + + public Boolean WaitForExit(Int32 milliseconds) + { + return _process.WaitForExit(milliseconds); + } + + public void Start() + { + _process.Start(); + } +} diff --git a/server/RdtClient.Service/Wrappers/ProcessFactory.cs b/server/RdtClient.Service/Wrappers/ProcessFactory.cs new file mode 100644 index 0000000..ba8814f --- /dev/null +++ b/server/RdtClient.Service/Wrappers/ProcessFactory.cs @@ -0,0 +1,9 @@ +namespace RdtClient.Service.Wrappers; + +public class ProcessFactory: IProcessFactory +{ + public IProcess NewProcess() + { + return new Process(); + } +} diff --git a/server/RdtClient.sln b/server/RdtClient.sln index 58f8ccd..ea46a35 100644 --- a/server/RdtClient.sln +++ b/server/RdtClient.sln @@ -9,6 +9,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RdtClient.Service", "RdtCli EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RdtClient.Data", "RdtClient.Data\RdtClient.Data.csproj", "{92EF8817-AD73-4301-93BD-745D7D61DD74}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RdtClient.Service.Test", "RdtClient.Service.Test\RdtClient.Service.Test.csproj", "{4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +29,10 @@ Global {92EF8817-AD73-4301-93BD-745D7D61DD74}.Debug|Any CPU.Build.0 = Debug|Any CPU {92EF8817-AD73-4301-93BD-745D7D61DD74}.Release|Any CPU.ActiveCfg = Release|Any CPU {92EF8817-AD73-4301-93BD-745D7D61DD74}.Release|Any CPU.Build.0 = Release|Any CPU + {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F8F6313-5113-4E03-9DAB-195B0DF6C2B9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE