Resize Covers on download

This commit is contained in:
2025-09-05 20:28:50 +02:00
parent d1b2f0ab19
commit 78e7e8fc06
8 changed files with 122 additions and 64 deletions

18
API/Constants.cs Normal file
View File

@@ -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);
}

View File

@@ -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 <see cref="Manga"/> with <paramref name="MangaId"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <param name="width">If <paramref name="width"/> is provided, <paramref name="height"/> needs to also be provided</param>
/// <param name="height">If <paramref name="height"/> is provided, <paramref name="width"/> needs to also be provided</param>
/// <param name="CoverSize">Size of the cover returned
/// <br /> - <see cref="CoverSize.Small"/> <see cref="Constants.ImageSmSize"/>
/// <br /> - <see cref="CoverSize.Medium"/> <see cref="Constants.ImageMdSize"/>
/// <br /> - <see cref="CoverSize.Large"/> <see cref="Constants.ImageLgSize"/>
/// </param>
/// <response code="200">JPEG Image</response>
/// <response code="204">Cover not loaded</response>
/// <response code="400">The formatting-request was invalid</response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
/// <response code="503">Retry later, downloading cover</response>
[HttpGet("{MangaId}/Cover")]
[ProducesResponseType<byte[]>(Status200OK,"image/jpeg")]
[HttpGet("{MangaId}/Cover/{CoverSize?}")]
[ProducesResponseType<FileContentResult>(Status200OK,"image/jpeg")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status503ServiceUnavailable)]
public async Task<Results<FileContentHttpResult, NoContent, BadRequest, NotFound<string>, StatusCodeHttpResult>> GetCover (string MangaId, [FromQuery]int? width, [FromQuery]int? height)
public async Task<Results<FileContentHttpResult, NoContent, BadRequest, NotFound<string>, 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 }
/// <summary>
/// Returns all <see cref="Chapter"/> of <see cref="Manga"/> with <paramref name="MangaId"/>

View File

@@ -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;
}
}

View File

@@ -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}";
}

View File

@@ -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()

View File

@@ -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" : "")})";

View File

@@ -200,18 +200,19 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
Log.Info($"Copying cover to {publicationFolder}");
await DbContext.Entry(mangaConnectorId).Navigation(nameof(MangaConnectorId<Manga>.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)

View File

@@ -11,11 +11,22 @@ public class CleanupMangaCoversWorker(TimeSpan? interval = null, IEnumerable<Bas
protected override Task<BaseWorker[]> DoWorkInternal()
{
Log.Info("Removing stale files...");
if (!Directory.Exists(TrangaSettings.coverImageCache))
return new Task<BaseWorker[]>(() => []);
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<BaseWorker[]>(() => []);
}
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<Bas
Log.Info($"Deleting {path}");
File.Delete(path);
}
return new Task<BaseWorker[]>(() => []);
}
}