diff --git a/API/Constants.cs b/API/Constants.cs new file mode 100644 index 0000000..253bf59 --- /dev/null +++ b/API/Constants.cs @@ -0,0 +1,18 @@ +using SixLabors.ImageSharp; + +namespace API; + +public struct Constants +{ + public const string TRANGA = + "\n\n" + + " _______ v2\n" + + "|_ _|.----..---.-..-----..-----..---.-.\n" + + " | | | _|| _ || || _ || _ |\n" + + " |___| |__| |___._||__|__||___ ||___._|\n" + + " |_____| \n\n"; + + public static readonly Size ImageSmSize = new (225, 320); + public static readonly Size ImageMdSize = new (450, 640); + public static readonly Size ImageLgSize = new (900, 1280); +} \ No newline at end of file diff --git a/API/Controllers/MangaController.cs b/API/Controllers/MangaController.cs index 60f7443..3788a98 100644 --- a/API/Controllers/MangaController.cs +++ b/API/Controllers/MangaController.cs @@ -1,4 +1,5 @@ -using API.Controllers.DTOs; +using System.Diagnostics.CodeAnalysis; +using API.Controllers.DTOs; using API.Schema.MangaContext; using API.Workers; using Asp.Versioning; @@ -7,10 +8,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.Net.Http.Headers; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Formats.Jpeg; -using SixLabors.ImageSharp.Processing; -using SixLabors.ImageSharp.Processing.Processors.Transforms; using static Microsoft.AspNetCore.Http.StatusCodes; using AltTitle = API.Controllers.DTOs.AltTitle; using Author = API.Controllers.DTOs.Author; @@ -190,54 +187,49 @@ public class MangaController(MangaContext context) : Controller /// Returns Cover of with /// /// .Key - /// If is provided, needs to also be provided - /// If is provided, needs to also be provided + /// Size of the cover returned + ///
- + ///
- + ///
- + /// /// JPEG Image /// Cover not loaded - /// The formatting-request was invalid /// with not found /// Retry later, downloading cover - [HttpGet("{MangaId}/Cover")] - [ProducesResponseType(Status200OK,"image/jpeg")] + [HttpGet("{MangaId}/Cover/{CoverSize?}")] + [ProducesResponseType(Status200OK,"image/jpeg")] [ProducesResponseType(Status204NoContent)] [ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status404NotFound, "text/plain")] [ProducesResponseType(Status503ServiceUnavailable)] - public async Task, StatusCodeHttpResult>> GetCover (string MangaId, [FromQuery]int? width, [FromQuery]int? height) + public async Task, StatusCodeHttpResult>> GetCover (string MangaId, CoverSize? CoverSize = null) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return TypedResults.NotFound(nameof(MangaId)); - - if (!System.IO.File.Exists(manga.CoverFileNameInCache)) + + string cache = CoverSize switch + { + MangaController.CoverSize.Small => TrangaSettings.coverImageCacheSmall, + MangaController.CoverSize.Medium => TrangaSettings.coverImageCacheMedium, + MangaController.CoverSize.Large => TrangaSettings.coverImageCacheLarge, + _ => TrangaSettings.coverImageCacheOriginal + }; + + if (await manga.GetCoverImage(cache, HttpContext.RequestAborted) is not { } data) { if (Tranga.GetRunningWorkers().Any(worker => worker is DownloadCoverFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId)) { Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}"); return TypedResults.StatusCode(Status503ServiceUnavailable); } - else - return TypedResults.NoContent(); - } - - Image image = await Image.LoadAsync(manga.CoverFileNameInCache, HttpContext.RequestAborted); - - if (width is { } w && height is { } h) - { - if (width < 10 || height < 10 || width > 65535 || height > 65535) - return TypedResults.BadRequest(); - image.Mutate(i => i.ApplyProcessor(new ResizeProcessor(new ResizeOptions() - { - Mode = ResizeMode.Max, - Size = new Size(w, h) - }, image.Size))); + return TypedResults.NoContent(); } - using MemoryStream ms = new(); - await image.SaveAsync(ms, new JpegEncoder(){Quality = 100}, HttpContext.RequestAborted); - DateTime lastModified = new FileInfo(manga.CoverFileNameInCache).LastWriteTime; + DateTime lastModified = data.fileInfo.LastWriteTime; HttpContext.Response.Headers.CacheControl = "public"; - return TypedResults.File(ms.GetBuffer(), "image/jpeg", lastModified: new DateTimeOffset(lastModified), entityTag: EntityTagHeaderValue.Parse($"\"{lastModified.Ticks}\"")); + return TypedResults.Bytes(data.stream.ToArray(), "image/jpeg", lastModified: new DateTimeOffset(lastModified), entityTag: EntityTagHeaderValue.Parse($"\"{lastModified.Ticks}\"")); } + public enum CoverSize { Original, Large, Medium, Small } /// /// Returns all of with diff --git a/API/MangaConnectors/MangaConnector.cs b/API/MangaConnectors/MangaConnector.cs index d02718a..ef72924 100644 --- a/API/MangaConnectors/MangaConnector.cs +++ b/API/MangaConnectors/MangaConnector.cs @@ -6,6 +6,9 @@ using API.Schema.MangaContext; using log4net; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Processing; namespace API.MangaConnectors; @@ -43,8 +46,8 @@ public abstract class MangaConnector(string name, string[] supportedLanguages, s Regex urlRex = new (@"https?:\/\/((?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]+)\/(?:.+\/)*(.+\.([a-zA-Z]+))"); //https?:\/\/[a-zA-Z0-9-]+\.([a-zA-Z0-9-]+\.[a-zA-Z0-9]+)\/(?:.+\/)*(.+\.([a-zA-Z]+)) for only second level domains Match match = urlRex.Match(mangaId.Obj.CoverUrl); - string filename = $"{match.Groups[1].Value}-{mangaId.Key}.{match.Groups[3].Value}"; - string saveImagePath = Path.Join(TrangaSettings.coverImageCache, filename); + string filename = $"{match.Groups[1].Value}-{mangaId.ObjId}.{mangaId.MangaConnectorName}.{match.Groups[3].Value}"; + string saveImagePath = Path.Join(TrangaSettings.coverImageCacheOriginal, filename); if (File.Exists(saveImagePath)) return saveImagePath; @@ -53,11 +56,36 @@ public abstract class MangaConnector(string name, string[] supportedLanguages, s if ((int)coverResult.statusCode < 200 || (int)coverResult.statusCode >= 300) return SaveCoverImageToCache(mangaId, --retries); - using MemoryStream ms = new(); - coverResult.result.CopyTo(ms); - Directory.CreateDirectory(TrangaSettings.coverImageCache); - File.WriteAllBytes(saveImagePath, ms.ToArray()); - - return saveImagePath; + try + { + using MemoryStream ms = new(); + coverResult.result.CopyTo(ms); + byte[] imageBytes = ms.ToArray(); + Directory.CreateDirectory(TrangaSettings.coverImageCacheOriginal); + File.WriteAllBytes(saveImagePath, imageBytes); + + using Image image = Image.Load(imageBytes); + Directory.CreateDirectory(TrangaSettings.coverImageCacheLarge); + using Image large = image.Clone(x => x.Resize(new ResizeOptions + { Size = Constants.ImageLgSize, Mode = ResizeMode.Max })); + large.SaveAsJpeg(Path.Join(TrangaSettings.coverImageCacheLarge, filename), new (){ Quality = 40 }); + + Directory.CreateDirectory(TrangaSettings.coverImageCacheMedium); + using Image medium = image.Clone(x => x.Resize(new ResizeOptions + { Size = Constants.ImageMdSize, Mode = ResizeMode.Max })); + medium.SaveAsJpeg(Path.Join(TrangaSettings.coverImageCacheMedium, filename), new (){ Quality = 40 }); + + Directory.CreateDirectory(TrangaSettings.coverImageCacheSmall); + using Image small = image.Clone(x => x.Resize(new ResizeOptions + { Size = Constants.ImageSmSize, Mode = ResizeMode.Max })); + small.SaveAsJpeg(Path.Join(TrangaSettings.coverImageCacheSmall, filename), new (){ Quality = 40 }); + } + catch (Exception e) + { + Log.Error(e); + } + + + return filename; } } \ No newline at end of file diff --git a/API/Schema/MangaContext/Manga.cs b/API/Schema/MangaContext/Manga.cs index 66b25c0..9c4bd1a 100644 --- a/API/Schema/MangaContext/Manga.cs +++ b/API/Schema/MangaContext/Manga.cs @@ -4,6 +4,10 @@ using System.Runtime.InteropServices; using System.Text; using API.Workers; using Microsoft.EntityFrameworkCore; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Processing.Processors.Transforms; using static System.IO.UnixFileMode; namespace API.Schema.MangaContext; @@ -155,6 +159,18 @@ public class Manga : Identifiable return newJobs.ToArray(); } + public async Task<(MemoryStream stream, FileInfo fileInfo)?> GetCoverImage(string cachePath, CancellationToken ct) + { + string fullPath = Path.Join(cachePath, CoverFileNameInCache); + if (!File.Exists(fullPath)) + return null; + + FileInfo fileInfo = new(fullPath); + MemoryStream stream = new (await File.ReadAllBytesAsync(fullPath, ct)); + + return (stream, fileInfo); + } + public override string ToString() => $"{base.ToString()} {Name}"; } diff --git a/API/Tranga.cs b/API/Tranga.cs index a5cf3d8..a6dc836 100644 --- a/API/Tranga.cs +++ b/API/Tranga.cs @@ -15,16 +15,6 @@ namespace API; public static class Tranga { - - // ReSharper disable once InconsistentNaming - private const string TRANGA = - "\n\n" + - " _______ v2\n" + - "|_ _|.----..---.-..-----..-----..---.-.\n" + - " | | | _|| _ || || _ || _ |\n" + - " |___| |__| |___._||__|__||___ ||___._|\n" + - " |_____| \n\n"; - private static IServiceProvider? ServiceProvider; private static readonly ILog Log = LogManager.GetLogger(typeof(Tranga)); @@ -45,7 +35,7 @@ public static class Tranga { XmlConfigurator.ConfigureAndWatch(loggerConfigFile); Log.Info("Logger Configured."); - Log.Info(TRANGA); + Log.Info(Constants.TRANGA); } internal static void AddDefaultWorkers() diff --git a/API/TrangaSettings.cs b/API/TrangaSettings.cs index 7cd24d6..f10ec75 100644 --- a/API/TrangaSettings.cs +++ b/API/TrangaSettings.cs @@ -11,8 +11,11 @@ public struct TrangaSettings() public static string workingDirectory => Path.Join(RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/usr/share" : Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "tranga-api"); [JsonIgnore] public static string settingsFilePath => Path.Join(workingDirectory, "settings.json"); - [JsonIgnore] - public static string coverImageCache => Path.Join(workingDirectory, "imageCache"); + [JsonIgnore] public static string coverImageCache => Path.Join(workingDirectory, "imageCache"); + [JsonIgnore] public static string coverImageCacheOriginal => Path.Join(coverImageCache, "original"); + [JsonIgnore] public static string coverImageCacheLarge => Path.Join(coverImageCache, "large"); + [JsonIgnore] public static string coverImageCacheMedium => Path.Join(coverImageCache, "medium"); + [JsonIgnore] public static string coverImageCacheSmall => Path.Join(coverImageCache, "small"); public string DownloadLocation => RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/Manga" : Path.Join(Directory.GetCurrentDirectory(), "Manga"); [JsonIgnore] internal static readonly string DefaultUserAgent = $"Tranga/2.0 ({Enum.GetName(Environment.OSVersion.Platform)}; {(Environment.Is64BitOperatingSystem ? "x64" : "")})"; diff --git a/API/Workers/MangaDownloadWorkers/DownloadChapterFromMangaconnectorWorker.cs b/API/Workers/MangaDownloadWorkers/DownloadChapterFromMangaconnectorWorker.cs index 94ecbf9..4fbe244 100644 --- a/API/Workers/MangaDownloadWorkers/DownloadChapterFromMangaconnectorWorker.cs +++ b/API/Workers/MangaDownloadWorkers/DownloadChapterFromMangaconnectorWorker.cs @@ -200,18 +200,19 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId c Log.Info($"Copying cover to {publicationFolder}"); await DbContext.Entry(mangaConnectorId).Navigation(nameof(MangaConnectorId.Obj)).LoadAsync(CancellationToken); - string? fileInCache = manga.CoverFileNameInCache ?? mangaConnector.SaveCoverImageToCache(mangaConnectorId); - if (fileInCache is null) + string? coverFileNameInCache = manga.CoverFileNameInCache ?? mangaConnector.SaveCoverImageToCache(mangaConnectorId); + if (coverFileNameInCache is null) { - Log.Error($"File {fileInCache} does not exist"); + Log.Error($"File {coverFileNameInCache} does not exist"); return; } - string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" ); - File.Copy(fileInCache, newFilePath, true); + string fullCoverPath = Path.Join(TrangaSettings.coverImageCacheOriginal, coverFileNameInCache); + string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(coverFileNameInCache).Split('.')[^1]}" ); + File.Copy(fullCoverPath, newFilePath, true); if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | UserRead | UserWrite | OtherRead | OtherWrite); - Log.Debug($"Copied cover from {fileInCache} to {newFilePath}"); + Log.Debug($"Copied cover from {fullCoverPath} to {newFilePath}"); } private bool DownloadImage(string imageUrl, string savePath) diff --git a/API/Workers/PeriodicWorkers/MaintenanceWorkers/CleanupMangaCoversWorker.cs b/API/Workers/PeriodicWorkers/MaintenanceWorkers/CleanupMangaCoversWorker.cs index c30a96a..d462d23 100644 --- a/API/Workers/PeriodicWorkers/MaintenanceWorkers/CleanupMangaCoversWorker.cs +++ b/API/Workers/PeriodicWorkers/MaintenanceWorkers/CleanupMangaCoversWorker.cs @@ -11,11 +11,22 @@ public class CleanupMangaCoversWorker(TimeSpan? interval = null, IEnumerable DoWorkInternal() { Log.Info("Removing stale files..."); - if (!Directory.Exists(TrangaSettings.coverImageCache)) - return new Task(() => []); string[] usedFiles = DbContext.Mangas.Select(m => m.CoverFileNameInCache).Where(s => s != null).ToArray()!; - string[] extraneousFiles = new DirectoryInfo(TrangaSettings.coverImageCache).GetFiles() - .Where(f => usedFiles.Contains(f.FullName) == false) + CleanupImageCache(usedFiles, TrangaSettings.coverImageCacheOriginal); + CleanupImageCache(usedFiles, TrangaSettings.coverImageCacheLarge); + CleanupImageCache(usedFiles, TrangaSettings.coverImageCacheMedium); + CleanupImageCache(usedFiles, TrangaSettings.coverImageCacheSmall); + return new Task(() => []); + } + + private void CleanupImageCache(string[] retainFilenames, string imageCachePath) + { + DirectoryInfo directory = new(imageCachePath); + if (!directory.Exists) + return; + string[] extraneousFiles = directory + .GetFiles() + .Where(f => retainFilenames.Contains(f.Name) == false) .Select(f => f.FullName) .ToArray(); foreach (string path in extraneousFiles) @@ -23,6 +34,5 @@ public class CleanupMangaCoversWorker(TimeSpan? interval = null, IEnumerable(() => []); } } \ No newline at end of file