using API.Controllers.DTOs; using API.MangaConnectors; using API.Schema.MangaContext; using API.Workers; using Asp.Versioning; using Microsoft.AspNetCore.Http.HttpResults; 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; using Chapter = API.Controllers.DTOs.Chapter; using Link = API.Controllers.DTOs.Link; using Manga = API.Controllers.DTOs.Manga; // 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")] [ProducesResponseType(Status500InternalServerError)] public async Task>, InternalServerError>> GetAllManga () { if (await context.Mangas.Include(m => m.MangaConnectorIds).ToArrayAsync(HttpContext.RequestAborted) is not { } result) return TypedResults.InternalServerError(); return TypedResults.Ok(result.Select(m => { IEnumerable ids = m.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); return new MinimalManga(m.Key, m.Name, m.Description, m.ReleaseStatus, ids); }).ToList()); } /// /// Returns all cached .Keys /// /// Keys/IDs /// Error during Database Operation [HttpGet("Keys")] [ProducesResponseType(Status200OK, "application/json")] [ProducesResponseType(Status500InternalServerError)] public async Task, InternalServerError>> GetAllMangaKeys () { if (await context.Mangas.Select(m => m.Key).ToArrayAsync(HttpContext.RequestAborted) is not { } result) return TypedResults.InternalServerError(); return TypedResults.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")] [ProducesResponseType(Status500InternalServerError)] public async Task>, InternalServerError>> GetMangaDownloading() { if (await context.Mangas .Include(m => m.MangaConnectorIds) .Where(m => m.MangaConnectorIds.Any(id => id.UseForDownload)) .ToArrayAsync(HttpContext.RequestAborted) is not { } result) return TypedResults.InternalServerError(); return TypedResults.Ok(result.Select(m => { IEnumerable ids = m.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); return new MinimalManga(m.Key, m.Name, m.Description, m.ReleaseStatus, ids); }).ToList()); } /// /// Returns all cached with /// /// Array of .Key /// /// Error during Database Operation [HttpPost("WithIDs")] [ProducesResponseType>(Status200OK, "application/json")] [ProducesResponseType(Status500InternalServerError)] public async Task>, InternalServerError>> GetMangaWithIds ([FromBody]string[] MangaIds) { if (await context.MangaIncludeAll() .Where(m => MangaIds.Contains(m.Key)) .ToArrayAsync(HttpContext.RequestAborted) is not { } result) return TypedResults.InternalServerError(); return TypedResults.Ok(result.Select(m => { IEnumerable ids = m.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); IEnumerable authors = m.Authors.Select(a => new Author(a.Key, a.AuthorName)); IEnumerable tags = m.MangaTags.Select(t => t.Tag); IEnumerable links = m.Links.Select(l => new Link(l.Key, l.LinkProvider, l.LinkUrl)); IEnumerable altTitles = m.AltTitles.Select(a => new AltTitle(a.Language, a.Title)); return new Manga(m.Key, m.Name, m.Description, m.ReleaseStatus, ids, m.IgnoreChaptersBefore, m.Year, m.OriginalLanguage, m.ChapterIds, authors, tags, links, altTitles); }).ToList()); } /// /// Return with /// /// .Key /// /// with not found [HttpGet("{MangaId}")] [ProducesResponseType(Status200OK, "application/json")] [ProducesResponseType(Status404NotFound, "text/plain")] public async Task, NotFound>> GetManga (string MangaId) { if (await context.MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return TypedResults.NotFound(nameof(MangaId)); IEnumerable ids = manga.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); IEnumerable authors = manga.Authors.Select(a => new Author(a.Key, a.AuthorName)); IEnumerable tags = manga.MangaTags.Select(t => t.Tag); IEnumerable links = manga.Links.Select(l => new Link(l.Key, l.LinkProvider, l.LinkUrl)); IEnumerable altTitles = manga.AltTitles.Select(a => new AltTitle(a.Language, a.Title)); Manga result = new (manga.Key, manga.Name, manga.Description, manga.ReleaseStatus, ids, manga.IgnoreChaptersBefore, manga.Year, manga.OriginalLanguage, manga.ChapterIds, authors, tags, links, altTitles); return TypedResults.Ok(result); } /// /// Delete with /// /// .Key /// /// with not found /// Error during Database Operation [HttpDelete("{MangaId}")] [ProducesResponseType(Status200OK)] [ProducesResponseType(Status404NotFound, "text/plain")] [ProducesResponseType(Status500InternalServerError, "text/plain")] public async Task, InternalServerError>> DeleteManga (string MangaId) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return TypedResults.NotFound(nameof(MangaId)); context.Mangas.Remove(manga); if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) return TypedResults.InternalServerError(result.exceptionMessage); return TypedResults.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)] [ProducesResponseType(Status404NotFound, "text/plain")] public async Task>> MergeIntoManga (string MangaIdFrom, string MangaIdInto) { if (await context.MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == MangaIdFrom, HttpContext.RequestAborted) is not { } from) return TypedResults.NotFound(nameof(MangaIdFrom)); if (await context.MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == MangaIdInto, HttpContext.RequestAborted) is not { } into) return TypedResults.NotFound(nameof(MangaIdInto)); BaseWorker[] newJobs = into.MergeFrom(from, context); Tranga.AddWorkers(newJobs); return TypedResults.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, "text/plain")] [ProducesResponseType(Status503ServiceUnavailable)] public async Task, StatusCodeHttpResult>> GetCover (string MangaId, [FromQuery]int? width, [FromQuery]int? height) { 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)) { 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))); } 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 TypedResults.File(ms.GetBuffer(), "image/jpeg", lastModified: new DateTimeOffset(lastModified), entityTag: EntityTagHeaderValue.Parse($"\"{lastModified.Ticks}\"")); } /// /// Returns all of with /// /// .Key /// /// with not found [HttpGet("{MangaId}/Chapters")] [ProducesResponseType>(Status200OK, "application/json")] [ProducesResponseType(Status404NotFound)] public async Task>, NotFound>> GetChapters(string MangaId) { if (await context.Mangas.Include(m => m.Chapters).ThenInclude(c => c.MangaConnectorIds).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return TypedResults.NotFound(nameof(MangaId)); List chapters = manga.Chapters.Select(c => { IEnumerable ids = c.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); return new Chapter(c.Key, c.ParentMangaId, c.VolumeNumber, c.ChapterNumber, c.Title, ids, c.Downloaded); }).ToList(); return TypedResults.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, "text/plain")] public async Task>, NoContent, NotFound>> GetChaptersDownloaded(string MangaId) { if (await context.Mangas.Include(m => m.Chapters).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return TypedResults.NotFound(nameof(MangaId)); List chapters = manga.Chapters.Where(c => c.Downloaded).Select(c => { IEnumerable ids = c.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); return new Chapter(c.Key, c.ParentMangaId, c.VolumeNumber, c.ChapterNumber, c.Title, ids, c.Downloaded); }).ToList(); if (chapters.Count == 0) return TypedResults.NoContent(); return TypedResults.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, "text/plain")] public async Task>, NoContent, NotFound>> GetChaptersNotDownloaded(string MangaId) { if (await context.Mangas.Include(m => m.Chapters).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return TypedResults.NotFound(nameof(MangaId)); List chapters = manga.Chapters.Where(c => c.Downloaded == false).Select(c => { IEnumerable ids = c.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); return new Chapter(c.Key, c.ParentMangaId, c.VolumeNumber, c.ChapterNumber, c.Title, ids, c.Downloaded); }).ToList(); if (chapters.Count == 0) return TypedResults.NoContent(); return TypedResults.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)] [ProducesResponseType(Status503ServiceUnavailable)] public async Task, NoContent, InternalServerError, NotFound, StatusCodeHttpResult>> GetLatestChapter(string MangaId) { if (await context.Mangas.Include(m => m.Chapters).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return TypedResults.NotFound(nameof(MangaId)); 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 TypedResults.StatusCode(Status503ServiceUnavailable); } else return TypedResults.NoContent(); } API.Schema.MangaContext.Chapter? max = chapters.Max(); if (max is null) return TypedResults.InternalServerError(); foreach (CollectionEntry collectionEntry in context.Entry(max).Collections) await collectionEntry.LoadAsync(HttpContext.RequestAborted); IEnumerable ids = max.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); return TypedResults.Ok(new Chapter(max.Key, max.ParentMangaId, max.VolumeNumber, max.ChapterNumber, max.Title,ids, max.Downloaded)); } /// /// 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, "text/plain")] [ProducesResponseType(Status412PreconditionFailed)] [ProducesResponseType(Status503ServiceUnavailable)] public async Task, NoContent, NotFound, StatusCodeHttpResult>> GetLatestChapterDownloaded(string MangaId) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return TypedResults.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 TypedResults.StatusCode(Status503ServiceUnavailable); }else return TypedResults.NoContent(); } API.Schema.MangaContext.Chapter? max = chapters.Max(); if (max is null) return TypedResults.StatusCode(Status412PreconditionFailed); IEnumerable ids = max.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); return TypedResults.Ok(new Chapter(max.Key, max.ParentMangaId, max.VolumeNumber, max.ChapterNumber, max.Title,ids, max.Downloaded)); } /// /// Configure the cut-off for /// /// .Key /// Threshold ( ChapterNumber) /// /// with not found. /// Error during Database Operation [HttpPatch("{MangaId}/IgnoreChaptersBefore")] [ProducesResponseType(Status200OK)] [ProducesResponseType(Status404NotFound, "text/plain")] [ProducesResponseType(Status500InternalServerError, "text/plain")] public async Task, InternalServerError>> IgnoreChaptersBefore(string MangaId, [FromBody]float chapterThreshold) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return TypedResults.NotFound(nameof(MangaId)); manga.IgnoreChaptersBefore = chapterThreshold; if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) return TypedResults.InternalServerError(result.exceptionMessage); return TypedResults.Ok(); } /// /// Move to different /// /// .Key /// .Key /// Folder is going to be moved /// or not found [HttpPost("{MangaId}/ChangeLibrary/{LibraryId}")] [ProducesResponseType(Status200OK)] [ProducesResponseType(Status404NotFound, "text/plain")] public async Task>> ChangeLibrary(string MangaId, string LibraryId) { if (await context.MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return TypedResults.NotFound(nameof(MangaId)); if (await context.FileLibraries.FirstOrDefaultAsync(l => l.Key == LibraryId, HttpContext.RequestAborted) is not { } library) return TypedResults.NotFound(nameof(LibraryId)); MoveMangaLibraryWorker moveLibrary = new(manga, library); Tranga.AddWorkers([moveLibrary]); return TypedResults.Ok(); } /// /// (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, StatusCodeHttpResult, InternalServerError>> MarkAsRequested(string MangaId, string MangaConnectorName, bool IsRequested) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } _) return TypedResults.NotFound(nameof(MangaId)); if(!Tranga.TryGetMangaConnector(MangaConnectorName, out MangaConnector? _)) return TypedResults.NotFound(nameof(MangaConnectorName)); if (context.MangaConnectorToManga .FirstOrDefault(id => id.MangaConnectorName == MangaConnectorName && id.ObjId == MangaId) is not { } mcId) { if(IsRequested) return TypedResults.StatusCode(Status428PreconditionRequired); else return TypedResults.StatusCode(Status412PreconditionFailed); } mcId.UseForDownload = IsRequested; if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) return TypedResults.InternalServerError(result.exceptionMessage); DownloadCoverFromMangaconnectorWorker downloadCover = new(mcId); RetrieveMangaChaptersFromMangaconnectorWorker retrieveChapters = new(mcId, Tranga.Settings.DownloadLanguage); Tranga.AddWorkers([downloadCover, retrieveChapters]); return TypedResults.Ok(); } /// /// Initiate a search for on a different /// /// with /// .Name /// exert of /// with Name not found /// with Name is disabled [HttpPost("{MangaId}/SearchOn/{MangaConnectorName}")] [ProducesResponseType>(Status200OK, "application/json")] [ProducesResponseType(Status404NotFound, "text/plain")] [ProducesResponseType(Status406NotAcceptable)] public async Task>, NotFound, StatusCodeHttpResult>> SearchOnDifferentConnector (string MangaId, string MangaConnectorName) { if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) return TypedResults.NotFound(nameof(MangaId)); return new SearchController(context).SearchManga(MangaConnectorName, manga.Name); } /// /// Returns all which where Authored by with /// /// .Key /// /// with /// /// Error during Database Operation [HttpGet("WithAuthorId/{AuthorId}")] [ProducesResponseType>(Status200OK, "application/json")] [ProducesResponseType(Status404NotFound, "text/plain")] public async Task>, NotFound, InternalServerError>> GetMangaWithAuthorIds (string AuthorId) { if (await context.Authors.FirstOrDefaultAsync(a => a.Key == AuthorId, HttpContext.RequestAborted) is not { } _) return TypedResults.NotFound(nameof(AuthorId)); if (await context.MangaIncludeAll().Where(m => m.Authors.Any(a => a.Key == AuthorId)).ToListAsync(HttpContext.RequestAborted) is not { } result) return TypedResults.InternalServerError(); return TypedResults.Ok(result.Select(m => { IEnumerable ids = m.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); IEnumerable authors = m.Authors.Select(a => new Author(a.Key, a.AuthorName)); IEnumerable tags = m.MangaTags.Select(t => t.Tag); IEnumerable links = m.Links.Select(l => new Link(l.Key, l.LinkProvider, l.LinkUrl)); IEnumerable altTitles = m.AltTitles.Select(a => new AltTitle(a.Language, a.Title)); return new Manga(m.Key, m.Name, m.Description, m.ReleaseStatus, ids, m.IgnoreChaptersBefore, m.Year, m.OriginalLanguage, m.ChapterIds, authors, tags, links, altTitles); }).ToList()); } /// /// Returns all with /// /// .Tag /// /// not found /// Error during Database Operation [HttpGet("WithTag/{Tag}")] [ProducesResponseType(Status200OK, "application/json")] [ProducesResponseType(Status404NotFound, "text/plain")] [ProducesResponseType(Status500InternalServerError)] public async Task>, NotFound, InternalServerError>> GetMangasWithTag (string Tag) { if (await context.Tags.FirstOrDefaultAsync(t => t.Tag == Tag, HttpContext.RequestAborted) is not { } tag) return TypedResults.NotFound(nameof(Tag)); if (await context.MangaIncludeAll().Where(m => m.MangaTags.Any(t => t.Tag.Equals(tag))) .ToListAsync(HttpContext.RequestAborted) is not { } result) return TypedResults.InternalServerError(); return TypedResults.Ok(result.Select(m => { IEnumerable ids = m.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); IEnumerable authors = m.Authors.Select(a => new Author(a.Key, a.AuthorName)); IEnumerable tags = m.MangaTags.Select(t => t.Tag); IEnumerable links = m.Links.Select(l => new Link(l.Key, l.LinkProvider, l.LinkUrl)); IEnumerable altTitles = m.AltTitles.Select(a => new AltTitle(a.Language, a.Title)); return new Manga(m.Key, m.Name, m.Description, m.ReleaseStatus, ids, m.IgnoreChaptersBefore, m.Year, m.OriginalLanguage, m.ChapterIds, authors, tags, links, altTitles); }).ToList()); } }