diff --git a/API/API.csproj b/API/API.csproj new file mode 100644 index 0000000..89a7bc5 --- /dev/null +++ b/API/API.csproj @@ -0,0 +1,41 @@ + + + + net9.0 + enable + enable + Linux + true + $(NoWarn);1591 + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + .dockerignore + + + + + + + + diff --git a/API/API.http b/API/API.http new file mode 100644 index 0000000..66c66ee --- /dev/null +++ b/API/API.http @@ -0,0 +1,6 @@ +@API_HostAddress = http://localhost:5105 + +GET {{API_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/API/APIJsonSerializer.cs b/API/APIJsonSerializer.cs new file mode 100644 index 0000000..aa98122 --- /dev/null +++ b/API/APIJsonSerializer.cs @@ -0,0 +1,61 @@ +using System.Reflection; +using System.Text.Json; +using API.Schema; +using API.Schema.Jobs; +using API.Schema.LibraryConnectors; +using API.Schema.NotificationConnectors; +using Newtonsoft.Json; +using JsonSerializer = Newtonsoft.Json.JsonSerializer; + +namespace API; + +internal class ApiJsonSerializer : System.Text.Json.Serialization.JsonConverter +{ + + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(Job); + + public override APISerializable? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + Span dest = stackalloc char[1024]; + string json = ""; + while (reader.Read()) + { + reader.CopyString(dest); + json += dest.ToString(); + } + JsonReader jr = new JsonTextReader(new StringReader(json)); + return new JobJsonDeserializer().ReadJson(jr, typeToConvert, null, JsonSerializer.Create(new JsonSerializerSettings())) as Job; + } + + public override void Write(Utf8JsonWriter writer, APISerializable value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach (PropertyInfo info in value.GetType().GetProperties()) + { + if(info.PropertyType == typeof(string)) + writer.WriteString(LowerCamelCase(info.Name), (string)info.GetValue(value)!); + else if(info.PropertyType == typeof(bool)) + writer.WriteBoolean(LowerCamelCase(info.Name), (bool)info.GetValue(value)!); + else if(info.PropertyType == typeof(int)) + writer.WriteNumber(LowerCamelCase(info.Name), (int)info.GetValue(value)!); + else if(info.PropertyType == typeof(ulong)) + writer.WriteNumber(LowerCamelCase(info.Name), (ulong)info.GetValue(value)!); + else if(info.PropertyType == typeof(JobType)) + writer.WriteString(LowerCamelCase(info.Name), Enum.GetName((JobType)info.GetValue(value)!)); + else if(info.PropertyType == typeof(JobState)) + writer.WriteString(LowerCamelCase(info.Name), Enum.GetName((JobState)info.GetValue(value)!)); + else if(info.PropertyType == typeof(NotificationConnectorType)) + writer.WriteString(LowerCamelCase(info.Name), Enum.GetName((NotificationConnectorType)info.GetValue(value)!)); + else if(info.PropertyType == typeof(LibraryType)) + writer.WriteString(LowerCamelCase(info.Name), Enum.GetName((LibraryType)info.GetValue(value)!)); + else if(info.PropertyType == typeof(DateTime)) + writer.WriteString(LowerCamelCase(info.Name), ((DateTime)info.GetValue(value)!).ToUniversalTime().ToString("u").Replace(' ','T')); + } + writer.WriteEndObject(); + } + + private static string LowerCamelCase(string s) + { + return char.ToLowerInvariant(s[0]) + s.Substring(1); + } +} \ No newline at end of file diff --git a/API/Controllers/ConnectorController.cs b/API/Controllers/ConnectorController.cs new file mode 100644 index 0000000..0da6115 --- /dev/null +++ b/API/Controllers/ConnectorController.cs @@ -0,0 +1,63 @@ +using API.Schema; +using API.Schema.Jobs; +using API.Schema.MangaConnectors; +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using Soenneker.Utils.String.NeedlemanWunsch; +using static Microsoft.AspNetCore.Http.StatusCodes; + +namespace API.Controllers; + +[ApiVersion(2)] +[ApiController] +[Produces("application/json")] +[Route("v{v:apiVersion}/[controller]")] +public class ConnectorController(PgsqlContext context) : Controller +{ + /// + /// Get all available Connectors (Scanlation-Sites) + /// + /// Array of MangaConnector + [HttpGet] + [ProducesResponseType(Status200OK)] + public IActionResult GetConnectors() + { + MangaConnector[] connectors = context.MangaConnectors.ToArray(); + return Ok(connectors); + } + + /// + /// Initiate a search for a Manga on all Connectors + /// + /// Name/Title of the Manga + /// Array of Manga + [HttpPost("SearchManga")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult SearchMangaGlobal(string name) + { + List allManga = new List(); + foreach (MangaConnector contextMangaConnector in context.MangaConnectors) + { + allManga.AddRange(contextMangaConnector.GetManga(name)); + } + return Ok(allManga.ToArray()); + } + + /// + /// Initiate a search for a Manga on a specific Connector + /// + /// Manga-Connector-ID + /// Name/Title of the Manga + /// Manga + [HttpPost("{id}/SearchManga")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + public IActionResult SearchManga(string id, [FromBody]string name) + { + MangaConnector? connector = context.MangaConnectors.Find(id); + if (connector is null) + return NotFound(new ProblemResponse("Connector not found.")); + Manga[] manga = connector.GetManga(name); + return Ok(manga); + } +} \ No newline at end of file diff --git a/API/Controllers/JobController.cs b/API/Controllers/JobController.cs new file mode 100644 index 0000000..b6c249f --- /dev/null +++ b/API/Controllers/JobController.cs @@ -0,0 +1,200 @@ +using API.Schema; +using API.Schema.Jobs; +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.StatusCodes; + +namespace API.Controllers; + +[ApiVersion(2)] +[ApiController] +[Produces("application/json")] +[Route("v{version:apiVersion}/[controller]")] +public class JobController(PgsqlContext context) : Controller +{ + /// + /// Returns Jobs with requested Job-IDs + /// + /// Array of Job-IDs + /// Array of Jobs + [HttpPost("WithIDs")] + [ProducesResponseType(Status200OK)] + public IActionResult GetJobs([FromBody] string[] ids) + { + Job[] ret = context.Jobs.Where(job => ids.Contains(job.JobId)).ToArray(); + return Ok(ret); + } + + /// + /// Get all due Jobs (NextExecution > CurrentTime) + /// + /// Array of Jobs + [HttpGet("Due")] + [ProducesResponseType(Status200OK)] + public IActionResult GetDueJobs() + { + DateTime now = DateTime.Now.ToUniversalTime(); + Job[] dueJobs = context.Jobs.Where(job => job.NextExecution < now && job.state < JobState.Running).ToArray(); + return Ok(dueJobs); + } + + /// + /// Get all Jobs in requested State + /// + /// Requested Job-State + /// Array of Jobs + [HttpGet("State/{state}")] + [ProducesResponseType(Status200OK)] + public IActionResult GetJobsInState(JobState state) + { + Job[] jobsInState = context.Jobs.Where(job => job.state == state).ToArray(); + return Ok(jobsInState); + } + + /// + /// Returns all Jobs of requested Type + /// + /// Requested Job-Type + /// Array of Jobs + [HttpPost("Type/{type}")] + [ProducesResponseType(Status200OK)] + public IActionResult GetJobsOfType(JobType type) + { + Job[] jobsOfType = context.Jobs.Where(job => job.JobType == type).ToArray(); + return Ok(jobsOfType); + } + + /// + /// Return Job with ID + /// + /// Job-ID + /// Job + [HttpGet("{id}")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + public IActionResult GetJob(string id) + { + Job? ret = context.Jobs.Find(id); + return (ret is not null) switch + { + true => Ok(ret), + false => NotFound() + }; + } + + /// + /// Updates the State of a Job + /// + /// Job-ID + /// New State + /// Nothing + [HttpPatch("{id}/Status")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult UpdateJobStatus(string id, [FromBody]JobState state) + { + try + { + Job? ret = context.Jobs.Find(id); + switch (ret is not null) + { + case true: + ret.state = state; + context.Update(ret); + context.SaveChanges(); + return Ok(); + case false: return NotFound(); + } + } + catch (Exception e) + { + return StatusCode(500, e.Message); + } + + } + + /// + /// Create a new Job + /// + /// Job + /// Nothing + [HttpPut] + [ProducesResponseType(Status201Created)] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult CreateJob([FromBody]Job job) + { + try + { + context.Jobs.Add(job); + context.SaveChanges(); + return Created(); + } + catch (Exception e) + { + return StatusCode(500, e.Message); + } + } + + /// + /// Delete Job with ID + /// + /// Job-ID + /// Nothing + [HttpDelete("{id}")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult DeleteJob(string id) + { + try + { + Job? ret = context.Jobs.Find(id); + switch (ret is not null) + { + case true: + context.Remove(ret); + context.SaveChanges(); + return Ok(); + case false: return NotFound(); + } + } + catch (Exception e) + { + return StatusCode(500, e.Message); + } + } + + /// + /// Starts the Job with the requested ID + /// + /// Job-ID + /// Nothing + [HttpPost("{id}/Start")] + [ProducesResponseType(Status202Accepted)] + [ProducesResponseType(Status404NotFound)] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult StartJob(string id) + { + Job? ret = context.Jobs.Find(id); + if (ret is null) + return NotFound(); + ret.NextExecution = DateTime.UnixEpoch; + try + { + context.Update(ret); + context.SaveChanges(); + return Accepted(); + } + catch (Exception e) + { + return StatusCode(500, e.Message); + } + } + + [HttpPost("{id}/Stop")] + public IActionResult StopJob(string id) + { + return NotFound(new ProblemResponse("Not implemented")); //TODO + } +} \ No newline at end of file diff --git a/API/Controllers/LibraryConnectorController.cs b/API/Controllers/LibraryConnectorController.cs new file mode 100644 index 0000000..69d034e --- /dev/null +++ b/API/Controllers/LibraryConnectorController.cs @@ -0,0 +1,95 @@ +using API.Schema; +using API.Schema.LibraryConnectors; +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.StatusCodes; + +namespace API.Controllers; + +[ApiVersion(2)] +[ApiController] +[Produces("application/json")] +[Route("v{v:apiVersion}/[controller]")] +public class LibraryConnectorController(PgsqlContext context) : Controller +{ + /// + /// Gets all configured Library-Connectors + /// + /// Array of configured Library-Connectors + [HttpGet] + [ProducesResponseType(Status200OK)] + public IActionResult GetAllConnectors() + { + LibraryConnector[] connectors = context.LibraryConnectors.ToArray(); + return Ok(connectors); + } + + /// + /// Returns Library-Connector with requested ID + /// + /// Library-Connector-ID + /// Library-Connector + [HttpGet("{id}")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + public IActionResult GetConnector(string id) + { + LibraryConnector? ret = context.LibraryConnectors.Find(id); + return (ret is not null) switch + { + true => Ok(ret), + false => NotFound() + }; + } + + /// + /// Creates a new Library-Connector + /// + /// Library-Connector + /// Nothing + [HttpPut] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult CreateConnector([FromBody]LibraryConnector libraryConnector) + { + try + { + context.LibraryConnectors.Add(libraryConnector); + context.SaveChanges(); + return Created(); + } + catch (Exception e) + { + return StatusCode(500, e.Message); + } + } + + /// + /// Deletes the Library-Connector with the requested ID + /// + /// Library-Connector-ID + /// Nothing + [HttpDelete("{id}")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult DeleteConnector(string id) + { + try + { + LibraryConnector? ret = context.LibraryConnectors.Find(id); + switch (ret is not null) + { + case true: + context.Remove(ret); + context.SaveChanges(); + return Ok(); + case false: return NotFound(); + } + } + catch (Exception e) + { + return StatusCode(500, e.Message); + } + } +} \ No newline at end of file diff --git a/API/Controllers/MangaController.cs b/API/Controllers/MangaController.cs new file mode 100644 index 0000000..530f3f1 --- /dev/null +++ b/API/Controllers/MangaController.cs @@ -0,0 +1,234 @@ +using API.Schema; +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.StatusCodes; + +namespace API.Controllers; + +[ApiVersion(2)] +[ApiController] +[Produces("application/json")] +[Route("v{v:apiVersion}/[controller]")] +public class MangaController(PgsqlContext context) : Controller +{ + /// + /// Returns all cached Manga with IDs + /// + /// Array of Manga-IDs + /// Array of Manga + [HttpPost("WithIDs")] + [ProducesResponseType(Status200OK)] + public IActionResult GetManga([FromBody]string[] ids) + { + Manga[] ret = context.Manga.Where(m => ids.Contains(m.MangaId)).ToArray(); + return Ok(ret); + } + + /// + /// Return Manga with ID + /// + /// Manga-ID + /// Manga + [HttpGet("{id}")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + public IActionResult GetManga(string id) + { + Manga? ret = context.Manga.Find(id); + return (ret is not null) switch + { + true => Ok(ret), + false => NotFound() + }; + } + + /// + /// Delete Manga with ID + /// + /// Manga-ID + /// Nothing + [HttpDelete("{id}")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult DeleteManga(string id) + { + try + { + Manga? ret = context.Manga.Find(id); + switch (ret is not null) + { + case true: + context.Remove(ret); + context.SaveChanges(); + return Ok(); + case false: return NotFound(); + } + } + catch (Exception e) + { + return StatusCode(500, e.Message); + } + } + + /// + /// Create new Manga + /// + /// Manga + /// Nothing + [HttpPut] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult CreateManga([FromBody] Manga manga) + { + try + { + context.Manga.Add(manga); + context.SaveChanges(); + return Ok(); + } + catch (Exception e) + { + return StatusCode(500, e.Message); + } + } + + /// + /// Update Manga MetaData + /// + /// Manga-ID + /// New Manga-Info + /// Nothing + [HttpPatch("{id}")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult UpdateMangaMetadata(string id, [FromBody]Manga manga) + { + try + { + Manga? ret = context.Manga.Find(id); + switch (ret is not null) + { + case true: + ret.UpdateWithInfo(manga); + context.Update(ret); + context.SaveChanges(); + return Ok(); + case false: return NotFound(); + } + } + catch (Exception e) + { + return StatusCode(500, e.Message); + } + } + + /// + /// Returns URL of Cover of Manga + /// + /// Manga-ID + /// URL of Cover + [HttpGet("{id}/Cover")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult GetCover(string id) + { + return StatusCode(500, "Not implemented"); //TODO + } + + /// + /// Returns all Chapters of Manga + /// + /// Manga-ID + /// Array of Chapters + [HttpGet("{id}/Chapters")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + public IActionResult GetChapters(string id) + { + Manga? m = context.Manga.Find(id); + if (m is null) + return NotFound("Manga could not be found"); + Chapter[] ret = context.Chapters.Where(c => c.ParentMangaId == m.MangaId).ToArray(); + return Ok(ret); + } + + /// + /// Adds/Creates new Chapter for Manga + /// + /// Manga-ID + /// Array of Chapters + /// Manga-ID and all Chapters have to be the same + /// Nothing + [HttpPut("{id}")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult CreateChapters(string id, [FromBody]Chapter[] chapters) + { + try + { + Manga? ret = context.Manga.Find(id); + if(ret is null) + return NotFound("Manga could not be found"); + if(chapters.All(c => c.ParentMangaId == ret.MangaId)) + return BadRequest("Chapters belong to different Manga."); + + context.Chapters.AddRange(chapters); + context.SaveChanges(); + return Ok(); + } + catch (Exception e) + { + return StatusCode(500, e.Message); + } + } + + /// + /// Returns the latest Chapter of requested Manga + /// + /// Manga-ID + /// Latest Chapter + [HttpGet("{id}/Chapter/Latest")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + public IActionResult GetLatestChapter(string id) + { + Manga? m = context.Manga.Find(id); + if (m is null) + return NotFound("Manga could not be found"); + Chapter? c = context.Chapters.Find(m.LatestChapterAvailableId); + if (c is null) + return NotFound("Chapter could not be found"); + return Ok(c); + } + + /// + /// Configure the cut-off for Manga + /// + /// This is important for the DownloadNewChapters-Job + /// Manga-ID + /// Nothing + [HttpPatch("{id}/IgnoreChaptersBefore")] + [ProducesResponseType(Status200OK)] + public IActionResult IgnoreChaptersBefore(string id) + { + Manga? m = context.Manga.Find(id); + if (m is null) + return NotFound("Manga could not be found"); + return Ok(m.IgnoreChapterBefore); + } + + /// + /// Move the Directory the .cbz-files are located in + /// + /// Manga-ID + /// New Directory-Path + /// Nothing + [HttpPost("{id}/MoveFolder")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult MoveFolder(string id, [FromBody]string folder) + { + return StatusCode(500, "Not implemented"); //TODO + } +} \ No newline at end of file diff --git a/API/Controllers/NotificationConnectorController.cs b/API/Controllers/NotificationConnectorController.cs new file mode 100644 index 0000000..20315d6 --- /dev/null +++ b/API/Controllers/NotificationConnectorController.cs @@ -0,0 +1,95 @@ +using API.Schema; +using API.Schema.NotificationConnectors; +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.StatusCodes; + +namespace API.Controllers; + +[ApiVersion(2)] +[ApiController] +[Produces("application/json")] +[Route("v{v:apiVersion}/[controller]")] +public class NotificationConnectorController(PgsqlContext context) : Controller +{ + /// + /// Gets all configured Notification-Connectors + /// + /// Array of configured Notification-Connectors + [HttpGet] + [ProducesResponseType(Status200OK)] + public IActionResult GetAllConnectors() + { + NotificationConnector[] ret = context.NotificationConnectors.ToArray(); + return Ok(ret); + } + + /// + /// Returns Notification-Connector with requested ID + /// + /// Notification-Connector-ID + /// Notification-Connector + [HttpGet("{id}")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + public IActionResult GetConnector(string id) + { + NotificationConnector? ret = context.NotificationConnectors.Find(id); + return (ret is not null) switch + { + true => Ok(ret), + false => NotFound() + }; + } + + /// + /// Creates a new Notification-Connector + /// + /// Notification-Connector + /// Nothing + [HttpPut] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult CreateConnector([FromBody]NotificationConnector notificationConnector) + { + try + { + context.NotificationConnectors.Add(notificationConnector); + context.SaveChanges(); + return Created(); + } + catch (Exception e) + { + return StatusCode(500, e.Message); + } + } + + /// + /// Deletes the Notification-Connector with the requested ID + /// + /// Notification-Connector-ID + /// Nothing + [HttpDelete("{id}")] + [ProducesResponseType(Status200OK)] + [ProducesResponseType(Status404NotFound)] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult DeleteConnector(string id) + { + try + { + NotificationConnector? ret = context.NotificationConnectors.Find(id); + switch (ret is not null) + { + case true: + context.Remove(ret); + context.SaveChanges(); + return Ok(); + case false: return NotFound(); + } + } + catch (Exception e) + { + return StatusCode(500, e.Message); + } + } +} \ No newline at end of file diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs new file mode 100644 index 0000000..c7f8d02 --- /dev/null +++ b/API/Controllers/SettingsController.cs @@ -0,0 +1,161 @@ +using API.Schema; +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.StatusCodes; + +namespace API.Controllers; + +[ApiVersion(2)] +[ApiController] +[Produces("application/json")] +[Route("v{v:apiVersion}/[controller]")] +public class SettingsController(PgsqlContext context) : Controller +{ + /// + /// Get all Settings + /// + /// + [HttpGet] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult GetSettings() + { + return StatusCode(500, "Not implemented"); //TODO + } + + /// + /// Get the current UserAgent used by Tranga + /// + /// UserAgent as string + [HttpGet("UserAgent")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult GetUserAgent() + { + return StatusCode(500, "Not implemented"); //TODO + } + + /// + /// Set a new UserAgent + /// + /// Nothing + [HttpPatch("UserAgent")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult SetUserAgent() + { + return StatusCode(500, "Not implemented"); //TODO + } + + /// + /// Reset the UserAgent to default + /// + /// Nothing + [HttpDelete("UserAgent")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult ResetUserAgent() + { + return StatusCode(500, "Not implemented"); //TODO + } + + /// + /// Get all Request-Limits + /// + /// + [HttpGet("RequestLimits")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult GetRequestLimits() + { + return StatusCode(500, "Not implemented"); //TODO + } + + /// + /// Update all Request-Limits to new values + /// + /// Nothing + [HttpPatch("RequestLimits")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult SetRequestLimits() + { + return StatusCode(500, "Not implemented"); //TODO + } + + /// + /// Reset all Request-Limits + /// + /// Nothing + [HttpDelete("RequestLimits")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult ResetRequestLimits() + { + return StatusCode(500, "Not implemented"); //TODO + } + + /// + /// Returns Level of Image-Compression for Images + /// + /// + [HttpGet("ImageCompression")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult GetImageCompression() + { + return StatusCode(500, "Not implemented"); //TODO + } + + /// + /// Set the Image-Compression-Level for Images + /// + /// 100 to disable, 0-99 for JPEG compression-Level + /// Nothing + [HttpPatch("ImageCompression")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult SetImageCompression(int percentage) + { + return StatusCode(500, "Not implemented"); //TODO + } + + /// + /// Get state of Black/White-Image setting + /// + /// True if enabled + [HttpGet("BWImages")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult GetBwImagesToggle() + { + return StatusCode(500, "Not implemented"); //TODO + } + + /// + /// Enable/Disable conversion of Images to Black and White + /// + /// true to enable + /// Nothing + [HttpPatch("BWImages")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult SetBwImagesToggle(bool enabled) + { + return StatusCode(500, "Not implemented"); //TODO + } + + /// + /// Get state of April Fools Mode + /// + /// April Fools Mode disables all downloads on April 1st + /// True if enabled + [HttpGet("AprilFoolsMode")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult GetAprilFoolsMode() + { + return StatusCode(500, "Not implemented"); //TODO + } + + /// + /// Enable/Disable April Fools Mode + /// + /// April Fools Mode disables all downloads on April 1st + /// true to enable + /// Nothing + [HttpPatch("AprilFoolsMode")] + [ProducesResponseType(Status500InternalServerError)] + public IActionResult SetAprilFoolsMode(bool enabled) + { + return StatusCode(500, "Not implemented"); //TODO + } +} \ No newline at end of file diff --git a/API/Dockerfile b/API/Dockerfile new file mode 100644 index 0000000..1f62941 --- /dev/null +++ b/API/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["API/API.csproj", "API/"] +RUN dotnet restore "API/API.csproj" +COPY . . +WORKDIR "/src/API" +RUN dotnet build "API.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "API.dll"] diff --git a/API/MangaDownloadClients/ChromiumDownloadClient.cs b/API/MangaDownloadClients/ChromiumDownloadClient.cs new file mode 100644 index 0000000..f437d11 --- /dev/null +++ b/API/MangaDownloadClients/ChromiumDownloadClient.cs @@ -0,0 +1,110 @@ +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using HtmlAgilityPack; +using PuppeteerSharp; + +namespace API.MangaDownloadClients; + +internal class ChromiumDownloadClient : DownloadClient +{ + private static IBrowser? _browser; + private const int StartTimeoutMs = 10000; + private readonly HttpDownloadClient _httpDownloadClient; + + private static async Task StartBrowser() + { + return await Puppeteer.LaunchAsync(new LaunchOptions + { + Headless = true, + Args = new [] { + "--disable-gpu", + "--disable-dev-shm-usage", + "--disable-setuid-sandbox", + "--no-sandbox"}, + Timeout = StartTimeoutMs + }, new LoggerFactory([new LogProvider()])); //TODO + } + + private class LogProvider : ILoggerProvider + { + //TODO + public void Dispose() { } + + public ILogger CreateLogger(string categoryName) => new Logger(); + } + + private class Logger : ILogger + { + public Logger() : base() { } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (logLevel <= LogLevel.Information) + return; + //TODO + } + + public bool IsEnabled(LogLevel logLevel) => true; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + } + + public ChromiumDownloadClient() + { + _httpDownloadClient = new(); + if(_browser is null) + _browser = StartBrowser().Result; + } + + private readonly Regex _imageUrlRex = new(@"https?:\/\/.*\.(?:p?jpe?g|gif|a?png|bmp|avif|webp)(\?.*)?"); + internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null) + { + return _imageUrlRex.IsMatch(url) + ? _httpDownloadClient.MakeRequestInternal(url, referrer) + : MakeRequestBrowser(url, referrer, clickButton); + } + + private RequestResult MakeRequestBrowser(string url, string? referrer = null, string? clickButton = null) + { + IPage page = _browser.NewPageAsync().Result; + page.DefaultTimeout = 10000; + IResponse response; + try + { + response = page.GoToAsync(url, WaitUntilNavigation.Networkidle0).Result; + } + catch (Exception e) + { + page.CloseAsync(); + return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null); + } + + Stream stream = Stream.Null; + HtmlDocument? document = null; + + if (response.Headers.TryGetValue("Content-Type", out string? content)) + { + if (content.Contains("text/html")) + { + if (clickButton is not null && page.QuerySelectorAsync(clickButton).Result is not null) + page.ClickAsync(clickButton).Wait(); + string htmlString = page.GetContentAsync().Result; + stream = new MemoryStream(Encoding.Default.GetBytes(htmlString)); + document = new (); + document.LoadHtml(htmlString); + }else if (content.Contains("image")) + { + stream = new MemoryStream(response.BufferAsync().Result); + } + } + else + { + page.CloseAsync(); + return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null); + } + + page.CloseAsync(); + return new RequestResult(response.Status, document, stream, false, ""); + } +} \ No newline at end of file diff --git a/API/MangaDownloadClients/DownloadClient.cs b/API/MangaDownloadClients/DownloadClient.cs new file mode 100644 index 0000000..9dcd049 --- /dev/null +++ b/API/MangaDownloadClients/DownloadClient.cs @@ -0,0 +1,42 @@ +using System.Net; +using API.Schema; + +namespace API.MangaDownloadClients; + +internal abstract class DownloadClient +{ + private readonly Dictionary _lastExecutedRateLimit; + + protected DownloadClient() + { + this._lastExecutedRateLimit = new(); + } + + public RequestResult MakeRequest(string url, RequestType requestType, string? referrer = null, string? clickButton = null) + { + if (!TrangaSettings.requestLimits.ContainsKey(requestType)) + { + return new RequestResult(HttpStatusCode.NotAcceptable, null, Stream.Null); + } + + int rateLimit = TrangaSettings.userAgent == TrangaSettings.DefaultUserAgent + ? TrangaSettings.DefaultRequestLimits[requestType] + : TrangaSettings.requestLimits[requestType]; + + TimeSpan timeBetweenRequests = TimeSpan.FromMinutes(1).Divide(rateLimit); + _lastExecutedRateLimit.TryAdd(requestType, DateTime.Now.Subtract(timeBetweenRequests)); + + TimeSpan rateLimitTimeout = timeBetweenRequests.Subtract(DateTime.Now.Subtract(_lastExecutedRateLimit[requestType])); + + if (rateLimitTimeout > TimeSpan.Zero) + { + Thread.Sleep(rateLimitTimeout); + } + + RequestResult result = MakeRequestInternal(url, referrer, clickButton); + _lastExecutedRateLimit[requestType] = DateTime.Now; + return result; + } + + internal abstract RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null); +} \ No newline at end of file diff --git a/API/MangaDownloadClients/HttpDownloadClient.cs b/API/MangaDownloadClients/HttpDownloadClient.cs new file mode 100644 index 0000000..332cfab --- /dev/null +++ b/API/MangaDownloadClients/HttpDownloadClient.cs @@ -0,0 +1,73 @@ +using System.Net; +using API.Schema; +using HtmlAgilityPack; + +namespace API.MangaDownloadClients; + +internal class HttpDownloadClient : DownloadClient +{ + private static readonly HttpClient Client = new() + { + Timeout = TimeSpan.FromSeconds(10) + }; + + public HttpDownloadClient() + { + Client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", TrangaSettings.userAgent); + } + + internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null) + { + //TODO + //if (clickButton is not null) + //Log("Can not click button on static site."); + HttpResponseMessage? response = null; + while (response is null) + { + HttpRequestMessage requestMessage = new(HttpMethod.Get, url); + if (referrer is not null) + requestMessage.Headers.Referrer = new Uri(referrer); + //Log($"Requesting {requestType} {url}"); + try + { + response = Client.Send(requestMessage); + } + catch (Exception e) + { + switch (e) + { + case TaskCanceledException: + return new RequestResult(HttpStatusCode.RequestTimeout, null, Stream.Null); + case HttpRequestException: + return new RequestResult(HttpStatusCode.BadRequest, null, Stream.Null); + } + } + } + + if (!response.IsSuccessStatusCode) + { + return new RequestResult(response.StatusCode, null, Stream.Null); + } + + Stream stream = response.Content.ReadAsStream(); + + HtmlDocument? document = null; + + if (response.Content.Headers.ContentType?.MediaType == "text/html") + { + StreamReader reader = new (stream); + document = new (); + document.LoadHtml(reader.ReadToEnd()); + stream.Position = 0; + } + + // Request has been redirected to another page. For example, it redirects directly to the results when there is only 1 result + if (response.RequestMessage is not null && response.RequestMessage.RequestUri is not null) + { + return new RequestResult(response.StatusCode, document, stream, true, + response.RequestMessage.RequestUri.AbsoluteUri); + } + + return new RequestResult(response.StatusCode, document, stream); + } +} \ No newline at end of file diff --git a/API/MangaDownloadClients/RequestResult.cs b/API/MangaDownloadClients/RequestResult.cs new file mode 100644 index 0000000..b42cfba --- /dev/null +++ b/API/MangaDownloadClients/RequestResult.cs @@ -0,0 +1,27 @@ +using System.Net; +using HtmlAgilityPack; + +namespace API.MangaDownloadClients; + +public struct RequestResult +{ + public HttpStatusCode statusCode { get; } + public Stream result { get; } + public bool hasBeenRedirected { get; } + public string? redirectedToUrl { get; } + public HtmlDocument? htmlDocument { get; } + + public RequestResult(HttpStatusCode statusCode, HtmlDocument? htmlDocument, Stream result) + { + this.statusCode = statusCode; + this.htmlDocument = htmlDocument; + this.result = result; + } + + public RequestResult(HttpStatusCode statusCode, HtmlDocument? htmlDocument, Stream result, bool hasBeenRedirected, string redirectedTo) + : this(statusCode, htmlDocument, result) + { + this.hasBeenRedirected = hasBeenRedirected; + redirectedToUrl = redirectedTo; + } +} \ No newline at end of file diff --git a/API/MangaDownloadClients/RequestType.cs b/API/MangaDownloadClients/RequestType.cs new file mode 100644 index 0000000..855c3ed --- /dev/null +++ b/API/MangaDownloadClients/RequestType.cs @@ -0,0 +1,11 @@ +namespace API.MangaDownloadClients; + +public enum RequestType : byte +{ + Default = 0, + MangaDexFeed = 1, + MangaImage = 2, + MangaCover = 3, + MangaDexImage = 5, + MangaInfo = 6 +} \ No newline at end of file diff --git a/API/Migrations/20241201235443_Initial.Designer.cs b/API/Migrations/20241201235443_Initial.Designer.cs new file mode 100644 index 0000000..d5f2e42 --- /dev/null +++ b/API/Migrations/20241201235443_Initial.Designer.cs @@ -0,0 +1,781 @@ +// +using System; +using API.Schema; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace API.Migrations +{ + [DbContext(typeof(PgsqlContext))] + [Migration("20241201235443_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("API.Schema.Author", b => + { + b.Property("AuthorId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AuthorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("API.Schema.Chapter", b => + { + b.Property("ChapterId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ArchiveFileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChapterIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChapterNumber") + .HasColumnType("real"); + + b.Property("Downloaded") + .HasColumnType("boolean"); + + b.Property("ParentMangaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("VolumeNumber") + .HasColumnType("real"); + + b.HasKey("ChapterId"); + + b.HasIndex("ParentMangaId"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("API.Schema.Jobs.Job", b => + { + b.Property("JobId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.PrimitiveCollection("DependsOnJobIds") + .HasMaxLength(64) + .HasColumnType("text[]"); + + b.Property("JobId1") + .HasColumnType("character varying(64)"); + + b.Property("JobType") + .HasColumnType("smallint"); + + b.Property("LastExecution") + .HasColumnType("timestamp with time zone"); + + b.Property("NextExecution") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentJobId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RecurrenceMs") + .HasColumnType("numeric(20,0)"); + + b.Property("state") + .HasColumnType("integer"); + + b.HasKey("JobId"); + + b.HasIndex("JobId1"); + + b.ToTable("Jobs"); + + b.HasDiscriminator("JobType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("API.Schema.LibraryConnectors.LibraryConnector", b => + { + b.Property("LibraryConnectorId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("BaseUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("LibraryType") + .HasColumnType("smallint"); + + b.HasKey("LibraryConnectorId"); + + b.ToTable("LibraryConnectors"); + + b.HasDiscriminator("LibraryType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("API.Schema.Link", b => + { + b.Property("LinkId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LinkIds") + .HasColumnType("text"); + + b.Property("LinkProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("LinkUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("MangaId") + .IsRequired() + .HasColumnType("character varying(64)"); + + b.HasKey("LinkId"); + + b.HasIndex("MangaId"); + + b.ToTable("Link"); + }); + + modelBuilder.Entity("API.Schema.Manga", b => + { + b.Property("MangaId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.PrimitiveCollection("AltTitleIds") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("AuthorIds") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ConnectorId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CoverFileNameInCache") + .HasColumnType("text"); + + b.Property("CoverUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("FolderName") + .IsRequired() + .HasColumnType("text"); + + b.Property("IgnoreChapterBefore") + .HasColumnType("real"); + + b.Property("LatestChapterAvailableId") + .HasColumnType("character varying(64)"); + + b.Property("LatestChapterDownloadedId") + .HasColumnType("character varying(64)"); + + b.PrimitiveCollection("LinkIds") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("MangaConnectorName") + .IsRequired() + .HasColumnType("character varying(32)"); + + b.Property("MangaIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OriginalLanguage") + .HasColumnType("text"); + + b.Property("ReleaseStatus") + .HasColumnType("smallint"); + + b.PrimitiveCollection("TagIds") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("year") + .HasColumnType("bigint"); + + b.HasKey("MangaId"); + + b.HasIndex("LatestChapterAvailableId") + .IsUnique(); + + b.HasIndex("LatestChapterDownloadedId") + .IsUnique(); + + b.HasIndex("MangaConnectorName"); + + b.ToTable("Manga"); + }); + + modelBuilder.Entity("API.Schema.MangaAltTitle", b => + { + b.Property("AltTitleId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AltTitleIds") + .HasColumnType("text"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("MangaId") + .IsRequired() + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("AltTitleId"); + + b.HasIndex("MangaId"); + + b.ToTable("AltTitles"); + }); + + modelBuilder.Entity("API.Schema.MangaConnector", b => + { + b.Property("Name") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.PrimitiveCollection("BaseUris") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("SupportedLanguages") + .IsRequired() + .HasColumnType("text[]"); + + b.HasKey("Name"); + + b.ToTable("MangaConnectors"); + }); + + modelBuilder.Entity("API.Schema.MangaTag", b => + { + b.Property("Tag") + .HasColumnType("text"); + + b.HasKey("Tag"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("API.Schema.NotificationConnectors.NotificationConnector", b => + { + b.Property("NotificationConnectorId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NotificationConnectorType") + .HasColumnType("smallint"); + + b.HasKey("NotificationConnectorId"); + + b.ToTable("NotificationConnectors"); + + b.HasDiscriminator("NotificationConnectorType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("MangaAuthor", b => + { + b.Property("MangaId") + .HasColumnType("character varying(64)"); + + b.Property("AuthorId") + .HasColumnType("character varying(64)"); + + b.Property("AuthorIds") + .HasColumnType("text"); + + b.Property("MangaIds") + .HasColumnType("text"); + + b.HasKey("MangaId", "AuthorId"); + + b.HasIndex("AuthorId"); + + b.ToTable("MangaAuthor"); + }); + + modelBuilder.Entity("MangaTag", b => + { + b.Property("MangaId") + .HasColumnType("character varying(64)"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("MangaIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("TagIds") + .HasColumnType("text"); + + b.HasKey("MangaId", "Tag"); + + b.HasIndex("MangaIds"); + + b.HasIndex("Tag"); + + b.ToTable("MangaTag"); + }); + + modelBuilder.Entity("API.Schema.Jobs.CreateArchiveJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("ChapterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ComicInfoLocation") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImagesLocation") + .IsRequired() + .HasColumnType("text"); + + b.HasIndex("ChapterId"); + + b.ToTable("Jobs", t => + { + t.Property("ChapterId") + .HasColumnName("CreateArchiveJob_ChapterId"); + }); + + b.HasDiscriminator().HasValue((byte)4); + }); + + modelBuilder.Entity("API.Schema.Jobs.CreateComicInfoXmlJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("ChapterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasIndex("ChapterId"); + + b.ToTable("Jobs", t => + { + t.Property("ChapterId") + .HasColumnName("CreateComicInfoXmlJob_ChapterId"); + }); + + b.HasDiscriminator().HasValue((byte)6); + }); + + modelBuilder.Entity("API.Schema.Jobs.DownloadNewChaptersJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("MangaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasIndex("MangaId"); + + b.HasDiscriminator().HasValue((byte)1); + }); + + modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("ChapterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasIndex("ChapterId"); + + b.HasDiscriminator().HasValue((byte)0); + }); + + modelBuilder.Entity("API.Schema.Jobs.MoveFileOrFolderJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("FromLocation") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToLocation") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue((byte)3); + }); + + modelBuilder.Entity("API.Schema.Jobs.ProcessImagesJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("Bw") + .HasColumnType("boolean"); + + b.Property("Compression") + .HasColumnType("integer"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.ToTable("Jobs", t => + { + t.Property("Path") + .HasColumnName("ProcessImagesJob_Path"); + }); + + b.HasDiscriminator().HasValue((byte)5); + }); + + modelBuilder.Entity("API.Schema.Jobs.SearchMangaJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("MangaConnectorName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SearchString") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue((byte)7); + }); + + modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("MangaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasIndex("MangaId"); + + b.ToTable("Jobs", t => + { + t.Property("MangaId") + .HasColumnName("UpdateMetadataJob_MangaId"); + }); + + b.HasDiscriminator().HasValue((byte)2); + }); + + modelBuilder.Entity("API.Schema.LibraryConnectors.Kavita", b => + { + b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector"); + + b.HasDiscriminator().HasValue((byte)1); + }); + + modelBuilder.Entity("API.Schema.LibraryConnectors.Komga", b => + { + b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector"); + + b.HasDiscriminator().HasValue((byte)0); + }); + + modelBuilder.Entity("API.Schema.NotificationConnectors.Gotify", b => + { + b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector"); + + b.Property("AppToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue((byte)0); + }); + + modelBuilder.Entity("API.Schema.NotificationConnectors.Lunasea", b => + { + b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector"); + + b.Property("Id") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue((byte)1); + }); + + modelBuilder.Entity("API.Schema.NotificationConnectors.Ntfy", b => + { + b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.ToTable("NotificationConnectors", t => + { + t.Property("Endpoint") + .HasColumnName("Ntfy_Endpoint"); + }); + + b.HasDiscriminator().HasValue((byte)2); + }); + + modelBuilder.Entity("API.Schema.Chapter", b => + { + b.HasOne("API.Schema.Manga", "ParentManga") + .WithMany("Chapters") + .HasForeignKey("ParentMangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ParentManga"); + }); + + modelBuilder.Entity("API.Schema.Jobs.Job", b => + { + b.HasOne("API.Schema.Jobs.Job", null) + .WithMany("DependsOnJobs") + .HasForeignKey("JobId1"); + }); + + modelBuilder.Entity("API.Schema.Link", b => + { + b.HasOne("API.Schema.Manga", "Manga") + .WithMany("Links") + .HasForeignKey("MangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Manga"); + }); + + modelBuilder.Entity("API.Schema.Manga", b => + { + b.HasOne("API.Schema.Chapter", "LatestChapterAvailable") + .WithOne() + .HasForeignKey("API.Schema.Manga", "LatestChapterAvailableId"); + + b.HasOne("API.Schema.Chapter", "LatestChapterDownloaded") + .WithOne() + .HasForeignKey("API.Schema.Manga", "LatestChapterDownloadedId"); + + b.HasOne("API.Schema.MangaConnector", "MangaConnector") + .WithMany("Mangas") + .HasForeignKey("MangaConnectorName") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LatestChapterAvailable"); + + b.Navigation("LatestChapterDownloaded"); + + b.Navigation("MangaConnector"); + }); + + modelBuilder.Entity("API.Schema.MangaAltTitle", b => + { + b.HasOne("API.Schema.Manga", "Manga") + .WithMany("AltTitles") + .HasForeignKey("MangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Manga"); + }); + + modelBuilder.Entity("MangaAuthor", b => + { + b.HasOne("API.Schema.Author", null) + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Schema.Manga", null) + .WithMany() + .HasForeignKey("MangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MangaTag", b => + { + b.HasOne("API.Schema.Manga", null) + .WithMany() + .HasForeignKey("MangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Schema.MangaTag", null) + .WithMany() + .HasForeignKey("MangaIds") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Schema.MangaTag", null) + .WithMany() + .HasForeignKey("Tag") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Schema.Jobs.CreateArchiveJob", b => + { + b.HasOne("API.Schema.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Schema.Jobs.CreateComicInfoXmlJob", b => + { + b.HasOne("API.Schema.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Schema.Jobs.DownloadNewChaptersJob", b => + { + b.HasOne("API.Schema.Manga", "Manga") + .WithMany() + .HasForeignKey("MangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Manga"); + }); + + modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b => + { + b.HasOne("API.Schema.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b => + { + b.HasOne("API.Schema.Manga", "Manga") + .WithMany() + .HasForeignKey("MangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Manga"); + }); + + modelBuilder.Entity("API.Schema.Jobs.Job", b => + { + b.Navigation("DependsOnJobs"); + }); + + modelBuilder.Entity("API.Schema.Manga", b => + { + b.Navigation("AltTitles"); + + b.Navigation("Chapters"); + + b.Navigation("Links"); + }); + + modelBuilder.Entity("API.Schema.MangaConnector", b => + { + b.Navigation("Mangas"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Migrations/20241201235443_Initial.cs b/API/Migrations/20241201235443_Initial.cs new file mode 100644 index 0000000..62c7b10 --- /dev/null +++ b/API/Migrations/20241201235443_Initial.cs @@ -0,0 +1,447 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Authors", + columns: table => new + { + AuthorId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + AuthorName = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Authors", x => x.AuthorId); + }); + + migrationBuilder.CreateTable( + name: "LibraryConnectors", + columns: table => new + { + LibraryConnectorId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + LibraryType = table.Column(type: "smallint", nullable: false), + BaseUrl = table.Column(type: "text", nullable: false), + Auth = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LibraryConnectors", x => x.LibraryConnectorId); + }); + + migrationBuilder.CreateTable( + name: "MangaConnectors", + columns: table => new + { + Name = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + SupportedLanguages = table.Column(type: "text[]", nullable: false), + BaseUris = table.Column(type: "text[]", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MangaConnectors", x => x.Name); + }); + + migrationBuilder.CreateTable( + name: "NotificationConnectors", + columns: table => new + { + NotificationConnectorId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + NotificationConnectorType = table.Column(type: "smallint", nullable: false), + Endpoint = table.Column(type: "text", nullable: true), + AppToken = table.Column(type: "text", nullable: true), + Id = table.Column(type: "text", nullable: true), + Ntfy_Endpoint = table.Column(type: "text", nullable: true), + Auth = table.Column(type: "text", nullable: true), + Topic = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_NotificationConnectors", x => x.NotificationConnectorId); + }); + + migrationBuilder.CreateTable( + name: "Tags", + columns: table => new + { + Tag = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tags", x => x.Tag); + }); + + migrationBuilder.CreateTable( + name: "AltTitles", + columns: table => new + { + AltTitleId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Language = table.Column(type: "character varying(8)", maxLength: 8, nullable: false), + Title = table.Column(type: "text", nullable: false), + MangaId = table.Column(type: "character varying(64)", nullable: false), + AltTitleIds = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AltTitles", x => x.AltTitleId); + }); + + migrationBuilder.CreateTable( + name: "Chapters", + columns: table => new + { + ChapterId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + VolumeNumber = table.Column(type: "real", nullable: true), + ChapterNumber = table.Column(type: "real", nullable: false), + Url = table.Column(type: "text", nullable: false), + Title = table.Column(type: "text", nullable: true), + ArchiveFileName = table.Column(type: "text", nullable: false), + Downloaded = table.Column(type: "boolean", nullable: false), + ParentMangaId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + ChapterIds = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Chapters", x => x.ChapterId); + }); + + migrationBuilder.CreateTable( + name: "Manga", + columns: table => new + { + MangaId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + ConnectorId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: false), + CoverUrl = table.Column(type: "text", nullable: false), + CoverFileNameInCache = table.Column(type: "text", nullable: true), + year = table.Column(type: "bigint", nullable: false), + OriginalLanguage = table.Column(type: "text", nullable: true), + ReleaseStatus = table.Column(type: "smallint", nullable: false), + FolderName = table.Column(type: "text", nullable: false), + IgnoreChapterBefore = table.Column(type: "real", nullable: false), + LatestChapterDownloadedId = table.Column(type: "character varying(64)", nullable: true), + LatestChapterAvailableId = table.Column(type: "character varying(64)", nullable: true), + MangaConnectorName = table.Column(type: "character varying(32)", nullable: false), + AuthorIds = table.Column(type: "text[]", nullable: false), + TagIds = table.Column(type: "text[]", nullable: false), + LinkIds = table.Column(type: "text[]", nullable: false), + AltTitleIds = table.Column(type: "text[]", nullable: false), + MangaIds = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Manga", x => x.MangaId); + table.ForeignKey( + name: "FK_Manga_Chapters_LatestChapterAvailableId", + column: x => x.LatestChapterAvailableId, + principalTable: "Chapters", + principalColumn: "ChapterId"); + table.ForeignKey( + name: "FK_Manga_Chapters_LatestChapterDownloadedId", + column: x => x.LatestChapterDownloadedId, + principalTable: "Chapters", + principalColumn: "ChapterId"); + table.ForeignKey( + name: "FK_Manga_MangaConnectors_MangaConnectorName", + column: x => x.MangaConnectorName, + principalTable: "MangaConnectors", + principalColumn: "Name", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Jobs", + columns: table => new + { + JobId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + ParentJobId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + DependsOnJobIds = table.Column(type: "text[]", maxLength: 64, nullable: true), + JobType = table.Column(type: "smallint", nullable: false), + RecurrenceMs = table.Column(type: "numeric(20,0)", nullable: false), + LastExecution = table.Column(type: "timestamp with time zone", nullable: false), + NextExecution = table.Column(type: "timestamp with time zone", nullable: false), + state = table.Column(type: "integer", nullable: false), + JobId1 = table.Column(type: "character varying(64)", nullable: true), + ImagesLocation = table.Column(type: "text", nullable: true), + ComicInfoLocation = table.Column(type: "text", nullable: true), + CreateArchiveJob_ChapterId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + Path = table.Column(type: "text", nullable: true), + CreateComicInfoXmlJob_ChapterId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + MangaId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + ChapterId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + FromLocation = table.Column(type: "text", nullable: true), + ToLocation = table.Column(type: "text", nullable: true), + ProcessImagesJob_Path = table.Column(type: "text", nullable: true), + Bw = table.Column(type: "boolean", nullable: true), + Compression = table.Column(type: "integer", nullable: true), + SearchString = table.Column(type: "text", nullable: true), + MangaConnectorName = table.Column(type: "text", nullable: true), + UpdateMetadataJob_MangaId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Jobs", x => x.JobId); + table.ForeignKey( + name: "FK_Jobs_Chapters_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapters", + principalColumn: "ChapterId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Jobs_Chapters_CreateArchiveJob_ChapterId", + column: x => x.CreateArchiveJob_ChapterId, + principalTable: "Chapters", + principalColumn: "ChapterId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Jobs_Chapters_CreateComicInfoXmlJob_ChapterId", + column: x => x.CreateComicInfoXmlJob_ChapterId, + principalTable: "Chapters", + principalColumn: "ChapterId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Jobs_Jobs_JobId1", + column: x => x.JobId1, + principalTable: "Jobs", + principalColumn: "JobId"); + table.ForeignKey( + name: "FK_Jobs_Manga_MangaId", + column: x => x.MangaId, + principalTable: "Manga", + principalColumn: "MangaId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Jobs_Manga_UpdateMetadataJob_MangaId", + column: x => x.UpdateMetadataJob_MangaId, + principalTable: "Manga", + principalColumn: "MangaId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Link", + columns: table => new + { + LinkId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + LinkProvider = table.Column(type: "text", nullable: false), + LinkUrl = table.Column(type: "text", nullable: false), + MangaId = table.Column(type: "character varying(64)", nullable: false), + LinkIds = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Link", x => x.LinkId); + table.ForeignKey( + name: "FK_Link_Manga_MangaId", + column: x => x.MangaId, + principalTable: "Manga", + principalColumn: "MangaId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "MangaAuthor", + columns: table => new + { + MangaId = table.Column(type: "character varying(64)", nullable: false), + AuthorId = table.Column(type: "character varying(64)", nullable: false), + AuthorIds = table.Column(type: "text", nullable: true), + MangaIds = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MangaAuthor", x => new { x.MangaId, x.AuthorId }); + table.ForeignKey( + name: "FK_MangaAuthor_Authors_AuthorId", + column: x => x.AuthorId, + principalTable: "Authors", + principalColumn: "AuthorId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_MangaAuthor_Manga_MangaId", + column: x => x.MangaId, + principalTable: "Manga", + principalColumn: "MangaId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "MangaTag", + columns: table => new + { + MangaId = table.Column(type: "character varying(64)", nullable: false), + Tag = table.Column(type: "text", nullable: false), + MangaIds = table.Column(type: "text", nullable: false), + TagIds = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MangaTag", x => new { x.MangaId, x.Tag }); + table.ForeignKey( + name: "FK_MangaTag_Manga_MangaId", + column: x => x.MangaId, + principalTable: "Manga", + principalColumn: "MangaId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_MangaTag_Tags_MangaIds", + column: x => x.MangaIds, + principalTable: "Tags", + principalColumn: "Tag", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_MangaTag_Tags_Tag", + column: x => x.Tag, + principalTable: "Tags", + principalColumn: "Tag", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AltTitles_MangaId", + table: "AltTitles", + column: "MangaId"); + + migrationBuilder.CreateIndex( + name: "IX_Chapters_ParentMangaId", + table: "Chapters", + column: "ParentMangaId"); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_ChapterId", + table: "Jobs", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_CreateArchiveJob_ChapterId", + table: "Jobs", + column: "CreateArchiveJob_ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_CreateComicInfoXmlJob_ChapterId", + table: "Jobs", + column: "CreateComicInfoXmlJob_ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_JobId1", + table: "Jobs", + column: "JobId1"); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_MangaId", + table: "Jobs", + column: "MangaId"); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_UpdateMetadataJob_MangaId", + table: "Jobs", + column: "UpdateMetadataJob_MangaId"); + + migrationBuilder.CreateIndex( + name: "IX_Link_MangaId", + table: "Link", + column: "MangaId"); + + migrationBuilder.CreateIndex( + name: "IX_Manga_LatestChapterAvailableId", + table: "Manga", + column: "LatestChapterAvailableId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Manga_LatestChapterDownloadedId", + table: "Manga", + column: "LatestChapterDownloadedId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Manga_MangaConnectorName", + table: "Manga", + column: "MangaConnectorName"); + + migrationBuilder.CreateIndex( + name: "IX_MangaAuthor_AuthorId", + table: "MangaAuthor", + column: "AuthorId"); + + migrationBuilder.CreateIndex( + name: "IX_MangaTag_MangaIds", + table: "MangaTag", + column: "MangaIds"); + + migrationBuilder.CreateIndex( + name: "IX_MangaTag_Tag", + table: "MangaTag", + column: "Tag"); + + migrationBuilder.AddForeignKey( + name: "FK_AltTitles_Manga_MangaId", + table: "AltTitles", + column: "MangaId", + principalTable: "Manga", + principalColumn: "MangaId", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Chapters_Manga_ParentMangaId", + table: "Chapters", + column: "ParentMangaId", + principalTable: "Manga", + principalColumn: "MangaId", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Chapters_Manga_ParentMangaId", + table: "Chapters"); + + migrationBuilder.DropTable( + name: "AltTitles"); + + migrationBuilder.DropTable( + name: "Jobs"); + + migrationBuilder.DropTable( + name: "LibraryConnectors"); + + migrationBuilder.DropTable( + name: "Link"); + + migrationBuilder.DropTable( + name: "MangaAuthor"); + + migrationBuilder.DropTable( + name: "MangaTag"); + + migrationBuilder.DropTable( + name: "NotificationConnectors"); + + migrationBuilder.DropTable( + name: "Authors"); + + migrationBuilder.DropTable( + name: "Tags"); + + migrationBuilder.DropTable( + name: "Manga"); + + migrationBuilder.DropTable( + name: "Chapters"); + + migrationBuilder.DropTable( + name: "MangaConnectors"); + } + } +} diff --git a/API/Migrations/PgsqlContextModelSnapshot.cs b/API/Migrations/PgsqlContextModelSnapshot.cs new file mode 100644 index 0000000..64848e5 --- /dev/null +++ b/API/Migrations/PgsqlContextModelSnapshot.cs @@ -0,0 +1,778 @@ +// +using System; +using API.Schema; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace API.Migrations +{ + [DbContext(typeof(PgsqlContext))] + partial class PgsqlContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("API.Schema.Author", b => + { + b.Property("AuthorId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AuthorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("AuthorId"); + + b.ToTable("Authors"); + }); + + modelBuilder.Entity("API.Schema.Chapter", b => + { + b.Property("ChapterId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ArchiveFileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChapterIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChapterNumber") + .HasColumnType("real"); + + b.Property("Downloaded") + .HasColumnType("boolean"); + + b.Property("ParentMangaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("VolumeNumber") + .HasColumnType("real"); + + b.HasKey("ChapterId"); + + b.HasIndex("ParentMangaId"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("API.Schema.Jobs.Job", b => + { + b.Property("JobId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.PrimitiveCollection("DependsOnJobIds") + .HasMaxLength(64) + .HasColumnType("text[]"); + + b.Property("JobId1") + .HasColumnType("character varying(64)"); + + b.Property("JobType") + .HasColumnType("smallint"); + + b.Property("LastExecution") + .HasColumnType("timestamp with time zone"); + + b.Property("NextExecution") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentJobId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RecurrenceMs") + .HasColumnType("numeric(20,0)"); + + b.Property("state") + .HasColumnType("integer"); + + b.HasKey("JobId"); + + b.HasIndex("JobId1"); + + b.ToTable("Jobs"); + + b.HasDiscriminator("JobType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("API.Schema.LibraryConnectors.LibraryConnector", b => + { + b.Property("LibraryConnectorId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("BaseUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("LibraryType") + .HasColumnType("smallint"); + + b.HasKey("LibraryConnectorId"); + + b.ToTable("LibraryConnectors"); + + b.HasDiscriminator("LibraryType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("API.Schema.Link", b => + { + b.Property("LinkId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LinkIds") + .HasColumnType("text"); + + b.Property("LinkProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("LinkUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("MangaId") + .IsRequired() + .HasColumnType("character varying(64)"); + + b.HasKey("LinkId"); + + b.HasIndex("MangaId"); + + b.ToTable("Link"); + }); + + modelBuilder.Entity("API.Schema.Manga", b => + { + b.Property("MangaId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.PrimitiveCollection("AltTitleIds") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("AuthorIds") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ConnectorId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CoverFileNameInCache") + .HasColumnType("text"); + + b.Property("CoverUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("FolderName") + .IsRequired() + .HasColumnType("text"); + + b.Property("IgnoreChapterBefore") + .HasColumnType("real"); + + b.Property("LatestChapterAvailableId") + .HasColumnType("character varying(64)"); + + b.Property("LatestChapterDownloadedId") + .HasColumnType("character varying(64)"); + + b.PrimitiveCollection("LinkIds") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("MangaConnectorName") + .IsRequired() + .HasColumnType("character varying(32)"); + + b.Property("MangaIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OriginalLanguage") + .HasColumnType("text"); + + b.Property("ReleaseStatus") + .HasColumnType("smallint"); + + b.PrimitiveCollection("TagIds") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("year") + .HasColumnType("bigint"); + + b.HasKey("MangaId"); + + b.HasIndex("LatestChapterAvailableId") + .IsUnique(); + + b.HasIndex("LatestChapterDownloadedId") + .IsUnique(); + + b.HasIndex("MangaConnectorName"); + + b.ToTable("Manga"); + }); + + modelBuilder.Entity("API.Schema.MangaAltTitle", b => + { + b.Property("AltTitleId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AltTitleIds") + .HasColumnType("text"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("MangaId") + .IsRequired() + .HasColumnType("character varying(64)"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("AltTitleId"); + + b.HasIndex("MangaId"); + + b.ToTable("AltTitles"); + }); + + modelBuilder.Entity("API.Schema.MangaConnector", b => + { + b.Property("Name") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.PrimitiveCollection("BaseUris") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection("SupportedLanguages") + .IsRequired() + .HasColumnType("text[]"); + + b.HasKey("Name"); + + b.ToTable("MangaConnectors"); + }); + + modelBuilder.Entity("API.Schema.MangaTag", b => + { + b.Property("Tag") + .HasColumnType("text"); + + b.HasKey("Tag"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("API.Schema.NotificationConnectors.NotificationConnector", b => + { + b.Property("NotificationConnectorId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NotificationConnectorType") + .HasColumnType("smallint"); + + b.HasKey("NotificationConnectorId"); + + b.ToTable("NotificationConnectors"); + + b.HasDiscriminator("NotificationConnectorType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("MangaAuthor", b => + { + b.Property("MangaId") + .HasColumnType("character varying(64)"); + + b.Property("AuthorId") + .HasColumnType("character varying(64)"); + + b.Property("AuthorIds") + .HasColumnType("text"); + + b.Property("MangaIds") + .HasColumnType("text"); + + b.HasKey("MangaId", "AuthorId"); + + b.HasIndex("AuthorId"); + + b.ToTable("MangaAuthor"); + }); + + modelBuilder.Entity("MangaTag", b => + { + b.Property("MangaId") + .HasColumnType("character varying(64)"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("MangaIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("TagIds") + .HasColumnType("text"); + + b.HasKey("MangaId", "Tag"); + + b.HasIndex("MangaIds"); + + b.HasIndex("Tag"); + + b.ToTable("MangaTag"); + }); + + modelBuilder.Entity("API.Schema.Jobs.CreateArchiveJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("ChapterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ComicInfoLocation") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImagesLocation") + .IsRequired() + .HasColumnType("text"); + + b.HasIndex("ChapterId"); + + b.ToTable("Jobs", t => + { + t.Property("ChapterId") + .HasColumnName("CreateArchiveJob_ChapterId"); + }); + + b.HasDiscriminator().HasValue((byte)4); + }); + + modelBuilder.Entity("API.Schema.Jobs.CreateComicInfoXmlJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("ChapterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.HasIndex("ChapterId"); + + b.ToTable("Jobs", t => + { + t.Property("ChapterId") + .HasColumnName("CreateComicInfoXmlJob_ChapterId"); + }); + + b.HasDiscriminator().HasValue((byte)6); + }); + + modelBuilder.Entity("API.Schema.Jobs.DownloadNewChaptersJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("MangaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasIndex("MangaId"); + + b.HasDiscriminator().HasValue((byte)1); + }); + + modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("ChapterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasIndex("ChapterId"); + + b.HasDiscriminator().HasValue((byte)0); + }); + + modelBuilder.Entity("API.Schema.Jobs.MoveFileOrFolderJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("FromLocation") + .IsRequired() + .HasColumnType("text"); + + b.Property("ToLocation") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue((byte)3); + }); + + modelBuilder.Entity("API.Schema.Jobs.ProcessImagesJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("Bw") + .HasColumnType("boolean"); + + b.Property("Compression") + .HasColumnType("integer"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.ToTable("Jobs", t => + { + t.Property("Path") + .HasColumnName("ProcessImagesJob_Path"); + }); + + b.HasDiscriminator().HasValue((byte)5); + }); + + modelBuilder.Entity("API.Schema.Jobs.SearchMangaJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("MangaConnectorName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SearchString") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue((byte)7); + }); + + modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b => + { + b.HasBaseType("API.Schema.Jobs.Job"); + + b.Property("MangaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasIndex("MangaId"); + + b.ToTable("Jobs", t => + { + t.Property("MangaId") + .HasColumnName("UpdateMetadataJob_MangaId"); + }); + + b.HasDiscriminator().HasValue((byte)2); + }); + + modelBuilder.Entity("API.Schema.LibraryConnectors.Kavita", b => + { + b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector"); + + b.HasDiscriminator().HasValue((byte)1); + }); + + modelBuilder.Entity("API.Schema.LibraryConnectors.Komga", b => + { + b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector"); + + b.HasDiscriminator().HasValue((byte)0); + }); + + modelBuilder.Entity("API.Schema.NotificationConnectors.Gotify", b => + { + b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector"); + + b.Property("AppToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue((byte)0); + }); + + modelBuilder.Entity("API.Schema.NotificationConnectors.Lunasea", b => + { + b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector"); + + b.Property("Id") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue((byte)1); + }); + + modelBuilder.Entity("API.Schema.NotificationConnectors.Ntfy", b => + { + b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("text"); + + b.ToTable("NotificationConnectors", t => + { + t.Property("Endpoint") + .HasColumnName("Ntfy_Endpoint"); + }); + + b.HasDiscriminator().HasValue((byte)2); + }); + + modelBuilder.Entity("API.Schema.Chapter", b => + { + b.HasOne("API.Schema.Manga", "ParentManga") + .WithMany("Chapters") + .HasForeignKey("ParentMangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ParentManga"); + }); + + modelBuilder.Entity("API.Schema.Jobs.Job", b => + { + b.HasOne("API.Schema.Jobs.Job", null) + .WithMany("DependsOnJobs") + .HasForeignKey("JobId1"); + }); + + modelBuilder.Entity("API.Schema.Link", b => + { + b.HasOne("API.Schema.Manga", "Manga") + .WithMany("Links") + .HasForeignKey("MangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Manga"); + }); + + modelBuilder.Entity("API.Schema.Manga", b => + { + b.HasOne("API.Schema.Chapter", "LatestChapterAvailable") + .WithOne() + .HasForeignKey("API.Schema.Manga", "LatestChapterAvailableId"); + + b.HasOne("API.Schema.Chapter", "LatestChapterDownloaded") + .WithOne() + .HasForeignKey("API.Schema.Manga", "LatestChapterDownloadedId"); + + b.HasOne("API.Schema.MangaConnector", "MangaConnector") + .WithMany("Mangas") + .HasForeignKey("MangaConnectorName") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LatestChapterAvailable"); + + b.Navigation("LatestChapterDownloaded"); + + b.Navigation("MangaConnector"); + }); + + modelBuilder.Entity("API.Schema.MangaAltTitle", b => + { + b.HasOne("API.Schema.Manga", "Manga") + .WithMany("AltTitles") + .HasForeignKey("MangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Manga"); + }); + + modelBuilder.Entity("MangaAuthor", b => + { + b.HasOne("API.Schema.Author", null) + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Schema.Manga", null) + .WithMany() + .HasForeignKey("MangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MangaTag", b => + { + b.HasOne("API.Schema.Manga", null) + .WithMany() + .HasForeignKey("MangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Schema.MangaTag", null) + .WithMany() + .HasForeignKey("MangaIds") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Schema.MangaTag", null) + .WithMany() + .HasForeignKey("Tag") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Schema.Jobs.CreateArchiveJob", b => + { + b.HasOne("API.Schema.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Schema.Jobs.CreateComicInfoXmlJob", b => + { + b.HasOne("API.Schema.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Schema.Jobs.DownloadNewChaptersJob", b => + { + b.HasOne("API.Schema.Manga", "Manga") + .WithMany() + .HasForeignKey("MangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Manga"); + }); + + modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b => + { + b.HasOne("API.Schema.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b => + { + b.HasOne("API.Schema.Manga", "Manga") + .WithMany() + .HasForeignKey("MangaId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Manga"); + }); + + modelBuilder.Entity("API.Schema.Jobs.Job", b => + { + b.Navigation("DependsOnJobs"); + }); + + modelBuilder.Entity("API.Schema.Manga", b => + { + b.Navigation("AltTitles"); + + b.Navigation("Chapters"); + + b.Navigation("Links"); + }); + + modelBuilder.Entity("API.Schema.MangaConnector", b => + { + b.Navigation("Mangas"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/NamedSwaggerGenOptions.cs b/API/NamedSwaggerGenOptions.cs new file mode 100644 index 0000000..96bd9ee --- /dev/null +++ b/API/NamedSwaggerGenOptions.cs @@ -0,0 +1,46 @@ +using Asp.Versioning.ApiExplorer; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace API; + +public class NamedSwaggerGenOptions : IConfigureNamedOptions +{ + private readonly IApiVersionDescriptionProvider provider; + public NamedSwaggerGenOptions(IApiVersionDescriptionProvider provider) + { + this.provider = provider; + } + + public void Configure(string? name, SwaggerGenOptions options) + { + Configure(options); + } + + public void Configure(SwaggerGenOptions options) + { + // add swagger document for every API version discovered + foreach (var description in provider.ApiVersionDescriptions) + { + options.SwaggerDoc( + description.GroupName, + CreateVersionInfo(description)); + } + } + + private OpenApiInfo CreateVersionInfo( + ApiVersionDescription description) + { + var info = new OpenApiInfo() + { + Title = "Test API " + description.GroupName, + Version = description.ApiVersion.ToString() + }; + if (description.IsDeprecated) + { + info.Description += " This API version has been deprecated."; + } + return info; + } +} \ No newline at end of file diff --git a/API/ProblemResponse.cs b/API/ProblemResponse.cs new file mode 100644 index 0000000..b189690 --- /dev/null +++ b/API/ProblemResponse.cs @@ -0,0 +1,3 @@ +namespace API; + +public record ProblemResponse(string title, string? message = null); \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs new file mode 100644 index 0000000..3efffca --- /dev/null +++ b/API/Program.cs @@ -0,0 +1,118 @@ +using System.Reflection; +using System.Text.Json.Serialization; +using API; +using API.Schema; +using API.Schema.Jobs; +using API.Schema.MangaConnectors; +using Asp.Versioning; +using Asp.Versioning.Builder; +using Asp.Versioning.Conventions; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", + policy => + { + policy + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +builder.Services.AddMvc().AddJsonOptions(opts => +{ + opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + opts.JsonSerializerOptions.Converters.Add(new ApiJsonSerializer()); +}); + +builder.Services.AddApiVersioning(option => +{ + option.AssumeDefaultVersionWhenUnspecified = true; + option.DefaultApiVersion = new ApiVersion(2); + option.ReportApiVersions = true; + option.ApiVersionReader = ApiVersionReader.Combine( + new UrlSegmentApiVersionReader(), + new QueryStringApiVersionReader("api-version"), + new HeaderApiVersionReader("X-Version"), + new MediaTypeApiVersionReader("x-version")); +}) +.AddMvc(options => +{ + options.Conventions.Add(new VersionByNamespaceConvention()); +}) + .AddApiExplorer(options => { + options.GroupNameFormat = "'v'V"; + options.SubstituteApiVersionInUrl = true; +}); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(opt => +{ + var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + opt.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename)); +}); +builder.Services.ConfigureOptions(); + + +builder.Services.AddDbContext(options => + options.UseNpgsql($"Host={Environment.GetEnvironmentVariable("POSTGRES_Host")??"localhost:5432"}; " + + $"Database={Environment.GetEnvironmentVariable("POSTGRES_DB")??"postgres"}; " + + $"Username={Environment.GetEnvironmentVariable("POSTGRES_USER")??"postgres"}; " + + $"Password={Environment.GetEnvironmentVariable("POSTGRES_PASSWORD")??"postgres"}")); + +builder.Services.AddControllers(); + +var app = builder.Build(); + +ApiVersionSet apiVersionSet = app.NewApiVersionSet() + .HasApiVersion(new ApiVersion(2)) + .ReportApiVersions() + .Build(); + + +app.UseCors("AllowAll"); + +app.MapControllers() + .WithApiVersionSet(apiVersionSet) + .MapToApiVersion(2); + +app.UseSwagger(); +app.UseSwaggerUI(options => +{ + options.SwaggerEndpoint( + $"/swagger/v2/swagger.json", "v2"); +}); + +app.UseHttpsRedirection(); + +using (var scope = app.Services.CreateScope()) +{ + PgsqlContext context = scope.ServiceProvider.GetService()!; + + MangaConnector[] connectors = + [ + new AsuraToon(), + new Bato(), + new MangaDex(), + new MangaHere(), + new MangaKatana(), + new MangaLife(), + new Manganato(), + new Mangasee(), + new Mangaworld(), + new ManhuaPlus() + ]; + MangaConnector[] newConnectors = context.MangaConnectors.Where(c => !connectors.Contains(c)).ToArray(); + context.MangaConnectors.AddRange(newConnectors); + + context.Jobs.RemoveRange(context.Jobs.Where(j => j.state == JobState.Completed && j.RecurrenceMs < 1)); + + context.SaveChanges(); +} + +app.UseCors("AllowAll"); + +app.Run(); \ No newline at end of file diff --git a/API/Properties/launchSettings.json b/API/Properties/launchSettings.json new file mode 100644 index 0000000..7142c37 --- /dev/null +++ b/API/Properties/launchSettings.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:5976", + "sslPort": 44332, + "environmentVariables": { + "POSTGRES_Host": "localhost:5432" + } + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5287", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "POSTGRES_Host": "localhost:5432" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7206;http://localhost:5287", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "POSTGRES_Host": "localhost:5432" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "POSTGRES_Host": "localhost:5432" + } + } + } +} diff --git a/API/Schema/APISerializable.cs b/API/Schema/APISerializable.cs new file mode 100644 index 0000000..6c52606 --- /dev/null +++ b/API/Schema/APISerializable.cs @@ -0,0 +1,3 @@ +namespace API.Schema; + +public interface APISerializable; \ No newline at end of file diff --git a/API/Schema/Author.cs b/API/Schema/Author.cs new file mode 100644 index 0000000..c757ca5 --- /dev/null +++ b/API/Schema/Author.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace API.Schema; + +[PrimaryKey("AuthorId")] +public class Author(string authorName) +{ + [MaxLength(64)] + public string AuthorId { get; init; } = TokenGen.CreateToken(typeof(Author), 64); + public string AuthorName { get; init; } = authorName; + + [ForeignKey("MangaIds")] + public virtual Manga[] Mangas { get; internal set; } = []; +} \ No newline at end of file diff --git a/API/Schema/Chapter.cs b/API/Schema/Chapter.cs new file mode 100644 index 0000000..5976bd7 --- /dev/null +++ b/API/Schema/Chapter.cs @@ -0,0 +1,115 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Xml.Linq; +using API.Schema.Jobs; +using Microsoft.EntityFrameworkCore; + +namespace API.Schema; + +[PrimaryKey("ChapterId")] +public class Chapter : IComparable +{ + [MaxLength(64)] + public string ChapterId { get; init; } = TokenGen.CreateToken(typeof(Chapter), 64); + public float? VolumeNumber { get; private set; } + public float ChapterNumber { get; private set; } + public string Url { get; internal set; } + public string? Title { get; private set; } + public string ArchiveFileName { get; private set; } + public bool Downloaded { get; internal set; } = false; + + [MaxLength(64)] + public string ParentMangaId { get; init; } + [ForeignKey("ParentMangaId")] + public virtual Manga ParentManga { get; init; } + + public Chapter(string parentMangaId, string url, float chapterNumber, + float? volumeNumber = null, string? title = null) + { + this.ParentMangaId = parentMangaId; + this.Url = url; + this.ChapterNumber = chapterNumber; + this.VolumeNumber = volumeNumber; + this.Title = title; + this.ArchiveFileName = BuildArchiveFileName(); + } + + public Chapter(Manga parentManga, string url, float chapterNumber, + float? volumeNumber = null, string? title = null) : this(parentManga.MangaId, url, chapterNumber, volumeNumber, title) + { + } + + public MoveFileOrFolderJob? UpdateChapterNumber(float chapterNumber) + { + this.ChapterNumber = chapterNumber; + return UpdateArchiveFileName(); + } + + public MoveFileOrFolderJob? UpdateVolumeNumber(float? volumeNumber) + { + this.VolumeNumber = volumeNumber; + return UpdateArchiveFileName(); + } + + public MoveFileOrFolderJob? UpdateTitle(string? title) + { + this.Title = title; + return UpdateArchiveFileName(); + } + + private string BuildArchiveFileName() + { + return $"{this.ParentManga.Name} - Vol.{this.VolumeNumber ?? 0} Ch.{this.ChapterNumber}{(this.Title is null ? "" : $" - {this.Title}")}.cbz"; + } + + private MoveFileOrFolderJob? UpdateArchiveFileName() + { + string oldPath = GetArchiveFilePath(); + this.ArchiveFileName = BuildArchiveFileName(); + if (Downloaded) + { + return new MoveFileOrFolderJob(oldPath, GetArchiveFilePath()); + } + return null; + } + + /// + /// Creates full file path of chapter-archive + /// + /// Filepath + internal string GetArchiveFilePath() + { + return Path.Join(TrangaSettings.downloadLocation, ParentManga.FolderName, ArchiveFileName); + } + + public bool IsDownloaded() + { + string path = GetArchiveFilePath(); + return File.Exists(path); + } + + public int CompareTo(Chapter? other) + { + if(other is not { } otherChapter) + throw new ArgumentException($"{other} can not be compared to {this}"); + return this.VolumeNumber?.CompareTo(otherChapter.VolumeNumber) switch + { + <0 => -1, + >0 => 1, + _ => this.ChapterNumber.CompareTo(otherChapter.ChapterNumber) + }; + } + + internal string GetComicInfoXmlString() + { + XElement comicInfo = new XElement("ComicInfo", + new XElement("Tags", string.Join(',', ParentManga.Tags.Select(tag => tag.Tag))), + new XElement("LanguageISO", ParentManga.OriginalLanguage), + new XElement("Title", this.Title), + new XElement("Writer", string.Join(',', ParentManga.Authors.Select(author => author.AuthorName))), + new XElement("Volume", this.VolumeNumber), + new XElement("Number", this.ChapterNumber) + ); + return comicInfo.ToString(); + } +} \ No newline at end of file diff --git a/API/Schema/Jobs/DownloadNewChaptersJob.cs b/API/Schema/Jobs/DownloadNewChaptersJob.cs new file mode 100644 index 0000000..f767894 --- /dev/null +++ b/API/Schema/Jobs/DownloadNewChaptersJob.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using API.Schema.MangaConnectors; +using Newtonsoft.Json; + +namespace API.Schema.Jobs; + +public class DownloadNewChaptersJob(ulong recurrenceMs, string mangaId, string? parentJobId = null, string[]? dependsOnJobIds = null) + : Job(TokenGen.CreateToken(typeof(DownloadNewChaptersJob), 64), JobType.DownloadNewChaptersJob, recurrenceMs, parentJobId, dependsOnJobIds) +{ + [MaxLength(64)] + public string MangaId { get; init; } = mangaId; + public virtual Manga Manga { get; init; } + + public override IEnumerable Run() + { + MangaConnector connector = Manga.MangaConnector; + Chapter[] newChapters = connector.GetNewChapters(Manga); + return newChapters.Select(chapter => new DownloadSingleChapterJob(chapter.ChapterId, this.JobId)); + } +} \ No newline at end of file diff --git a/API/Schema/Jobs/DownloadSingleChapterJob.cs b/API/Schema/Jobs/DownloadSingleChapterJob.cs new file mode 100644 index 0000000..1975cb6 --- /dev/null +++ b/API/Schema/Jobs/DownloadSingleChapterJob.cs @@ -0,0 +1,139 @@ +using System.ComponentModel.DataAnnotations; +using System.IO.Compression; +using System.Runtime.InteropServices; +using API.MangaDownloadClients; +using API.Schema.MangaConnectors; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Processing.Processors.Binarization; +using static System.IO.UnixFileMode; + +namespace API.Schema.Jobs; + +public class DownloadSingleChapterJob(string chapterId, string? parentJobId = null, string[]? dependsOnJobIds = null) + : Job(TokenGen.CreateToken(typeof(DownloadSingleChapterJob), 64), JobType.DownloadSingleChapterJob, 0, parentJobId, dependsOnJobIds) +{ + [MaxLength(64)] + public string ChapterId { get; init; } = chapterId; + public virtual Chapter Chapter { get; init; } + + public override IEnumerable Run() + { + MangaConnector connector = Chapter.ParentManga.MangaConnector; + DownloadChapterImages(Chapter); + return []; + } + + private bool DownloadChapterImages(Chapter chapter) + { + MangaConnector connector = Chapter.ParentManga.MangaConnector; + string[] imageUrls = connector.GetChapterImageUrls(Chapter); + string saveArchiveFilePath = chapter.GetArchiveFilePath(); + + //Check if Publication Directory already exists + string directoryPath = Path.GetDirectoryName(saveArchiveFilePath)!; + if (!Directory.Exists(directoryPath)) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + Directory.CreateDirectory(directoryPath, + UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute ); + else + Directory.CreateDirectory(directoryPath); + + if (File.Exists(saveArchiveFilePath)) //Don't download twice. Redownload + File.Delete(saveArchiveFilePath); + + //Create a temporary folder to store images + string tempFolder = Directory.CreateTempSubdirectory("trangatemp").FullName; + + int chapterNum = 0; + //Download all Images to temporary Folder + if (imageUrls.Length == 0) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + File.SetUnixFileMode(saveArchiveFilePath, UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute); + Directory.Delete(tempFolder, true); + return false; + } + + foreach (string imageUrl in imageUrls) + { + string extension = imageUrl.Split('.')[^1].Split('?')[0]; + string imagePath = Path.Join(tempFolder, $"{chapterNum++}.{extension}"); + bool status = DownloadImage(imageUrl, imagePath); + if (status is false) + return false; + } + + CopyCoverFromCacheToDownloadLocation(); + + File.WriteAllText(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString()); + + //ZIP-it and ship-it + ZipFile.CreateFromDirectory(tempFolder, saveArchiveFilePath); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + File.SetUnixFileMode(saveArchiveFilePath, UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute | OtherRead | OtherExecute); + Directory.Delete(tempFolder, true); //Cleanup + + return true; + } + + private void ProcessImage(string imagePath) + { + if (!TrangaSettings.bwImages && TrangaSettings.compression == 100) + return; + DateTime start = DateTime.Now; + using Image image = Image.Load(imagePath); + File.Delete(imagePath); + if(TrangaSettings.bwImages) + image.Mutate(i => i.ApplyProcessor(new AdaptiveThresholdProcessor())); + image.SaveAsJpeg(imagePath, new JpegEncoder() + { + Quality = TrangaSettings.compression + }); + } + + private void CopyCoverFromCacheToDownloadLocation(int? retries = 1) + { + //Check if Publication already has a Folder and cover + string publicationFolder = Chapter.ParentManga.CreatePublicationFolder(); + DirectoryInfo dirInfo = new (publicationFolder); + if (dirInfo.EnumerateFiles().Any(info => info.Name.Contains("cover", StringComparison.InvariantCultureIgnoreCase))) + { + return; + } + + string? fileInCache = Chapter.ParentManga.CoverFileNameInCache; + if (fileInCache is null || !File.Exists(fileInCache)) + { + if (retries > 0 && Chapter.ParentManga.CoverUrl is not null) + { + Chapter.ParentManga.SaveCoverImageToCache(); + CopyCoverFromCacheToDownloadLocation(--retries); + } + + return; + } + string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" ); + File.Copy(fileInCache, newFilePath, true); + if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | UserRead | UserWrite); + } + + private bool DownloadImage(string imageUrl, string savePath) + { + HttpDownloadClient downloadClient = new(); + RequestResult requestResult = downloadClient.MakeRequest(imageUrl, RequestType.MangaImage); + + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return false; + if (requestResult.result == Stream.Null) + return false; + + FileStream fs = new (savePath, FileMode.Create); + requestResult.result.CopyTo(fs); + fs.Close(); + ProcessImage(savePath); + return true; + } +} \ No newline at end of file diff --git a/API/Schema/Jobs/Job.cs b/API/Schema/Jobs/Job.cs new file mode 100644 index 0000000..58ebec4 --- /dev/null +++ b/API/Schema/Jobs/Job.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; + +namespace API.Schema.Jobs; + +[PrimaryKey("JobId")] +public abstract class Job : APISerializable +{ + [MaxLength(64)] + public string JobId { get; init; } + + [MaxLength(64)] + public string? ParentJobId { get; internal set; } + internal virtual Job ParentJob { get; } + + [MaxLength(64)] + public string[]? DependsOnJobIds { get; init; } + public virtual Job[] DependsOnJobs { get; init; } + + public JobType JobType { get; init; } + public ulong RecurrenceMs { get; set; } + public DateTime LastExecution { get; internal set; } = DateTime.UnixEpoch; + public DateTime NextExecution { get; internal set; } + public JobState state { get; internal set; } = JobState.Waiting; + + public Job(string jobId, JobType jobType, ulong recurrenceMs, string? parentJobId = null, + string[]? dependsOnJobIds = null) + { + JobId = jobId; + ParentJobId = parentJobId; + DependsOnJobIds = dependsOnJobIds; + JobType = jobType; + RecurrenceMs = recurrenceMs; + NextExecution = LastExecution.AddMilliseconds(RecurrenceMs); + } + + public abstract IEnumerable Run(); +} \ No newline at end of file diff --git a/API/Schema/Jobs/JobJsonDeserializer.cs b/API/Schema/Jobs/JobJsonDeserializer.cs new file mode 100644 index 0000000..071a3f0 --- /dev/null +++ b/API/Schema/Jobs/JobJsonDeserializer.cs @@ -0,0 +1,29 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using JsonSerializer = Newtonsoft.Json.JsonSerializer; + +namespace API.Schema.Jobs; + +public class JobJsonDeserializer : JsonConverter +{ + public override bool CanWrite { get; } = false; + + public override void WriteJson(JsonWriter writer, Job? value, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + + public override Job? ReadJson(JsonReader reader, Type objectType, Job? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + JObject j = JObject.Load(reader); + JobType? type = Enum.Parse(j.GetValue("jobType")!.Value()!); + return type switch + { + JobType.DownloadSingleChapterJob => j.ToObject(), + JobType.DownloadNewChaptersJob => j.ToObject(), + JobType.UpdateMetaDataJob => j.ToObject(), + JobType.MoveFileOrFolderJob => j.ToObject(), + _ => null + }; + } +} \ No newline at end of file diff --git a/API/Schema/Jobs/JobState.cs b/API/Schema/Jobs/JobState.cs new file mode 100644 index 0000000..b9907c7 --- /dev/null +++ b/API/Schema/Jobs/JobState.cs @@ -0,0 +1,8 @@ +namespace API.Schema.Jobs; + +public enum JobState +{ + Waiting, + Running, + Completed +} \ No newline at end of file diff --git a/API/Schema/Jobs/JobType.cs b/API/Schema/Jobs/JobType.cs new file mode 100644 index 0000000..ab32639 --- /dev/null +++ b/API/Schema/Jobs/JobType.cs @@ -0,0 +1,10 @@ +namespace API.Schema.Jobs; + + +public enum JobType : byte +{ + DownloadSingleChapterJob = 0, + DownloadNewChaptersJob = 1, + UpdateMetaDataJob = 2, + MoveFileOrFolderJob = 3 +} \ No newline at end of file diff --git a/API/Schema/Jobs/MoveFileOrFolderJob.cs b/API/Schema/Jobs/MoveFileOrFolderJob.cs new file mode 100644 index 0000000..a8dd2f0 --- /dev/null +++ b/API/Schema/Jobs/MoveFileOrFolderJob.cs @@ -0,0 +1,13 @@ +namespace API.Schema.Jobs; + +public class MoveFileOrFolderJob(string fromLocation, string toLocation, string? parentJobId = null, string[]? dependsOnJobIds = null) + : Job(TokenGen.CreateToken(typeof(MoveFileOrFolderJob), 64), JobType.MoveFileOrFolderJob, 0, parentJobId, dependsOnJobIds) +{ + public string FromLocation { get; init; } = fromLocation; + public string ToLocation { get; init; } = toLocation; + + public override IEnumerable Run() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/API/Schema/Jobs/UpdateMetadataJob.cs b/API/Schema/Jobs/UpdateMetadataJob.cs new file mode 100644 index 0000000..12ae59f --- /dev/null +++ b/API/Schema/Jobs/UpdateMetadataJob.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.Schema.Jobs; + +public class UpdateMetadataJob(ulong recurrenceMs, string mangaId, string? parentJobId = null, string[]? dependsOnJobIds = null) + : Job(TokenGen.CreateToken(typeof(UpdateMetadataJob), 64), JobType.UpdateMetaDataJob, recurrenceMs, parentJobId, dependsOnJobIds) +{ + [MaxLength(64)] + public string MangaId { get; init; } = mangaId; + public virtual Manga Manga { get; init; } + + public override IEnumerable Run() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/API/Schema/LibraryConnectors/Kavita.cs b/API/Schema/LibraryConnectors/Kavita.cs new file mode 100644 index 0000000..4c6b595 --- /dev/null +++ b/API/Schema/LibraryConnectors/Kavita.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace API.Schema.LibraryConnectors; + +public class Kavita(string baseUrl, string auth) + : LibraryConnector(TokenGen.CreateToken(typeof(Kavita), 64), LibraryType.Kavita, baseUrl, auth) +{ + private static string GetToken(string baseUrl, string username, string password) + { + HttpClient client = new() + { + DefaultRequestHeaders = + { + { "Accept", "application/json" } + } + }; + HttpRequestMessage requestMessage = new () + { + Method = HttpMethod.Post, + RequestUri = new Uri($"{baseUrl}/api/Account/login"), + Content = new StringContent($"{{\"username\":\"{username}\",\"password\":\"{password}\"}}", System.Text.Encoding.UTF8, "application/json") + }; + try + { + HttpResponseMessage response = client.Send(requestMessage); + if (response.IsSuccessStatusCode) + { + JsonObject? result = JsonSerializer.Deserialize(response.Content.ReadAsStream()); + if (result is not null) + return result["token"]!.GetValue(); + } + else + { + } + } + catch (HttpRequestException e) + { + } + return ""; + } + + protected override void UpdateLibraryInternal() + { + foreach (KavitaLibrary lib in GetLibraries()) + NetClient.MakePost($"{baseUrl}/api/Library/scan?libraryId={lib.id}", "Bearer", auth); + } + + internal override bool Test() + { + foreach (KavitaLibrary lib in GetLibraries()) + if (NetClient.MakePost($"{baseUrl}/api/Library/scan?libraryId={lib.id}", "Bearer", auth)) + return true; + return false; + } + + /// + /// Fetches all libraries available to the user + /// + /// Array of KavitaLibrary + private IEnumerable GetLibraries() + { + Stream data = NetClient.MakeRequest($"{baseUrl}/api/Library/libraries", "Bearer", auth); + if (data == Stream.Null) + { + return Array.Empty(); + } + JsonArray? result = JsonSerializer.Deserialize(data); + if (result is null) + { + return Array.Empty(); + } + + List ret = new(); + + foreach (JsonNode? jsonNode in result) + { + JsonObject? jObject = (JsonObject?)jsonNode; + if(jObject is null) + continue; + int libraryId = jObject!["id"]!.GetValue(); + string libraryName = jObject["name"]!.GetValue(); + ret.Add(new KavitaLibrary(libraryId, libraryName)); + } + + return ret; + } + + private struct KavitaLibrary + { + public int id { get; } + // ReSharper disable once UnusedAutoPropertyAccessor.Local + public string name { get; } + + public KavitaLibrary(int id, string name) + { + this.id = id; + this.name = name; + } + } +} \ No newline at end of file diff --git a/API/Schema/LibraryConnectors/Komga.cs b/API/Schema/LibraryConnectors/Komga.cs new file mode 100644 index 0000000..4098d74 --- /dev/null +++ b/API/Schema/LibraryConnectors/Komga.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace API.Schema.LibraryConnectors; + +public class Komga(string baseUrl, string auth) + : LibraryConnector(TokenGen.CreateToken(typeof(Komga), 64), LibraryType.Komga, baseUrl, auth) +{ + protected override void UpdateLibraryInternal() + { + foreach (KomgaLibrary lib in GetLibraries()) + NetClient.MakePost($"{baseUrl}/api/v1/libraries/{lib.id}/scan", "Basic", auth); + } + + internal override bool Test() + { + foreach (KomgaLibrary lib in GetLibraries()) + if (NetClient.MakePost($"{baseUrl}/api/v1/libraries/{lib.id}/scan", "Basic", auth)) + return true; + return false; + } + + /// + /// Fetches all libraries available to the user + /// + /// Array of KomgaLibraries + private IEnumerable GetLibraries() + { + Stream data = NetClient.MakeRequest($"{baseUrl}/api/v1/libraries", "Basic", auth); + if (data == Stream.Null) + { + return Array.Empty(); + } + JsonArray? result = JsonSerializer.Deserialize(data); + if (result is null) + { + return Array.Empty(); + } + + HashSet ret = new(); + + foreach (JsonNode? jsonNode in result) + { + var jObject = (JsonObject?)jsonNode; + string libraryId = jObject!["id"]!.GetValue(); + string libraryName = jObject["name"]!.GetValue(); + ret.Add(new KomgaLibrary(libraryId, libraryName)); + } + + return ret; + } + + private struct KomgaLibrary + { + public string id { get; } + // ReSharper disable once UnusedAutoPropertyAccessor.Local + public string name { get; } + + public KomgaLibrary(string id, string name) + { + this.id = id; + this.name = name; + } + } +} \ No newline at end of file diff --git a/API/Schema/LibraryConnectors/LibraryConnector.cs b/API/Schema/LibraryConnectors/LibraryConnector.cs new file mode 100644 index 0000000..0c78999 --- /dev/null +++ b/API/Schema/LibraryConnectors/LibraryConnector.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; + +namespace API.Schema.LibraryConnectors; + +[PrimaryKey("LibraryConnectorId")] +public abstract class LibraryConnector(string libraryConnectorId, LibraryType libraryType, string baseUrl, string auth) : APISerializable +{ + [MaxLength(64)] + public string LibraryConnectorId { get; } = libraryConnectorId; + + public LibraryType LibraryType { get; init; } = libraryType; + public string BaseUrl { get; init; } = baseUrl; + public string Auth { get; init; } = auth; + + protected abstract void UpdateLibraryInternal(); + internal abstract bool Test(); +} \ No newline at end of file diff --git a/API/Schema/LibraryConnectors/LibraryType.cs b/API/Schema/LibraryConnectors/LibraryType.cs new file mode 100644 index 0000000..3fe500d --- /dev/null +++ b/API/Schema/LibraryConnectors/LibraryType.cs @@ -0,0 +1,7 @@ +namespace API.Schema.LibraryConnectors; + +public enum LibraryType : byte +{ + Komga = 0, + Kavita = 1 +} \ No newline at end of file diff --git a/API/Schema/LibraryConnectors/NetClient.cs b/API/Schema/LibraryConnectors/NetClient.cs new file mode 100644 index 0000000..a2a472b --- /dev/null +++ b/API/Schema/LibraryConnectors/NetClient.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace API.Schema.LibraryConnectors; + +public class NetClient +{ + public static Stream MakeRequest(string url, string authScheme, string auth) + { + HttpClient client = new(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(authScheme, auth); + + HttpRequestMessage requestMessage = new () + { + Method = HttpMethod.Get, + RequestUri = new Uri(url) + }; + try + { + + HttpResponseMessage response = client.Send(requestMessage); + + if (response.StatusCode is HttpStatusCode.Unauthorized && + response.RequestMessage!.RequestUri!.AbsoluteUri != url) + return MakeRequest(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth); + else if (response.IsSuccessStatusCode) + return response.Content.ReadAsStream(); + else + return Stream.Null; + } + catch (Exception e) + { + switch (e) + { + case HttpRequestException: + + break; + default: + throw; + } + return Stream.Null; + } + } + + public static bool MakePost(string url, string authScheme, string auth) + { + HttpClient client = new() + { + DefaultRequestHeaders = + { + { "Accept", "application/json" }, + { "Authorization", new AuthenticationHeaderValue(authScheme, auth).ToString() } + } + }; + HttpRequestMessage requestMessage = new () + { + Method = HttpMethod.Post, + RequestUri = new Uri(url) + }; + HttpResponseMessage response = client.Send(requestMessage); + + if(response.StatusCode is HttpStatusCode.Unauthorized && response.RequestMessage!.RequestUri!.AbsoluteUri != url) + return MakePost(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth); + else if (response.IsSuccessStatusCode) + return true; + else + return false; + } +} \ No newline at end of file diff --git a/API/Schema/Link.cs b/API/Schema/Link.cs new file mode 100644 index 0000000..5a81bdc --- /dev/null +++ b/API/Schema/Link.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace API.Schema; + +[PrimaryKey("LinkId")] +public class Link(string linkProvider, string linkUrl) +{ + [MaxLength(64)] + public string LinkId { get; init; } = TokenGen.CreateToken(typeof(Link), 64); + public string LinkProvider { get; init; } = linkProvider; + public string LinkUrl { get; init; } = linkUrl; + + [ForeignKey("MangaId")] + public virtual Manga Manga { get; init; } +} \ No newline at end of file diff --git a/API/Schema/Manga.cs b/API/Schema/Manga.cs new file mode 100644 index 0000000..e0c6134 --- /dev/null +++ b/API/Schema/Manga.cs @@ -0,0 +1,134 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using API.MangaDownloadClients; +using API.Schema.Jobs; +using API.Schema.MangaConnectors; +using Microsoft.EntityFrameworkCore; +using static System.IO.UnixFileMode; + +namespace API.Schema; + +[PrimaryKey("MangaId")] +public class Manga( + string connectorId, + string name, + string description, + string websiteUrl, + string coverUrl, + string? coverFileNameInCache, + uint year, + string? originalLanguage, + MangaReleaseStatus releaseStatus, + float ignoreChapterBefore, + string? latestChapterDownloadedId, + string? latestChapterAvailableId, + string mangaConnectorName, + string[] authorIds, + string[] tagIds, + string[] linkIds, + string[] altTitleIds) +{ + [MaxLength(64)] + public string MangaId { get; init; } = TokenGen.CreateToken(typeof(Manga), 64); + [MaxLength(64)] + public string ConnectorId { get; init; } = connectorId; + + public string Name { get; internal set; } = name; + public string Description { get; internal set; } = description; + public string WebsiteUrl { get; internal set; } = websiteUrl; + public string CoverUrl { get; internal set; } = coverUrl; + public string? CoverFileNameInCache { get; internal set; } = coverFileNameInCache; + public uint year { get; internal set; } = year; + public string? OriginalLanguage { get; internal set; } = originalLanguage; + public MangaReleaseStatus ReleaseStatus { get; internal set; } = releaseStatus; + public string FolderName { get; private set; } = BuildFolderName(name); + public float IgnoreChapterBefore { get; internal set; } = ignoreChapterBefore; + + public string? LatestChapterDownloadedId { get; internal set; } = latestChapterDownloadedId; + public virtual Chapter? LatestChapterDownloaded { get; } + + public string? LatestChapterAvailableId { get; internal set; } = latestChapterAvailableId; + public virtual Chapter? LatestChapterAvailable { get; } + + public string MangaConnectorName { get; init; } = mangaConnectorName; + public virtual MangaConnector MangaConnector { get; } + + public string[] AuthorIds { get; internal set; } = authorIds; + [ForeignKey("AuthorIds")] + public virtual Author[] Authors { get; } + + public string[] TagIds { get; internal set; } = tagIds; + [ForeignKey("TagIds")] + public virtual MangaTag[] Tags { get; } + + public string[] LinkIds { get; internal set; } = linkIds; + [ForeignKey("LinkIds")] + public virtual Link[] Links { get; } + + public string[] AltTitleIds { get; internal set; } = altTitleIds; + [ForeignKey("AltTitleIds")] + public virtual MangaAltTitle[] AltTitles { get; } + + [ForeignKey("ChapterIds")] + public virtual Chapter[] Chapters { get; internal set; } + + public MoveFileOrFolderJob UpdateFolderName(string downloadLocation, string newName) + { + string oldName = this.FolderName; + this.FolderName = newName; + return new MoveFileOrFolderJob(Path.Join(downloadLocation, oldName), Path.Join(downloadLocation, this.FolderName)); + } + + internal void UpdateWithInfo(Manga other) + { + this.Name = other.Name; + this.year = other.year; + this.Description = other.Description; + this.CoverUrl = other.CoverUrl; + this.OriginalLanguage = other.OriginalLanguage; + this.AuthorIds = other.AuthorIds; + this.LinkIds = other.LinkIds; + this.TagIds = other.TagIds; + this.AltTitleIds = other.AltTitleIds; + this.LatestChapterAvailableId = other.LatestChapterAvailableId; + this.ReleaseStatus = other.ReleaseStatus; + } + + private static string BuildFolderName(string mangaName) + { + return mangaName; + } + + internal string SaveCoverImageToCache() + { + 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(CoverUrl); + string filename = $"{match.Groups[1].Value}-{MangaId}.{match.Groups[3].Value}"; + string saveImagePath = Path.Join(TrangaSettings.coverImageCache, filename); + + if (File.Exists(saveImagePath)) + return saveImagePath; + + RequestResult coverResult = new HttpDownloadClient().MakeRequest(CoverUrl, RequestType.MangaCover); + using MemoryStream ms = new(); + coverResult.result.CopyTo(ms); + Directory.CreateDirectory(TrangaSettings.coverImageCache); + File.WriteAllBytes(saveImagePath, ms.ToArray()); + return saveImagePath; + } + + public string CreatePublicationFolder() + { + string publicationFolder = Path.Join(TrangaSettings.downloadLocation, this.FolderName); + if(!Directory.Exists(publicationFolder)) + Directory.CreateDirectory(publicationFolder); + if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + File.SetUnixFileMode(publicationFolder, GroupRead | GroupWrite | GroupExecute | OtherRead | OtherWrite | OtherExecute | UserRead | UserWrite | UserExecute); + return publicationFolder; + } + + //TODO onchanges create job to update metadata files in archives, etc. +} \ No newline at end of file diff --git a/API/Schema/MangaAltTitle.cs b/API/Schema/MangaAltTitle.cs new file mode 100644 index 0000000..ba5b173 --- /dev/null +++ b/API/Schema/MangaAltTitle.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace API.Schema; + +[PrimaryKey("AltTitleId")] +public class MangaAltTitle(string language, string title) +{ + [MaxLength(64)] + public string AltTitleId { get; init; } = TokenGen.CreateToken("AltTitle", 64); + [MaxLength(8)] + public string Language { get; init; } = language; + public string Title { get; set; } = title; + + [ForeignKey("MangaId")] + public virtual Manga Manga { get; init; } +} \ No newline at end of file diff --git a/API/Schema/MangaConnectors/AsuraToon.cs b/API/Schema/MangaConnectors/AsuraToon.cs new file mode 100644 index 0000000..1921b42 --- /dev/null +++ b/API/Schema/MangaConnectors/AsuraToon.cs @@ -0,0 +1,184 @@ +using System.Text.RegularExpressions; +using API.MangaDownloadClients; +using HtmlAgilityPack; + +namespace API.Schema.MangaConnectors; + +public class AsuraToon : MangaConnector +{ + + public AsuraToon() : base("AsuraToon", ["en"], ["https://asuracomic.net"]) + { + this.downloadClient = new ChromiumDownloadClient(); + } + + public override Manga[] GetManga(string publicationTitle = "") + { + string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower(); + string requestUrl = $"https://asuracomic.net/series?name={sanitizedTitle}"; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return Array.Empty(); + + if (requestResult.htmlDocument is null) + { + return []; + } + + Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument); + return publications; + } + + public override Manga? GetMangaFromId(string publicationId) + { + return GetMangaFromUrl($"https://asuracomic.net/series/{publicationId}"); + } + + public override Manga? GetMangaFromUrl(string url) + { + RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return null; + if (requestResult.htmlDocument is null) + { + return null; + } + return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url); + } + + private Manga[] ParsePublicationsFromHtml(HtmlDocument document) + { + HtmlNodeCollection mangaList = document.DocumentNode.SelectNodes("//a[starts-with(@href,'series')]"); + if (mangaList is null || mangaList.Count < 1) + return []; + + IEnumerable urls = mangaList.Select(a => $"https://asuracomic.net/{a.GetAttributeValue("href", "")}"); + + List ret = new(); + foreach (string url in urls) + { + Manga? manga = GetMangaFromUrl(url); + if (manga is not null) + ret.Add((Manga)manga); + } + + return ret.ToArray(); + } + + private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) + { + string? originalLanguage = null; + Dictionary altTitles = new(), links = new(); + + HtmlNodeCollection genreNodes = document.DocumentNode.SelectNodes("//h3[text()='Genres']/../div/button"); + string[] tags = genreNodes.Select(b => b.InnerText).ToArray(); + + HtmlNode statusNode = document.DocumentNode.SelectSingleNode("//h3[text()='Status']/../h3[2]"); + MangaReleaseStatus releaseStatus = statusNode.InnerText.ToLower() switch + { + "ongoing" => MangaReleaseStatus.Continuing, + "hiatus" => MangaReleaseStatus.OnHiatus, + "completed" => MangaReleaseStatus.Completed, + "dropped" => MangaReleaseStatus.Cancelled, + "season end" => MangaReleaseStatus.Continuing, + "coming soon" => MangaReleaseStatus.Unreleased, + _ => MangaReleaseStatus.Unreleased + }; + + HtmlNode coverNode = + document.DocumentNode.SelectSingleNode("//img[@alt='poster']"); + string coverUrl = coverNode.GetAttributeValue("src", ""); + + HtmlNode titleNode = + document.DocumentNode.SelectSingleNode("//title"); + string sortName = Regex.Match(titleNode.InnerText, @"(.*) - Asura Scans").Groups[1].Value; + + HtmlNode descriptionNode = + document.DocumentNode.SelectSingleNode("//h3[starts-with(text(),'Synopsis')]/../span"); + string description = descriptionNode?.InnerText??""; + + HtmlNodeCollection authorNodes = document.DocumentNode.SelectNodes("//h3[text()='Author']/../h3[not(text()='Author' or text()='_')]"); + HtmlNodeCollection artistNodes = document.DocumentNode.SelectNodes("//h3[text()='Artist']/../h3[not(text()='Artist' or text()='_')]"); + IEnumerable authorNames = authorNodes is null ? [] : authorNodes.Select(a => a.InnerText); + IEnumerable artistNames = artistNodes is null ? [] : artistNodes.Select(a => a.InnerText); + List authors = authorNames.Concat(artistNames).ToList(); + + HtmlNode? firstChapterNode = document.DocumentNode.SelectSingleNode("//a[contains(@href, 'chapter/1')]/../following-sibling::h3"); + uint year = uint.Parse(firstChapterNode?.InnerText.Split(' ')[^1] ?? "2000"); + + Manga manga = new Manga(publicationId, sortName, description, websiteUrl, coverUrl, null, year, + originalLanguage, releaseStatus, -1, null, null, + this.Name, authors, tags, links, altTitles); //TODO + + return manga; + } + + public override Chapter[] GetChapters(Manga manga, string language="en") + { + string requestUrl = $"https://asuracomic.net/series/{manga.MangaId}"; + // Leaving this in for verification if the page exists + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return []; + + //Return Chapters ordered by Chapter-Number + List chapters = ParseChaptersFromHtml(manga, requestUrl); + return chapters.Order().ToArray(); + } + + private List ParseChaptersFromHtml(Manga manga, string mangaUrl) + { + RequestResult result = downloadClient.MakeRequest(mangaUrl, RequestType.Default); + if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null) + { + return new List(); + } + + List ret = new(); + + HtmlNodeCollection chapterURLNodes = result.htmlDocument.DocumentNode.SelectNodes("//a[contains(@href, '/chapter/')]"); + Regex infoRex = new(@"Chapter ([0-9]+)(.*)?"); + + foreach (HtmlNode chapterInfo in chapterURLNodes) + { + string chapterUrl = chapterInfo.GetAttributeValue("href", ""); + + Match match = infoRex.Match(chapterInfo.InnerText); + float chapterNumber = float.Parse(match.Groups[1].Value); + string? chapterName = match.Groups[2].Success && match.Groups[2].Length > 1 ? match.Groups[2].Value : null; + string url = $"https://asuracomic.net/series/{chapterUrl}"; + try + { + ret.Add(new Chapter(manga, url, chapterNumber, null, chapterName)); + } + catch (Exception e) + { + } + } + + return ret; + } + + internal override string[] GetChapterImageUrls(Chapter chapter) + { + string requestUrl = chapter.Url; + // Leaving this in to check if the page exists + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) + { + return []; + } + string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument); + return imageUrls; + } + + private string[] ParseImageUrlsFromHtml(HtmlDocument document) + { + HtmlNodeCollection images = document.DocumentNode.SelectNodes("//img[contains(@alt, 'chapter page')]"); + + return images.Select(i => i.GetAttributeValue("src", "")).ToArray(); + } +} \ No newline at end of file diff --git a/API/Schema/MangaConnectors/Bato.cs b/API/Schema/MangaConnectors/Bato.cs new file mode 100644 index 0000000..0644983 --- /dev/null +++ b/API/Schema/MangaConnectors/Bato.cs @@ -0,0 +1,194 @@ +using System.Net; +using System.Text.RegularExpressions; +using API.MangaDownloadClients; +using HtmlAgilityPack; + +namespace API.Schema.MangaConnectors; + +public class Bato : MangaConnector +{ + + public Bato() : base("Bato", ["en"], ["bato.to"]) + { + this.downloadClient = new HttpDownloadClient(); + } + + public override Manga[] GetManga(string publicationTitle = "") + { + string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower(); + string requestUrl = $"https://bato.to/v3x-search?word={sanitizedTitle}&lang=en"; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return Array.Empty(); + + if (requestResult.htmlDocument is null) + { + return []; + } + + Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument); + return publications; + } + + public override Manga? GetMangaFromId(string publicationId) + { + return GetMangaFromUrl($"https://bato.to/title/{publicationId}"); + } + + public override Manga? GetMangaFromUrl(string url) + { + RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return null; + if (requestResult.htmlDocument is null) + { + return null; + } + return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url); + } + + private Manga[] ParsePublicationsFromHtml(HtmlDocument document) + { + HtmlNode mangaList = document.DocumentNode.SelectSingleNode("//div[@data-hk='0-0-2']"); + if (!mangaList.ChildNodes.Any(node => node.Name == "div")) + return []; + + List urls = mangaList.ChildNodes + .Select(node => $"https://bato.to{node.Descendants("div").First().FirstChild.GetAttributeValue("href", "")}").ToList(); + + HashSet ret = new(); + foreach (string url in urls) + { + Manga? manga = GetMangaFromUrl(url); + if (manga is not null) + ret.Add((Manga)manga); + } + + return ret.ToArray(); + } + + private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) + { + HtmlNode infoNode = document.DocumentNode.SelectSingleNode("/html/body/div/main/div[1]/div[2]"); + + string sortName = infoNode.Descendants("h3").First().InnerText; + string description = document.DocumentNode + .SelectSingleNode("//div[contains(concat(' ',normalize-space(@class),' '),'prose')]").InnerText; + + string[] altTitlesList = infoNode.ChildNodes[1].ChildNodes[2].InnerText.Split('/'); + int i = 0; + Dictionary altTitles = altTitlesList.ToDictionary(s => i++.ToString(), s => s); + + string posterUrl = document.DocumentNode.SelectNodes("//img") + .First(child => child.GetAttributeValue("data-hk", "") == "0-1-0").GetAttributeValue("src", "").Replace("&", "&"); + + List genreNodes = document.DocumentNode.SelectSingleNode("//b[text()='Genres:']/..").SelectNodes("span").ToList(); + string[] tags = genreNodes.Select(node => node.FirstChild.InnerText).ToArray(); + + List authorsNodes = infoNode.ChildNodes[1].ChildNodes[3].Descendants("a").ToList(); + List authors = authorsNodes.Select(node => node.InnerText.Replace("amp;", "")).ToList(); + + HtmlNode? originalLanguageNode = document.DocumentNode.SelectSingleNode("//span[text()='Tr From']/.."); + string originalLanguage = originalLanguageNode is not null ? originalLanguageNode.LastChild.InnerText : ""; + + if (!int.TryParse( + document.DocumentNode.SelectSingleNode("//span[text()='Original Publication:']/..").LastChild.InnerText.Split('-')[0], + out int year)) + year = DateTime.Now.Year; + + string status = document.DocumentNode.SelectSingleNode("//span[text()='Original Publication:']/..") + .ChildNodes[2].InnerText; + MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased; + switch (status.ToLower()) + { + case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break; + case "completed": releaseStatus = MangaReleaseStatus.Completed; break; + case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break; + case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break; + case "pending": releaseStatus = MangaReleaseStatus.Unreleased; break; + } + + Manga manga = //TODO + return manga; + } + + public override Chapter[] GetChapters(Manga manga, string language="en") + { + string requestUrl = $"https://bato.to/title/{manga.MangaId}"; + // Leaving this in for verification if the page exists + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return []; + + //Return Chapters ordered by Chapter-Number + List chapters = ParseChaptersFromHtml(manga, requestUrl); + return chapters.Order().ToArray(); + } + + private List ParseChaptersFromHtml(Manga manga, string mangaUrl) + { + RequestResult result = downloadClient.MakeRequest(mangaUrl, RequestType.Default); + if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null) + { + return new List(); + } + + List ret = new(); + + HtmlNode chapterList = + result.htmlDocument.DocumentNode.SelectSingleNode("/html/body/div/main/div[3]/astro-island/div/div[2]/div/div/astro-slot"); + + Regex numberRex = new(@"\/title\/.+\/([0-9])+(?:-vol_([0-9]+))?-ch_([0-9\.]+)"); + + foreach (HtmlNode chapterInfo in chapterList.SelectNodes("div")) + { + HtmlNode infoNode = chapterInfo.FirstChild.FirstChild; + string chapterUrl = infoNode.GetAttributeValue("href", ""); + + Match match = numberRex.Match(chapterUrl); + string id = match.Groups[1].Value; + float? volumeNumber = match.Groups[2].Success ? float.Parse(match.Groups[2].Value) : null; + float chapterNumber = float.Parse(match.Groups[3].Value); + string url = $"https://bato.to{chapterUrl}?load=2"; + try + { + ret.Add(new Chapter(manga, url, chapterNumber, volumeNumber, null)); + } + catch (Exception e) + { + } + } + + return ret; + } + + internal override string[] GetChapterImageUrls(Chapter chapter) + { + string requestUrl = chapter.Url; + // Leaving this in to check if the page exists + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) + { + return []; + } + + string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument); + return imageUrls; + } + + private string[] ParseImageUrlsFromHtml(HtmlDocument document) + { + HtmlNode images = document.DocumentNode.SelectNodes("//astro-island").First(node => + node.GetAttributeValue("component-url", "").Contains("/_astro/ImageList.")); + + string weirdString = images.OuterHtml; + string weirdString2 = Regex.Match(weirdString, @"props=\""(.*)}\""").Groups[1].Value; + string[] urls = Regex.Matches(weirdString2, @"(https:\/\/[A-z\-0-9\.\?\&\;\=\/]+)\\") + .Select(match => match.Groups[1].Value.Replace("&", "&")).ToArray(); + + return urls; + } +} \ No newline at end of file diff --git a/API/Schema/MangaConnectors/MangaConnector.cs b/API/Schema/MangaConnectors/MangaConnector.cs new file mode 100644 index 0000000..9a0db12 --- /dev/null +++ b/API/Schema/MangaConnectors/MangaConnector.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using API.MangaDownloadClients; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; + +namespace API.Schema.MangaConnectors; + +[PrimaryKey("Name")] +public abstract class MangaConnector(string name, string[] supportedLanguages, string[] baseUris) +{ + [MaxLength(32)] + public string Name { get; init; } = name; + public string[] SupportedLanguages { get; init; } = supportedLanguages; + public string[] BaseUris { get; init; } = baseUris; + + [ForeignKey("MangaIds")] + public virtual Manga[] Mangas { get; internal set; } = []; + + + public abstract Manga[] GetManga(string publicationTitle = ""); + + public abstract Manga? GetMangaFromUrl(string url); + + public abstract Manga? GetMangaFromId(string publicationId); + + public abstract Chapter[] GetChapters(Manga manga, string language="en"); + + [JsonIgnore] + [NotMapped] + internal DownloadClient downloadClient { get; init; } = null!; + + public Chapter[] GetNewChapters(Manga manga) + { + Chapter[] allChapters = GetChapters(manga); + if (allChapters.Length < 1) + return []; + + return allChapters.Where(chapter => !chapter.IsDownloaded()).ToArray(); + } + + internal abstract string[] GetChapterImageUrls(Chapter chapter); +} \ No newline at end of file diff --git a/API/Schema/MangaConnectors/MangaDex.cs b/API/Schema/MangaConnectors/MangaDex.cs new file mode 100644 index 0000000..dff475b --- /dev/null +++ b/API/Schema/MangaConnectors/MangaDex.cs @@ -0,0 +1,267 @@ +using System.Net; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using API.MangaDownloadClients; +using JsonSerializer = System.Text.Json.JsonSerializer; + +namespace API.Schema.MangaConnectors; + +public class MangaDex : MangaConnector +{ + //https://api.mangadex.org/docs/3-enumerations/#language-codes--localization + //https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes + //https://gist.github.com/Josantonius/b455e315bc7f790d14b136d61d9ae469 + public MangaDex() : base("MangaDex", ["en","pt","pt-br","it","de","ru","aa","ab","ae","af","ak","am","an","ar-ae","ar-bh","ar-dz","ar-eg","ar-iq","ar-jo","ar-kw","ar-lb","ar-ly","ar-ma","ar-om","ar-qa","ar-sa","ar-sy","ar-tn","ar-ye","ar","as","av","ay","az","ba","be","bg","bh","bi","bm","bn","bo","br","bs","ca","ce","ch","co","cr","cs","cu","cv","cy","da","de-at","de-ch","de-de","de-li","de-lu","div","dv","dz","ee","el","en-au","en-bz","en-ca","en-cb","en-gb","en-ie","en-jm","en-nz","en-ph","en-tt","en-us","en-za","en-zw","eo","es-ar","es-bo","es-cl","es-co","es-cr","es-do","es-ec","es-es","es-gt","es-hn","es-la","es-mx","es-ni","es-pa","es-pe","es-pr","es-py","es-sv","es-us","es-uy","es-ve","es","et","eu","fa","ff","fi","fj","fo","fr-be","fr-ca","fr-ch","fr-fr","fr-lu","fr-mc","fr","fy","ga","gd","gl","gn","gu","gv","ha","he","hi","ho","hr-ba","hr-hr","hr","ht","hu","hy","hz","ia","id","ie","ig","ii","ik","in","io","is","it-ch","it-it","iu","iw","ja","ja-ro","ji","jv","jw","ka","kg","ki","kj","kk","kl","km","kn","ko","ko-ro","kr","ks","ku","kv","kw","ky","kz","la","lb","lg","li","ln","lo","ls","lt","lu","lv","mg","mh","mi","mk","ml","mn","mo","mr","ms-bn","ms-my","ms","mt","my","na","nb","nd","ne","ng","nl-be","nl-nl","nl","nn","no","nr","ns","nv","ny","oc","oj","om","or","os","pa","pi","pl","ps","pt-pt","qu-bo","qu-ec","qu-pe","qu","rm","rn","ro","rw","sa","sb","sc","sd","se-fi","se-no","se-se","se","sg","sh","si","sk","sl","sm","sn","so","sq","sr-ba","sr-sp","sr","ss","st","su","sv-fi","sv-se","sv","sw","sx","syr","ta","te","tg","th","ti","tk","tl","tn","to","tr","ts","tt","tw","ty","ug","uk","ur","us","uz","ve","vi","vo","wa","wo","xh","yi","yo","za","zh-cn","zh-hk","zh-mo","zh-ro","zh-sg","zh-tw","zh","zu"], ["mangadex.org"]) + { + this.downloadClient = new HttpDownloadClient(); + } + + public override Manga[] GetManga(string publicationTitle = "") + { + const int limit = 100; //How many values we want returned at once + int offset = 0; //"Page" + int total = int.MaxValue; //How many total results are there, is updated on first request + HashSet retManga = new(); + int loadedPublicationData = 0; + List results = new(); + + //Request all search-results + while (offset < total) //As long as we haven't requested all "Pages" + { + //Request next Page + RequestResult requestResult = downloadClient.MakeRequest( + $"https://api.mangadex.org/manga?limit={limit}&title={publicationTitle}&offset={offset}" + + $"&contentRating%5B%5D=safe&contentRating%5B%5D=suggestive&contentRating%5B%5D=erotica" + + $"&contentRating%5B%5D=pornographic" + + $"&includes%5B%5D=manga&includes%5B%5D=cover_art&includes%5B%5D=author" + + $"&includes%5B%5D=artist&includes%5B%5D=tag", RequestType.MangaInfo); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + break; + JsonObject? result = JsonSerializer.Deserialize(requestResult.result); + + offset += limit; + if (result is null) + break; + + if(result.ContainsKey("total")) + total = result["total"]!.GetValue(); //Update the total number of Publications + else continue; + + if (result.ContainsKey("data")) + results.AddRange(result["data"]!.AsArray()!);//Manga-data-Array + } + + foreach (JsonNode mangaNode in results) + { + if(MangaFromJsonObject(mangaNode.AsObject()) is { } manga) + retManga.Add(manga); //Add Publication (Manga) to result + } + return retManga.ToArray(); + } + + public override Manga? GetMangaFromId(string publicationId) + { + RequestResult requestResult = + downloadClient.MakeRequest($"https://api.mangadex.org/manga/{publicationId}?includes%5B%5D=manga&includes%5B%5D=cover_art&includes%5B%5D=author&includes%5B%5D=artist&includes%5B%5D=tag", RequestType.MangaInfo); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return null; + JsonObject? result = JsonSerializer.Deserialize(requestResult.result); + if(result is not null) + return MangaFromJsonObject(result["data"]!.AsObject()); + return null; + } + + public override Manga? GetMangaFromUrl(string url) + { + Regex idRex = new (@"https:\/\/mangadex.org\/title\/([A-z0-9-]*)\/.*"); + string id = idRex.Match(url).Groups[1].Value; + return GetMangaFromId(id); + } + + private Manga? MangaFromJsonObject(JsonObject manga) + { + if (!manga.TryGetPropertyValue("id", out JsonNode? idNode)) + return null; + string publicationId = idNode!.GetValue(); + + if (!manga.TryGetPropertyValue("attributes", out JsonNode? attributesNode)) + return null; + JsonObject attributes = attributesNode!.AsObject(); + + if (!attributes.TryGetPropertyValue("title", out JsonNode? titleNode)) + return null; + string title = titleNode!.AsObject().ContainsKey("en") switch + { + true => titleNode.AsObject()["en"]!.GetValue(), + false => titleNode.AsObject().First().Value!.GetValue() + }; + + Dictionary altTitlesDict = new(); + if (attributes.TryGetPropertyValue("altTitles", out JsonNode? altTitlesNode)) + { + foreach (JsonNode? altTitleNode in altTitlesNode!.AsArray()) + { + JsonObject altTitleNodeObject = altTitleNode!.AsObject(); + altTitlesDict.TryAdd(altTitleNodeObject.First().Key, altTitleNodeObject.First().Value!.GetValue()); + } + } + + if (!attributes.TryGetPropertyValue("description", out JsonNode? descriptionNode)) + return null; + string description = descriptionNode!.AsObject().ContainsKey("en") switch + { + true => descriptionNode.AsObject()["en"]!.GetValue(), + false => descriptionNode.AsObject().FirstOrDefault().Value?.GetValue() ?? "" + }; + + Dictionary linksDict = new(); + if (attributes.TryGetPropertyValue("links", out JsonNode? linksNode) && linksNode is not null) + foreach (KeyValuePair linkKv in linksNode!.AsObject()) + linksDict.TryAdd(linkKv.Key, linkKv.Value.GetValue()); + + string? originalLanguage = + attributes.TryGetPropertyValue("originalLanguage", out JsonNode? originalLanguageNode) switch + { + true => originalLanguageNode?.GetValue(), + false => null + }; + + MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased; + if (attributes.TryGetPropertyValue("status", out JsonNode? statusNode)) + { + releaseStatus = statusNode?.GetValue().ToLower() switch + { + "ongoing" => MangaReleaseStatus.Continuing, + "completed" => MangaReleaseStatus.Completed, + "hiatus" => MangaReleaseStatus.OnHiatus, + "cancelled" => MangaReleaseStatus.Cancelled, + _ => MangaReleaseStatus.Unreleased + }; + } + + int? year = attributes.TryGetPropertyValue("year", out JsonNode? yearNode) switch + { + true => yearNode?.GetValue(), + false => null + }; + + HashSet tags = new(128); + if (attributes.TryGetPropertyValue("tags", out JsonNode? tagsNode)) + foreach (JsonNode? tagNode in tagsNode!.AsArray()) + tags.Add(tagNode!["attributes"]!["name"]!["en"]!.GetValue()); + + + if (!manga.TryGetPropertyValue("relationships", out JsonNode? relationshipsNode)) + return null; + + JsonNode? coverNode = relationshipsNode!.AsArray() + .FirstOrDefault(rel => rel!["type"]!.GetValue().Equals("cover_art")); + if (coverNode is null) + return null; + string fileName = coverNode["attributes"]!["fileName"]!.GetValue(); + string coverUrl = $"https://uploads.mangadex.org/covers/{publicationId}/{fileName}"; + + List authors = new(); + JsonNode?[] authorNodes = relationshipsNode.AsArray() + .Where(rel => rel!["type"]!.GetValue().Equals("author") || rel!["type"]!.GetValue().Equals("artist")).ToArray(); + foreach (JsonNode? authorNode in authorNodes) + { + string authorName = authorNode!["attributes"]!["name"]!.GetValue(); + if(!authors.Contains(authorName)) + authors.Add(authorName); + } + + Manga pub = //TODO + return pub; + } + + public override Chapter[] GetChapters(Manga manga, string language="en") + { + const int limit = 100; //How many values we want returned at once + int offset = 0; //"Page" + int total = int.MaxValue; //How many total results are there, is updated on first request + List chapters = new(); + //As long as we haven't requested all "Pages" + while (offset < total) + { + //Request next "Page" + RequestResult requestResult = + downloadClient.MakeRequest( + $"https://api.mangadex.org/manga/{manga.MangaId}/feed?limit={limit}&offset={offset}&translatedLanguage%5B%5D={language}&contentRating%5B%5D=safe&contentRating%5B%5D=suggestive&contentRating%5B%5D=erotica&contentRating%5B%5D=pornographic", RequestType.MangaDexFeed); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + break; + JsonObject? result = JsonSerializer.Deserialize(requestResult.result); + + offset += limit; + if (result is null) + break; + + total = result["total"]!.GetValue(); + JsonArray chaptersInResult = result["data"]!.AsArray(); + //Loop through all Chapters in result and extract information from JSON + foreach (JsonNode? jsonNode in chaptersInResult) + { + JsonObject chapter = (JsonObject)jsonNode!; + JsonObject attributes = chapter["attributes"]!.AsObject(); + + string chapterId = chapter["id"]!.GetValue(); + string url = $"https://mangadex.org/chapter/{chapterId}"; + + string? title = attributes.ContainsKey("title") && attributes["title"] is not null + ? attributes["title"]!.GetValue() + : null; + + float? volume = attributes.ContainsKey("volume") && attributes["volume"] is not null + ? float.Parse(attributes["volume"]!.GetValue()) + : null; + + float chapterNum = attributes.ContainsKey("chapter") && attributes["chapter"] is not null + ? float.Parse(attributes["chapter"]!.GetValue()) + : 0; + + + if (attributes.ContainsKey("pages") && attributes["pages"] is not null && + attributes["pages"]!.GetValue() < 1) + { + continue; + } + + try + { + Chapter newChapter = new Chapter(manga, url, chapterNum, volume, title); + if(!chapters.Contains(newChapter)) + chapters.Add(newChapter); + } + catch (Exception e) + { + } + } + } + + //Return Chapters ordered by Chapter-Number + return chapters.Order().ToArray(); + } + + internal override string[] GetChapterImageUrls(Chapter chapter) + {//Request URLs for Chapter-Images + RequestResult requestResult = + downloadClient.MakeRequest($"https://api.mangadex.org/at-home/server/{chapter.ChapterId}?forcePort443=false", RequestType.MangaDexImage); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + { + return []; + } + JsonObject? result = JsonSerializer.Deserialize(requestResult.result); + if (result is null) + { + return []; + } + string baseUrl = result["baseUrl"]!.GetValue(); + string hash = result["chapter"]!["hash"]!.GetValue(); + JsonArray imageFileNames = result["chapter"]!["data"]!.AsArray(); + //Loop through all imageNames and construct urls (imageUrl) + List imageUrls = new(); + foreach (JsonNode? image in imageFileNames) + imageUrls.Add($"{baseUrl}/data/{hash}/{image!.GetValue()}"); + return imageUrls.ToArray(); + } +} \ No newline at end of file diff --git a/API/Schema/MangaConnectors/MangaHere.cs b/API/Schema/MangaConnectors/MangaHere.cs new file mode 100644 index 0000000..bd29dbb --- /dev/null +++ b/API/Schema/MangaConnectors/MangaHere.cs @@ -0,0 +1,174 @@ +using System.Text.RegularExpressions; +using API.MangaDownloadClients; +using HtmlAgilityPack; + +namespace API.Schema.MangaConnectors; + +public class MangaHere : MangaConnector +{ + public MangaHere() : base("MangaHere", ["en"], ["www.mangahere.cc"]) + { + this.downloadClient = new ChromiumDownloadClient(); + } + + public override Manga[] GetManga(string publicationTitle = "") + { + string sanitizedTitle = string.Join('+', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower(); + string requestUrl = $"https://www.mangahere.cc/search?title={sanitizedTitle}"; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) + return Array.Empty(); + + Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument); + return publications; + } + + private Manga[] ParsePublicationsFromHtml(HtmlDocument document) + { + if (document.DocumentNode.SelectNodes("//div[contains(concat(' ',normalize-space(@class),' '),' container ')]").Any(node => node.ChildNodes.Any(cNode => cNode.HasClass("search-keywords")))) + return Array.Empty(); + + List urls = document.DocumentNode + .SelectNodes("//a[contains(@href, '/manga/') and not(contains(@href, '.html'))]") + .Select(thumb => $"https://www.mangahere.cc{thumb.GetAttributeValue("href", "")}").Distinct().ToList(); + + HashSet ret = new(); + foreach (string url in urls) + { + Manga? manga = GetMangaFromUrl(url); + if (manga is not null) + ret.Add((Manga)manga); + } + + return ret.ToArray(); + } + + public override Manga? GetMangaFromId(string publicationId) + { + return GetMangaFromUrl($"https://www.mangahere.cc/manga/{publicationId}"); + } + + public override Manga? GetMangaFromUrl(string url) + { + RequestResult requestResult = + downloadClient.MakeRequest(url, RequestType.MangaInfo); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) + return null; + + Regex idRex = new (@"https:\/\/www\.mangahere\.[a-z]{0,63}\/manga\/([0-9A-z\-]+).*"); + string id = idRex.Match(url).Groups[1].Value; + return ParseSinglePublicationFromHtml(requestResult.htmlDocument, id, url); + } + + private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) + { + string originalLanguage = "", status = ""; + Dictionary altTitles = new(), links = new(); + MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased; + + //We dont get posters, because same origin bs HtmlNode posterNode = document.DocumentNode.SelectSingleNode("//img[contains(concat(' ',normalize-space(@class),' '),' detail-info-cover-img ')]"); + string posterUrl = "http://static.mangahere.cc/v20230914/mangahere/images/nopicture.jpg"; + + HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//span[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-title-font ')]"); + string sortName = titleNode.InnerText; + + List authors = document.DocumentNode + .SelectNodes("//p[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-say ')]/a") + .Select(node => node.InnerText) + .ToList(); + + HashSet tags = document.DocumentNode + .SelectNodes("//p[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-tag-list ')]/a") + .Select(node => node.InnerText) + .ToHashSet(); + + status = document.DocumentNode.SelectSingleNode("//span[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-title-tip ')]").InnerText; + switch (status.ToLower()) + { + case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break; + case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break; + case "discontinued": releaseStatus = MangaReleaseStatus.Cancelled; break; + case "complete": releaseStatus = MangaReleaseStatus.Completed; break; + case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break; + } + + HtmlNode descriptionNode = document.DocumentNode + .SelectSingleNode("//p[contains(concat(' ',normalize-space(@class),' '),' fullcontent ')]"); + string description = descriptionNode.InnerText; + + Manga manga =//TODO + return manga; + } + + public override Chapter[] GetChapters(Manga manga, string language="en") + { + string requestUrl = $"https://www.mangahere.cc/manga/{manga.MangaId}"; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) + return Array.Empty(); + + List urls = requestResult.htmlDocument.DocumentNode.SelectNodes("//div[@id='list-1']/ul//li//a[contains(@href, '/manga/')]") + .Select(node => node.GetAttributeValue("href", "")).ToList(); + Regex chapterRex = new(@".*\/manga\/[a-zA-Z0-9\-\._\~\!\$\&\'\(\)\*\+\,\;\=\:\@]+\/v([0-9(TBD)]+)\/c([0-9\.]+)\/.*"); + + List chapters = new(); + foreach (string url in urls) + { + Match rexMatch = chapterRex.Match(url); + + float? volumeNumber = rexMatch.Groups[1].Value == "TBD" ? null : float.Parse(rexMatch.Groups[1].Value); + float chapterNumber = float.Parse(rexMatch.Groups[2].Value); + string fullUrl = $"https://www.mangahere.cc{url}"; + + try + { + chapters.Add(new Chapter(manga, fullUrl, chapterNumber, volumeNumber, null)); + } + catch (Exception e) + { + } + } + //Return Chapters ordered by Chapter-Number + return chapters.Order().ToArray(); + } + + internal override string[] GetChapterImageUrls(Chapter chapter) + { + List imageUrls = new(); + + int downloaded = 1; + int images = 1; + string url = string.Join('/', chapter.Url.Split('/')[..^1]); + do + { + RequestResult requestResult = + downloadClient.MakeRequest($"{url}/{downloaded}.html", RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) + { + return []; + } + + imageUrls.AddRange(ParseImageUrlsFromHtml(requestResult.htmlDocument)); + + images = requestResult.htmlDocument.DocumentNode + .SelectNodes("//a[contains(@href, '/manga/')]") + .MaxBy(node => node.GetAttributeValue("data-page", 0))!.GetAttributeValue("data-page", 0); + } while (downloaded++ <= images); + + return imageUrls.ToArray(); + } + + private string[] ParseImageUrlsFromHtml(HtmlDocument document) + { + return document.DocumentNode + .SelectNodes("//img[contains(concat(' ',normalize-space(@class),' '),' reader-main-img ')]") + .Select(node => + { + string url = node.GetAttributeValue("src", ""); + return url.StartsWith("//") ? $"https:{url}" : url; + }) + .ToArray(); + } +} \ No newline at end of file diff --git a/API/Schema/MangaConnectors/MangaKatana.cs b/API/Schema/MangaConnectors/MangaKatana.cs new file mode 100644 index 0000000..6c44295 --- /dev/null +++ b/API/Schema/MangaConnectors/MangaKatana.cs @@ -0,0 +1,223 @@ +using System.Text.RegularExpressions; +using API.MangaDownloadClients; +using HtmlAgilityPack; + +namespace API.Schema.MangaConnectors; + +public class MangaKatana : MangaConnector +{ + public MangaKatana() : base("MangaKatana", ["en"], ["mangakatana.com"]) + { + this.downloadClient = new HttpDownloadClient(); + } + + public override Manga[] GetManga(string publicationTitle = "") + { + string sanitizedTitle = string.Join("%20", Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower(); + string requestUrl = $"https://mangakatana.com/?search={sanitizedTitle}&search_by=book_name"; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return Array.Empty(); + + // ReSharper disable once MergeIntoPattern + // If a single result is found, the user will be redirected to the results directly instead of a result page + if(requestResult.hasBeenRedirected + && requestResult.redirectedToUrl is not null + && requestResult.redirectedToUrl.Contains("mangakatana.com/manga")) + { + return new [] { ParseSinglePublicationFromHtml(requestResult.result, requestResult.redirectedToUrl.Split('/')[^1], requestResult.redirectedToUrl) }; + } + + Manga[] publications = ParsePublicationsFromHtml(requestResult.result); + return publications; + } + + public override Manga? GetMangaFromId(string publicationId) + { + return GetMangaFromUrl($"https://mangakatana.com/manga/{publicationId}"); + } + + public override Manga? GetMangaFromUrl(string url) + { + RequestResult requestResult = + downloadClient.MakeRequest(url, RequestType.MangaInfo); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return null; + return ParseSinglePublicationFromHtml(requestResult.result, url.Split('/')[^1], url); + } + + private Manga[] ParsePublicationsFromHtml(Stream html) + { + StreamReader reader = new(html); + string htmlString = reader.ReadToEnd(); + HtmlDocument document = new(); + document.LoadHtml(htmlString); + IEnumerable searchResults = document.DocumentNode.SelectNodes("//*[@id='book_list']/div"); + if (searchResults is null || !searchResults.Any()) + return Array.Empty(); + List urls = new(); + foreach (HtmlNode mangaResult in searchResults) + { + urls.Add(mangaResult.Descendants("a").First().GetAttributes() + .First(a => a.Name == "href").Value); + } + + HashSet ret = new(); + foreach (string url in urls) + { + Manga? manga = GetMangaFromUrl(url); + if (manga is not null) + ret.Add((Manga)manga); + } + + return ret.ToArray(); + } + + private Manga ParseSinglePublicationFromHtml(Stream html, string publicationId, string websiteUrl) + { + StreamReader reader = new(html); + string htmlString = reader.ReadToEnd(); + HtmlDocument document = new(); + document.LoadHtml(htmlString); + Dictionary altTitles = new(); + Dictionary? links = null; + HashSet tags = new(); + string[] authors = Array.Empty(); + string originalLanguage = ""; + MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased; + + HtmlNode infoNode = document.DocumentNode.SelectSingleNode("//*[@id='single_book']"); + string sortName = infoNode.Descendants("h1").First(n => n.HasClass("heading")).InnerText; + HtmlNode infoTable = infoNode.SelectSingleNode("//*[@id='single_book']/div[2]/div/ul"); + + foreach (HtmlNode row in infoTable.Descendants("li")) + { + string key = row.SelectNodes("div").First().InnerText.ToLower(); + string value = row.SelectNodes("div").Last().InnerText; + string keySanitized = string.Concat(Regex.Matches(key, "[a-z]")); + + switch (keySanitized) + { + case "altnames": + string[] alts = value.Split(" ; "); + for (int i = 0; i < alts.Length; i++) + altTitles.Add(i.ToString(), alts[i]); + break; + case "authorsartists": + authors = value.Split(','); + break; + case "status": + switch (value.ToLower()) + { + case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break; + case "completed": releaseStatus = MangaReleaseStatus.Completed; break; + } + break; + case "genres": + tags = row.SelectNodes("div").Last().Descendants("a").Select(a => a.InnerText).ToHashSet(); + break; + } + } + + string posterUrl = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[1]/div").Descendants("img").First() + .GetAttributes().First(a => a.Name == "src").Value; + + string description = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[3]/p").InnerText; + while (description.StartsWith('\n')) + description = description.Substring(1); + + int year = DateTime.Now.Year; + string yearString = infoTable.Descendants("div").First(d => d.HasClass("updateAt")) + .InnerText.Split('-')[^1]; + + if(yearString.Contains("ago") == false) + { + year = Convert.ToInt32(yearString); + } + + Manga manga = //TODO + return manga; + } + + public override Chapter[] GetChapters(Manga manga, string language="en") + { + Log($"Getting chapters {manga}"); + string requestUrl = $"https://mangakatana.com/manga/{manga.MangaId}"; + // Leaving this in for verification if the page exists + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return Array.Empty(); + + //Return Chapters ordered by Chapter-Number + List chapters = ParseChaptersFromHtml(manga, requestUrl); + return chapters.Order().ToArray(); + } + + private List ParseChaptersFromHtml(Manga manga, string mangaUrl) + { + // Using HtmlWeb will include the chapters since they are loaded with js + HtmlWeb web = new(); + HtmlDocument document = web.Load(mangaUrl); + + List ret = new(); + + HtmlNode chapterList = document.DocumentNode.SelectSingleNode("//div[contains(@class, 'chapters')]/table/tbody"); + + Regex volumeRex = new(@"[0-9a-z\-\.]+\/[0-9a-z\-]*v([0-9\.]+)"); + Regex chapterNumRex = new(@"[0-9a-z\-\.]+\/[0-9a-z\-]*c([0-9\.]+)"); + Regex chapterNameRex = new(@"Chapter [0-9\.]+:? (.*)"); + + foreach (HtmlNode chapterInfo in chapterList.Descendants("tr")) + { + string fullString = chapterInfo.Descendants("a").First().InnerText; + string url = chapterInfo.Descendants("a").First() + .GetAttributeValue("href", ""); + + float? volumeNumber = volumeRex.IsMatch(url) ? float.Parse(volumeRex.Match(url).Groups[1].Value) : null; + float chapterNumber = float.Parse(chapterNumRex.Match(url).Groups[1].Value); + string chapterName = chapterNameRex.Match(fullString).Groups[1].Value; + try + { + ret.Add(new Chapter(manga, url, chapterNumber, volumeNumber, chapterName)); + } + catch (Exception e) + { + } + } + + return ret; + } + + internal override string[] GetChapterImageUrls(Chapter chapter) + { + string requestUrl = chapter.Url; + // Leaving this in to check if the page exists + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) + { + return []; + } + + string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument); + return imageUrls; + } + + private string[] ParseImageUrlsFromHtml(HtmlDocument document) + { + // Images are loaded dynamically, but the urls are present in a piece of js code on the page + string js = document.DocumentNode.SelectSingleNode("//script[contains(., 'data-src')]").InnerText + .Replace("\r", "") + .Replace("\n", "") + .Replace("\t", ""); + + // ReSharper disable once StringLiteralTypo + string regexPat = @"(var thzq=\[')(.*)(,];function)"; + var group = Regex.Matches(js, regexPat).First().Groups[2].Value.Replace("'", ""); + var urls = group.Split(','); + + return urls; + } +} \ No newline at end of file diff --git a/API/Schema/MangaConnectors/MangaLife.cs b/API/Schema/MangaConnectors/MangaLife.cs new file mode 100644 index 0000000..ec2f398 --- /dev/null +++ b/API/Schema/MangaConnectors/MangaLife.cs @@ -0,0 +1,175 @@ +using System.Net; +using System.Text.RegularExpressions; +using API.MangaDownloadClients; +using HtmlAgilityPack; + +namespace API.Schema.MangaConnectors; + +public class MangaLife : MangaConnector +{ + public MangaLife() : base("Manga4Life", ["en"], ["manga4life.com"]) + { + this.downloadClient = new ChromiumDownloadClient(); + } + + public override Manga[] GetManga(string publicationTitle = "") + { + string sanitizedTitle = WebUtility.UrlEncode(publicationTitle); + string requestUrl = $"https://manga4life.com/search/?name={sanitizedTitle}"; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return Array.Empty(); + + if (requestResult.htmlDocument is null) + return Array.Empty(); + Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument); + return publications; + } + + public override Manga? GetMangaFromId(string publicationId) + { + return GetMangaFromUrl($"https://manga4life.com/manga/{publicationId}"); + } + + public override Manga? GetMangaFromUrl(string url) + { + Regex publicationIdRex = new(@"https:\/\/(www\.)?manga4life.com\/manga\/(.*)(\/.*)*"); + string publicationId = publicationIdRex.Match(url).Groups[2].Value; + + RequestResult requestResult = this.downloadClient.MakeRequest(url, RequestType.MangaInfo); + if(requestResult.htmlDocument is not null) + return ParseSinglePublicationFromHtml(requestResult.htmlDocument, publicationId, url); + return null; + } + + private Manga[] ParsePublicationsFromHtml(HtmlDocument document) + { + HtmlNode resultsNode = document.DocumentNode.SelectSingleNode("//div[@class='BoxBody']/div[last()]/div[1]/div"); + if (resultsNode.Descendants("div").Count() == 1 && resultsNode.Descendants("div").First().HasClass("NoResults")) + { + return []; + } + + HashSet ret = new(); + + foreach (HtmlNode resultNode in resultsNode.SelectNodes("div")) + { + string url = resultNode.Descendants().First(d => d.HasClass("SeriesName")).GetAttributeValue("href", ""); + Manga? manga = GetMangaFromUrl($"https://manga4life.com{url}"); + if (manga is not null) + ret.Add((Manga)manga); + } + + return ret.ToArray(); + } + + + private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) + { + string originalLanguage = "", status = ""; + Dictionary altTitles = new(), links = new(); + HashSet tags = new(); + MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased; + + HtmlNode posterNode = document.DocumentNode.SelectSingleNode("//div[@class='BoxBody']//div[@class='row']//img"); + string posterUrl = posterNode.GetAttributeValue("src", ""); + + HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//div[@class='BoxBody']//div[@class='row']//h1"); + string sortName = titleNode.InnerText; + + HtmlNode[] authorsNodes = document.DocumentNode + .SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Author(s):']/..").Descendants("a") + .ToArray(); + List authors = new(); + foreach (HtmlNode authorNode in authorsNodes) + authors.Add(authorNode.InnerText); + + HtmlNode[] genreNodes = document.DocumentNode + .SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Genre(s):']/..").Descendants("a") + .ToArray(); + foreach (HtmlNode genreNode in genreNodes) + tags.Add(genreNode.InnerText); + + HtmlNode yearNode = document.DocumentNode + .SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Released:']/..").Descendants("a") + .First(); + int year = Convert.ToInt32(yearNode.InnerText); + + HtmlNode[] statusNodes = document.DocumentNode + .SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Status:']/..").Descendants("a") + .ToArray(); + foreach (HtmlNode statusNode in statusNodes) + if (statusNode.InnerText.Contains("publish", StringComparison.CurrentCultureIgnoreCase)) + status = statusNode.InnerText.Split(' ')[0]; + switch (status.ToLower()) + { + case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break; + case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break; + case "discontinued": releaseStatus = MangaReleaseStatus.Cancelled; break; + case "complete": releaseStatus = MangaReleaseStatus.Completed; break; + case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break; + } + + HtmlNode descriptionNode = document.DocumentNode + .SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Description:']/..") + .Descendants("div").First(); + string description = descriptionNode.InnerText; + + Manga manga = //TODO + return manga; + } + + public override Chapter[] GetChapters(Manga manga, string language="en") + { + RequestResult result = downloadClient.MakeRequest($"https://manga4life.com/manga/{manga.MangaId}", RequestType.Default, clickButton:"[class*='ShowAllChapters']"); + if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null) + { + return Array.Empty(); + } + + HtmlNodeCollection chapterNodes = result.htmlDocument.DocumentNode.SelectNodes( + "//a[contains(concat(' ',normalize-space(@class),' '),' ChapterLink ')]"); + string[] urls = chapterNodes.Select(node => node.GetAttributeValue("href", "")).ToArray(); + Regex urlRex = new (@"-chapter-([0-9\\.]+)(-index-([0-9\\.]+))?"); + + List chapters = new(); + foreach (string url in urls) + { + Match rexMatch = urlRex.Match(url); + + float? volumeNumber = rexMatch.Groups[3].Success && rexMatch.Groups[3].Value.Length > 0 ? + float.Parse(rexMatch.Groups[3].Value) : null; + float chapterNumber = float.Parse(rexMatch.Groups[1].Value); + string fullUrl = $"https://manga4life.com{url}"; + fullUrl = fullUrl.Replace(Regex.Match(url,"(-page-[0-9])").Value,""); + try + { + chapters.Add(new Chapter(manga, fullUrl, chapterNumber, volumeNumber, null)); + } + catch (Exception e) + { + } + } + //Return Chapters ordered by Chapter-Number + return chapters.Order().ToArray(); + } + + internal override string[] GetChapterImageUrls(Chapter chapter) + { + RequestResult requestResult = this.downloadClient.MakeRequest(chapter.Url, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) + { + return []; + } + + HtmlDocument document = requestResult.htmlDocument; + + HtmlNode gallery = document.DocumentNode.Descendants("div").First(div => div.HasClass("ImageGallery")); + HtmlNode[] images = gallery.Descendants("img").Where(img => img.HasClass("img-fluid")).ToArray(); + List urls = new(); + foreach(HtmlNode galleryImage in images) + urls.Add(galleryImage.GetAttributeValue("src", "")); + return urls.ToArray(); + } +} \ No newline at end of file diff --git a/API/Schema/MangaConnectors/Manganato.cs b/API/Schema/MangaConnectors/Manganato.cs new file mode 100644 index 0000000..b8b6884 --- /dev/null +++ b/API/Schema/MangaConnectors/Manganato.cs @@ -0,0 +1,212 @@ +using System.Globalization; +using System.Net; +using System.Text.RegularExpressions; +using API.MangaDownloadClients; +using HtmlAgilityPack; + +namespace API.Schema.MangaConnectors; + +public class Manganato : MangaConnector +{ + public Manganato() : base("Manganato", ["en"], ["manganato.com"]) + { + this.downloadClient = new HttpDownloadClient(); + } + + public override Manga[] GetManga(string publicationTitle = "") + { + string sanitizedTitle = string.Join('_', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower(); + string requestUrl = $"https://manganato.com/search/story/{sanitizedTitle}"; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 ||requestResult.htmlDocument is null) + return []; + Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument); + return publications; + } + + private Manga[] ParsePublicationsFromHtml(HtmlDocument document) + { + List searchResults = document.DocumentNode.Descendants("div").Where(n => n.HasClass("search-story-item")).ToList(); + List urls = new(); + foreach (HtmlNode mangaResult in searchResults) + { + urls.Add(mangaResult.Descendants("a").First(n => n.HasClass("item-title")).GetAttributes() + .First(a => a.Name == "href").Value); + } + + HashSet ret = new(); + foreach (string url in urls) + { + Manga? manga = GetMangaFromUrl(url); + if (manga is not null) + ret.Add(manga); + } + + return ret.ToArray(); + } + + public override Manga? GetMangaFromId(string publicationId) + { + return GetMangaFromUrl($"https://chapmanganato.com/{publicationId}"); + } + + public override Manga? GetMangaFromUrl(string url) + { + RequestResult requestResult = + downloadClient.MakeRequest(url, RequestType.MangaInfo); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return null; + + if (requestResult.htmlDocument is null) + return null; + return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url); + } + + private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) + { + Dictionary altTitles = new(); + Dictionary? links = null; + HashSet tags = new(); + string[] authors = Array.Empty(); + string originalLanguage = ""; + MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased; + + HtmlNode infoNode = document.DocumentNode.Descendants("div").First(d => d.HasClass("story-info-right")); + + string sortName = infoNode.Descendants("h1").First().InnerText; + + HtmlNode infoTable = infoNode.Descendants().First(d => d.Name == "table"); + + foreach (HtmlNode row in infoTable.Descendants("tr")) + { + string key = row.SelectNodes("td").First().InnerText.ToLower(); + string value = row.SelectNodes("td").Last().InnerText; + string keySanitized = string.Concat(Regex.Matches(key, "[a-z]")); + + switch (keySanitized) + { + case "alternative": + string[] alts = value.Split(" ; "); + for(int i = 0; i < alts.Length; i++) + altTitles.Add(i.ToString(), alts[i]); + break; + case "authors": + authors = value.Split('-'); + for (int i = 0; i < authors.Length; i++) + authors[i] = authors[i].Replace("\r\n", ""); + break; + case "status": + switch (value.ToLower()) + { + case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break; + case "completed": releaseStatus = MangaReleaseStatus.Completed; break; + } + break; + case "genres": + string[] genres = value.Split(" - "); + for (int i = 0; i < genres.Length; i++) + genres[i] = genres[i].Replace("\r\n", ""); + tags = genres.ToHashSet(); + break; + } + } + + string posterUrl = document.DocumentNode.Descendants("span").First(s => s.HasClass("info-image")).Descendants("img").First() + .GetAttributes().First(a => a.Name == "src").Value; + + string description = document.DocumentNode.Descendants("div").First(d => d.HasClass("panel-story-info-description")) + .InnerText.Replace("Description :", ""); + while (description.StartsWith('\n')) + description = description.Substring(1); + + string pattern = "MMM dd,yyyy HH:mm"; + + HtmlNode? oldestChapter = document.DocumentNode + .SelectNodes("//span[contains(concat(' ',normalize-space(@class),' '),' chapter-time ')]").MaxBy( + node => DateTime.ParseExact(node.GetAttributeValue("title", "Dec 31 2400, 23:59"), pattern, + CultureInfo.InvariantCulture).Millisecond); + + + int year = DateTime.ParseExact(oldestChapter?.GetAttributeValue("title", "Dec 31 2400, 23:59")??"Dec 31 2400, 23:59", pattern, + CultureInfo.InvariantCulture).Year; + + Manga manga = //TODO + return manga; + } + + public override Chapter[] GetChapters(Manga manga, string language="en") + { + string requestUrl = $"https://chapmanganato.com/{manga.MangaId}"; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return []; + + //Return Chapters ordered by Chapter-Number + if (requestResult.htmlDocument is null) + return []; + List chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument); + return chapters.Order().ToArray(); + } + + + private List ParseChaptersFromHtml(Manga manga, HtmlDocument document) + { + List ret = new(); + + HtmlNode chapterList = document.DocumentNode.Descendants("ul").First(l => l.HasClass("row-content-chapter")); + + Regex volRex = new(@"Vol\.([0-9]+).*"); + Regex chapterRex = new(@"https:\/\/chapmanganato.[A-z]+\/manga-[A-z0-9]+\/chapter-([0-9\.]+)"); + Regex nameRex = new(@"Chapter ([0-9]+(\.[0-9]+)*){1}:? (.*)"); + + foreach (HtmlNode chapterInfo in chapterList.Descendants("li")) + { + string fullString = chapterInfo.Descendants("a").First(d => d.HasClass("chapter-name")).InnerText; + + string url = chapterInfo.Descendants("a").First(d => d.HasClass("chapter-name")) + .GetAttributeValue("href", ""); + + float? volumeNumber = volRex.IsMatch(fullString) ? float.Parse(volRex.Match(fullString).Groups[1].Value) : null; + float chapterNumber = float.Parse(chapterRex.Match(url).Groups[1].Value); + string chapterName = nameRex.Match(fullString).Groups[3].Value; + try + { + ret.Add(new Chapter(manga, url, chapterNumber, volumeNumber, chapterName)); + } + catch (Exception e) + { + } + } + ret.Reverse(); + return ret; + } + + internal override string[] GetChapterImageUrls(Chapter chapter) + { + string requestUrl = chapter.Url; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || + requestResult.htmlDocument is null) + { + return []; + } + + string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument); + return imageUrls; + } + + private string[] ParseImageUrlsFromHtml(HtmlDocument document) + { + List ret = new(); + + HtmlNode imageContainer = + document.DocumentNode.Descendants("div").First(i => i.HasClass("container-chapter-reader")); + foreach(HtmlNode imageNode in imageContainer.Descendants("img")) + ret.Add(imageNode.GetAttributeValue("src", "")); + + return ret.ToArray(); + } +} \ No newline at end of file diff --git a/API/Schema/MangaConnectors/Mangasee.cs b/API/Schema/MangaConnectors/Mangasee.cs new file mode 100644 index 0000000..98c9934 --- /dev/null +++ b/API/Schema/MangaConnectors/Mangasee.cs @@ -0,0 +1,204 @@ +using System.Data; +using System.Net; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using API.MangaDownloadClients; +using HtmlAgilityPack; +using Newtonsoft.Json; +using Soenneker.Utils.String.NeedlemanWunsch; + +namespace API.Schema.MangaConnectors; + +public class Mangasee : MangaConnector +{ + public Mangasee() : base("Mangasee", ["en"], ["mangasee123.com"]) + { + this.downloadClient = new ChromiumDownloadClient(); + } + + private struct SearchResult + { + public string i { get; set; } + public string s { get; set; } + public string[] a { get; set; } + } + + public override Manga[] GetManga(string publicationTitle = "") + { + string requestUrl = "https://mangasee123.com/_search.php"; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) + { + return []; + } + + try + { + SearchResult[] searchResults = JsonConvert.DeserializeObject(requestResult.htmlDocument!.DocumentNode.InnerText) ?? + throw new NoNullAllowedException(); + SearchResult[] filteredResults = FilteredResults(publicationTitle, searchResults); + + + string[] urls = filteredResults.Select(result => $"https://mangasee123.com/manga/{result.i}").ToArray(); + List searchResultManga = new(); + foreach (string url in urls) + { + Manga? newManga = GetMangaFromUrl(url); + if(newManga is { } manga) + searchResultManga.Add(manga); + } + return searchResultManga.ToArray(); + } + catch (NoNullAllowedException) + { + return []; + } + } + + private readonly string[] _filterWords = {"a", "the", "of", "as", "to", "no", "for", "on", "with", "be", "and", "in", "wa", "at", "be", "ni"}; + private string ToFilteredString(string input) => string.Join(' ', input.ToLower().Split(' ').Where(word => _filterWords.Contains(word) == false)); + private SearchResult[] FilteredResults(string publicationTitle, SearchResult[] unfilteredSearchResults) + { + Dictionary similarity = new(); + foreach (SearchResult sr in unfilteredSearchResults) + { + List scores = new(); + string filteredPublicationString = ToFilteredString(publicationTitle); + string filteredSString = ToFilteredString(sr.s); + scores.Add(NeedlemanWunschStringUtil.CalculateSimilarity(filteredSString, filteredPublicationString)); + foreach (string srA in sr.a) + { + string filteredAString = ToFilteredString(srA); + scores.Add(NeedlemanWunschStringUtil.CalculateSimilarity(filteredAString, filteredPublicationString)); + } + similarity.Add(sr, scores.Sum() / scores.Count); + } + + List ret = similarity.OrderBy(s => s.Value).Take(10).Select(s => s.Key).ToList(); + return ret.ToArray(); + } + + public override Manga? GetMangaFromId(string publicationId) + { + return GetMangaFromUrl($"https://mangasee123.com/manga/{publicationId}"); + } + + public override Manga? GetMangaFromUrl(string url) + { + Regex publicationIdRex = new(@"https:\/\/mangasee123.com\/manga\/(.*)(\/.*)*"); + string publicationId = publicationIdRex.Match(url).Groups[1].Value; + + RequestResult requestResult = this.downloadClient.MakeRequest(url, RequestType.MangaInfo); + if((int)requestResult.statusCode < 300 && (int)requestResult.statusCode >= 200 && requestResult.htmlDocument is not null) + return ParseSinglePublicationFromHtml(requestResult.htmlDocument, publicationId, url); + return null; + } + + private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) + { + string originalLanguage = "", status = ""; + Dictionary altTitles = new(), links = new(); + HashSet tags = new(); + MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased; + + HtmlNode posterNode = document.DocumentNode.SelectSingleNode("//div[@class='BoxBody']//div[@class='row']//img"); + string posterUrl = posterNode.GetAttributeValue("src", ""); + + HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//div[@class='BoxBody']//div[@class='row']//h1"); + string sortName = titleNode.InnerText; + + HtmlNode[] authorsNodes = document.DocumentNode + .SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Author(s):']/..").Descendants("a") + .ToArray(); + List authors = new(); + foreach (HtmlNode authorNode in authorsNodes) + authors.Add(authorNode.InnerText); + + HtmlNode[] genreNodes = document.DocumentNode + .SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Genre(s):']/..").Descendants("a") + .ToArray(); + foreach (HtmlNode genreNode in genreNodes) + tags.Add(genreNode.InnerText); + + HtmlNode yearNode = document.DocumentNode + .SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Released:']/..").Descendants("a") + .First(); + int year = Convert.ToInt32(yearNode.InnerText); + + HtmlNode[] statusNodes = document.DocumentNode + .SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Status:']/..").Descendants("a") + .ToArray(); + foreach (HtmlNode statusNode in statusNodes) + if (statusNode.InnerText.Contains("publish", StringComparison.CurrentCultureIgnoreCase)) + status = statusNode.InnerText.Split(' ')[0]; + switch (status.ToLower()) + { + case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break; + case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break; + case "discontinued": releaseStatus = MangaReleaseStatus.Cancelled; break; + case "complete": releaseStatus = MangaReleaseStatus.Completed; break; + case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break; + } + + HtmlNode descriptionNode = document.DocumentNode + .SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Description:']/..") + .Descendants("div").First(); + string description = descriptionNode.InnerText; + + Manga manga = //TODO + return manga; + } + + public override Chapter[] GetChapters(Manga manga, string language="en") + { + try + { + XDocument doc = XDocument.Load($"https://mangasee123.com/rss/{manga.MangaId}.xml"); + XElement[] chapterItems = doc.Descendants("item").ToArray(); + List chapters = new(); + Regex chVolRex = new(@".*chapter-([0-9\.]+)(?:-index-([0-9\.]+))?.*"); + foreach (XElement chapter in chapterItems) + { + string url = chapter.Descendants("link").First().Value; + Match m = chVolRex.Match(url); + float? volumeNumber = m.Groups[2].Success ? float.Parse(m.Groups[2].Value) : null; + float chapterNumber = float.Parse(m.Groups[1].Value); + + string chapterUrl = Regex.Replace(url, @"-page-[0-9]+(\.html)", ".html"); + try + { + chapters.Add(new Chapter(manga, chapterUrl,chapterNumber, volumeNumber, null)); + } + catch (Exception e) + { + } + } + + //Return Chapters ordered by Chapter-Number + return chapters.Order().ToArray(); + } + catch (HttpRequestException e) + { + return Array.Empty(); + } + } + + internal override string[] GetChapterImageUrls(Chapter chapter) + { + RequestResult requestResult = this.downloadClient.MakeRequest(chapter.Url, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) + { + return []; + } + + HtmlDocument document = requestResult.htmlDocument; + + HtmlNode gallery = document.DocumentNode.Descendants("div").First(div => div.HasClass("ImageGallery")); + HtmlNode[] images = gallery.Descendants("img").Where(img => img.HasClass("img-fluid")).ToArray(); + List urls = new(); + foreach(HtmlNode galleryImage in images) + urls.Add(galleryImage.GetAttributeValue("src", "")); + return urls.ToArray(); + } +} \ No newline at end of file diff --git a/API/Schema/MangaConnectors/Mangaworld.cs b/API/Schema/MangaConnectors/Mangaworld.cs new file mode 100644 index 0000000..eb34bf5 --- /dev/null +++ b/API/Schema/MangaConnectors/Mangaworld.cs @@ -0,0 +1,211 @@ +using System.Net; +using System.Text.RegularExpressions; +using API.MangaDownloadClients; +using HtmlAgilityPack; + +namespace API.Schema.MangaConnectors; + +public class Mangaworld : MangaConnector +{ + public Mangaworld() : base("Mangaworld", ["it"], ["www.mangaworld.ac"]) + { + this.downloadClient = new HttpDownloadClient(); + } + + public override Manga[] GetManga(string publicationTitle = "") + { + string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower(); + string requestUrl = $"https://www.mangaworld.ac/archive?keyword={sanitizedTitle}"; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return Array.Empty(); + + if (requestResult.htmlDocument is null) + return Array.Empty(); + Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument); + return publications; + } + + private Manga[] ParsePublicationsFromHtml(HtmlDocument document) + { + if (!document.DocumentNode.SelectSingleNode("//div[@class='comics-grid']").ChildNodes + .Any(node => node.HasClass("entry"))) + return Array.Empty(); + + List urls = document.DocumentNode + .SelectNodes( + "//div[@class='comics-grid']//div[@class='entry']//a[contains(concat(' ',normalize-space(@class),' '),'thumb')]") + .Select(thumb => thumb.GetAttributeValue("href", "")).ToList(); + + HashSet ret = new(); + foreach (string url in urls) + { + Manga? manga = GetMangaFromUrl(url); + if (manga is not null) + ret.Add((Manga)manga); + } + + return ret.ToArray(); + } + + public override Manga? GetMangaFromId(string publicationId) + { + return GetMangaFromUrl($"https://www.mangaworld.ac/manga/{publicationId}"); + } + + public override Manga? GetMangaFromUrl(string url) + { + RequestResult requestResult = + downloadClient.MakeRequest(url, RequestType.MangaInfo); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return null; + + if (requestResult.htmlDocument is null) + return null; + + Regex idRex = new (@"https:\/\/www\.mangaworld\.[a-z]{0,63}\/manga\/([0-9]+\/[0-9A-z\-]+).*"); + string id = idRex.Match(url).Groups[1].Value; + return ParseSinglePublicationFromHtml(requestResult.htmlDocument, id, url); + } + + private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) + { + Dictionary altTitles = new(); + Dictionary? links = null; + string originalLanguage = ""; + MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased; + + HtmlNode infoNode = document.DocumentNode.Descendants("div").First(d => d.HasClass("info")); + + string sortName = infoNode.Descendants("h1").First().InnerText; + + HtmlNode metadata = infoNode.Descendants().First(d => d.HasClass("meta-data")); + + HtmlNode altTitlesNode = metadata.SelectSingleNode("//span[text()='Titoli alternativi: ' or text()='Titolo alternativo: ']/..").ChildNodes[1]; + + string[] alts = altTitlesNode.InnerText.Split(", "); + for(int i = 0; i < alts.Length; i++) + altTitles.Add(i.ToString(), alts[i]); + + HtmlNode genresNode = + metadata.SelectSingleNode("//span[text()='Generi: ' or text()='Genero: ']/.."); + HashSet tags = genresNode.SelectNodes("a").Select(node => node.InnerText).ToHashSet(); + + HtmlNode authorsNode = + metadata.SelectSingleNode("//span[text()='Autore: ' or text()='Autori: ']/.."); + string[] authors = authorsNode.SelectNodes("a").Select(node => node.InnerText).ToArray(); + + string status = metadata.SelectSingleNode("//span[text()='Stato: ']/..").SelectNodes("a").First().InnerText; + // ReSharper disable 5 times StringLiteralTypo + switch (status.ToLower()) + { + case "cancellato": releaseStatus = MangaReleaseStatus.Cancelled; break; + case "in pausa": releaseStatus = MangaReleaseStatus.OnHiatus; break; + case "droppato": releaseStatus = MangaReleaseStatus.Cancelled; break; + case "finito": releaseStatus = MangaReleaseStatus.Completed; break; + case "in corso": releaseStatus = MangaReleaseStatus.Continuing; break; + } + + string posterUrl = document.DocumentNode.SelectSingleNode("//img[@class='rounded']").GetAttributeValue("src", ""); + + string description = document.DocumentNode.SelectSingleNode("//div[@id='noidungm']").InnerText; + + string yearString = metadata.SelectSingleNode("//span[text()='Anno di uscita: ']/..").SelectNodes("a").First().InnerText; + int year = Convert.ToInt32(yearString); + + Manga manga = //TODO + return manga; + } + + public override Chapter[] GetChapters(Manga manga, string language="en") + { + string requestUrl = $"https://www.mangaworld.ac/manga/{manga.MangaId}"; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) + return []; + + List chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument); + return chapters.Order().ToArray(); + } + + private List ParseChaptersFromHtml(Manga manga, HtmlDocument document) + { + List ret = new(); + + HtmlNode chaptersWrapper = + document.DocumentNode.SelectSingleNode( + "//div[contains(concat(' ',normalize-space(@class),' '),'chapters-wrapper')]"); + + Regex volumeRex = new(@"[Vv]olume ([0-9]+).*"); + Regex chapterRex = new(@"[Cc]apitolo ([0-9]+(?:\.[0-9]+)?).*"); + Regex idRex = new(@".*\/read\/([a-z0-9]+)(?:[?\/].*)?"); + if (chaptersWrapper.Descendants("div").Any(descendant => descendant.HasClass("volume-element"))) + { + foreach (HtmlNode volNode in document.DocumentNode.SelectNodes("//div[contains(concat(' ',normalize-space(@class),' '),'volume-element')]")) + { + string volume = volumeRex.Match(volNode.SelectNodes("div").First(node => node.HasClass("volume")).SelectSingleNode("p").InnerText).Groups[1].Value; + foreach (HtmlNode chNode in volNode.SelectNodes("div").First(node => node.HasClass("volume-chapters")).SelectNodes("div")) + { + + string number = chapterRex.Match(chNode.SelectSingleNode("a").SelectSingleNode("span").InnerText).Groups[1].Value; + string url = chNode.SelectSingleNode("a").GetAttributeValue("href", ""); + string id = idRex.Match(chNode.SelectSingleNode("a").GetAttributeValue("href", "")).Groups[1].Value; + try + { + ret.Add(new Chapter(manga, null, volume, number, url, id)); + } + catch (Exception e) + { + } + } + } + } + else + { + foreach (HtmlNode chNode in chaptersWrapper.SelectNodes("div").Where(node => node.HasClass("chapter"))) + { + string number = chapterRex.Match(chNode.SelectSingleNode("a").SelectSingleNode("span").InnerText).Groups[1].Value; + string url = chNode.SelectSingleNode("a").GetAttributeValue("href", ""); + string id = idRex.Match(chNode.SelectSingleNode("a").GetAttributeValue("href", "")).Groups[1].Value; + try + { + ret.Add(new Chapter(manga, null, null, number, url, id)); + } + catch (Exception e) + { + } + } + } + + ret.Reverse(); + return ret; + } + + internal override string[] GetChapterImageUrls(Chapter chapter) + { + string requestUrl = $"{chapter.Url}?style=list"; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) + { + return []; + } + + string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument); + return imageUrls; + } + + private string[] ParseImageUrlsFromHtml(HtmlDocument document) + { + List ret = new(); + + HtmlNode imageContainer = + document.DocumentNode.SelectSingleNode("//div[@id='page']"); + foreach(HtmlNode imageNode in imageContainer.Descendants("img")) + ret.Add(imageNode.GetAttributeValue("src", "")); + + return ret.ToArray(); + } +} \ No newline at end of file diff --git a/API/Schema/MangaConnectors/ManhuaPlus.cs b/API/Schema/MangaConnectors/ManhuaPlus.cs new file mode 100644 index 0000000..efa8c5d --- /dev/null +++ b/API/Schema/MangaConnectors/ManhuaPlus.cs @@ -0,0 +1,173 @@ +using System.Net; +using System.Text.RegularExpressions; +using API.MangaDownloadClients; +using HtmlAgilityPack; + +namespace API.Schema.MangaConnectors; + +public class ManhuaPlus : MangaConnector +{ + public ManhuaPlus() : base("ManhuaPlus", ["en"], ["manhuaplus.org"]) + { + this.downloadClient = new ChromiumDownloadClient(); + } + + public override Manga[] GetManga(string publicationTitle = "") + { + string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower(); + string requestUrl = $"https://manhuaplus.org/search?keyword={sanitizedTitle}"; + RequestResult requestResult = + downloadClient.MakeRequest(requestUrl, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) + return Array.Empty(); + + if (requestResult.htmlDocument is null) + return Array.Empty(); + Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument); + return publications; + } + + private Manga[] ParsePublicationsFromHtml(HtmlDocument document) + { + if (document.DocumentNode.SelectSingleNode("//h1/../..").ChildNodes//I already want to not. + .Any(node => node.InnerText.Contains("No manga found"))) + return Array.Empty(); + + List urls = document.DocumentNode + .SelectNodes("//h1/../..//a[contains(@href, 'https://manhuaplus.org/manga/') and contains(concat(' ',normalize-space(@class),' '),' clamp ') and not(contains(@href, '/chapter'))]") + .Select(mangaNode => mangaNode.GetAttributeValue("href", "")).ToList(); + + HashSet ret = new(); + foreach (string url in urls) + { + Manga? manga = GetMangaFromUrl(url); + if (manga is not null) + ret.Add((Manga)manga); + } + + return ret.ToArray(); + } + + public override Manga? GetMangaFromId(string publicationId) + { + return GetMangaFromUrl($"https://manhuaplus.org/manga/{publicationId}"); + } + + public override Manga? GetMangaFromUrl(string url) + { + Regex publicationIdRex = new(@"https:\/\/manhuaplus.org\/manga\/(.*)(\/.*)*"); + string publicationId = publicationIdRex.Match(url).Groups[1].Value; + + RequestResult requestResult = this.downloadClient.MakeRequest(url, RequestType.MangaInfo); + if((int)requestResult.statusCode < 300 && (int)requestResult.statusCode >= 200 && requestResult.htmlDocument is not null && requestResult.redirectedToUrl != "https://manhuaplus.org/home") //When manga doesnt exists it redirects to home + return ParseSinglePublicationFromHtml(requestResult.htmlDocument, publicationId, url); + return null; + } + + private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) + { + string originalLanguage = "", status = ""; + Dictionary altTitles = new(), links = new(); + HashSet tags = new(); + MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased; + + HtmlNode posterNode = document.DocumentNode.SelectSingleNode("/html/body/main/div/div/div[2]/div[1]/figure/a/img");//BRUH + Regex posterRex = new(@".*(\/uploads/covers/[a-zA-Z0-9\-\._\~\!\$\&\'\(\)\*\+\,\;\=\:\@]+).*"); + string posterUrl = $"https://manhuaplus.org/{posterRex.Match(posterNode.GetAttributeValue("src", "")).Groups[1].Value}"; + + HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//h1"); + string sortName = titleNode.InnerText.Replace("\n", ""); + + List authors = new(); + try + { + HtmlNode[] authorsNodes = document.DocumentNode + .SelectNodes("//a[contains(@href, 'https://manhuaplus.org/authors/')]") + .ToArray(); + foreach (HtmlNode authorNode in authorsNodes) + authors.Add(authorNode.InnerText); + } + catch (ArgumentNullException e) + { + } + + try + { + HtmlNode[] genreNodes = document.DocumentNode + .SelectNodes("//a[contains(@href, 'https://manhuaplus.org/genres/')]").ToArray(); + foreach (HtmlNode genreNode in genreNodes) + tags.Add(genreNode.InnerText.Replace("\n", "")); + } + catch (ArgumentNullException e) + { + } + + Regex yearRex = new(@"(?:[0-9]{1,2}\/){2}([0-9]{2,4}) [0-9]{1,2}:[0-9]{1,2}"); + HtmlNode yearNode = document.DocumentNode.SelectSingleNode("//aside//i[contains(concat(' ',normalize-space(@class),' '),' fa-clock ')]/../span"); + Match match = yearRex.Match(yearNode.InnerText); + int year = match.Success && match.Groups[1].Success ? int.Parse(match.Groups[1].Value) : 1960; + + status = document.DocumentNode.SelectSingleNode("//aside//i[contains(concat(' ',normalize-space(@class),' '),' fa-rss ')]/../span").InnerText.Replace("\n", ""); + switch (status.ToLower()) + { + case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break; + case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break; + case "discontinued": releaseStatus = MangaReleaseStatus.Cancelled; break; + case "complete": releaseStatus = MangaReleaseStatus.Completed; break; + case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break; + } + + HtmlNode descriptionNode = document.DocumentNode + .SelectSingleNode("//div[@id='syn-target']"); + string description = descriptionNode.InnerText; + + Manga manga = //TODO + return manga; + } + + public override Chapter[] GetChapters(Manga manga, string language="en") + { + RequestResult result = downloadClient.MakeRequest($"https://manhuaplus.org/manga/{manga.MangaId}", RequestType.Default); + if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null) + { + return Array.Empty(); + } + + HtmlNodeCollection chapterNodes = result.htmlDocument.DocumentNode.SelectNodes("//li[contains(concat(' ',normalize-space(@class),' '),' chapter ')]//a"); + string[] urls = chapterNodes.Select(node => node.GetAttributeValue("href", "")).ToArray(); + Regex urlRex = new (@".*\/chapter-([0-9\-]+).*"); + + List chapters = new(); + foreach (string url in urls) + { + Match rexMatch = urlRex.Match(url); + + float chapterNumber = float.Parse(rexMatch.Groups[1].Value); + string fullUrl = url; + try + { + chapters.Add(new Chapter(manga, fullUrl, chapterNumber, null, null)); + } + catch (Exception e) + { + } + } + //Return Chapters ordered by Chapter-Number + return chapters.Order().ToArray(); + } + + internal override string[] GetChapterImageUrls(Chapter chapter) + { + RequestResult requestResult = this.downloadClient.MakeRequest(chapter.Url, RequestType.Default); + if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) + { + return []; + } + + HtmlDocument document = requestResult.htmlDocument; + + HtmlNode[] images = document.DocumentNode.SelectNodes("//a[contains(concat(' ',normalize-space(@class),' '),' readImg ')]/img").ToArray(); + List urls = images.Select(node => node.GetAttributeValue("src", "")).ToList(); + return urls.ToArray(); + } +} \ No newline at end of file diff --git a/API/Schema/MangaReleaseStatus.cs b/API/Schema/MangaReleaseStatus.cs new file mode 100644 index 0000000..e405b0e --- /dev/null +++ b/API/Schema/MangaReleaseStatus.cs @@ -0,0 +1,10 @@ +namespace API.Schema; + +public enum MangaReleaseStatus : byte +{ + Continuing = 0, + Completed = 1, + OnHiatus = 2, + Cancelled = 3, + Unreleased = 4 +} \ No newline at end of file diff --git a/API/Schema/MangaTag.cs b/API/Schema/MangaTag.cs new file mode 100644 index 0000000..fdec050 --- /dev/null +++ b/API/Schema/MangaTag.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace API.Schema; + +[PrimaryKey("Tag")] +public class MangaTag(string tag) +{ + public string Tag { get; init; } = tag; + + [ForeignKey("MangaIds")] + public virtual Manga[] Mangas { get; internal set; } = []; +} \ No newline at end of file diff --git a/API/Schema/NotificationConnectors/Gotify.cs b/API/Schema/NotificationConnectors/Gotify.cs new file mode 100644 index 0000000..bc43d29 --- /dev/null +++ b/API/Schema/NotificationConnectors/Gotify.cs @@ -0,0 +1,8 @@ +namespace API.Schema.NotificationConnectors; + +public class Gotify(string endpoint, string appToken) + : NotificationConnector(TokenGen.CreateToken(typeof(Gotify), 64), NotificationConnectorType.Gotify) +{ + public string Endpoint { get; init; } = endpoint; + public string AppToken { get; init; } = appToken; +} \ No newline at end of file diff --git a/API/Schema/NotificationConnectors/Lunasea.cs b/API/Schema/NotificationConnectors/Lunasea.cs new file mode 100644 index 0000000..db6bd6b --- /dev/null +++ b/API/Schema/NotificationConnectors/Lunasea.cs @@ -0,0 +1,7 @@ +namespace API.Schema.NotificationConnectors; + +public class Lunasea(string id) + : NotificationConnector(TokenGen.CreateToken(typeof(Lunasea), 64), NotificationConnectorType.LunaSea) +{ + public string Id { get; init; } = id; +} \ No newline at end of file diff --git a/API/Schema/NotificationConnectors/NotificationConnector.cs b/API/Schema/NotificationConnectors/NotificationConnector.cs new file mode 100644 index 0000000..64c53be --- /dev/null +++ b/API/Schema/NotificationConnectors/NotificationConnector.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; + +namespace API.Schema.NotificationConnectors; + +[PrimaryKey("NotificationConnectorId")] +public abstract class NotificationConnector(string notificationConnectorId, NotificationConnectorType notificationConnectorType) : APISerializable +{ + [MaxLength(64)] + public string NotificationConnectorId { get; } = notificationConnectorId; + + public NotificationConnectorType NotificationConnectorType { get; init; } = notificationConnectorType; +} \ No newline at end of file diff --git a/API/Schema/NotificationConnectors/NotificationConnectorType.cs b/API/Schema/NotificationConnectors/NotificationConnectorType.cs new file mode 100644 index 0000000..9f38779 --- /dev/null +++ b/API/Schema/NotificationConnectors/NotificationConnectorType.cs @@ -0,0 +1,9 @@ +namespace API.Schema.NotificationConnectors; + + +public enum NotificationConnectorType : byte +{ + Gotify = 0, + LunaSea = 1, + Ntfy = 2 +} \ No newline at end of file diff --git a/API/Schema/NotificationConnectors/Ntfy.cs b/API/Schema/NotificationConnectors/Ntfy.cs new file mode 100644 index 0000000..446c261 --- /dev/null +++ b/API/Schema/NotificationConnectors/Ntfy.cs @@ -0,0 +1,9 @@ +namespace API.Schema.NotificationConnectors; + +public class Ntfy(string endpoint, string auth, string topic) + : NotificationConnector(TokenGen.CreateToken(typeof(Ntfy), 64), NotificationConnectorType.Ntfy) +{ + public string Endpoint { get; init; } = endpoint; + public string Auth { get; init; } = auth; + public string Topic { get; init; } = topic; +} \ No newline at end of file diff --git a/API/Schema/PgsqlContext.cs b/API/Schema/PgsqlContext.cs new file mode 100644 index 0000000..e19970e --- /dev/null +++ b/API/Schema/PgsqlContext.cs @@ -0,0 +1,78 @@ +using API.Schema.Jobs; +using API.Schema.LibraryConnectors; +using API.Schema.MangaConnectors; +using API.Schema.NotificationConnectors; +using Microsoft.EntityFrameworkCore; + +namespace API.Schema; + +public class PgsqlContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Jobs { get; set; } + public DbSet MangaConnectors { get; set; } + public DbSet Manga { get; set; } + public DbSet Chapters { get; set; } + public DbSet Authors { get; set; } + public DbSet Link { get; set; } + public DbSet Tags { get; set; } + public DbSet AltTitles { get; set; } + public DbSet LibraryConnectors { get; set; } + public DbSet NotificationConnectors { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasDiscriminator(l => l.LibraryType) + .HasValue(LibraryType.Komga) + .HasValue(LibraryType.Kavita); + modelBuilder.Entity() + .HasDiscriminator(n => n.NotificationConnectorType) + .HasValue(NotificationConnectorType.Gotify) + .HasValue(NotificationConnectorType.Ntfy) + .HasValue(NotificationConnectorType.LunaSea); + modelBuilder.Entity() + .HasDiscriminator(j => j.JobType) + .HasValue(JobType.MoveFileOrFolderJob) + .HasValue(JobType.DownloadNewChaptersJob) + .HasValue(JobType.DownloadSingleChapterJob) + .HasValue(JobType.UpdateMetaDataJob); + + modelBuilder.Entity() + .HasOne(c => c.ParentManga) + .WithMany(m => m.Chapters) + .HasForeignKey(c => c.ParentMangaId); + + modelBuilder.Entity() + .HasOne(m => m.LatestChapterAvailable) + .WithOne(); + modelBuilder.Entity() + .HasOne(m => m.LatestChapterDownloaded) + .WithOne(); + modelBuilder.Entity() + .HasOne(m => m.MangaConnector) + .WithMany(c => c.Mangas) + .HasForeignKey(m => m.MangaConnectorName); + modelBuilder.Entity() + .HasMany(m => m.Authors) + .WithMany(a => a.Mangas) + .UsingEntity( + "MangaAuthor", + l => l.HasOne(typeof(Manga)).WithMany().HasForeignKey("MangaId").HasPrincipalKey("MangaId"), + r => r.HasOne(typeof(Author)).WithMany().HasForeignKey("AuthorId").HasPrincipalKey("AuthorId"), + j => j.HasKey("MangaId", "AuthorId")); + modelBuilder.Entity() + .HasMany(m => m.Tags) + .WithMany(t => t.Mangas) + .UsingEntity( + "MangaTag", + l => l.HasOne(typeof(Manga)).WithMany().HasForeignKey("MangaId").HasPrincipalKey("MangaId"), + r => r.HasOne(typeof(MangaTag)).WithMany().HasForeignKey("Tag").HasPrincipalKey("Tag"), + j => j.HasKey("MangaId", "Tag")); + modelBuilder.Entity() + .HasMany(m => m.Links) + .WithOne(c => c.Manga); + modelBuilder.Entity() + .HasMany(m => m.AltTitles) + .WithOne(c => c.Manga); + } +} \ No newline at end of file diff --git a/API/TokenGen.cs b/API/TokenGen.cs new file mode 100644 index 0000000..2464ae0 --- /dev/null +++ b/API/TokenGen.cs @@ -0,0 +1,23 @@ +using System.Security.Cryptography; + +namespace API; + +public static class TokenGen +{ + private const uint MinimumLength = 8; + private const string Chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + + public static string CreateToken(Type t, uint fullLength) => CreateToken(t.Name, fullLength); + + public static string CreateToken(string prefix, uint fullLength) + { + if (prefix.Length + 1 >= fullLength - MinimumLength) + throw new ArgumentException("Prefix to long to create Token of meaningful length."); + long l = fullLength - prefix.Length - 1; + byte[] rng = new byte[l]; + RandomNumberGenerator.Create().GetBytes(rng); + string key = new (rng.Select(b => Chars[b % Chars.Length]).ToArray()); + key = string.Join('-', prefix, key); + return key; + } +} \ No newline at end of file diff --git a/API/TrangaSettings.cs b/API/TrangaSettings.cs new file mode 100644 index 0000000..e9e005e --- /dev/null +++ b/API/TrangaSettings.cs @@ -0,0 +1,215 @@ +using System.Runtime.InteropServices; +using API.MangaDownloadClients; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using static System.IO.UnixFileMode; + +namespace API; + +public static class TrangaSettings +{ + [JsonIgnore] internal static readonly string DefaultUserAgent = $"Tranga ({Enum.GetName(Environment.OSVersion.Platform)}; {(Environment.Is64BitOperatingSystem ? "x64" : "")}) / 1.0"; + public static string downloadLocation { get; private set; } = (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/Manga" : Path.Join(Directory.GetCurrentDirectory(), "Downloads")); + public static string workingDirectory { get; private set; } = Path.Join(RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/usr/share" : Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "tranga-api"); + public static int apiPortNumber { get; private set; } = 6531; + public static string userAgent { get; private set; } = DefaultUserAgent; + public static bool bufferLibraryUpdates { get; private set; } = false; + public static bool bufferNotifications { get; private set; } = false; + public static int compression{ get; private set; } = 40; + public static bool bwImages { get; private set; } = false; + [JsonIgnore] public static string settingsFilePath => Path.Join(workingDirectory, "settings.json"); + [JsonIgnore] public static string libraryConnectorsFilePath => Path.Join(workingDirectory, "libraryConnectors.json"); + [JsonIgnore] public static string notificationConnectorsFilePath => Path.Join(workingDirectory, "notificationConnectors.json"); + [JsonIgnore] public static string jobsFolderPath => Path.Join(workingDirectory, "jobs"); + [JsonIgnore] public static string coverImageCache => Path.Join(workingDirectory, "imageCache"); + [JsonIgnore] public static string mangaCacheFolderPath => Path.Join(workingDirectory, "mangaCache"); + public static ushort? version { get; } = 2; + public static bool aprilFoolsMode { get; private set; } = true; + [JsonIgnore]internal static readonly Dictionary DefaultRequestLimits = new () + { + {RequestType.MangaInfo, 250}, + {RequestType.MangaDexFeed, 250}, + {RequestType.MangaDexImage, 40}, + {RequestType.MangaImage, 60}, + {RequestType.MangaCover, 250}, + {RequestType.Default, 60} + }; + + public static Dictionary requestLimits { get; set; } = DefaultRequestLimits; + + public static void LoadFromWorkingDirectory(string directory) + { + TrangaSettings.workingDirectory = directory; + if(File.Exists(settingsFilePath)) + Deserialize(File.ReadAllText(settingsFilePath)); + else return; + + Directory.CreateDirectory(downloadLocation); + Directory.CreateDirectory(workingDirectory); + ExportSettings(); + } + + public static void CreateOrUpdate(string? downloadDirectory = null, string? pWorkingDirectory = null, + int? pApiPortNumber = null, string? pUserAgent = null, bool? pAprilFoolsMode = null, + bool? pBufferLibraryUpdates = null, bool? pBufferNotifications = null, int? pCompression = null, bool? pbwImages = null) + { + if(pWorkingDirectory is null && File.Exists(settingsFilePath)) + LoadFromWorkingDirectory(workingDirectory); + downloadLocation = downloadDirectory ?? downloadLocation; + workingDirectory = pWorkingDirectory ?? workingDirectory; + apiPortNumber = pApiPortNumber ?? apiPortNumber; + userAgent = pUserAgent ?? userAgent; + aprilFoolsMode = pAprilFoolsMode ?? aprilFoolsMode; + bufferLibraryUpdates = pBufferLibraryUpdates ?? bufferLibraryUpdates; + bufferNotifications = pBufferNotifications ?? bufferNotifications; + compression = pCompression ?? compression; + bwImages = pbwImages ?? bwImages; + Directory.CreateDirectory(downloadLocation); + Directory.CreateDirectory(workingDirectory); + ExportSettings(); + } + + public static void UpdateAprilFoolsMode(bool enabled) + { + aprilFoolsMode = enabled; + ExportSettings(); + } + + public static void UpdateCompressImages(int value) + { + compression = int.Clamp(value, 1, 100); + ExportSettings(); + } + + public static void UpdateBwImages(bool enabled) + { + bwImages = enabled; + ExportSettings(); + } + + public static void UpdateDownloadLocation(string newPath, bool moveFiles = true) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + Directory.CreateDirectory(newPath, GroupRead | GroupWrite | None | OtherRead | OtherWrite | UserRead | UserWrite); + else + Directory.CreateDirectory(newPath); + + if (moveFiles) + MoveContentsOfDirectoryTo(TrangaSettings.downloadLocation, newPath); + + TrangaSettings.downloadLocation = newPath; + ExportSettings(); + } + + private static void MoveContentsOfDirectoryTo(string oldDir, string newDir) + { + string[] directoryPaths = Directory.GetDirectories(oldDir); + string[] filePaths = Directory.GetFiles(oldDir); + foreach (string file in filePaths) + { + string newPath = Path.Join(newDir, Path.GetFileName(file)); + File.Move(file, newPath, true); + } + foreach(string directory in directoryPaths) + { + string? dirName = Path.GetDirectoryName(directory); + if(dirName is null) + continue; + string newPath = Path.Join(newDir, dirName); + if(Directory.Exists(newPath)) + MoveContentsOfDirectoryTo(directory, newPath); + else + Directory.Move(directory, newPath); + } + } + + public static void UpdateUserAgent(string? customUserAgent) + { + userAgent = customUserAgent ?? DefaultUserAgent; + ExportSettings(); + } + + public static void UpdateRateLimit(RequestType requestType, int newLimit) + { + requestLimits[requestType] = newLimit; + ExportSettings(); + } + + public static void ResetRateLimits() + { + requestLimits = DefaultRequestLimits; + ExportSettings(); + } + + public static void ExportSettings() + { + if (File.Exists(settingsFilePath)) + { + while(IsFileInUse(settingsFilePath)) + Thread.Sleep(100); + } + else + Directory.CreateDirectory(new FileInfo(settingsFilePath).DirectoryName!); + File.WriteAllText(settingsFilePath, Serialize()); + } + + internal static bool IsFileInUse(string filePath) + { + if (!File.Exists(filePath)) + return false; + try + { + using FileStream stream = new (filePath, FileMode.Open, FileAccess.Read, FileShare.None); + stream.Close(); + return false; + } + catch (IOException) + { + return true; + } + } + + public static JObject AsJObject() + { + JObject jobj = new JObject(); + jobj.Add("downloadLocation", JToken.FromObject(downloadLocation)); + jobj.Add("workingDirectory", JToken.FromObject(workingDirectory)); + jobj.Add("apiPortNumber", JToken.FromObject(apiPortNumber)); + jobj.Add("userAgent", JToken.FromObject(userAgent)); + jobj.Add("aprilFoolsMode", JToken.FromObject(aprilFoolsMode)); + jobj.Add("version", JToken.FromObject(version)); + jobj.Add("requestLimits", JToken.FromObject(requestLimits)); + jobj.Add("bufferLibraryUpdates", JToken.FromObject(bufferLibraryUpdates)); + jobj.Add("bufferNotifications", JToken.FromObject(bufferNotifications)); + jobj.Add("compression", JToken.FromObject(compression)); + jobj.Add("bwImages", JToken.FromObject(bwImages)); + return jobj; + } + + public static string Serialize() => AsJObject().ToString(); + + public static void Deserialize(string serialized) + { + JObject jobj = JObject.Parse(serialized); + if (jobj.TryGetValue("downloadLocation", out JToken? dl)) + downloadLocation = dl.Value()!; + if (jobj.TryGetValue("workingDirectory", out JToken? wd)) + workingDirectory = wd.Value()!; + if (jobj.TryGetValue("apiPortNumber", out JToken? apn)) + apiPortNumber = apn.Value(); + if (jobj.TryGetValue("userAgent", out JToken? ua)) + userAgent = ua.Value()!; + if (jobj.TryGetValue("aprilFoolsMode", out JToken? afm)) + aprilFoolsMode = afm.Value()!; + if (jobj.TryGetValue("requestLimits", out JToken? rl)) + requestLimits = rl.ToObject>()!; + if (jobj.TryGetValue("bufferLibraryUpdates", out JToken? blu)) + bufferLibraryUpdates = blu.Value()!; + if (jobj.TryGetValue("bufferNotifications", out JToken? bn)) + bufferNotifications = bn.Value()!; + if (jobj.TryGetValue("compression", out JToken? ci)) + compression = ci.Value()!; + if (jobj.TryGetValue("bwImages", out JToken? bwi)) + bwImages = bwi.Value()!; + } +} \ No newline at end of file diff --git a/API/appsettings.Development.json b/API/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/API/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/API/appsettings.json b/API/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/API/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Tranga.sln b/Tranga.sln index 501c3c3..babebcb 100644 --- a/Tranga.sln +++ b/Tranga.sln @@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logging", "Logging\Logging. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CLI", "CLI\CLI.csproj", "{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{EDB07E7B-351F-4FCC-9AEF-777838E5551E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -24,5 +26,9 @@ Global {4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Debug|Any CPU.Build.0 = Debug|Any CPU {4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Release|Any CPU.ActiveCfg = Release|Any CPU {4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Release|Any CPU.Build.0 = Release|Any CPU + {EDB07E7B-351F-4FCC-9AEF-777838E5551E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EDB07E7B-351F-4FCC-9AEF-777838E5551E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EDB07E7B-351F-4FCC-9AEF-777838E5551E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EDB07E7B-351F-4FCC-9AEF-777838E5551E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/docker-compose.yaml b/docker-compose.yaml index c1b2275..63bb0ea 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,4 +16,9 @@ services: - "9555:80" depends_on: - tranga-api - restart: unless-stopped \ No newline at end of file + restart: unless-stopped + api: + image: api + build: + context: . + dockerfile: API/Dockerfile