using API.Controllers.DTOs; using API.MangaConnectors; using API.Schema.MangaContext; using API.Workers; using Asp.Versioning; 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; // ReSharper disable InconsistentNaming namespace API.Controllers; [ApiVersion(2)] [ApiController] [Route("v{v:apiVersion}/[controller]")] public class MangaController(MangaContext context) : Controller { /// /// Returns all cached /// /// exert of . Use for more information /// Error during Database Operation [HttpGet] [ProducesResponseType(Status200OK, "application/json")] public async Task GetAllManga () { if(await context.Mangas.ToArrayAsync(HttpContext.RequestAborted) is not { } result) return StatusCode(Status500InternalServerError); return Ok(result.Select(m => new MinimalManga(m.Key, m.Name, m.Description, m.ReleaseStatus))); } /// /// Returns all cached .Keys /// /// Keys/IDs /// Error during Database Operation [HttpGet("Keys")] [ProducesResponseType(Status200OK, "application/json")] public async Task GetAllMangaKeys () { if(await context.Mangas.Select(m => m.Key).ToArrayAsync(HttpContext.RequestAborted) is not { } result) return StatusCode(Status500InternalServerError); return Ok(result); } /// /// Returns all that are being downloaded from at least one /// /// exert of . Use for more information /// Error during Database Operation [HttpGet("Downloading")] [ProducesResponseType(Status200OK, "application/json")] public async Task GetMangaDownloading () { if(await context.Mangas .Include(m => m.MangaConnectorIds) .Where(m => m.MangaConnectorIds.Any(id => id.UseForDownload)) .ToArrayAsync(HttpContext.RequestAborted) is not { } result) return StatusCode(Status500InternalServerError); return Ok(result.Select(m => new MinimalManga(m.Key, m.Name, m.Description, m.ReleaseStatus, m.MangaConnectorIds))); } /// /// Returns all cached with /// /// Array of .Key /// /// Error during Database Operation [HttpPost("WithIDs")] [ProducesResponseType(Status200OK, "application/json")] public async Task GetMangaWithIds ([FromBody]string[] MangaIds) { if(await context.MangaIncludeAll() .Where(m => MangaIds.Contains(m.Key)) .ToArrayAsync(HttpContext.RequestAborted) is not { } result) return StatusCode(Status500InternalServerError); return Ok(result); } /// /// Return with /// /// .Key /// /// with not found [HttpGet("{MangaId}")] [ProducesResponseType(Status200OK, "application/json")] [ProducesResponseType(Status404NotFound)] public async Task GetManga (string MangaId) { if (await context.MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return NotFound(nameof(MangaId)); return Ok(manga); } /// /// Delete with /// /// .Key /// /// with not found /// Error during Database Operation [HttpDelete("{MangaId}")] [ProducesResponseType(Status200OK)] [ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status500InternalServerError, "text/plain")] public async Task DeleteManga (string MangaId) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return NotFound(nameof(MangaId)); context.Mangas.Remove(manga); if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) return StatusCode(Status500InternalServerError, result.exceptionMessage); return Ok(); } /// /// Merge two into one. THIS IS NOT REVERSIBLE! /// /// .Key of merging data from (getting deleted) /// .Key of merging data into /// /// with or not found [HttpPatch("{MangaIdFrom}/MergeInto/{MangaIdInto}")] [ProducesResponseType(Status200OK,"image/jpeg")] [ProducesResponseType(Status404NotFound)] public async Task MergeIntoManga (string MangaIdFrom, string MangaIdInto) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaIdFrom, HttpContext.RequestAborted) is not { } from) return NotFound(nameof(MangaIdFrom)); if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaIdInto, HttpContext.RequestAborted) is not { } into) return NotFound(nameof(MangaIdInto)); foreach (CollectionEntry collectionEntry in context.Entry(from).Collections) await collectionEntry.LoadAsync(HttpContext.RequestAborted); await context.Entry(from).Navigation(nameof(Manga.Library)).LoadAsync(HttpContext.RequestAborted); foreach (CollectionEntry collectionEntry in context.Entry(into).Collections) await collectionEntry.LoadAsync(HttpContext.RequestAborted); await context.Entry(into).Navigation(nameof(Manga.Library)).LoadAsync(HttpContext.RequestAborted); BaseWorker[] newJobs = into.MergeFrom(from, context); Tranga.AddWorkers(newJobs); return Ok(); } /// /// Returns Cover of with /// /// .Key /// If is provided, needs to also be provided /// If is provided, needs to also be provided /// JPEG Image /// Cover not loaded /// The formatting-request was invalid /// with not found /// Retry later, downloading cover [HttpGet("{MangaId}/Cover")] [ProducesResponseType(Status200OK,"image/jpeg")] [ProducesResponseType(Status204NoContent)] [ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status503ServiceUnavailable, "text/plain")] public async Task GetCover (string MangaId, [FromQuery]int? width, [FromQuery]int? height) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return NotFound(nameof(MangaId)); if (!System.IO.File.Exists(manga.CoverFileNameInCache)) { 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 StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000); } else return 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 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; HttpContext.Response.Headers.CacheControl = "public"; return File(ms.GetBuffer(), "image/jpeg", new DateTimeOffset(lastModified), EntityTagHeaderValue.Parse($"\"{lastModified.Ticks}\"")); } /// /// Returns all of with /// /// .Key /// /// with not found [HttpGet("{MangaId}/Chapters")] [ProducesResponseType(Status200OK, "application/json")] [ProducesResponseType(Status404NotFound)] public async Task GetChapters (string MangaId) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return NotFound(nameof(MangaId)); await context.Entry(manga).Collection(m => m.Chapters).LoadAsync(); Chapter[] chapters = manga.Chapters.ToArray(); return Ok(chapters); } /// /// Returns all downloaded for with /// /// .Key /// /// No available chapters /// with not found. [HttpGet("{MangaId}/Chapters/Downloaded")] [ProducesResponseType(Status200OK, "application/json")] [ProducesResponseType(Status204NoContent)] [ProducesResponseType(Status404NotFound)] public async Task GetChaptersDownloaded (string MangaId) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return NotFound(nameof(MangaId)); await context.Entry(manga).Collection(m => m.Chapters).LoadAsync(); List chapters = manga.Chapters.Where(c => c.Downloaded).ToList(); if (chapters.Count == 0) return NoContent(); return Ok(chapters); } /// /// Returns all not downloaded for with /// /// .Key /// /// No available chapters /// with not found. [HttpGet("{MangaId}/Chapters/NotDownloaded")] [ProducesResponseType(Status200OK, "application/json")] [ProducesResponseType(Status204NoContent)] [ProducesResponseType(Status404NotFound)] public async Task GetChaptersNotDownloaded (string MangaId) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return NotFound(nameof(MangaId)); await context.Entry(manga).Collection(m => m.Chapters).LoadAsync(HttpContext.RequestAborted); List chapters = manga.Chapters.Where(c => c.Downloaded == false).ToList(); if (chapters.Count == 0) return NoContent(); return Ok(chapters); } /// /// Returns the latest of requested available on /// /// .Key /// /// No available chapters /// with not found. /// Could not retrieve the maximum chapter-number /// Retry after timeout, updating value [HttpGet("{MangaId}/Chapter/LatestAvailable")] [ProducesResponseType(Status200OK, "application/json")] [ProducesResponseType(Status204NoContent)] [ProducesResponseType(Status404NotFound, "text/plain")] [ProducesResponseType(Status500InternalServerError, "text/plain")] [ProducesResponseType(Status503ServiceUnavailable, "text/plain")] public async Task GetLatestChapter (string MangaId) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return NotFound(nameof(MangaId)); await context.Entry(manga).Collection(m => m.Chapters).LoadAsync(HttpContext.RequestAborted); List chapters = manga.Chapters.ToList(); if (chapters.Count == 0) { if (Tranga.GetRunningWorkers().Any(worker => worker is RetrieveMangaChaptersFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId && w.State < WorkerExecutionState.Completed)) { Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}"); return StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2/ 1000); }else return Ok(0); } Chapter? max = chapters.Max(); if (max is null) return StatusCode(Status500InternalServerError, "Max chapter could not be found"); foreach (CollectionEntry collectionEntry in context.Entry(max).Collections) await collectionEntry.LoadAsync(HttpContext.RequestAborted); await context.Entry(max).Navigation(nameof(Chapter.ParentManga)).LoadAsync(HttpContext.RequestAborted); return Ok(max); } /// /// Returns the latest of requested that is downloaded /// /// .Key /// /// No available chapters /// with not found. /// Could not retrieve the maximum chapter-number /// Retry after timeout, updating value [HttpGet("{MangaId}/Chapter/LatestDownloaded")] [ProducesResponseType(Status200OK, "application/json")] [ProducesResponseType(Status204NoContent)] [ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status412PreconditionFailed, "text/plain")] [ProducesResponseType(Status503ServiceUnavailable, "text/plain")] public async Task GetLatestChapterDownloaded (string MangaId) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return NotFound(nameof(MangaId)); await context.Entry(manga).Collection(m => m.Chapters).LoadAsync(HttpContext.RequestAborted); List chapters = manga.Chapters.ToList(); if (chapters.Count == 0) { if (Tranga.GetRunningWorkers().Any(worker => worker is RetrieveMangaChaptersFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId && w.State < WorkerExecutionState.Completed)) { Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}"); return StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2/ 1000); }else return NoContent(); } Chapter? max = chapters.Max(); if (max is null) return StatusCode(Status412PreconditionFailed, "Max chapter could not be found"); foreach (CollectionEntry collectionEntry in context.Entry(max).Collections) await collectionEntry.LoadAsync(HttpContext.RequestAborted); await context.Entry(max).Navigation(nameof(Chapter.ParentManga)).LoadAsync(HttpContext.RequestAborted); return Ok(max); } /// /// Configure the cut-off for /// /// .Key /// Threshold ( ChapterNumber) /// /// with not found. /// Error during Database Operation [HttpPatch("{MangaId}/IgnoreChaptersBefore")] [ProducesResponseType(Status202Accepted)] [ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status500InternalServerError, "text/plain")] public async Task IgnoreChaptersBefore (string MangaId, [FromBody]float chapterThreshold) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return NotFound(nameof(MangaId)); manga.IgnoreChaptersBefore = chapterThreshold; if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) return StatusCode(Status500InternalServerError, result.exceptionMessage); return Accepted(); } /// /// Move to different /// /// .Key /// .Key /// Folder is going to be moved /// or not found [HttpPost("{MangaId}/ChangeLibrary/{LibraryId}")] [ProducesResponseType(Status202Accepted)] [ProducesResponseType(Status404NotFound)] public async Task ChangeLibrary (string MangaId, string LibraryId) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return NotFound(nameof(MangaId)); if (await context.FileLibraries.FirstOrDefaultAsync(l => l.Key == LibraryId, HttpContext.RequestAborted) is not { } library) return NotFound(nameof(LibraryId)); foreach (CollectionEntry collectionEntry in context.Entry(manga).Collections) await collectionEntry.LoadAsync(HttpContext.RequestAborted); await context.Entry(manga).Navigation(nameof(Manga.Library)).LoadAsync(HttpContext.RequestAborted); MoveMangaLibraryWorker moveLibrary = new(manga, library); Tranga.AddWorkers([moveLibrary]); return Accepted(); } /// /// (Un-)Marks as requested for Download from /// /// with /// with /// true to mark as requested, false to mark as not-requested /// /// or not found /// was not linked to , so nothing changed /// is not linked to yet. Search for on first (to create a ). /// Error during Database Operation [HttpPost("{MangaId}/SetAsDownloadFrom/{MangaConnectorName}/{IsRequested}")] [ProducesResponseType(Status200OK)] [ProducesResponseType(Status404NotFound, "text/plain")] [ProducesResponseType(Status412PreconditionFailed, "text/plain")] [ProducesResponseType(Status428PreconditionRequired, "text/plain")] [ProducesResponseType(Status500InternalServerError, "text/plain")] public async Task MarkAsRequested (string MangaId, string MangaConnectorName, bool IsRequested) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } _) return NotFound(nameof(MangaId)); if(!Tranga.TryGetMangaConnector(MangaConnectorName, out MangaConnector? _)) return NotFound(nameof(MangaConnectorName)); if (context.MangaConnectorToManga .FirstOrDefault(id => id.MangaConnectorName == MangaConnectorName && id.ObjId == MangaId) is not { } mcId) { if(IsRequested) return StatusCode(Status428PreconditionRequired, "Don't know how to download this Manga from MangaConnector"); else return StatusCode(Status412PreconditionFailed, "Not linked anyways."); } mcId.UseForDownload = IsRequested; if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) return StatusCode(Status500InternalServerError, result.exceptionMessage); DownloadCoverFromMangaconnectorWorker downloadCover = new(mcId); RetrieveMangaChaptersFromMangaconnectorWorker retrieveChapters = new(mcId, Tranga.Settings.DownloadLanguage); Tranga.AddWorkers([downloadCover, retrieveChapters]); return Ok(); } /// /// Initiate a search for on a different /// /// with /// .Name /// /// with Name not found /// with Name is disabled [HttpPost("{MangaId}/SearchOn/{MangaConnectorName}")] [ProducesResponseType(Status200OK, "application/json")] [ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status406NotAcceptable)] public async Task SearchOnDifferentConnector (string MangaId, string MangaConnectorName) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return NotFound(nameof(MangaId)); return new SearchController(context).SearchManga(MangaConnectorName, manga.Name); } /// /// Returns all which where Authored by with /// /// .Key /// /// with [HttpGet("WithAuthorId/{AuthorId}")] [ProducesResponseType(Status200OK, "application/json")] public async Task GetMangaWithAuthorIds (string AuthorId) { if (await context.Authors.FirstOrDefaultAsync(a => a.Key == AuthorId, HttpContext.RequestAborted) is not { } author) return NotFound(nameof(AuthorId)); return Ok(context.Mangas.Where(m => m.Authors.Contains(author))); } /// /// Returns all with /// /// .Tag /// /// not found [HttpGet("WithTag/{Tag}")] [ProducesResponseType(Status200OK, "application/json")] public async Task GetMangasWithTag (string Tag) { if (await context.Tags.FirstOrDefaultAsync(t => t.Tag == Tag, HttpContext.RequestAborted) is not { } tag) return NotFound(nameof(Tag)); return Ok(context.Mangas.Where(m => m.MangaTags.Contains(tag))); } }