using System.Reflection; using OF_DL.Models.Config; using OF_DL.Models.Downloads; using OF_DL.Models; using PostEntities = OF_DL.Models.Entities.Posts; using PurchasedEntities = OF_DL.Models.Entities.Purchased; using MessageEntities = OF_DL.Models.Entities.Messages; using OF_DL.Services; namespace OF_DL.Tests.Services; public class DownloadServiceTests { [Fact] public async Task ProcessMediaDownload_RenamesServerFileAndUpdatesDb_WhenNotDownloadedButServerFileExists() { using TempFolder temp = new(); string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); string path = "/Posts/Free"; string url = "https://example.com/image.jpg"; string serverFilename = "server"; string resolvedFilename = "custom"; string serverFilePath = $"{folder}{path}/{serverFilename}.jpg"; Directory.CreateDirectory(Path.GetDirectoryName(serverFilePath) ?? throw new InvalidOperationException()); await File.WriteAllTextAsync(serverFilePath, "abc"); MediaTrackingDbService dbService = new() { CheckDownloadedResult = false }; FakeConfigService configService = new(new Config { ShowScrapeSize = false }); DownloadService service = CreateService(configService, dbService); ProgressRecorder progress = new(); bool isNew = await service.ProcessMediaDownload(folder, 1, "Posts", url, path, serverFilename, resolvedFilename, ".jpg", progress); string renamedPath = $"{folder}{path}/{resolvedFilename}.jpg"; Assert.False(isNew); Assert.False(File.Exists(serverFilePath)); Assert.True(File.Exists(renamedPath)); Assert.NotNull(dbService.LastUpdateMedia); Assert.Equal($"{folder}{path}", dbService.LastUpdateMedia.Value.directory); Assert.Equal("custom.jpg", dbService.LastUpdateMedia.Value.filename); Assert.Equal(new FileInfo(renamedPath).Length, dbService.LastUpdateMedia.Value.size); Assert.Equal(1, progress.Total); } [Fact] public async Task ProcessMediaDownload_RenamesExistingFile_WhenDownloadedAndCustomFormatEnabled() { using TempFolder temp = new(); string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); string path = "/Posts/Free"; string url = "https://example.com/image.jpg"; string serverFilename = "server"; string resolvedFilename = "custom"; string serverFilePath = $"{folder}{path}/{serverFilename}.jpg"; Directory.CreateDirectory(Path.GetDirectoryName(serverFilePath) ?? throw new InvalidOperationException()); await File.WriteAllTextAsync(serverFilePath, "abc"); MediaTrackingDbService dbService = new() { CheckDownloadedResult = true, StoredFileSize = 123 }; FakeConfigService configService = new(new Config { ShowScrapeSize = false, RenameExistingFilesWhenCustomFormatIsSelected = true }); DownloadService service = CreateService(configService, dbService); ProgressRecorder progress = new(); bool isNew = await service.ProcessMediaDownload(folder, 1, "Posts", url, path, serverFilename, resolvedFilename, ".jpg", progress); string renamedPath = $"{folder}{path}/{resolvedFilename}.jpg"; Assert.False(isNew); Assert.False(File.Exists(serverFilePath)); Assert.True(File.Exists(renamedPath)); Assert.NotNull(dbService.LastUpdateMedia); Assert.Equal("custom.jpg", dbService.LastUpdateMedia.Value.filename); Assert.Equal(123, dbService.LastUpdateMedia.Value.size); Assert.Equal(1, progress.Total); } [Fact] public async Task GetDecryptionInfo_UsesOfdlWhenCdmMissing() { StaticApiService apiService = new(); DownloadService service = CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService); (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo( "https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post", true, false); Assert.NotNull(result); Assert.Equal("ofdl-key", result.Value.decryptionKey); Assert.Equal(apiService.LastModifiedToReturn, result.Value.lastModified); Assert.True(apiService.OfdlCalled); Assert.False(apiService.CdmCalled); } [Fact] public async Task GetDecryptionInfo_UsesCdmWhenAvailable() { StaticApiService apiService = new(); DownloadService service = CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService); (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo( "https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post", false, false); Assert.NotNull(result); Assert.Equal("cdm-key", result.Value.decryptionKey); Assert.Equal(apiService.LastModifiedToReturn, result.Value.lastModified); Assert.True(apiService.CdmCalled); Assert.False(apiService.OfdlCalled); } [Fact] public async Task FinalizeDrmDownload_DoesNotDeleteFile_WhenCustomPathMatchesTempPath() { using TempFolder temp = new(); string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); string path = "/Posts/Free"; string filename = "video"; string customFileName = "video_source"; string tempFilename = $"{folder}{path}/{filename}_source.mp4"; Directory.CreateDirectory(Path.GetDirectoryName(tempFilename) ?? throw new InvalidOperationException()); await File.WriteAllTextAsync(tempFilename, "abc"); MediaTrackingDbService dbService = new(); DownloadService service = CreateService(new FakeConfigService(new Config { ShowScrapeSize = false }), dbService); ProgressRecorder progress = new(); MethodInfo? finalizeMethod = typeof(DownloadService).GetMethod("FinalizeDrmDownload", BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull(finalizeMethod); object? resultObject = finalizeMethod.Invoke(service, [ tempFilename, DateTime.UtcNow, folder, path, customFileName, filename, 1L, "Posts", progress ]); bool result = await Assert.IsType>(resultObject!); Assert.True(result); Assert.True(File.Exists(tempFilename)); Assert.NotNull(dbService.LastUpdateMedia); Assert.Equal("video_source.mp4", dbService.LastUpdateMedia.Value.filename); } [Fact] public async Task DownloadHighlights_ReturnsZeroWhenNoMedia() { StaticApiService apiService = new() { MediaToReturn = new Dictionary() }; DownloadService service = CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService); DownloadResult result = await service.DownloadHighlights("user", 1, "/tmp/creator", new HashSet(), new ProgressRecorder()); Assert.Equal(0, result.TotalCount); Assert.Equal(0, result.NewDownloads); Assert.Equal(0, result.ExistingDownloads); Assert.Equal("Highlights", result.MediaType); Assert.True(result.Success); } [Fact] public async Task DownloadHighlights_CountsExistingWhenAlreadyDownloaded() { using TempFolder temp = new(); string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); StaticApiService apiService = new() { MediaToReturn = new Dictionary { { 1, "https://example.com/one.jpg" }, { 2, "https://example.com/two.jpg" } } }; MediaTrackingDbService dbService = new() { CheckDownloadedResult = true }; FakeConfigService configService = new(new Config { ShowScrapeSize = false }); ProgressRecorder progress = new(); DownloadService service = CreateService(configService, dbService, apiService); DownloadResult result = await service.DownloadHighlights("user", 1, folder, new HashSet(), progress); Assert.Equal(2, result.TotalCount); Assert.Equal(0, result.NewDownloads); Assert.Equal(2, result.ExistingDownloads); Assert.Equal("Highlights", result.MediaType); Assert.True(result.Success); Assert.Equal(2, progress.Total); } [Fact] public async Task DownloadFreePosts_UsesDefaultFilenameWhenNoGlobalOrCreatorFormatIsDefined() { using TempFolder temp = new(); string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); const string serverFilename = "server-name"; string existingFilePath = $"{folder}/Posts/Free/Images/{serverFilename}.jpg"; Directory.CreateDirectory(Path.GetDirectoryName(existingFilePath) ?? throw new InvalidOperationException()); await File.WriteAllTextAsync(existingFilePath, "abc"); Config config = new() { ShowScrapeSize = false, PostFileNameFormat = "", CreatorConfigs = new Dictionary { ["creator"] = new() { PostFileNameFormat = "" } } }; MediaTrackingDbService dbService = new() { CheckDownloadedResult = false }; DownloadService service = CreateService(new FakeConfigService(config), dbService); ProgressRecorder progress = new(); PostEntities.PostCollection posts = new() { Posts = new Dictionary { { 1, $"https://example.com/{serverFilename}.jpg" } } }; DownloadResult result = await service.DownloadFreePosts("creator", 1, folder, new Dictionary(), false, false, posts, progress); Assert.Equal(1, result.TotalCount); Assert.Equal(0, result.NewDownloads); Assert.Equal(1, result.ExistingDownloads); Assert.NotNull(dbService.LastUpdateMedia); Assert.Equal($"{serverFilename}.jpg", dbService.LastUpdateMedia.Value.filename); Assert.Equal(1, progress.Total); } [Fact] public async Task DownloadFreePosts_UsesGlobalCustomFormatWhenCreatorCustomFormatNotDefined() { using TempFolder temp = new(); string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); const string serverFilename = "server-name"; string existingFilePath = $"{folder}/Posts/Free/Images/{serverFilename}.jpg"; Directory.CreateDirectory(Path.GetDirectoryName(existingFilePath) ?? throw new InvalidOperationException()); await File.WriteAllTextAsync(existingFilePath, "abc"); Config config = new() { ShowScrapeSize = false, PostFileNameFormat = "global-custom-name", CreatorConfigs = new Dictionary { ["creator"] = new() { PostFileNameFormat = "" } } }; MediaTrackingDbService dbService = new() { CheckDownloadedResult = false }; DownloadService service = CreateService(new FakeConfigService(config), dbService, fileNameService: new DeterministicFileNameService()); ProgressRecorder progress = new(); PostEntities.PostCollection posts = CreatePostCollection(1, serverFilename); DownloadResult result = await service.DownloadFreePosts("creator", 1, folder, new Dictionary(), false, false, posts, progress); string renamedPath = $"{folder}/Posts/Free/Images/global-custom-name.jpg"; Assert.Equal(1, result.TotalCount); Assert.Equal(0, result.NewDownloads); Assert.Equal(1, result.ExistingDownloads); Assert.False(File.Exists(existingFilePath)); Assert.True(File.Exists(renamedPath)); Assert.NotNull(dbService.LastUpdateMedia); Assert.Equal("global-custom-name.jpg", dbService.LastUpdateMedia.Value.filename); Assert.Equal(1, progress.Total); } [Fact] public async Task DownloadFreePosts_UsesCreatorCustomFormatWhenGlobalAndCreatorFormatsAreDefined() { using TempFolder temp = new(); string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); const string serverFilename = "server-name"; string existingFilePath = $"{folder}/Posts/Free/Images/{serverFilename}.jpg"; Directory.CreateDirectory(Path.GetDirectoryName(existingFilePath) ?? throw new InvalidOperationException()); await File.WriteAllTextAsync(existingFilePath, "abc"); Config config = new() { ShowScrapeSize = false, PostFileNameFormat = "global-custom-name", CreatorConfigs = new Dictionary { ["creator"] = new() { PostFileNameFormat = "creator-custom-name" } } }; MediaTrackingDbService dbService = new() { CheckDownloadedResult = false }; DownloadService service = CreateService(new FakeConfigService(config), dbService, fileNameService: new DeterministicFileNameService()); ProgressRecorder progress = new(); PostEntities.PostCollection posts = CreatePostCollection(1, serverFilename); DownloadResult result = await service.DownloadFreePosts("creator", 1, folder, new Dictionary(), false, false, posts, progress); string renamedPath = $"{folder}/Posts/Free/Images/creator-custom-name.jpg"; Assert.Equal(1, result.TotalCount); Assert.Equal(0, result.NewDownloads); Assert.Equal(1, result.ExistingDownloads); Assert.False(File.Exists(existingFilePath)); Assert.True(File.Exists(renamedPath)); Assert.NotNull(dbService.LastUpdateMedia); Assert.Equal("creator-custom-name.jpg", dbService.LastUpdateMedia.Value.filename); Assert.Equal(1, progress.Total); } [Fact] public async Task DownloadPaidPosts_AppliesPaidCustomFormatForDrm_WhenAuthorExistsButFromUserMissing() { using TempFolder temp = new(); string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); const string customName = "paid-custom-name"; const string drmBaseFilename = "video-file"; string basePath = $"{folder}/Posts/Paid/Videos"; Directory.CreateDirectory(basePath); await File.WriteAllTextAsync($"{basePath}/{customName}.mp4", "custom"); await File.WriteAllTextAsync($"{basePath}/{drmBaseFilename}_source.mp4", "server"); Config config = new() { ShowScrapeSize = false, PaidPostFileNameFormat = customName, PostFileNameFormat = "" }; MediaTrackingDbService dbService = new() { CheckDownloadedResult = false }; StaticApiService apiService = new(); FakeAuthService authService = new() { CurrentAuth = new Auth { Cookie = "sess=test;", UserAgent = "unit-test-agent" } }; DownloadService service = CreateService(new FakeConfigService(config), dbService, apiService, new DeterministicFileNameService(), authService); ProgressRecorder progress = new(); PurchasedEntities.PaidPostCollection posts = CreatePaidPostCollectionForDrm(1, $"https://cdn3.onlyfans.com/dash/files/{drmBaseFilename}.mpd,policy,signature,kvp,1,2"); DownloadResult result = await service.DownloadPaidPosts("creator", 1, folder, new Dictionary(), false, false, posts, progress); Assert.Equal(1, result.TotalCount); Assert.Equal(0, result.NewDownloads); Assert.Equal(1, result.ExistingDownloads); Assert.NotNull(dbService.LastUpdateMedia); Assert.Equal($"{customName}.mp4", dbService.LastUpdateMedia.Value.filename); Assert.Equal(1, progress.Total); } private static DownloadService CreateService(FakeConfigService configService, MediaTrackingDbService dbService, StaticApiService? apiService = null, IFileNameService? fileNameService = null, IAuthService? authService = null) => new(authService ?? new FakeAuthService(), configService, dbService, fileNameService ?? new FakeFileNameService(), apiService ?? new StaticApiService()); private static PostEntities.PostCollection CreatePostCollection(long mediaId, string serverFilename) { PostEntities.Medium media = new() { Id = mediaId }; PostEntities.ListItem post = new() { Id = 10, PostedAt = new DateTime(2024, 1, 1), Author = new OF_DL.Models.Entities.Common.Author { Id = 99 }, Media = [media] }; return new PostEntities.PostCollection { Posts = new Dictionary { { mediaId, $"https://example.com/{serverFilename}.jpg" } }, PostMedia = [media], PostObjects = [post] }; } private static PurchasedEntities.PaidPostCollection CreatePaidPostCollectionForDrm(long mediaId, string drmUrl) { MessageEntities.Medium media = new() { Id = mediaId }; PurchasedEntities.ListItem post = new() { Id = 20, PostedAt = new DateTime(2024, 1, 1), Author = new OF_DL.Models.Entities.Common.Author { Id = 99 }, FromUser = null, Media = [media] }; return new PurchasedEntities.PaidPostCollection { PaidPosts = new Dictionary { { mediaId, drmUrl } }, PaidPostMedia = [media], PaidPostObjects = [post] }; } private static string NormalizeFolder(string folder) => folder.Replace("\\", "/"); private sealed class DeterministicFileNameService : IFileNameService { public Task BuildFilename(string fileFormat, Dictionary values) => Task.FromResult(fileFormat); public Task> GetFilename(object info, object media, object author, List selectedProperties, string username, Dictionary? users = null) => Task.FromResult(new Dictionary()); } }