using System; using System.Collections.Generic; using System.IO; using System.IO.Abstractions; using System.IO.Compression; using System.Linq; using System.Text; using System.Text.Json; using System.Threading.Tasks; using System.Xml; using System.Xml.Serialization; using API.Data; using API.Data.Metadata; using API.Entities; using API.Entities.Enums; using API.Helpers; using API.Helpers.Builders; using API.Services; using API.Services.Plus; using API.Services.Tasks; using API.Services.Tasks.Metadata; using API.Services.Tasks.Scanner; using API.SignalR; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit.Abstractions; namespace API.Tests.Helpers; #nullable enable public class ScannerHelper { private readonly IUnitOfWork _unitOfWork; private readonly ITestOutputHelper _testOutputHelper; private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"); private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases"); private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png"); private static readonly string[] ComicInfoExtensions = [".cbz", ".cbr", ".zip", ".rar"]; private static readonly string[] EpubExtensions = [".epub"]; public ScannerHelper(IUnitOfWork unitOfWork, ITestOutputHelper testOutputHelper) { _unitOfWork = unitOfWork; _testOutputHelper = testOutputHelper; } public async Task GenerateScannerData(string testcase, Dictionary? comicInfos = null) { var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase), comicInfos); var (publisher, type) = SplitPublisherAndLibraryType(Path.GetFileNameWithoutExtension(testcase)); var library = new LibraryBuilder(publisher, type) .WithFolders([new FolderPath() {Path = testDirectoryPath}]) .Build(); var admin = new AppUserBuilder("admin", "admin@kavita.com", Seed.DefaultThemes[0]) .WithLibrary(library) .Build(); _unitOfWork.UserRepository.Add(admin); // Admin is needed for generating collections/reading lists _unitOfWork.LibraryRepository.Add(library); await _unitOfWork.CommitAsync(); return library; } public ScannerService CreateServices(DirectoryService ds = null, IFileSystem fs = null) { fs ??= new FileSystem(); ds ??= new DirectoryService(Substitute.For>(), fs); var archiveService = new ArchiveService(Substitute.For>(), ds, Substitute.For(), Substitute.For()); var readingItemService = new ReadingItemService(archiveService, Substitute.For(), Substitute.For(), ds, Substitute.For>()); var processSeries = new ProcessSeries(_unitOfWork, Substitute.For>(), Substitute.For(), ds, Substitute.For(), readingItemService, new FileService(fs), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For()); var scanner = new ScannerService(_unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), Substitute.For(), ds, readingItemService, processSeries, Substitute.For()); return scanner; } private static (string Publisher, LibraryType Type) SplitPublisherAndLibraryType(string input) { // Split the input string based on " - " var parts = input.Split(" - ", StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 2) { throw new ArgumentException("Input must be in the format 'Publisher - LibraryType'"); } var publisher = parts[0].Trim(); var libraryTypeString = parts[1].Trim(); // Try to parse the right-hand side as a LibraryType enum if (!Enum.TryParse(libraryTypeString, out var libraryType)) { throw new ArgumentException($"'{libraryTypeString}' is not a valid LibraryType"); } return (publisher, libraryType); } private async Task GenerateTestDirectory(string mapPath, Dictionary? comicInfos = null) { // Read the map file var mapContent = await File.ReadAllTextAsync(mapPath); // Deserialize the JSON content into a list of strings using System.Text.Json var filePaths = JsonSerializer.Deserialize>(mapContent); // Create a test directory var testDirectory = Path.Combine(_testDirectory, Path.GetFileNameWithoutExtension(mapPath)); if (Directory.Exists(testDirectory)) { Directory.Delete(testDirectory, true); } Directory.CreateDirectory(testDirectory); // Generate the files and folders await Scaffold(testDirectory, filePaths ?? [], comicInfos); _testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}"); return Path.GetFullPath(testDirectory); } public async Task Scaffold(string testDirectory, List filePaths, Dictionary? comicInfos = null) { foreach (var relativePath in filePaths) { var fullPath = Path.Combine(testDirectory, relativePath); var fileDir = Path.GetDirectoryName(fullPath); // Create the directory if it doesn't exist if (!Directory.Exists(fileDir)) { Directory.CreateDirectory(fileDir); Console.WriteLine($"Created directory: {fileDir}"); } var ext = Path.GetExtension(fullPath).ToLower(); if (ComicInfoExtensions.Contains(ext) && comicInfos != null && comicInfos.TryGetValue(Path.GetFileName(relativePath), out var info)) { CreateMinimalCbz(fullPath, info); } else if (EpubExtensions.Contains(ext) && comicInfos != null && comicInfos.TryGetValue(Path.GetFileName(relativePath), out var epubInfo)) { CreateMinimalEpub(fullPath, epubInfo); } else { // Create an empty file await File.Create(fullPath).DisposeAsync(); Console.WriteLine($"Created empty file: {fullPath}"); } } } private void CreateMinimalCbz(string filePath, ComicInfo? comicInfo = null) { using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create)) { // Add the 1x1 image to the archive archive.CreateEntryFromFile(_imagePath, "1x1.png"); if (comicInfo != null) { // Serialize ComicInfo object to XML var comicInfoXml = SerializeComicInfoToXml(comicInfo); // Create an entry for ComicInfo.xml in the archive var entry = archive.CreateEntry("ComicInfo.xml"); using var entryStream = entry.Open(); using var writer = new StreamWriter(entryStream, Encoding.UTF8); // Write the XML to the archive writer.Write(comicInfoXml); } } Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata."); } private static string SerializeComicInfoToXml(ComicInfo comicInfo) { var xmlSerializer = new XmlSerializer(typeof(ComicInfo)); using var stringWriter = new StringWriter(); using (var xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false), OmitXmlDeclaration = false})) { xmlSerializer.Serialize(xmlWriter, comicInfo); } // For the love of god, I spent 2 hours trying to get utf-8 with no BOM return stringWriter.ToString().Replace("""""", @""); } private void CreateMinimalEpub(string filePath, ComicInfo? comicInfo = null) { using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create)) { // EPUB requires a mimetype file as the first entry (uncompressed) var mimetypeEntry = archive.CreateEntry("mimetype", CompressionLevel.NoCompression); using (var mimetypeStream = mimetypeEntry.Open()) using (var writer = new StreamWriter(mimetypeStream, Encoding.ASCII)) { writer.Write("application/epub+zip"); } // Create META-INF/container.xml var containerEntry = archive.CreateEntry("META-INF/container.xml"); using (var containerStream = containerEntry.Open()) using (var writer = new StreamWriter(containerStream, Encoding.UTF8)) { writer.Write(""" """); } // Create content.opf with metadata var contentOpf = GenerateContentOpf(comicInfo); var contentEntry = archive.CreateEntry("OEBPS/content.opf"); using (var contentStream = contentEntry.Open()) using (var writer = new StreamWriter(contentStream, Encoding.UTF8)) { writer.Write(contentOpf); } // Add a minimal chapter XHTML file var chapterEntry = archive.CreateEntry("OEBPS/chapter1.xhtml"); using (var chapterStream = chapterEntry.Open()) using (var writer = new StreamWriter(chapterStream, Encoding.UTF8)) { writer.Write(""" Chapter 1

Test content.

"""); } // Add the cover image archive.CreateEntryFromFile(_imagePath, "OEBPS/cover.png"); } Console.WriteLine($"Created minimal EPUB archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata."); } private static string GenerateContentOpf(ComicInfo? comicInfo) { var sb = new StringBuilder(); sb.AppendLine(""""""); sb.AppendLine(""""""); // Metadata section sb.AppendLine(" "); if (comicInfo != null) { if (!string.IsNullOrEmpty(comicInfo.Title)) sb.AppendLine($" {EscapeXml(comicInfo.Title)}"); else sb.AppendLine(" Untitled"); if (!string.IsNullOrEmpty(comicInfo.Series)) { sb.AppendLine($" {EscapeXml(comicInfo.Series)}"); sb.AppendLine(" series"); } if (!string.IsNullOrEmpty(comicInfo.Writer)) sb.AppendLine($" {EscapeXml(comicInfo.Writer)}"); if (!string.IsNullOrEmpty(comicInfo.Publisher)) sb.AppendLine($" {EscapeXml(comicInfo.Publisher)}"); if (!string.IsNullOrEmpty(comicInfo.Summary)) sb.AppendLine($" {EscapeXml(comicInfo.Summary)}"); if (!string.IsNullOrEmpty(comicInfo.LanguageISO)) sb.AppendLine($" {EscapeXml(comicInfo.LanguageISO)}"); else sb.AppendLine(" en"); if (!string.IsNullOrEmpty(comicInfo.Isbn)) sb.AppendLine($" {EscapeXml(comicInfo.Isbn)}"); else sb.AppendLine($" urn:uuid:{Guid.NewGuid()}"); if (comicInfo.Year > 0) { var date = $"{comicInfo.Year:D4}"; if (comicInfo.Month > 0) { date += $"-{comicInfo.Month:D2}"; if (comicInfo.Day > 0) date += $"-{comicInfo.Day:D2}"; } sb.AppendLine($" {date}"); } if (!string.IsNullOrEmpty(comicInfo.TitleSort)) sb.AppendLine($" "); if (!string.IsNullOrEmpty(comicInfo.SeriesSort)) sb.AppendLine($" "); if (!string.IsNullOrEmpty(comicInfo.Number)) sb.AppendLine($" "); } else { sb.AppendLine(" Untitled"); sb.AppendLine(" en"); sb.AppendLine($" urn:uuid:{Guid.NewGuid()}"); } sb.AppendLine(" "); // Manifest section sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(" "); // Spine section sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(""); return sb.ToString(); } private static string EscapeXml(string text) { if (string.IsNullOrEmpty(text)) return text; return text .Replace("&", "&") .Replace("<", "<") .Replace(">", ">") .Replace("\"", """) .Replace("'", "'"); } }