mirror of
https://github.com/C9Glax/tranga.git
synced 2025-09-10 03:48:19 +02:00
Resize Covers on download
This commit is contained in:
18
API/Constants.cs
Normal file
18
API/Constants.cs
Normal 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);
|
||||
}
|
@@ -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();
|
||||
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)));
|
||||
}
|
||||
|
||||
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"/>
|
||||
|
@@ -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());
|
||||
try
|
||||
{
|
||||
using MemoryStream ms = new();
|
||||
coverResult.result.CopyTo(ms);
|
||||
byte[] imageBytes = ms.ToArray();
|
||||
Directory.CreateDirectory(TrangaSettings.coverImageCacheOriginal);
|
||||
File.WriteAllBytes(saveImagePath, imageBytes);
|
||||
|
||||
return saveImagePath;
|
||||
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;
|
||||
}
|
||||
}
|
@@ -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}";
|
||||
}
|
||||
|
||||
|
@@ -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()
|
||||
|
@@ -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" : "")})";
|
||||
|
@@ -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)
|
||||
|
@@ -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[]>(() => []);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user