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