1 Commits

Author SHA1 Message Date
9790e0f9e9 Merge bf9fe517b0 into 6aa8413c40 2025-01-09 05:35:46 +01:00
154 changed files with 8729 additions and 7873 deletions

27
.dockerignore Normal file
View File

@ -0,0 +1,27 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
Manga
settings

View File

@ -12,7 +12,7 @@ body:
- type: checkboxes
attributes:
label: Is the Website free to access?
description: We can't support pay-to-use sites, or captcha-proxied sites as Cloudflare.
description: We can't support pay-to-use sites.
options:
- label: The Website is freely accessible.
required: true
@ -20,4 +20,4 @@ body:
attributes:
label: Anything else?
validations:
required: false
required: false

View File

@ -17,12 +17,12 @@ jobs:
# https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.6.0
uses: docker/setup-qemu-action@v3.2.0
# https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3.10.0
uses: docker/setup-buildx-action@v3.7.1
# https://github.com/docker/login-action#docker-hub
- name: Login to Docker Hub
@ -33,7 +33,7 @@ jobs:
# https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push API
uses: docker/build-push-action@v6.15.0
uses: docker/build-push-action@v6.9.0
with:
context: ./
file: ./Dockerfile

45
.github/workflows/docker-image-dev.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: Docker Image CI
on:
push:
branches: [ "dev" ]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0
# https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3.7.1
# https://github.com/docker/login-action#docker-hub
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push API
uses: docker/build-push-action@v6.9.0
with:
context: ./
file: ./Dockerfile
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
platforms: linux/amd64,linux/arm64
pull: true
push: true
tags: |
glax/tranga-api:dev

View File

@ -17,12 +17,12 @@ jobs:
# https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.6.0
uses: docker/setup-qemu-action@v3.2.0
# https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3.10.0
uses: docker/setup-buildx-action@v3.7.1
# https://github.com/docker/login-action#docker-hub
- name: Login to Docker Hub
@ -33,7 +33,7 @@ jobs:
# https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push API
uses: docker/build-push-action@v6.15.0
uses: docker/build-push-action@v6.9.0
with:
context: ./
file: ./Dockerfile

View File

@ -2,7 +2,7 @@ name: Docker Image CI
on:
push:
branches: [ "postgres-Server-V2" ]
branches: [ "Server-V2" ]
workflow_dispatch:
jobs:
@ -17,12 +17,12 @@ jobs:
# https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.6.0
uses: docker/setup-qemu-action@v3.2.0
# https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3.10.0
uses: docker/setup-buildx-action@v3.7.1
# https://github.com/docker/login-action#docker-hub
- name: Login to Docker Hub
@ -33,7 +33,7 @@ jobs:
# https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push API
uses: docker/build-push-action@v6.15.0
uses: docker/build-push-action@v6.9.0
with:
context: ./
file: ./Dockerfile

View File

@ -1,36 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
<PackageReference Include="JikanDotNet" Version="2.9.1" />
<PackageReference Include="log4net" Version="3.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="9.0.5" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql" Version="9.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="PuppeteerSharp" Version="20.1.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="Soenneker.Utils.String.NeedlemanWunsch" Version="3.0.929" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="8.1.0" />
<PackageReference Include="System.Drawing.Common" Version="9.0.3" />
</ItemGroup>
</Project>

View File

@ -1,6 +0,0 @@
@API_HostAddress = http://localhost:5105
GET {{API_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -1,5 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace API.APIEndpointRecords;
public record DownloadAvailableChaptersJobRecord([Required]string language, [Required]ulong recurrenceTimeMs, [Required]string localLibraryId);

View File

@ -1,16 +0,0 @@
namespace API.APIEndpointRecords;
public record GotifyRecord(string Name, string Endpoint, string AppToken, int Priority)
{
public bool Validate()
{
if (Endpoint == string.Empty)
return false;
if (AppToken == string.Empty)
return false;
if (Priority < 0 || Priority > 10)
return false;
return true;
}
}

View File

@ -1,3 +0,0 @@
namespace API.APIEndpointRecords;
public record ModifyWorkerRecord(ulong? IntervalMs);

View File

@ -1,17 +0,0 @@
namespace API.APIEndpointRecords;
public record NtfyRecord(string Name, string Endpoint, string Username, string Password, string Topic, int Priority)
{
public bool Validate()
{
if (Endpoint == string.Empty)
return false;
if (Username == string.Empty)
return false;
if (Password == string.Empty)
return false;
if (Priority < 1 || Priority > 5)
return false;
return true;
}
}

View File

@ -1,13 +0,0 @@
namespace API.APIEndpointRecords;
public record PushoverRecord(string Name, string AppToken, string User)
{
public bool Validate()
{
if (AppToken == string.Empty)
return false;
if (User == string.Empty)
return false;
return true;
}
}

View File

@ -1,134 +0,0 @@
using API.Schema.MangaContext;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class FileLibraryController(MangaContext context) : Controller
{
/// <summary>
/// Returns all <see cref="FileLibrary"/>
/// </summary>
/// <response code="200"></response>
[HttpGet]
[ProducesResponseType<FileLibrary[]>(Status200OK, "application/json")]
public IActionResult GetFileLibraries()
{
return Ok(context.FileLibraries.ToArray());
}
/// <summary>
/// Returns <see cref="FileLibrary"/> with <paramref name="FileLibraryId"/>
/// </summary>
/// <param name="FileLibraryId"><see cref="FileLibrary"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="FileLibrary"/> with <paramref name="FileLibraryId"/> not found.</response>
[HttpGet("{FileLibraryId}")]
[ProducesResponseType<FileLibrary>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetFileLibrary(string FileLibraryId)
{
if (context.FileLibraries.Find(FileLibraryId) is not { } library)
return NotFound();
return Ok(library);
}
/// <summary>
/// Changes the <see cref="FileLibraryId"/>.BasePath with <paramref name="FileLibraryId"/>
/// </summary>
/// <param name="FileLibraryId"><see cref="FileLibrary"/>.Key</param>
/// <param name="newBasePath">New <see cref="FileLibraryId"/>.BasePath</param>
/// <response code="200"></response>
/// <response code="404"><see cref="FileLibrary"/> with <paramref name="FileLibraryId"/> not found.</response>
/// <response code="500">Error during Database Operation</response>
[HttpPatch("{FileLibraryId}/ChangeBasePath")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult ChangeLibraryBasePath(string FileLibraryId, [FromBody]string newBasePath)
{
if (context.FileLibraries.Find(FileLibraryId) is not { } library)
return NotFound();
//TODO Path check
library.BasePath = newBasePath;
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Ok();
}
/// <summary>
/// Changes the <see cref="FileLibraryId"/>.LibraryName with <paramref name="FileLibraryId"/>
/// </summary>
/// <param name="FileLibraryId"><see cref="FileLibrary"/>.Key</param>
/// <param name="newName">New <see cref="FileLibraryId"/>.LibraryName</param>
/// <response code="200"></response>
/// <response code="404"><see cref="FileLibrary"/> with <paramref name="FileLibraryId"/> not found.</response>
/// <response code="500">Error during Database Operation</response>
[HttpPatch("{FileLibraryId}/ChangeName")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult ChangeLibraryName(string FileLibraryId, [FromBody] string newName)
{
if (context.FileLibraries.Find(FileLibraryId) is not { } library)
return NotFound();
//TODO Name check
library.LibraryName = newName;
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Ok();
}
/// <summary>
/// Creates new <see cref="FileLibraryId"/>
/// </summary>
/// <param name="library">New <see cref="FileLibrary"/> to add</param>
/// <response code="200"></response>
/// <response code="500">Error during Database Operation</response>
[HttpPut]
[ProducesResponseType(Status201Created)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateNewLibrary([FromBody]FileLibrary library)
{
//TODO Parameter check
context.FileLibraries.Add(library);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Created();
}
/// <summary>
/// Deletes the <see cref="FileLibraryId"/>.LibraryName with <paramref name="FileLibraryId"/>
/// </summary>
/// <param name="FileLibraryId"><see cref="FileLibrary"/>.Key</param>
/// <response code="200"></response>
/// <response code="500">Error during Database Operation</response>
[HttpDelete("{FileLibraryId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult DeleteLocalLibrary(string FileLibraryId)
{
if (context.FileLibraries.Find(FileLibraryId) is not { } library)
return NotFound();
context.FileLibraries.Remove(library);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Ok();
}
}

View File

@ -1,86 +0,0 @@
using API.Schema.LibraryContext;
using API.Schema.LibraryContext.LibraryConnectors;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class LibraryConnectorController(LibraryContext context) : Controller
{
/// <summary>
/// Gets all configured <see cref="LibraryConnector"/>
/// </summary>
/// <response code="200"></response>
[HttpGet]
[ProducesResponseType<LibraryConnector[]>(Status200OK, "application/json")]
public IActionResult GetAllConnectors()
{
LibraryConnector[] connectors = context.LibraryConnectors.ToArray();
return Ok(connectors);
}
/// <summary>
/// Returns <see cref="LibraryConnector"/> with <paramref name="LibraryConnectorId"/>
/// </summary>
/// <param name="LibraryConnectorId"><see cref="LibraryConnector"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="LibraryConnector"/> with <paramref name="LibraryConnectorId"/> not found.</response>
[HttpGet("{LibraryConnectorId}")]
[ProducesResponseType<LibraryConnector>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetConnector(string LibraryConnectorId)
{
if (context.LibraryConnectors.Find(LibraryConnectorId) is not { } connector)
return NotFound();
return Ok(connector);
}
/// <summary>
/// Creates a new <see cref="LibraryConnector"/>
/// </summary>
/// <param name="libraryConnector"></param>
/// <response code="201"></response>
/// <response code="500">Error during Database Operation</response>
[HttpPut]
[ProducesResponseType(Status201Created)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateConnector([FromBody]LibraryConnector libraryConnector)
{
context.LibraryConnectors.Add(libraryConnector);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Created();
}
/// <summary>
/// Deletes <see cref="LibraryConnector"/> with <paramref name="LibraryConnectorId"/>
/// </summary>
/// <param name="LibraryConnectorId">ToFileLibrary-Connector-ID</param>
/// <response code="200"></response>
/// <response code="404"><see cref="LibraryConnector"/> with <<paramref name="LibraryConnectorId"/> not found.</response>
/// <response code="500">Error during Database Operation</response>
[HttpDelete("{LibraryConnectorId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult DeleteConnector(string LibraryConnectorId)
{
if (context.LibraryConnectors.Find(LibraryConnectorId) is not { } connector)
return NotFound();
context.LibraryConnectors.Remove(connector);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Ok();
}
}

View File

@ -1,90 +0,0 @@
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class MangaConnectorController(MangaContext context) : Controller
{
/// <summary>
/// Get all <see cref="MangaConnector"/> (Scanlation-Sites)
/// </summary>
/// <response code="200">Names of <see cref="MangaConnector"/> (Scanlation-Sites)</response>
[HttpGet]
[ProducesResponseType<MangaConnector[]>(Status200OK, "application/json")]
public IActionResult GetConnectors()
{
return Ok(context.MangaConnectors.Select(c => c.Name).ToArray());
}
/// <summary>
/// Returns the <see cref="MangaConnector"/> (Scanlation-Sites) with the requested Name
/// </summary>
/// <param name="MangaConnectorName"><see cref="MangaConnector"/>.Name</param>
/// <response code="200"></response>
/// <response code="404"><see cref="MangaConnector"/> (Scanlation-Sites) with Name not found.</response>
[HttpGet("{MangaConnectorName}")]
[ProducesResponseType<MangaConnector>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetConnector(string MangaConnectorName)
{
if(context.MangaConnectors.Find(MangaConnectorName) is not { } connector)
return NotFound();
return Ok(connector);
}
/// <summary>
/// Get all enabled <see cref="MangaConnector"/> (Scanlation-Sites)
/// </summary>
/// <response code="200"></response>
[HttpGet("Enabled")]
[ProducesResponseType<MangaConnector[]>(Status200OK, "application/json")]
public IActionResult GetEnabledConnectors()
{
return Ok(context.MangaConnectors.Where(c => c.Enabled).ToArray());
}
/// <summary>
/// Get all disabled <see cref="MangaConnector"/> (Scanlation-Sites)
/// </summary>
/// <response code="200"></response>
[HttpGet("Disabled")]
[ProducesResponseType<MangaConnector[]>(Status200OK, "application/json")]
public IActionResult GetDisabledConnectors()
{
return Ok(context.MangaConnectors.Where(c => c.Enabled == false).ToArray());
}
/// <summary>
/// Enabled or disables <see cref="MangaConnector"/> (Scanlation-Sites) with Name
/// </summary>
/// <param name="MangaConnectorName"><see cref="MangaConnector"/>.Name</param>
/// <param name="Enabled">Set true to enable, false to disable</param>
/// <response code="202"></response>
/// <response code="404"><see cref="MangaConnector"/> (Scanlation-Sites) with Name not found.</response>
/// <response code="500">Error during Database Operation</response>
[HttpPatch("{MangaConnectorName}/SetEnabled/{Enabled}")]
[ProducesResponseType(Status202Accepted)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult SetEnabled(string MangaConnectorName, bool Enabled)
{
if(context.MangaConnectors.Find(MangaConnectorName) is not { } connector)
return NotFound();
connector.Enabled = Enabled;
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Accepted();
}
}

View File

@ -1,390 +0,0 @@
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
using API.Workers;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Transforms;
using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class MangaController(MangaContext context) : Controller
{
/// <summary>
/// Returns all cached <see cref="Manga"/>
/// </summary>
/// <response code="200"></response>
[HttpGet]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult GetAllManga()
{
Manga[] ret = context.Mangas.ToArray();
return Ok(ret);
}
/// <summary>
/// Returns all cached <see cref="Manga"/> with <paramref name="MangaIds"/>
/// </summary>
/// <param name="MangaIds">Array of <<see cref="Manga"/>.Key</param>
/// <response code="200"></response>
[HttpPost("WithIDs")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult GetManga([FromBody]string[] MangaIds)
{
Manga[] ret = context.Mangas.Where(m => MangaIds.Contains(m.Key)).ToArray();
return Ok(ret);
}
/// <summary>
/// Return <see cref="Manga"/> with <paramref name="MangaId"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
[HttpGet("{MangaId}")]
[ProducesResponseType<Manga>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetManga(string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
return Ok(manga);
}
/// <summary>
/// Delete <see cref="Manga"/> with <paramref name="MangaId"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><<see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpDelete("{MangaId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult DeleteManga(string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
context.Mangas.Remove(manga);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Ok();
}
/// <summary>
/// Merge two <see cref="Manga"/> into one. THIS IS NOT REVERSIBLE!
/// </summary>
/// <param name="MangaIdFrom"><see cref="Manga"/>.Key of <see cref="Manga"/> merging data from (getting deleted)</param>
/// <param name="MangaIdInto"><see cref="Manga"/>.Key of <see cref="Manga"/> merging data into</param>
/// <response code="200"></response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaIdFrom"/> or <paramref name="MangaIdInto"/> not found</response>
[HttpPatch("{MangaIdFrom}/MergeInto/{MangaIdInto}")]
[ProducesResponseType<byte[]>(Status200OK,"image/jpeg")]
[ProducesResponseType(Status404NotFound)]
public IActionResult MergeIntoManga(string MangaIdFrom, string MangaIdInto)
{
if (context.Mangas.Find(MangaIdFrom) is not { } from)
return NotFound(nameof(MangaIdFrom));
if (context.Mangas.Find(MangaIdInto) is not { } into)
return NotFound(nameof(MangaIdInto));
BaseWorker[] newJobs = into.MergeFrom(from, context);
Tranga.AddWorkers(newJobs);
return Ok();
}
/// <summary>
/// Returns Cover of <see cref="Manga"/> with <paramref name="MangaId"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <param name="width">If <paramref name="width"/> is provided, <paramref name="height"/> needs to also be provided</param>
/// <param name="height">If <paramref name="height"/> is provided, <paramref name="width"/> needs to also be provided</param>
/// <response code="200">JPEG Image</response>
/// <response code="204">Cover not loaded</response>
/// <response code="400">The formatting-request was invalid</response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
/// <response code="503">Retry later, downloading cover</response>
[HttpGet("{MangaId}/Cover")]
[ProducesResponseType<byte[]>(Status200OK,"image/jpeg")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
public IActionResult GetCover(string MangaId, [FromQuery]int? width, [FromQuery]int? height)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
if (!System.IO.File.Exists(manga.CoverFileNameInCache))
{
if (Tranga.GetRunningWorkers().Any(worker => worker is DownloadCoverFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId))
{
Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}");
return StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000);
}
else
return NoContent();
}
Image image = Image.Load(manga.CoverFileNameInCache);
if (width is { } w && height is { } h)
{
if (width < 10 || height < 10 || width > 65535 || height > 65535)
return BadRequest();
image.Mutate(i => i.ApplyProcessor(new ResizeProcessor(new ResizeOptions()
{
Mode = ResizeMode.Max,
Size = new Size(w, h)
}, image.Size)));
}
using MemoryStream ms = new();
image.Save(ms, new JpegEncoder(){Quality = 100});
DateTime lastModified = new FileInfo(manga.CoverFileNameInCache).LastWriteTime;
HttpContext.Response.Headers.CacheControl = "public";
return File(ms.GetBuffer(), "image/jpeg", new DateTimeOffset(lastModified), EntityTagHeaderValue.Parse($"\"{lastModified.Ticks}\""));
}
/// <summary>
/// Returns all <see cref="Chapter"/> of <see cref="Manga"/> with <paramref name="MangaId"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
[HttpGet("{MangaId}/Chapters")]
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetChapters(string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
Chapter[] chapters = manga.Chapters.ToArray();
return Ok(chapters);
}
/// <summary>
/// Returns all downloaded <see cref="Chapter"/> for <see cref="Manga"/> with <paramref name="MangaId"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <response code="200"></response>
/// <response code="204">No available chapters</response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found.</response>
[HttpGet("{MangaId}/Chapters/Downloaded")]
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetChaptersDownloaded(string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.Where(c => c.Downloaded).ToList();
if (chapters.Count == 0)
return NoContent();
return Ok(chapters);
}
/// <summary>
/// Returns all <see cref="Chapter"/> not downloaded for <see cref="Manga"/> with <paramref name="MangaId"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <response code="200"></response>
/// <response code="204">No available chapters</response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found.</response>
[HttpGet("{MangaId}/Chapters/NotDownloaded")]
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetChaptersNotDownloaded(string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.Where(c => c.Downloaded == false).ToList();
if (chapters.Count == 0)
return NoContent();
return Ok(chapters);
}
/// <summary>
/// Returns the latest <see cref="Chapter"/> of requested <see cref="Manga"/> available on <see cref="MangaConnector"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <response code="200"></response>
/// <response code="204">No available chapters</response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found.</response>
/// <response code="412">Could not retrieve the maximum chapter-number</response>
/// <response code="503">Retry after timeout, updating value</response>
[HttpGet("{MangaId}/Chapter/LatestAvailable")]
[ProducesResponseType<Chapter>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
[ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
public IActionResult GetLatestChapter(string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.ToList();
if (chapters.Count == 0)
{
if (Tranga.GetRunningWorkers().Any(worker => worker is RetrieveMangaChaptersFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId && w.State < WorkerExecutionState.Completed))
{
Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}");
return StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2/ 1000);
}else
return Ok(0);
}
Chapter? max = chapters.Max();
if (max is null)
return StatusCode(Status500InternalServerError, "Max chapter could not be found");
return Ok(max);
}
/// <summary>
/// Returns the latest <see cref="Chapter"/> of requested <see cref="Manga"/> that is downloaded
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <response code="200"></response>
/// <response code="204">No available chapters</response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found.</response>
/// <response code="412">Could not retrieve the maximum chapter-number</response>
/// <response code="503">Retry after timeout, updating value</response>
[HttpGet("{MangaId}/Chapter/LatestDownloaded")]
[ProducesResponseType<Chapter>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
[ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
public IActionResult GetLatestChapterDownloaded(string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.ToList();
if (chapters.Count == 0)
{
if (Tranga.GetRunningWorkers().Any(worker => worker is RetrieveMangaChaptersFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId && w.State < WorkerExecutionState.Completed))
{
Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}");
return StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2/ 1000);
}else
return NoContent();
}
Chapter? max = chapters.Max();
if (max is null)
return StatusCode(Status412PreconditionFailed, "Max chapter could not be found");
return Ok(max);
}
/// <summary>
/// Configure the <see cref="Chapter"/> cut-off for <see cref="Manga"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <param name="chapterThreshold">Threshold (<see cref="Chapter"/> ChapterNumber)</param>
/// <response code="202"></response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found.</response>
/// <response code="500">Error during Database Operation</response>
[HttpPatch("{MangaId}/IgnoreChaptersBefore")]
[ProducesResponseType(Status202Accepted)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult IgnoreChaptersBefore(string MangaId, [FromBody]float chapterThreshold)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound();
manga.IgnoreChaptersBefore = chapterThreshold;
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Accepted();
}
/// <summary>
/// Move <see cref="Manga"/> to different <see cref="FileLibrary"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <param name="LibraryId"><see cref="FileLibrary"/>.Key</param>
/// <response code="202">Folder is going to be moved</response>
/// <response code="404"><paramref name="MangaId"/> or <paramref name="LibraryId"/> not found</response>
[HttpPost("{MangaId}/ChangeLibrary/{LibraryId}")]
[ProducesResponseType(Status202Accepted)]
[ProducesResponseType(Status404NotFound)]
public IActionResult MoveFolder(string MangaId, string LibraryId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
if(context.FileLibraries.Find(LibraryId) is not { } library)
return NotFound(nameof(LibraryId));
MoveMangaLibraryWorker moveLibrary = new(manga, library);
Tranga.AddWorkers([moveLibrary]);
return Accepted();
}
/// <summary>
/// (Un-)Marks <see cref="Manga"/> as requested for Download from <see cref="MangaConnector"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/> with <paramref name="MangaId"/></param>
/// <param name="MangaConnectorName"><see cref="MangaConnector"/> with <paramref name="MangaConnectorName"/></param>
/// <param name="IsRequested">true to mark as requested, false to mark as not-requested</param>
/// <response code="200"></response>
/// <response code="404"><paramref name="MangaId"/> or <paramref name="MangaConnectorName"/> not found</response>
/// <response code="412"><see cref="Manga"/> was not linked to <see cref="MangaConnector"/>, so nothing changed</response>
/// <response code="428"><see cref="Manga"/> is not linked to <see cref="MangaConnector"/> yet. Search for <see cref="Manga"/> on <see cref="MangaConnector"/> first (to create a <see cref="MangaConnectorId{T}"/>).</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("{MangaId}/SetAsDownloadFrom/{MangaConnectorName}/{IsRequested}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
[ProducesResponseType<string>(Status428PreconditionRequired, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult MarkAsRequested(string MangaId, string MangaConnectorName, bool IsRequested)
{
if (context.Mangas.Find(MangaId) is null)
return NotFound(nameof(MangaId));
if(context.MangaConnectors.Find(MangaConnectorName) is null)
return NotFound(nameof(MangaConnectorName));
if (context.MangaConnectorToManga.FirstOrDefault(id => id.MangaConnectorName == MangaConnectorName && id.ObjId == MangaId) is not { } mcId)
if(IsRequested)
return StatusCode(Status428PreconditionRequired, "Don't know how to download this Manga from MangaConnector");
else
return StatusCode(Status412PreconditionFailed, "Not linked anyways.");
mcId.UseForDownload = IsRequested;
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
DownloadCoverFromMangaconnectorWorker downloadCover = new(mcId);
RetrieveMangaChaptersFromMangaconnectorWorker retrieveChapters = new(mcId, Tranga.Settings.DownloadLanguage);
Tranga.AddWorkers([downloadCover, retrieveChapters]);
return Ok();
}
}

View File

@ -1,121 +0,0 @@
using API.Schema.MangaContext;
using API.Schema.MangaContext.MetadataFetchers;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class MetadataFetcherController(MangaContext context) : Controller
{
/// <summary>
/// Get all <see cref="MetadataFetcher"/> (Metadata-Sites)
/// </summary>
/// <response code="200">Names of <see cref="MetadataFetcher"/> (Metadata-Sites)</response>
[HttpGet]
[ProducesResponseType<string[]>(Status200OK, "application/json")]
public IActionResult GetConnectors()
{
return Ok(Tranga.MetadataFetchers.Select(m => m.Name).ToArray());
}
/// <summary>
/// Returns all <see cref="MetadataEntry"/>
/// </summary>
/// <response code="200"></response>
[HttpGet("Links")]
[ProducesResponseType<MetadataEntry[]>(Status200OK, "application/json")]
public IActionResult GetLinkedEntries()
{
return Ok(context.MetadataEntries.ToArray());
}
/// <summary>
/// Searches <see cref="MetadataFetcher"/> (Metadata-Sites) for Manga-Metadata
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <param name="MetadataFetcherName"><see cref="MetadataFetcher"/>.Name</param>
/// <param name="searchTerm">Instead of using the <paramref name="MangaId"/> for search on Website, use a specific term</param>
/// <response code="200"></response>
/// <response code="400"><see cref="MetadataFetcher"/> (Metadata-Sites) with <paramref name="MetadataFetcherName"/> does not exist</response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
[HttpPost("{MetadataFetcherName}/SearchManga/{MangaId}")]
[ProducesResponseType<MetadataSearchResult[]>(Status200OK, "application/json")]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
public IActionResult SearchMangaMetadata(string MangaId, string MetadataFetcherName, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)]string? searchTerm = null)
{
if(context.Mangas.Find(MangaId) is not { } manga)
return NotFound();
if(Tranga.MetadataFetchers.FirstOrDefault(f => f.Name == MetadataFetcherName) is not { } fetcher)
return BadRequest();
MetadataSearchResult[] searchResults = searchTerm is null ? fetcher.SearchMetadataEntry(manga) : fetcher.SearchMetadataEntry(searchTerm);
return Ok(searchResults);
}
/// <summary>
/// Links <see cref="MetadataFetcher"/> (Metadata-Sites) using Provider-Specific Identifier to <see cref="Manga"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <param name="MetadataFetcherName"><see cref="MetadataFetcher"/>.Name</param>
/// <param name="Identifier"><see cref="MetadataFetcherName"/>-Specific ID</param>
/// <response code="200"></response>
/// <response code="400"><see cref="MetadataFetcher"/> (Metadata-Sites) with <paramref name="MetadataFetcherName"/> does not exist</response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("{MetadataFetcherName}/Link/{MangaId}")]
[ProducesResponseType<MetadataEntry>(Status200OK, "application/json")]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult LinkMangaMetadata(string MangaId, string MetadataFetcherName, [FromBody]string Identifier)
{
if(context.Mangas.Find(MangaId) is not { } manga)
return NotFound();
if(Tranga.MetadataFetchers.FirstOrDefault(f => f.Name == MetadataFetcherName) is not { } fetcher)
return BadRequest();
MetadataEntry entry = fetcher.CreateMetadataEntry(manga, Identifier);
context.MetadataEntries.Add(entry);
if(context.Sync() is { } errorMessage)
return StatusCode(Status500InternalServerError, errorMessage);
return Ok(entry);
}
/// <summary>
/// Un-Links <see cref="MetadataFetcher"/> (Metadata-Sites) from <see cref="Manga"/>
/// </summary>
/// <response code="200"></response>
/// <response code="400"><see cref="MetadataFetcher"/> (Metadata-Sites) with <paramref name="MetadataFetcherName"/> does not exist</response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
/// <response code="412">No <see cref="MetadataEntry"/> linking <see cref="Manga"/> and <see cref="MetadataFetcher"/> found</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("{MetadataFetcherName}/Unlink/{MangaId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult UnlinkMangaMetadata(string MangaId, string MetadataFetcherName)
{
if(context.Mangas.Find(MangaId) is null)
return NotFound();
if(Tranga.MetadataFetchers.FirstOrDefault(f => f.Name == MetadataFetcherName) is null)
return BadRequest();
if(context.MetadataEntries.FirstOrDefault(e => e.MangaId == MangaId && e.MetadataFetcherName == MetadataFetcherName) is not { } entry)
return StatusCode(Status412PreconditionFailed, "No entry found");
context.Remove(entry);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Ok();
}
}

View File

@ -1,159 +0,0 @@
using System.Text;
using API.APIEndpointRecords;
using API.Schema.NotificationsContext;
using API.Schema.NotificationsContext.NotificationConnectors;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Produces("application/json")]
[Route("v{v:apiVersion}/[controller]")]
public class NotificationConnectorController(NotificationsContext context) : Controller
{
/// <summary>
/// Gets all configured <see cref="NotificationConnector"/>
/// </summary>
/// <response code="200"></response>
[HttpGet]
[ProducesResponseType<NotificationConnector[]>(Status200OK, "application/json")]
public IActionResult GetAllConnectors()
{
return Ok(context.NotificationConnectors.ToArray());
}
/// <summary>
/// Returns <see cref="NotificationConnector"/> with requested Name
/// </summary>
/// <param name="Name"><see cref="NotificationConnector"/>.Name</param>
/// <response code="200"></response>
/// <response code="404"><see cref="NotificationConnector"/> with <paramref name="Name"/> not found</response>
[HttpGet("{Name}")]
[ProducesResponseType<NotificationConnector>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetConnector(string Name)
{
if(context.NotificationConnectors.Find(Name) is not { } connector)
return NotFound();
return Ok(connector);
}
/// <summary>
/// Creates a new <see cref="NotificationConnector"/>
/// </summary>
/// <remarks>Formatting placeholders: "%title" and "%text" can be placed in url, header-values and body and will be replaced when notifications are sent</remarks>
/// <response code="201"></response>
/// <response code="500">Error during Database Operation</response>
[HttpPut]
[ProducesResponseType(Status201Created)]
[ProducesResponseType(Status409Conflict)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateConnector([FromBody]NotificationConnector notificationConnector)
{
context.NotificationConnectors.Add(notificationConnector);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Created();
}
/// <summary>
/// Creates a new Gotify-<see cref="NotificationConnector"/>
/// </summary>
/// <remarks>Priority needs to be between 0 and 10</remarks>
/// <response code="201"></response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("Gotify")]
[ProducesResponseType<string>(Status201Created, "application/json")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateGotifyConnector([FromBody]GotifyRecord gotifyData)
{
//TODO Validate Data
NotificationConnector gotifyConnector = new (gotifyData.Name,
gotifyData.Endpoint,
new Dictionary<string, string>() { { "X-Gotify-IDOnConnector", gotifyData.AppToken } },
"POST",
$"{{\"message\": \"%text\", \"title\": \"%title\", \"Priority\": {gotifyData.Priority}}}");
return CreateConnector(gotifyConnector);
}
/// <summary>
/// Creates a new Ntfy-<see cref="NotificationConnector"/>
/// </summary>
/// <remarks>Priority needs to be between 1 and 5</remarks>
/// <response code="201"></response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("Ntfy")]
[ProducesResponseType<string>(Status201Created, "application/json")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateNtfyConnector([FromBody]NtfyRecord ntfyRecord)
{
//TODO Validate Data
string authHeader = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ntfyRecord.Username}:{ntfyRecord.Password}"));
string auth = Convert.ToBase64String(Encoding.UTF8.GetBytes(authHeader)).Replace("=","");
NotificationConnector ntfyConnector = new (ntfyRecord.Name,
$"{ntfyRecord.Endpoint}/{ntfyRecord.Topic}?auth={auth}",
new Dictionary<string, string>()
{
{"Title", "%title"},
{"Priority", ntfyRecord.Priority.ToString()},
},
"POST",
"%text");
return CreateConnector(ntfyConnector);
}
/// <summary>
/// Creates a new Pushover-<see cref="NotificationConnector"/>
/// </summary>
/// <remarks>https://pushover.net/api</remarks>
/// <response code="201">ID of new connector</response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("Pushover")]
[ProducesResponseType<string>(Status201Created, "application/json")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreatePushoverConnector([FromBody]PushoverRecord pushoverRecord)
{
//TODO Validate Data
NotificationConnector pushoverConnector = new (pushoverRecord.Name,
$"https://api.pushover.net/1/messages.json",
new Dictionary<string, string>(),
"POST",
$"{{\"token\": \"{pushoverRecord.AppToken}\", \"user\": \"{pushoverRecord.User}\", \"message:\":\"%text\", \"%title\" }}");
return CreateConnector(pushoverConnector);
}
/// <summary>
/// Deletes the <see cref="NotificationConnector"/> with the requested Name
/// </summary>
/// <param name="Name"><see cref="NotificationConnector"/>.Name</param>
/// <response code="200"></response>
/// <response code="404"><see cref="NotificationConnector"/> with Name not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpDelete("{Name}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult DeleteConnector(string Name)
{
if(context.NotificationConnectors.Find(Name) is not { } connector)
return NotFound();
context.NotificationConnectors.Remove(connector);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Created();
}
}

View File

@ -1,78 +0,0 @@
using API.Schema.MangaContext;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class QueryController(MangaContext context) : Controller
{
/// <summary>
/// Returns the <see cref="Author"/> with <paramref name="AuthorId"/>
/// </summary>
/// <param name="AuthorId"><see cref="Author"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="Author"/> with <paramref name="AuthorId"/> not found</response>
[HttpGet("Author/{AuthorId}")]
[ProducesResponseType<Author>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetAuthor(string AuthorId)
{
if (context.Authors.Find(AuthorId) is not { } author)
return NotFound();
return Ok(author);
}
/// <summary>
/// Returns all <see cref="Manga"/> which where Authored by <see cref="Author"/> with <paramref name="AuthorId"/>
/// </summary>
/// <param name="AuthorId"><see cref="Author"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="Author"/> with <paramref name="AuthorId"/></response>
[HttpGet("Mangas/WithAuthorId/{AuthorId}")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult GetMangaWithAuthorIds(string AuthorId)
{
if (context.Authors.Find(AuthorId) is not { } author)
return NotFound();
return Ok(context.Mangas.Where(m => m.Authors.Contains(author)));
}
/// <summary>
/// Returns all <see cref="Manga"/> with <see cref="Tag"/>
/// </summary>
/// <param name="Tag"><see cref="Tag"/>.Tag</param>
/// <response code="200"></response>
/// <response code="404"><see cref="Tag"/> not found</response>
[HttpGet("Mangas/WithTag/{Tag}")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult GetMangasWithTag(string Tag)
{
if (context.Tags.Find(Tag) is not { } tag)
return NotFound();
return Ok(context.Mangas.Where(m => m.MangaTags.Contains(tag)));
}
/// <summary>
/// Returns <see cref="Chapter"/> with <paramref name="ChapterId"/>
/// </summary>
/// <param name="ChapterId"><see cref="Chapter"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="Chapter"/> with <paramref name="ChapterId"/> not found</response>
[HttpGet("Chapter/{ChapterId}")]
[ProducesResponseType<Chapter>(Status200OK, "application/json")]
public IActionResult GetChapter(string ChapterId)
{
if (context.Chapters.Find(ChapterId) is not { } chapter)
return NotFound();
return Ok(chapter);
}
}

View File

@ -1,70 +0,0 @@
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class SearchController(MangaContext context) : Controller
{
/// <summary>
/// Initiate a search for a <see cref="Manga"/> on <see cref="MangaConnector"/> with searchTerm
/// </summary>
/// <param name="MangaConnectorName"><see cref="MangaConnector"/>.Name</param>
/// <param name="Query">searchTerm</param>
/// <response code="200"></response>
/// <response code="404"><see cref="MangaConnector"/> with Name not found</response>
/// <response code="412"><see cref="MangaConnector"/> with Name is disabled</response>
[HttpGet("{MangaConnectorName}/{Query}")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status406NotAcceptable)]
public IActionResult SearchManga(string MangaConnectorName, string Query)
{
if(context.MangaConnectors.Find(MangaConnectorName) is not { } connector)
return NotFound();
if (connector.Enabled is false)
return StatusCode(Status412PreconditionFailed);
(Manga, MangaConnectorId<Manga>)[] mangas = connector.SearchManga(Query);
List<Manga> retMangas = new();
foreach ((Manga manga, MangaConnectorId<Manga> mcId) manga in mangas)
{
if(Tranga.AddMangaToContext(manga, context, out Manga? add))
retMangas.Add(add);
}
return Ok(retMangas.ToArray());
}
/// <summary>
/// Returns <see cref="Manga"/> from the <see cref="MangaConnector"/> associated with <paramref name="url"/>
/// </summary>
/// <param name="url"></param>
/// <response code="200"></response>
/// <response code="300">Multiple <see cref="MangaConnector"/> found for URL</response>
/// <response code="404"><see cref="Manga"/> not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("Url")]
[ProducesResponseType<Manga>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status500InternalServerError)]
public IActionResult GetMangaFromUrl([FromBody]string url)
{
if (context.MangaConnectors.Find("Global") is not { } connector)
return StatusCode(Status500InternalServerError, "Could not find Global Connector.");
if(connector.GetMangaFromUrl(url) is not { } manga)
return NotFound();
if(Tranga.AddMangaToContext(manga, context, out Manga? add) == false)
return StatusCode(Status500InternalServerError);
return Ok(add);
}
}

View File

@ -1,292 +0,0 @@
using API.MangaDownloadClients;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class SettingsController() : Controller
{
/// <summary>
/// Get all <see cref="Tranga.Settings"/>
/// </summary>
/// <response code="200"></response>
[HttpGet]
[ProducesResponseType<TrangaSettings>(Status200OK, "application/json")]
public IActionResult GetSettings()
{
return Ok(Tranga.Settings);
}
/// <summary>
/// Get the current UserAgent used by Tranga
/// </summary>
/// <response code="200"></response>
[HttpGet("UserAgent")]
[ProducesResponseType<string>(Status200OK, "text/plain")]
public IActionResult GetUserAgent()
{
return Ok(Tranga.Settings.UserAgent);
}
/// <summary>
/// Set a new UserAgent
/// </summary>
/// <response code="200"></response>
[HttpPatch("UserAgent")]
[ProducesResponseType(Status200OK)]
public IActionResult SetUserAgent([FromBody]string userAgent)
{
//TODO Validate
Tranga.Settings.SetUserAgent(userAgent);
return Ok();
}
/// <summary>
/// Reset the UserAgent to default
/// </summary>
/// <response code="200"></response>
[HttpDelete("UserAgent")]
[ProducesResponseType(Status200OK)]
public IActionResult ResetUserAgent()
{
Tranga.Settings.SetUserAgent(TrangaSettings.DefaultUserAgent);
return Ok();
}
/// <summary>
/// Get all Request-Limits
/// </summary>
/// <response code="200"></response>
[HttpGet("RequestLimits")]
[ProducesResponseType<Dictionary<RequestType,int>>(Status200OK, "application/json")]
public IActionResult GetRequestLimits()
{
return Ok(Tranga.Settings.RequestLimits);
}
/// <summary>
/// Update all Request-Limits to new values
/// </summary>
/// <remarks><h1>NOT IMPLEMENTED</h1></remarks>
[HttpPatch("RequestLimits")]
[ProducesResponseType(Status501NotImplemented)]
public IActionResult SetRequestLimits()
{
return StatusCode(501);
}
/// <summary>
/// Updates a Request-Limit value
/// </summary>
/// <param name="RequestType">Type of Request</param>
/// <param name="requestLimit">New limit in Requests/Minute</param>
/// <response code="200"></response>
/// <response code="400">Limit needs to be greater than 0</response>
[HttpPatch("RequestLimits/{RequestType}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status400BadRequest)]
public IActionResult SetRequestLimit(RequestType RequestType, [FromBody]int requestLimit)
{
if (requestLimit <= 0)
return BadRequest();
Tranga.Settings.SetRequestLimit(RequestType, requestLimit);
return Ok();
}
/// <summary>
/// Reset Request-Limit
/// </summary>
/// <response code="200"></response>
[HttpDelete("RequestLimits/{RequestType}")]
[ProducesResponseType<string>(Status200OK)]
public IActionResult ResetRequestLimits(RequestType RequestType)
{
Tranga.Settings.SetRequestLimit(RequestType, TrangaSettings.DefaultRequestLimits[RequestType]);
return Ok();
}
/// <summary>
/// Reset Request-Limit
/// </summary>
/// <response code="200"></response>
[HttpDelete("RequestLimits")]
[ProducesResponseType<string>(Status200OK)]
public IActionResult ResetRequestLimits()
{
Tranga.Settings.ResetRequestLimits();
return Ok();
}
/// <summary>
/// Returns Level of Image-Compression for Images
/// </summary>
/// <response code="200">JPEG ImageCompression-level as Integer</response>
[HttpGet("ImageCompressionLevel")]
[ProducesResponseType<int>(Status200OK, "text/plain")]
public IActionResult GetImageCompression()
{
return Ok(Tranga.Settings.ImageCompression);
}
/// <summary>
/// Set the Image-Compression-Level for Images
/// </summary>
/// <param name="level">100 to disable, 0-99 for JPEG ImageCompression-Level</param>
/// <response code="200"></response>
/// <response code="400">Level outside permitted range</response>
[HttpPatch("ImageCompressionLevel/{level}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status400BadRequest)]
public IActionResult SetImageCompression(int level)
{
if (level < 1 || level > 100)
return BadRequest();
Tranga.Settings.UpdateImageCompression(level);
return Ok();
}
/// <summary>
/// Get state of Black/White-Image setting
/// </summary>
/// <response code="200">True if enabled</response>
[HttpGet("BWImages")]
[ProducesResponseType<bool>(Status200OK, "text/plain")]
public IActionResult GetBwImagesToggle()
{
return Ok(Tranga.Settings.BlackWhiteImages);
}
/// <summary>
/// Enable/Disable conversion of Images to Black and White
/// </summary>
/// <param name="enabled">true to enable</param>
/// <response code="200"></response>
[HttpPatch("BWImages/{enabled}")]
[ProducesResponseType(Status200OK)]
public IActionResult SetBwImagesToggle(bool enabled)
{
Tranga.Settings.SetBlackWhiteImageEnabled(enabled);
return Ok();
}
/// <summary>
/// Gets the Chapter Naming Scheme
/// </summary>
/// <remarks>
/// Placeholders:
/// %M Obj Name
/// %V Volume
/// %C Chapter
/// %T Title
/// %A Author (first in list)
/// %I Chapter Internal ID
/// %i Obj Internal ID
/// %Y Year (Obj)
///
/// ?_(...) replace _ with a value from above:
/// Everything inside the braces will only be added if the value of %_ is not null
/// </remarks>
/// <response code="200"></response>
[HttpGet("ChapterNamingScheme")]
[ProducesResponseType<string>(Status200OK, "text/plain")]
public IActionResult GetCustomNamingScheme()
{
return Ok(Tranga.Settings.ChapterNamingScheme);
}
/// <summary>
/// Sets the Chapter Naming Scheme
/// </summary>
/// <remarks>
/// Placeholders:
/// %M Obj Name
/// %V Volume
/// %C Chapter
/// %T Title
/// %A Author (first in list)
/// %Y Year (Obj)
///
/// ?_(...) replace _ with a value from above:
/// Everything inside the braces will only be added if the value of %_ is not null
/// </remarks>
/// <response code="200"></response>
[HttpPatch("ChapterNamingScheme")]
[ProducesResponseType(Status200OK)]
public IActionResult SetCustomNamingScheme([FromBody]string namingScheme)
{
//TODO Move old Chapters
Tranga.Settings.SetChapterNamingScheme(namingScheme);
return Ok();
}
/// <summary>
/// Sets the FlareSolverr-URL
/// </summary>
/// <param name="flareSolverrUrl">URL of FlareSolverr-Instance</param>
/// <response code="200"></response>
[HttpPost("FlareSolverr/Url")]
[ProducesResponseType(Status200OK)]
public IActionResult SetFlareSolverrUrl([FromBody]string flareSolverrUrl)
{
Tranga.Settings.SetFlareSolverrUrl(flareSolverrUrl);
return Ok();
}
/// <summary>
/// Resets the FlareSolverr-URL (HttpClient does not use FlareSolverr anymore)
/// </summary>
/// <response code="200"></response>
[HttpDelete("FlareSolverr/Url")]
[ProducesResponseType(Status200OK)]
public IActionResult ClearFlareSolverrUrl()
{
Tranga.Settings.SetFlareSolverrUrl(string.Empty);
return Ok();
}
/// <summary>
/// Test FlareSolverr
/// </summary>
/// <response code="200">FlareSolverr is working!</response>
/// <response code="500">FlareSolverr is not working</response>
[HttpPost("FlareSolverr/Test")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status500InternalServerError)]
public IActionResult TestFlareSolverrReachable()
{
const string knownProtectedUrl = "https://prowlarr.servarr.com/v1/ping";
FlareSolverrDownloadClient client = new();
RequestResult result = client.MakeRequestInternal(knownProtectedUrl);
return (int)result.statusCode >= 200 && (int)result.statusCode < 300 ? Ok() : StatusCode(500, result.statusCode);
}
/// <summary>
/// Returns the language in which Manga are downloaded
/// </summary>
/// <response code="200"></response>
[HttpGet("DownloadLanguage")]
[ProducesResponseType<string>(Status200OK, "text/plain")]
public IActionResult GetDownloadLanguage()
{
return Ok(Tranga.Settings.DownloadLanguage);
}
/// <summary>
/// Sets the language in which Manga are downloaded
/// </summary>
/// <response code="200"></response>
[HttpPatch("DownloadLanguage/{Language}")]
[ProducesResponseType(Status200OK)]
public IActionResult SetDownloadLanguage(string Language)
{
//TODO Validation
Tranga.Settings.SetDownloadLanguage(Language);
return Ok();
}
}

View File

@ -1,154 +0,0 @@
using API.APIEndpointRecords;
using API.Workers;
using Asp.Versioning;
using log4net;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{version:apiVersion}/[controller]")]
public class WorkerController() : Controller
{
/// <summary>
/// Returns all <see cref="BaseWorker"/>
/// </summary>
/// <response code="200"></response>
[HttpGet]
[ProducesResponseType<BaseWorker[]>(Status200OK, "application/json")]
public IActionResult GetAllWorkers()
{
return Ok(Tranga.AllWorkers.ToArray());
}
/// <summary>
/// Returns <see cref="BaseWorker"/> with requested <paramref name="WorkerIds"/>
/// </summary>
/// <param name="WorkerIds">Array of <see cref="BaseWorker"/>.Key</param>
/// <response code="200"></response>
[HttpPost("WithIDs")]
[ProducesResponseType<BaseWorker[]>(Status200OK, "application/json")]
public IActionResult GetJobs([FromBody]string[] WorkerIds)
{
return Ok(Tranga.AllWorkers.Where(worker => WorkerIds.Contains(worker.Key)).ToArray());
}
/// <summary>
/// Get all <see cref="BaseWorker"/> in requested <see cref="WorkerExecutionState"/>
/// </summary>
/// <param name="State">Requested <see cref="WorkerExecutionState"/></param>
/// <response code="200"></response>
[HttpGet("State/{State}")]
[ProducesResponseType<BaseWorker[]>(Status200OK, "application/json")]
public IActionResult GetJobsInState(WorkerExecutionState State)
{
return Ok(Tranga.AllWorkers.Where(worker => worker.State == State).ToArray());
}
/// <summary>
/// Return <see cref="BaseWorker"/> with <paramref name="WorkerId"/>
/// </summary>
/// <param name="WorkerId"><see cref="BaseWorker"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response>
[HttpGet("{WorkerId}")]
[ProducesResponseType<BaseWorker>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetJob(string WorkerId)
{
if(Tranga.AllWorkers.FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return NotFound(nameof(WorkerId));
return Ok(worker);
}
/// <summary>
/// Delete <see cref="BaseWorker"/> with <paramref name="WorkerId"/> and all child-<see cref="BaseWorker"/>s
/// </summary>
/// <param name="WorkerId"><see cref="BaseWorker"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response>
[HttpDelete("{WorkerId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
public IActionResult DeleteJob(string WorkerId)
{
if(Tranga.AllWorkers.FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return NotFound(nameof(WorkerId));
Tranga.RemoveWorker(worker);
return Ok();
}
/// <summary>
/// Modify <see cref="BaseWorker"/> with <paramref name="WorkerId"/>
/// </summary>
/// <param name="WorkerId"><see cref="BaseWorker"/>.Key</param>
/// <param name="modifyWorkerRecord">Fields to modify, set to null to keep previous value</param>
/// <response code="202"></response>
/// <response code="400"></response>
/// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response>
/// <response code="409"><see cref="BaseWorker"/> is not <see cref="IPeriodic"/>, can not modify <paramref name="modifyWorkerRecord.IntervalMs"/></response>
[HttpPatch("{WorkerId}")]
[ProducesResponseType<BaseWorker>(Status202Accepted, "application/json")]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status409Conflict, "text/plain")]
public IActionResult ModifyJob(string WorkerId, [FromBody]ModifyWorkerRecord modifyWorkerRecord)
{
if(Tranga.AllWorkers.FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return NotFound(nameof(WorkerId));
if(modifyWorkerRecord.IntervalMs is not null && worker is not IPeriodic)
return Conflict("Can not modify Interval of non-Periodic worker");
else if(modifyWorkerRecord.IntervalMs is not null && worker is IPeriodic periodic)
periodic.Interval = TimeSpan.FromMilliseconds((long)modifyWorkerRecord.IntervalMs);
return Accepted(worker);
}
/// <summary>
/// Starts <see cref="BaseWorker"/> with <paramref name="WorkerId"/>
/// </summary>
/// <param name="WorkerId"><see cref="BaseWorker"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response>
/// <response code="412"><see cref="BaseWorker"/> was already running</response>
[HttpPost("{WorkerId}/Start")]
[ProducesResponseType(Status202Accepted)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
public IActionResult StartJob(string WorkerId)
{
if(Tranga.AllWorkers.FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return NotFound(nameof(WorkerId));
if (worker.State >= WorkerExecutionState.Waiting)
return StatusCode(Status412PreconditionFailed, "Already running");
Tranga.MarkWorkerForStart(worker);
return Ok();
}
/// <summary>
/// Stops <see cref="BaseWorker"/> with <paramref name="WorkerId"/>
/// </summary>
/// <param name="WorkerId"><see cref="BaseWorker"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response>
/// <response code="208"><see cref="BaseWorker"/> was not running</response>
[HttpPost("{WorkerId}/Stop")]
[ProducesResponseType(Status501NotImplemented)]
public IActionResult StopJob(string WorkerId)
{
if(Tranga.AllWorkers.FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return NotFound(nameof(WorkerId));
if(worker.State is < WorkerExecutionState.Running or >= WorkerExecutionState.Completed)
return StatusCode(Status208AlreadyReported, "Not running");
Tranga.StopWorker(worker);
return Ok();
}
}

View File

@ -1,35 +0,0 @@
namespace API;
public interface IHttpRequestTimeFeature
{
DateTime RequestTime { get; }
}
public class HttpRequestTimeFeature : IHttpRequestTimeFeature
{
public DateTime RequestTime { get; }
public HttpRequestTimeFeature()
{
RequestTime = DateTime.Now;
}
}
public class RequestTimeMiddleware
{
private readonly RequestDelegate _next;
public RequestTimeMiddleware(RequestDelegate next)
{
_next = next;
}
public Task InvokeAsync(HttpContext context)
{
var httpRequestTimeFeature = new HttpRequestTimeFeature();
context.Features.Set<IHttpRequestTimeFeature>(httpRequestTimeFeature);
// Call the next delegate/middleware in the pipeline
return this._next(context);
}
}

View File

@ -1,47 +0,0 @@
using System.Net;
using log4net;
namespace API.MangaDownloadClients;
public abstract class DownloadClient
{
private static readonly Dictionary<RequestType, DateTime> LastExecutedRateLimit = new();
protected ILog Log { get; init; }
protected DownloadClient()
{
this.Log = LogManager.GetLogger(GetType());
}
public RequestResult MakeRequest(string url, RequestType requestType, string? referrer = null, string? clickButton = null)
{
Log.Debug($"Requesting {requestType} {url}");
if (!Tranga.Settings.RequestLimits.ContainsKey(requestType))
{
return new RequestResult(HttpStatusCode.NotAcceptable, null, Stream.Null);
}
int rateLimit = Tranga.Settings.UserAgent == TrangaSettings.DefaultUserAgent
? TrangaSettings.DefaultRequestLimits[requestType]
: Tranga.Settings.RequestLimits[requestType];
TimeSpan timeBetweenRequests = TimeSpan.FromMinutes(1).Divide(rateLimit);
DateTime now = DateTime.Now;
LastExecutedRateLimit.TryAdd(requestType, now.Subtract(timeBetweenRequests));
TimeSpan rateLimitTimeout = timeBetweenRequests.Subtract(now.Subtract(LastExecutedRateLimit[requestType]));
Log.Debug($"Request limit {requestType} {rateLimit}/Minute timeBetweenRequests: {timeBetweenRequests:ss'.'fffff} Timeout: {rateLimitTimeout:ss'.'fffff}");
if (rateLimitTimeout > TimeSpan.Zero)
{
Thread.Sleep(rateLimitTimeout);
}
RequestResult result = MakeRequestInternal(url, referrer, clickButton);
LastExecutedRateLimit[requestType] = DateTime.UtcNow;
Log.Debug($"Result {url}: {result}");
return result;
}
internal abstract RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null);
}

View File

@ -1,180 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Text;
using System.Text.Json;
using HtmlAgilityPack;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace API.MangaDownloadClients;
public class FlareSolverrDownloadClient : DownloadClient
{
internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null)
{
if (clickButton is not null)
Log.Warn("Client can not click button");
if(referrer is not null)
Log.Warn("Client can not set referrer");
if (Tranga.Settings.FlareSolverrUrl == string.Empty)
{
Log.Error("FlareSolverr URL is empty");
return new(HttpStatusCode.InternalServerError, null, Stream.Null);
}
Uri flareSolverrUri = new (Tranga.Settings.FlareSolverrUrl);
if (flareSolverrUri.Segments.Last() != "v1")
flareSolverrUri = new UriBuilder(flareSolverrUri)
{
Path = "v1"
}.Uri;
HttpClient client = new()
{
Timeout = TimeSpan.FromSeconds(10),
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
DefaultRequestHeaders = { { "User-Agent", Tranga.Settings.UserAgent } }
};
JObject requestObj = new()
{
["cmd"] = "request.get",
["url"] = url
};
HttpRequestMessage requestMessage = new(HttpMethod.Post, flareSolverrUri)
{
Content = new StringContent(JsonConvert.SerializeObject(requestObj)),
};
requestMessage.Content.Headers.ContentType = new ("application/json");
Log.Debug($"Requesting {url}");
HttpResponseMessage? response;
try
{
response = client.Send(requestMessage);
}
catch (HttpRequestException e)
{
Log.Error(e);
return new (HttpStatusCode.Unused, null, Stream.Null);
}
if (!response.IsSuccessStatusCode)
{
Log.Debug($"Request returned status code {(int)response.StatusCode} {response.StatusCode}:\n" +
$"=====\n" +
$"Request:\n" +
$"{requestMessage.Method} {requestMessage.RequestUri}\n" +
$"{requestMessage.Version} {requestMessage.VersionPolicy}\n" +
$"Headers:\n\t{string.Join("\n\t", requestMessage.Headers.Select(h => $"{h.Key}: <{string.Join(">, <", h.Value)}"))}>\n" +
$"{requestMessage.Content?.ReadAsStringAsync().Result}" +
$"=====\n" +
$"Response:\n" +
$"{response.Version}\n" +
$"Headers:\n\t{string.Join("\n\t", response.Headers.Select(h => $"{h.Key}: <{string.Join(">, <", h.Value)}"))}>\n" +
$"{response.Content.ReadAsStringAsync().Result}");
return new (response.StatusCode, null, Stream.Null);
}
string responseString = response.Content.ReadAsStringAsync().Result;
JObject responseObj = JObject.Parse(responseString);
if (!IsInCorrectFormat(responseObj, out string? reason))
{
Log.Error($"Wrong format: {reason}");
return new(HttpStatusCode.Unused, null, Stream.Null);
}
string statusResponse = responseObj["status"]!.Value<string>()!;
if (statusResponse != "ok")
{
Log.Debug($"Status is not ok: {statusResponse}");
return new(HttpStatusCode.Unused, null, Stream.Null);
}
JObject solution = (responseObj["solution"] as JObject)!;
if (!Enum.TryParse(solution["status"]!.Value<int>().ToString(), out HttpStatusCode statusCode))
{
Log.Error($"Wrong format: Cant parse status code: {solution["status"]!.Value<int>()}");
return new(HttpStatusCode.Unused, null, Stream.Null);
}
if (statusCode < HttpStatusCode.OK || statusCode >= HttpStatusCode.MultipleChoices)
{
Log.Debug($"Status is: {statusCode}");
return new(statusCode, null, Stream.Null);
}
if (solution["response"]!.Value<string>() is not { } htmlString)
{
Log.Error("Wrong format: Cant find response in solution");
return new(HttpStatusCode.Unused, null, Stream.Null);
}
if (IsJson(htmlString, out HtmlDocument document, out string? json))
{
MemoryStream ms = new();
ms.Write(Encoding.UTF8.GetBytes(json));
ms.Position = 0;
return new(statusCode, document, ms);
}
else
{
MemoryStream ms = new();
ms.Write(Encoding.UTF8.GetBytes(htmlString));
ms.Position = 0;
return new(statusCode, document, ms);
}
}
private bool IsInCorrectFormat(JObject responseObj, [NotNullWhen(false)]out string? reason)
{
reason = null;
if (!responseObj.ContainsKey("status"))
{
reason = "Cant find status on response";
return false;
}
if (responseObj["solution"] is not JObject solution)
{
reason = "Cant find solution";
return false;
}
if (!solution.ContainsKey("status"))
{
reason = "Wrong format: Cant find status in solution";
return false;
}
if (!solution.ContainsKey("response"))
{
reason = "Wrong format: Cant find response in solution";
return false;
}
return true;
}
private bool IsJson(string htmlString, out HtmlDocument document, [NotNullWhen(true)]out string? jsonString)
{
jsonString = null;
document = new();
document.LoadHtml(htmlString);
HtmlNode pre = document.DocumentNode.SelectSingleNode("//pre");
try
{
using JsonDocument _ = JsonDocument.Parse(pre.InnerText);
jsonString = pre.InnerText;
return true;
}
catch (Exception)
{
return false;
}
}
}

View File

@ -1,87 +0,0 @@
using System.Net;
using HtmlAgilityPack;
namespace API.MangaDownloadClients;
internal class HttpDownloadClient : DownloadClient
{
internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null)
{
if (clickButton is not null)
Log.Warn("Client can not click button");
HttpClient client = new();
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher;
client.DefaultRequestHeaders.Add("User-Agent", Tranga.Settings.UserAgent);
HttpResponseMessage? response;
Uri uri = new(url);
HttpRequestMessage requestMessage = new(HttpMethod.Get, uri);
if (referrer is not null)
requestMessage.Headers.Referrer = new (referrer);
Log.Debug($"Requesting {url}");
try
{
response = client.Send(requestMessage);
}
catch (HttpRequestException e)
{
Log.Error(e);
return new (HttpStatusCode.Unused, null, Stream.Null);
}
if (!response.IsSuccessStatusCode)
{
Log.Debug($"Request returned status code {(int)response.StatusCode} {response.StatusCode}");
if (response.Headers.Server.Any(s =>
(s.Product?.Name ?? "").Contains("cloudflare", StringComparison.InvariantCultureIgnoreCase)))
{
Log.Debug("Retrying with FlareSolverr!");
return new FlareSolverrDownloadClient().MakeRequestInternal(url, referrer, clickButton);
}
else
{
Log.Debug($"Request returned status code {(int)response.StatusCode} {response.StatusCode}:\n" +
$"=====\n" +
$"Request:\n" +
$"{requestMessage.Method} {requestMessage.RequestUri}\n" +
$"{requestMessage.Version} {requestMessage.VersionPolicy}\n" +
$"Headers:\n\t{string.Join("\n\t", requestMessage.Headers.Select(h => $"{h.Key}: <{string.Join(">, <", h.Value)}"))}>\n" +
$"{requestMessage.Content?.ReadAsStringAsync().Result}" +
$"=====\n" +
$"Response:\n" +
$"{response.Version}\n" +
$"Headers:\n\t{string.Join("\n\t", response.Headers.Select(h => $"{h.Key}: <{string.Join(">, <", h.Value)}"))}>\n" +
$"{response.Content.ReadAsStringAsync().Result}");
}
}
Stream stream;
try
{
stream = response.Content.ReadAsStream();
}
catch (Exception e)
{
Log.Error(e);
return new (HttpStatusCode.Unused, null, Stream.Null);
}
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 && response.RequestMessage.RequestUri != uri)
{
return new (response.StatusCode, document, stream, true, response.RequestMessage.RequestUri.AbsoluteUri);
}
return new (response.StatusCode, document, stream);
}
}

View File

@ -1,70 +0,0 @@
// <auto-generated />
using API.Schema.LibraryContext;
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.Library
{
[DbContext(typeof(LibraryContext))]
[Migration("20250703191925_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.LibraryContext.LibraryConnectors.LibraryConnector", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("Auth")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("BaseUrl")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<byte>("LibraryType")
.HasColumnType("smallint");
b.HasKey("Key");
b.ToTable("LibraryConnectors");
b.HasDiscriminator<byte>("LibraryType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.LibraryContext.LibraryConnectors.Kavita", b =>
{
b.HasBaseType("API.Schema.LibraryContext.LibraryConnectors.LibraryConnector");
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.LibraryContext.LibraryConnectors.Komga", b =>
{
b.HasBaseType("API.Schema.LibraryContext.LibraryConnectors.LibraryConnector");
b.HasDiscriminator().HasValue((byte)0);
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,35 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations.Library
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LibraryConnectors",
columns: table => new
{
Key = table.Column<string>(type: "text", nullable: false),
LibraryType = table.Column<byte>(type: "smallint", nullable: false),
BaseUrl = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Auth = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LibraryConnectors", x => x.Key);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LibraryConnectors");
}
}
}

View File

@ -1,67 +0,0 @@
// <auto-generated />
using API.Schema.LibraryContext;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace API.Migrations.Library
{
[DbContext(typeof(LibraryContext))]
partial class LibraryContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.LibraryContext.LibraryConnectors.LibraryConnector", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("Auth")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("BaseUrl")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<byte>("LibraryType")
.HasColumnType("smallint");
b.HasKey("Key");
b.ToTable("LibraryConnectors");
b.HasDiscriminator<byte>("LibraryType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.LibraryContext.LibraryConnectors.Kavita", b =>
{
b.HasBaseType("API.Schema.LibraryContext.LibraryConnectors.LibraryConnector");
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.LibraryContext.LibraryConnectors.Komga", b =>
{
b.HasBaseType("API.Schema.LibraryContext.LibraryConnectors.LibraryConnector");
b.HasDiscriminator().HasValue((byte)0);
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,547 +0,0 @@
// <auto-generated />
using API.Schema.MangaContext;
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.Manga
{
[DbContext(typeof(MangaContext))]
[Migration("20250703192023_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.MangaContext.Author", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("AuthorName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Key");
b.ToTable("Authors");
});
modelBuilder.Entity("API.Schema.MangaContext.Chapter", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("ChapterNumber")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<bool>("Downloaded")
.HasColumnType("boolean");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ParentMangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("VolumeNumber")
.HasColumnType("integer");
b.HasKey("Key");
b.HasIndex("ParentMangaId");
b.ToTable("Chapters");
});
modelBuilder.Entity("API.Schema.MangaContext.FileLibrary", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("BasePath")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LibraryName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Key");
b.ToTable("FileLibraries");
});
modelBuilder.Entity("API.Schema.MangaContext.Manga", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("CoverFileNameInCache")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("CoverUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("DirectoryName")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<float>("IgnoreChaptersBefore")
.HasColumnType("real");
b.Property<string>("LibraryId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("OriginalLanguage")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<byte>("ReleaseStatus")
.HasColumnType("smallint");
b.Property<long?>("Year")
.HasColumnType("bigint");
b.HasKey("Key");
b.HasIndex("LibraryId");
b.ToTable("Mangas");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Chapter>", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("IdOnConnectorSite")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("MangaConnectorName")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ObjId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("UseForDownload")
.HasColumnType("boolean");
b.Property<string>("WebsiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Key");
b.HasIndex("MangaConnectorName");
b.HasIndex("ObjId");
b.ToTable("MangaConnectorToChapter");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Manga>", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("IdOnConnectorSite")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("MangaConnectorName")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ObjId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("UseForDownload")
.HasColumnType("boolean");
b.Property<string>("WebsiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Key");
b.HasIndex("MangaConnectorName");
b.HasIndex("ObjId");
b.ToTable("MangaConnectorToManga");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.MangaConnector", b =>
{
b.Property<string>("Name")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.PrimitiveCollection<string[]>("BaseUris")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("text[]");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("IconUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.PrimitiveCollection<string[]>("SupportedLanguages")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("text[]");
b.HasKey("Name");
b.ToTable("MangaConnectors");
b.HasDiscriminator<string>("Name").HasValue("MangaConnector");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.MangaContext.MangaTag", b =>
{
b.Property<string>("Tag")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Tag");
b.ToTable("Tags");
});
modelBuilder.Entity("API.Schema.MangaContext.MetadataFetchers.MetadataEntry", b =>
{
b.Property<string>("MetadataFetcherName")
.HasColumnType("text");
b.Property<string>("Identifier")
.HasColumnType("text");
b.Property<string>("MangaId")
.IsRequired()
.HasColumnType("text");
b.HasKey("MetadataFetcherName", "Identifier");
b.HasIndex("MangaId");
b.ToTable("MetadataEntries");
});
modelBuilder.Entity("API.Schema.MangaContext.MetadataFetchers.MetadataFetcher", b =>
{
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("MetadataEntry")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("character varying(21)");
b.HasKey("Name");
b.ToTable("MetadataFetcher");
b.HasDiscriminator<string>("MetadataEntry").HasValue("MetadataFetcher");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("AuthorToManga", b =>
{
b.Property<string>("AuthorIds")
.HasColumnType("text");
b.Property<string>("MangaIds")
.HasColumnType("text");
b.HasKey("AuthorIds", "MangaIds");
b.HasIndex("MangaIds");
b.ToTable("AuthorToManga");
});
modelBuilder.Entity("MangaTagToManga", b =>
{
b.Property<string>("MangaTagIds")
.HasColumnType("character varying(64)");
b.Property<string>("MangaIds")
.HasColumnType("text");
b.HasKey("MangaTagIds", "MangaIds");
b.HasIndex("MangaIds");
b.ToTable("MangaTagToManga");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.ComickIo", b =>
{
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("ComickIo");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.Global", b =>
{
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Global");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.MangaDex", b =>
{
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("MangaDex");
});
modelBuilder.Entity("API.Schema.MangaContext.MetadataFetchers.MyAnimeList", b =>
{
b.HasBaseType("API.Schema.MangaContext.MetadataFetchers.MetadataFetcher");
b.HasDiscriminator().HasValue("MyAnimeList");
});
modelBuilder.Entity("API.Schema.MangaContext.Chapter", b =>
{
b.HasOne("API.Schema.MangaContext.Manga", "ParentManga")
.WithMany("Chapters")
.HasForeignKey("ParentMangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentManga");
});
modelBuilder.Entity("API.Schema.MangaContext.Manga", b =>
{
b.HasOne("API.Schema.MangaContext.FileLibrary", "Library")
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.SetNull);
b.OwnsMany("API.Schema.MangaContext.AltTitle", "AltTitles", b1 =>
{
b1.Property<string>("Key")
.HasColumnType("text");
b1.Property<string>("Language")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b1.Property<string>("MangaKey")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b1.HasKey("Key");
b1.HasIndex("MangaKey");
b1.ToTable("AltTitle");
b1.WithOwner()
.HasForeignKey("MangaKey");
});
b.OwnsMany("API.Schema.MangaContext.Link", "Links", b1 =>
{
b1.Property<string>("Key")
.HasColumnType("text");
b1.Property<string>("LinkProvider")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b1.Property<string>("LinkUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("MangaKey")
.IsRequired()
.HasColumnType("text");
b1.HasKey("Key");
b1.HasIndex("MangaKey");
b1.ToTable("Link");
b1.WithOwner()
.HasForeignKey("MangaKey");
});
b.Navigation("AltTitles");
b.Navigation("Library");
b.Navigation("Links");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Chapter>", b =>
{
b.HasOne("API.Schema.MangaContext.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.Chapter", "Obj")
.WithMany("MangaConnectorIds")
.HasForeignKey("ObjId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.Navigation("MangaConnector");
b.Navigation("Obj");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Manga>", b =>
{
b.HasOne("API.Schema.MangaContext.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.Manga", "Obj")
.WithMany("MangaConnectorIds")
.HasForeignKey("ObjId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MangaConnector");
b.Navigation("Obj");
});
modelBuilder.Entity("API.Schema.MangaContext.MetadataFetchers.MetadataEntry", b =>
{
b.HasOne("API.Schema.MangaContext.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.MetadataFetchers.MetadataFetcher", "MetadataFetcher")
.WithMany()
.HasForeignKey("MetadataFetcherName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
b.Navigation("MetadataFetcher");
});
modelBuilder.Entity("AuthorToManga", b =>
{
b.HasOne("API.Schema.MangaContext.Author", null)
.WithMany()
.HasForeignKey("AuthorIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.Manga", null)
.WithMany()
.HasForeignKey("MangaIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MangaTagToManga", b =>
{
b.HasOne("API.Schema.MangaContext.Manga", null)
.WithMany()
.HasForeignKey("MangaIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.MangaTag", null)
.WithMany()
.HasForeignKey("MangaTagIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Schema.MangaContext.Chapter", b =>
{
b.Navigation("MangaConnectorIds");
});
modelBuilder.Entity("API.Schema.MangaContext.Manga", b =>
{
b.Navigation("Chapters");
b.Navigation("MangaConnectorIds");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,396 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations.Manga
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Authors",
columns: table => new
{
Key = table.Column<string>(type: "text", nullable: false),
AuthorName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Authors", x => x.Key);
});
migrationBuilder.CreateTable(
name: "FileLibraries",
columns: table => new
{
Key = table.Column<string>(type: "text", nullable: false),
BasePath = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
LibraryName = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FileLibraries", x => x.Key);
});
migrationBuilder.CreateTable(
name: "MangaConnectors",
columns: table => new
{
Name = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
SupportedLanguages = table.Column<string[]>(type: "text[]", maxLength: 8, nullable: false),
IconUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
BaseUris = table.Column<string[]>(type: "text[]", maxLength: 256, nullable: false),
Enabled = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MangaConnectors", x => x.Name);
});
migrationBuilder.CreateTable(
name: "MetadataFetcher",
columns: table => new
{
Name = table.Column<string>(type: "text", nullable: false),
MetadataEntry = table.Column<string>(type: "character varying(21)", maxLength: 21, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MetadataFetcher", x => x.Name);
});
migrationBuilder.CreateTable(
name: "Tags",
columns: table => new
{
Tag = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tags", x => x.Tag);
});
migrationBuilder.CreateTable(
name: "Mangas",
columns: table => new
{
Key = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Description = table.Column<string>(type: "text", nullable: false),
CoverUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
ReleaseStatus = table.Column<byte>(type: "smallint", nullable: false),
LibraryId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
IgnoreChaptersBefore = table.Column<float>(type: "real", nullable: false),
DirectoryName = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
CoverFileNameInCache = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
Year = table.Column<long>(type: "bigint", nullable: true),
OriginalLanguage = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Mangas", x => x.Key);
table.ForeignKey(
name: "FK_Mangas_FileLibraries_LibraryId",
column: x => x.LibraryId,
principalTable: "FileLibraries",
principalColumn: "Key",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "AltTitle",
columns: table => new
{
Key = table.Column<string>(type: "text", nullable: false),
Language = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
MangaKey = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AltTitle", x => x.Key);
table.ForeignKey(
name: "FK_AltTitle_Mangas_MangaKey",
column: x => x.MangaKey,
principalTable: "Mangas",
principalColumn: "Key",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AuthorToManga",
columns: table => new
{
AuthorIds = table.Column<string>(type: "text", nullable: false),
MangaIds = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AuthorToManga", x => new { x.AuthorIds, x.MangaIds });
table.ForeignKey(
name: "FK_AuthorToManga_Authors_AuthorIds",
column: x => x.AuthorIds,
principalTable: "Authors",
principalColumn: "Key",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AuthorToManga_Mangas_MangaIds",
column: x => x.MangaIds,
principalTable: "Mangas",
principalColumn: "Key",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Chapters",
columns: table => new
{
Key = table.Column<string>(type: "text", nullable: false),
ParentMangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
VolumeNumber = table.Column<int>(type: "integer", nullable: true),
ChapterNumber = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
Title = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
FileName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Downloaded = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Chapters", x => x.Key);
table.ForeignKey(
name: "FK_Chapters_Mangas_ParentMangaId",
column: x => x.ParentMangaId,
principalTable: "Mangas",
principalColumn: "Key",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Link",
columns: table => new
{
Key = table.Column<string>(type: "text", nullable: false),
LinkProvider = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
LinkUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
MangaKey = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Link", x => x.Key);
table.ForeignKey(
name: "FK_Link_Mangas_MangaKey",
column: x => x.MangaKey,
principalTable: "Mangas",
principalColumn: "Key",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MangaConnectorToManga",
columns: table => new
{
Key = table.Column<string>(type: "text", nullable: false),
ObjId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
MangaConnectorName = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
IdOnConnectorSite = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
WebsiteUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
UseForDownload = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MangaConnectorToManga", x => x.Key);
table.ForeignKey(
name: "FK_MangaConnectorToManga_MangaConnectors_MangaConnectorName",
column: x => x.MangaConnectorName,
principalTable: "MangaConnectors",
principalColumn: "Name",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MangaConnectorToManga_Mangas_ObjId",
column: x => x.ObjId,
principalTable: "Mangas",
principalColumn: "Key",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MangaTagToManga",
columns: table => new
{
MangaTagIds = table.Column<string>(type: "character varying(64)", nullable: false),
MangaIds = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MangaTagToManga", x => new { x.MangaTagIds, x.MangaIds });
table.ForeignKey(
name: "FK_MangaTagToManga_Mangas_MangaIds",
column: x => x.MangaIds,
principalTable: "Mangas",
principalColumn: "Key",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MangaTagToManga_Tags_MangaTagIds",
column: x => x.MangaTagIds,
principalTable: "Tags",
principalColumn: "Tag",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MetadataEntries",
columns: table => new
{
MetadataFetcherName = table.Column<string>(type: "text", nullable: false),
Identifier = table.Column<string>(type: "text", nullable: false),
MangaId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MetadataEntries", x => new { x.MetadataFetcherName, x.Identifier });
table.ForeignKey(
name: "FK_MetadataEntries_Mangas_MangaId",
column: x => x.MangaId,
principalTable: "Mangas",
principalColumn: "Key",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MetadataEntries_MetadataFetcher_MetadataFetcherName",
column: x => x.MetadataFetcherName,
principalTable: "MetadataFetcher",
principalColumn: "Name",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MangaConnectorToChapter",
columns: table => new
{
Key = table.Column<string>(type: "text", nullable: false),
ObjId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
MangaConnectorName = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
IdOnConnectorSite = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
WebsiteUrl = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
UseForDownload = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MangaConnectorToChapter", x => x.Key);
table.ForeignKey(
name: "FK_MangaConnectorToChapter_Chapters_ObjId",
column: x => x.ObjId,
principalTable: "Chapters",
principalColumn: "Key");
table.ForeignKey(
name: "FK_MangaConnectorToChapter_MangaConnectors_MangaConnectorName",
column: x => x.MangaConnectorName,
principalTable: "MangaConnectors",
principalColumn: "Name",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AltTitle_MangaKey",
table: "AltTitle",
column: "MangaKey");
migrationBuilder.CreateIndex(
name: "IX_AuthorToManga_MangaIds",
table: "AuthorToManga",
column: "MangaIds");
migrationBuilder.CreateIndex(
name: "IX_Chapters_ParentMangaId",
table: "Chapters",
column: "ParentMangaId");
migrationBuilder.CreateIndex(
name: "IX_Link_MangaKey",
table: "Link",
column: "MangaKey");
migrationBuilder.CreateIndex(
name: "IX_MangaConnectorToChapter_MangaConnectorName",
table: "MangaConnectorToChapter",
column: "MangaConnectorName");
migrationBuilder.CreateIndex(
name: "IX_MangaConnectorToChapter_ObjId",
table: "MangaConnectorToChapter",
column: "ObjId");
migrationBuilder.CreateIndex(
name: "IX_MangaConnectorToManga_MangaConnectorName",
table: "MangaConnectorToManga",
column: "MangaConnectorName");
migrationBuilder.CreateIndex(
name: "IX_MangaConnectorToManga_ObjId",
table: "MangaConnectorToManga",
column: "ObjId");
migrationBuilder.CreateIndex(
name: "IX_Mangas_LibraryId",
table: "Mangas",
column: "LibraryId");
migrationBuilder.CreateIndex(
name: "IX_MangaTagToManga_MangaIds",
table: "MangaTagToManga",
column: "MangaIds");
migrationBuilder.CreateIndex(
name: "IX_MetadataEntries_MangaId",
table: "MetadataEntries",
column: "MangaId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AltTitle");
migrationBuilder.DropTable(
name: "AuthorToManga");
migrationBuilder.DropTable(
name: "Link");
migrationBuilder.DropTable(
name: "MangaConnectorToChapter");
migrationBuilder.DropTable(
name: "MangaConnectorToManga");
migrationBuilder.DropTable(
name: "MangaTagToManga");
migrationBuilder.DropTable(
name: "MetadataEntries");
migrationBuilder.DropTable(
name: "Authors");
migrationBuilder.DropTable(
name: "Chapters");
migrationBuilder.DropTable(
name: "MangaConnectors");
migrationBuilder.DropTable(
name: "Tags");
migrationBuilder.DropTable(
name: "MetadataFetcher");
migrationBuilder.DropTable(
name: "Mangas");
migrationBuilder.DropTable(
name: "FileLibraries");
}
}
}

View File

@ -1,544 +0,0 @@
// <auto-generated />
using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace API.Migrations.Manga
{
[DbContext(typeof(MangaContext))]
partial class MangaContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.MangaContext.Author", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("AuthorName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Key");
b.ToTable("Authors");
});
modelBuilder.Entity("API.Schema.MangaContext.Chapter", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("ChapterNumber")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<bool>("Downloaded")
.HasColumnType("boolean");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ParentMangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int?>("VolumeNumber")
.HasColumnType("integer");
b.HasKey("Key");
b.HasIndex("ParentMangaId");
b.ToTable("Chapters");
});
modelBuilder.Entity("API.Schema.MangaContext.FileLibrary", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("BasePath")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LibraryName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Key");
b.ToTable("FileLibraries");
});
modelBuilder.Entity("API.Schema.MangaContext.Manga", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("CoverFileNameInCache")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("CoverUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("DirectoryName")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<float>("IgnoreChaptersBefore")
.HasColumnType("real");
b.Property<string>("LibraryId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("OriginalLanguage")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<byte>("ReleaseStatus")
.HasColumnType("smallint");
b.Property<long?>("Year")
.HasColumnType("bigint");
b.HasKey("Key");
b.HasIndex("LibraryId");
b.ToTable("Mangas");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Chapter>", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("IdOnConnectorSite")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("MangaConnectorName")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ObjId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("UseForDownload")
.HasColumnType("boolean");
b.Property<string>("WebsiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Key");
b.HasIndex("MangaConnectorName");
b.HasIndex("ObjId");
b.ToTable("MangaConnectorToChapter");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Manga>", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<string>("IdOnConnectorSite")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("MangaConnectorName")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("ObjId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("UseForDownload")
.HasColumnType("boolean");
b.Property<string>("WebsiteUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("Key");
b.HasIndex("MangaConnectorName");
b.HasIndex("ObjId");
b.ToTable("MangaConnectorToManga");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.MangaConnector", b =>
{
b.Property<string>("Name")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.PrimitiveCollection<string[]>("BaseUris")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("text[]");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("IconUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.PrimitiveCollection<string[]>("SupportedLanguages")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("text[]");
b.HasKey("Name");
b.ToTable("MangaConnectors");
b.HasDiscriminator<string>("Name").HasValue("MangaConnector");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.MangaContext.MangaTag", b =>
{
b.Property<string>("Tag")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Tag");
b.ToTable("Tags");
});
modelBuilder.Entity("API.Schema.MangaContext.MetadataFetchers.MetadataEntry", b =>
{
b.Property<string>("MetadataFetcherName")
.HasColumnType("text");
b.Property<string>("Identifier")
.HasColumnType("text");
b.Property<string>("MangaId")
.IsRequired()
.HasColumnType("text");
b.HasKey("MetadataFetcherName", "Identifier");
b.HasIndex("MangaId");
b.ToTable("MetadataEntries");
});
modelBuilder.Entity("API.Schema.MangaContext.MetadataFetchers.MetadataFetcher", b =>
{
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("MetadataEntry")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("character varying(21)");
b.HasKey("Name");
b.ToTable("MetadataFetcher");
b.HasDiscriminator<string>("MetadataEntry").HasValue("MetadataFetcher");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("AuthorToManga", b =>
{
b.Property<string>("AuthorIds")
.HasColumnType("text");
b.Property<string>("MangaIds")
.HasColumnType("text");
b.HasKey("AuthorIds", "MangaIds");
b.HasIndex("MangaIds");
b.ToTable("AuthorToManga");
});
modelBuilder.Entity("MangaTagToManga", b =>
{
b.Property<string>("MangaTagIds")
.HasColumnType("character varying(64)");
b.Property<string>("MangaIds")
.HasColumnType("text");
b.HasKey("MangaTagIds", "MangaIds");
b.HasIndex("MangaIds");
b.ToTable("MangaTagToManga");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.ComickIo", b =>
{
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("ComickIo");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.Global", b =>
{
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Global");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.MangaDex", b =>
{
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("MangaDex");
});
modelBuilder.Entity("API.Schema.MangaContext.MetadataFetchers.MyAnimeList", b =>
{
b.HasBaseType("API.Schema.MangaContext.MetadataFetchers.MetadataFetcher");
b.HasDiscriminator().HasValue("MyAnimeList");
});
modelBuilder.Entity("API.Schema.MangaContext.Chapter", b =>
{
b.HasOne("API.Schema.MangaContext.Manga", "ParentManga")
.WithMany("Chapters")
.HasForeignKey("ParentMangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentManga");
});
modelBuilder.Entity("API.Schema.MangaContext.Manga", b =>
{
b.HasOne("API.Schema.MangaContext.FileLibrary", "Library")
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.SetNull);
b.OwnsMany("API.Schema.MangaContext.AltTitle", "AltTitles", b1 =>
{
b1.Property<string>("Key")
.HasColumnType("text");
b1.Property<string>("Language")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b1.Property<string>("MangaKey")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b1.HasKey("Key");
b1.HasIndex("MangaKey");
b1.ToTable("AltTitle");
b1.WithOwner()
.HasForeignKey("MangaKey");
});
b.OwnsMany("API.Schema.MangaContext.Link", "Links", b1 =>
{
b1.Property<string>("Key")
.HasColumnType("text");
b1.Property<string>("LinkProvider")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b1.Property<string>("LinkUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b1.Property<string>("MangaKey")
.IsRequired()
.HasColumnType("text");
b1.HasKey("Key");
b1.HasIndex("MangaKey");
b1.ToTable("Link");
b1.WithOwner()
.HasForeignKey("MangaKey");
});
b.Navigation("AltTitles");
b.Navigation("Library");
b.Navigation("Links");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Chapter>", b =>
{
b.HasOne("API.Schema.MangaContext.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.Chapter", "Obj")
.WithMany("MangaConnectorIds")
.HasForeignKey("ObjId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.Navigation("MangaConnector");
b.Navigation("Obj");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Manga>", b =>
{
b.HasOne("API.Schema.MangaContext.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.Manga", "Obj")
.WithMany("MangaConnectorIds")
.HasForeignKey("ObjId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MangaConnector");
b.Navigation("Obj");
});
modelBuilder.Entity("API.Schema.MangaContext.MetadataFetchers.MetadataEntry", b =>
{
b.HasOne("API.Schema.MangaContext.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.MetadataFetchers.MetadataFetcher", "MetadataFetcher")
.WithMany()
.HasForeignKey("MetadataFetcherName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
b.Navigation("MetadataFetcher");
});
modelBuilder.Entity("AuthorToManga", b =>
{
b.HasOne("API.Schema.MangaContext.Author", null)
.WithMany()
.HasForeignKey("AuthorIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.Manga", null)
.WithMany()
.HasForeignKey("MangaIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MangaTagToManga", b =>
{
b.HasOne("API.Schema.MangaContext.Manga", null)
.WithMany()
.HasForeignKey("MangaIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.MangaTag", null)
.WithMany()
.HasForeignKey("MangaTagIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Schema.MangaContext.Chapter", b =>
{
b.Navigation("MangaConnectorIds");
});
modelBuilder.Entity("API.Schema.MangaContext.Manga", b =>
{
b.Navigation("Chapters");
b.Navigation("MangaConnectorIds");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,91 +0,0 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using API.Schema.NotificationsContext;
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.Notifications
{
[DbContext(typeof(NotificationsContext))]
[Migration("20250703191820_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.NotificationsContext.Notification", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSent")
.HasColumnType("boolean");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<byte>("Urgency")
.HasColumnType("smallint");
b.HasKey("Key");
b.ToTable("Notifications");
});
modelBuilder.Entity("API.Schema.NotificationsContext.NotificationConnectors.NotificationConnector", b =>
{
b.Property<string>("Name")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<Dictionary<string, string>>("Headers")
.IsRequired()
.HasColumnType("hstore");
b.Property<string>("HttpMethod")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Name");
b.ToTable("NotificationConnectors");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,60 +0,0 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations.Notifications
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:hstore", ",,");
migrationBuilder.CreateTable(
name: "NotificationConnectors",
columns: table => new
{
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
Headers = table.Column<Dictionary<string, string>>(type: "hstore", nullable: false),
HttpMethod = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
Body = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NotificationConnectors", x => x.Name);
});
migrationBuilder.CreateTable(
name: "Notifications",
columns: table => new
{
Key = table.Column<string>(type: "text", nullable: false),
Urgency = table.Column<byte>(type: "smallint", nullable: false),
Title = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Message = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsSent = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Notifications", x => x.Key);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NotificationConnectors");
migrationBuilder.DropTable(
name: "Notifications");
}
}
}

View File

@ -1,88 +0,0 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using API.Schema.NotificationsContext;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace API.Migrations.Notifications
{
[DbContext(typeof(NotificationsContext))]
partial class NotificationsContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.NotificationsContext.Notification", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsSent")
.HasColumnType("boolean");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<byte>("Urgency")
.HasColumnType("smallint");
b.HasKey("Key");
b.ToTable("Notifications");
});
modelBuilder.Entity("API.Schema.NotificationsContext.NotificationConnectors.NotificationConnector", b =>
{
b.Property<string>("Name")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)");
b.Property<Dictionary<string, string>>("Headers")
.IsRequired()
.HasColumnType("hstore");
b.Property<string>("HttpMethod")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Name");
b.ToTable("NotificationConnectors");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,46 +0,0 @@
using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace API;
public class NamedSwaggerGenOptions : IConfigureNamedOptions<SwaggerGenOptions>
{
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;
}
}

View File

@ -1,152 +0,0 @@
using System.Reflection;
using API;
using API.Schema.LibraryContext;
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
using API.Schema.NotificationsContext;
using Asp.Versioning;
using Asp.Versioning.Builder;
using Asp.Versioning.Conventions;
using log4net;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll",
policy =>
{
policy
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
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.AddSwaggerGenNewtonsoftSupport();
builder.Services.AddSwaggerGen(opt =>
{
var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
opt.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
});
builder.Services.ConfigureOptions<NamedSwaggerGenOptions>();
string connectionString = $"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.AddDbContext<MangaContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.AddDbContext<NotificationsContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.AddDbContext<LibraryContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.AddControllers(options =>
{
options.AllowEmptyInputInBodyModelBinding = true;
});
builder.Services.AddControllers().AddNewtonsoftJson(opts =>
{
opts.SerializerSettings.Converters.Add(new StringEnumConverter());
opts.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
});
builder.Services.AddScoped<ILog>(opts => LogManager.GetLogger("API"));
builder.WebHost.UseUrls("http://*:6531");
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();
app.UseMiddleware<RequestTimeMiddleware>();
using (IServiceScope scope = app.Services.CreateScope())
{
MangaContext context = scope.ServiceProvider.GetRequiredService<MangaContext>();
context.Database.Migrate();
MangaConnector[] connectors =
[
new MangaDex(),
new ComickIo(),
new Global(scope.ServiceProvider.GetService<MangaContext>()!)
];
MangaConnector[] newConnectors = connectors.Where(c => !context.MangaConnectors.Contains(c)).ToArray();
context.MangaConnectors.AddRange(newConnectors);
if (!context.FileLibraries.Any())
context.FileLibraries.Add(new FileLibrary(Tranga.Settings.DownloadLocation, "Default FileLibrary"));
context.Sync();
}
using (IServiceScope scope = app.Services.CreateScope())
{
NotificationsContext context = scope.ServiceProvider.GetRequiredService<NotificationsContext>();
context.Database.Migrate();
context.Notifications.RemoveRange(context.Notifications);
string[] emojis = { "(•‿•)", "(づ \u25d5‿\u25d5 )づ", "( \u02d8\u25bd\u02d8)っ\u2668", "=\uff3e\u25cf \u22cf \u25cf\uff3e=", "(ΦωΦ)", "(\u272a\u3268\u272a)", "( ノ・o・ )ノ", "(〜^\u2207^ )〜", "~(\u2267ω\u2266)~","૮ \u00b4• ﻌ \u00b4• ა", "(\u02c3ᆺ\u02c2)", "(=\ud83d\udf66 \u0f1d \ud83d\udf66=)"};
context.Notifications.Add(new Notification("Tranga Started", emojis[Random.Shared.Next(0, emojis.Length - 1)], NotificationUrgency.High));
context.Sync();
}
using (IServiceScope scope = app.Services.CreateScope())
{
LibraryContext context = scope.ServiceProvider.GetRequiredService<LibraryContext>();
context.Database.Migrate();
context.Sync();
}
Tranga.StartLogger();
Tranga.PeriodicWorkerStarterThread.Start(app.Services);
app.UseCors("AllowAll");
app.Run();

View File

@ -1,47 +0,0 @@
{
"$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"
}
}
}
}

View File

@ -1,21 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace API.Schema;
[PrimaryKey("Key")]
public abstract class Identifiable
{
public Identifiable()
{
this.Key = TokenGen.CreateToken(this.GetType());
}
public Identifiable(string key)
{
this.Key = key;
}
public string Key { get; init; }
public override string ToString() => Key;
}

View File

@ -1,57 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using log4net;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.LibraryContext.LibraryConnectors;
[PrimaryKey("Key")]
public abstract class LibraryConnector : Identifiable
{
[Required]
public LibraryType LibraryType { get; init; }
[StringLength(256)]
[Required]
[Url]
public string BaseUrl { get; init; }
[StringLength(256)]
[Required]
public string Auth { get; init; }
[JsonIgnore]
[NotMapped]
protected ILog Log { get; init; }
protected LibraryConnector(LibraryType libraryType, string baseUrl, string auth)
: base()
{
this.LibraryType = libraryType;
this.BaseUrl = baseUrl;
this.Auth = auth;
this.Log = LogManager.GetLogger(GetType());
}
/// <summary>
/// EF CORE ONLY!!!!
/// </summary>
internal LibraryConnector(string key, LibraryType libraryType, string baseUrl, string auth)
: base(key)
{
this.LibraryType = libraryType;
this.BaseUrl = baseUrl;
this.Auth = auth;
this.Log = LogManager.GetLogger(GetType());
}
public override string ToString() => $"{base.ToString()} {this.LibraryType} {this.BaseUrl}";
protected abstract void UpdateLibraryInternal();
internal abstract bool Test();
}
public enum LibraryType : byte
{
Komga = 0,
Kavita = 1
}

View File

@ -1,73 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using log4net;
namespace API.Schema.LibraryContext.LibraryConnectors;
public class NetClient
{
private static ILog Log = LogManager.GetLogger(typeof(NetClient));
public static Stream MakeRequest(string url, string authScheme, string auth)
{
Log.Debug($"Requesting {url}");
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:
Log.Debug(e);
break;
default:
throw;
}
Log.Info("Failed to make request");
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;
}
}

View File

@ -1,18 +0,0 @@
using API.Schema.LibraryContext.LibraryConnectors;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.LibraryContext;
public class LibraryContext(DbContextOptions<LibraryContext> options) : TrangaBaseContext<LibraryContext>(options)
{
public DbSet<LibraryConnector> LibraryConnectors { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
//LibraryConnector Types
modelBuilder.Entity<LibraryConnector>()
.HasDiscriminator(l => l.LibraryType)
.HasValue<Komga>(LibraryType.Komga)
.HasValue<Kavita>(LibraryType.Kavita);
}
}

View File

@ -1,17 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.MangaContext;
[PrimaryKey("Key")]
public class AltTitle(string language, string title) : Identifiable(TokenGen.CreateToken("AltTitle"))
{
[StringLength(8)]
[Required]
public string Language { get; init; } = language;
[StringLength(256)]
[Required]
public string Title { get; init; } = title;
public override string ToString() => $"{base.ToString()} {Language} {Title}";
}

View File

@ -1,14 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.MangaContext;
[PrimaryKey("Key")]
public class Author(string authorName) : Identifiable(TokenGen.CreateToken(typeof(Author), authorName))
{
[StringLength(128)]
[Required]
public string AuthorName { get; init; } = authorName;
public override string ToString() => $"{base.ToString()} {AuthorName}";
}

View File

@ -1,210 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
namespace API.Schema.MangaContext;
[PrimaryKey("Key")]
public class Chapter : Identifiable, IComparable<Chapter>
{
[StringLength(64)] [Required] public string ParentMangaId { get; init; } = null!;
private Manga? _parentManga;
[JsonIgnore]
public Manga ParentManga
{
get => _lazyLoader.Load(this, ref _parentManga) ?? throw new InvalidOperationException();
init
{
ParentMangaId = value.Key;
_parentManga = value;
}
}
[NotMapped]
public Dictionary<string, string> IdsOnMangaConnectors =>
MangaConnectorIds.ToDictionary(id => id.MangaConnectorName, id => id.IdOnConnectorSite);
private ICollection<MangaConnectorId<Chapter>>? _mangaConnectorIds;
[JsonIgnore]
public ICollection<MangaConnectorId<Chapter>> MangaConnectorIds
{
get => _lazyLoader.Load(this, ref _mangaConnectorIds) ?? throw new InvalidOperationException();
init => _mangaConnectorIds = value;
}
public int? VolumeNumber { get; private set; }
[StringLength(10)] [Required] public string ChapterNumber { get; private set; }
[StringLength(256)] public string? Title { get; private set; }
[StringLength(256)] [Required] public string FileName { get; private set; }
[Required] public bool Downloaded { get; internal set; }
[NotMapped] public string FullArchiveFilePath => Path.Join(ParentManga.FullDirectoryPath, FileName);
private readonly ILazyLoader _lazyLoader = null!;
public Chapter(Manga parentManga, string chapterNumber,
int? volumeNumber, string? title = null)
: base(TokenGen.CreateToken(typeof(Chapter), parentManga.Key, chapterNumber))
{
this.ParentManga = parentManga;
this.MangaConnectorIds = [];
this.VolumeNumber = volumeNumber;
this.ChapterNumber = chapterNumber;
this.Title = title;
this.FileName = GetArchiveFilePath();
this.Downloaded = false;
}
/// <summary>
/// EF ONLY!!!
/// </summary>
internal Chapter(ILazyLoader lazyLoader, string key, int? volumeNumber, string chapterNumber, string? title, string fileName, bool downloaded)
: base(key)
{
this._lazyLoader = lazyLoader;
this.VolumeNumber = volumeNumber;
this.ChapterNumber = chapterNumber;
this.Title = title;
this.FileName = fileName;
this.Downloaded = downloaded;
}
public int CompareTo(Chapter? other)
{
if (other is not { } otherChapter)
throw new ArgumentException($"{other} can not be compared to {this}");
return VolumeNumber?.CompareTo(otherChapter.VolumeNumber) switch
{
< 0 => -1,
> 0 => 1,
_ => CompareChapterNumbers(ChapterNumber, otherChapter.ChapterNumber)
};
}
/// <summary>
/// Checks the filesystem if an archive at the ArchiveFilePath exists
/// </summary>
/// <returns>True if archive exists on disk</returns>
public bool CheckDownloaded() => File.Exists(FullArchiveFilePath);
/// Placeholders:
/// %M Obj Name
/// %V Volume
/// %C Chapter
/// %T Title
/// %A Author (first in list)
/// %I Chapter Internal ID
/// %i Obj Internal ID
/// %Y Year (Obj)
private static readonly Regex NullableRex = new(@"\?([a-zA-Z])\(([^\)]*)\)|(.+?)");
private static readonly Regex ReplaceRexx = new(@"%([a-zA-Z])|(.+?)");
private string GetArchiveFilePath()
{
string archiveNamingScheme = Tranga.Settings.ChapterNamingScheme;
StringBuilder stringBuilder = new();
foreach (Match nullable in NullableRex.Matches(archiveNamingScheme))
{
if (nullable.Groups[3].Success)
{
stringBuilder.Append(nullable.Groups[3].Value);
continue;
}
char placeholder = nullable.Groups[1].Value[0];
bool isNull = placeholder switch
{
'M' => ParentManga?.Name is null,
'V' => VolumeNumber is null,
'C' => ChapterNumber is null,
'T' => Title is null,
'A' => ParentManga?.Authors?.FirstOrDefault()?.AuthorName is null,
'Y' => ParentManga?.Year is null,
_ => true
};
if(!isNull)
stringBuilder.Append(nullable.Groups[2].Value);
}
string checkedString = stringBuilder.ToString();
stringBuilder = new();
foreach (Match replace in ReplaceRexx.Matches(checkedString))
{
if (replace.Groups[2].Success)
{
stringBuilder.Append(replace.Groups[2].Value);
continue;
}
char placeholder = replace.Groups[1].Value[0];
string? value = placeholder switch
{
'M' => ParentManga?.Name,
'V' => VolumeNumber?.ToString(),
'C' => ChapterNumber,
'T' => Title,
'A' => ParentManga?.Authors?.FirstOrDefault()?.AuthorName,
'Y' => ParentManga?.Year.ToString(),
_ => null
};
stringBuilder.Append(value);
}
stringBuilder.Append(".cbz");
return stringBuilder.ToString();
}
private static int CompareChapterNumbers(string ch1, string ch2)
{
int[] ch1Arr = ch1.Split('.').Select(c => int.TryParse(c, out int result) ? result : -1).ToArray();
int[] ch2Arr = ch2.Split('.').Select(c => int.TryParse(c, out int result) ? result : -1).ToArray();
if (ch1Arr.Contains(-1) || ch2Arr.Contains(-1))
throw new ArgumentException("Chapter number is not in correct format");
int i = 0, j = 0;
while (i < ch1Arr.Length && j < ch2Arr.Length)
{
if (ch1Arr[i] < ch2Arr[j])
return -1;
if (ch1Arr[i] > ch2Arr[j])
return 1;
i++;
j++;
}
return 0;
}
internal string GetComicInfoXmlString()
{
XElement comicInfo = new("ComicInfo",
new XElement("Number", ChapterNumber)
);
if(Title is not null)
comicInfo.Add(new XElement("Title", Title));
if(ParentManga.MangaTags.Count > 0)
comicInfo.Add(new XElement("Tags", string.Join(',', ParentManga.MangaTags.Select(tag => tag.Tag))));
if(VolumeNumber is not null)
comicInfo.Add(new XElement("Volume", VolumeNumber));
if(ParentManga.Authors.Count > 0)
comicInfo.Add(new XElement("Writer", string.Join(',', ParentManga.Authors.Select(author => author.AuthorName))));
if(ParentManga.OriginalLanguage is not null)
comicInfo.Add(new XElement("LanguageISO", ParentManga.OriginalLanguage));
if(ParentManga.Description != string.Empty)
comicInfo.Add(new XElement("Summary", ParentManga.Description));
return comicInfo.ToString();
}
public override string ToString() => $"{base.ToString()} Vol.{VolumeNumber} Ch.{ChapterNumber} - {Title}";
}

View File

@ -1,19 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.MangaContext;
[PrimaryKey("Key")]
public class FileLibrary(string basePath, string libraryName)
: Identifiable(TokenGen.CreateToken(typeof(FileLibrary), basePath))
{
[StringLength(256)]
[Required]
public string BasePath { get; internal set; } = basePath;
[StringLength(512)]
[Required]
public string LibraryName { get; internal set; } = libraryName;
public override string ToString() => $"{base.ToString()} {LibraryName} - {BasePath}";
}

View File

@ -1,18 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.MangaContext;
[PrimaryKey("Key")]
public class Link(string linkProvider, string linkUrl) : Identifiable(TokenGen.CreateToken(typeof(Link), linkProvider, linkUrl))
{
[StringLength(64)]
[Required]
public string LinkProvider { get; init; } = linkProvider;
[StringLength(2048)]
[Required]
[Url]
public string LinkUrl { get; init; } = linkUrl;
public override string ToString() => $"{base.ToString()} {LinkProvider} {LinkUrl}";
}

View File

@ -1,196 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Runtime.InteropServices;
using System.Text;
using API.Workers;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
using static System.IO.UnixFileMode;
namespace API.Schema.MangaContext;
[PrimaryKey("Key")]
public class Manga : Identifiable
{
[StringLength(512)] [Required] public string Name { get; internal set; }
[Required] public string Description { get; internal set; }
[JsonIgnore] [Url] [StringLength(512)] public string CoverUrl { get; internal set; }
[Required] public MangaReleaseStatus ReleaseStatus { get; internal set; }
[StringLength(64)] public string? LibraryId { get; private set; }
private FileLibrary? _library;
[JsonIgnore]
public FileLibrary? Library
{
get => _lazyLoader.Load(this, ref _library);
set
{
LibraryId = value?.Key;
_library = value;
}
}
public ICollection<Author> Authors { get; internal set; }= null!;
public ICollection<MangaTag> MangaTags { get; internal set; }= null!;
public ICollection<Link> Links { get; internal set; }= null!;
public ICollection<AltTitle> AltTitles { get; internal set; } = null!;
[Required] public float IgnoreChaptersBefore { get; internal set; }
[StringLength(1024)] [Required] public string DirectoryName { get; private set; }
[JsonIgnore] [StringLength(512)] public string? CoverFileNameInCache { get; internal set; }
public uint? Year { get; internal init; }
[StringLength(8)] public string? OriginalLanguage { get; internal init; }
[JsonIgnore]
[NotMapped]
public string? FullDirectoryPath => Library is not null ? Path.Join(Library.BasePath, DirectoryName) : null;
[NotMapped] public ICollection<string> ChapterIds => Chapters.Select(c => c.Key).ToList();
private ICollection<Chapter>? _chapters;
[JsonIgnore]
public ICollection<Chapter> Chapters
{
get => _lazyLoader.Load(this, ref _chapters) ?? throw new InvalidOperationException();
init => _chapters = value;
}
[NotMapped] public Dictionary<string, string> IdsOnMangaConnectors =>
MangaConnectorIds.ToDictionary(id => id.MangaConnectorName, id => id.IdOnConnectorSite);
private ICollection<MangaConnectorId<Manga>>? _mangaConnectorIds;
[JsonIgnore]
public ICollection<MangaConnectorId<Manga>> MangaConnectorIds
{
get => _lazyLoader.Load(this, ref _mangaConnectorIds) ?? throw new InvalidOperationException();
private set => _mangaConnectorIds = value;
}
private readonly ILazyLoader _lazyLoader = null!;
public Manga(string name, string description, string coverUrl, MangaReleaseStatus releaseStatus,
ICollection<Author> authors, ICollection<MangaTag> mangaTags, ICollection<Link> links, ICollection<AltTitle> altTitles,
FileLibrary? library = null, float ignoreChaptersBefore = 0f, uint? year = null, string? originalLanguage = null)
:base(TokenGen.CreateToken(typeof(Manga), name))
{
this.Name = name;
this.Description = description;
this.CoverUrl = coverUrl;
this.ReleaseStatus = releaseStatus;
this.Library = library;
this.Authors = authors;
this.MangaTags = mangaTags;
this.Links = links;
this.AltTitles = altTitles;
this.IgnoreChaptersBefore = ignoreChaptersBefore;
this.DirectoryName = CleanDirectoryName(name);
this.Year = year;
this.OriginalLanguage = originalLanguage;
this.Chapters = [];
}
/// <summary>
/// EF ONLY!!!
/// </summary>
public Manga(ILazyLoader lazyLoader, string key, string name, string description, string coverUrl,
MangaReleaseStatus releaseStatus,
string directoryName, float ignoreChaptersBefore, string? libraryId, uint? year, string? originalLanguage)
: base(key)
{
this._lazyLoader = lazyLoader;
this.Name = name;
this.Description = description;
this.CoverUrl = coverUrl;
this.ReleaseStatus = releaseStatus;
this.DirectoryName = directoryName;
this.LibraryId = libraryId;
this.IgnoreChaptersBefore = ignoreChaptersBefore;
this.Year = year;
this.OriginalLanguage = originalLanguage;
}
public string CreatePublicationFolder()
{
string? publicationFolder = FullDirectoryPath;
if (publicationFolder is null)
throw new DirectoryNotFoundException("Publication folder not found");
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;
}
//https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
//less than 32 is control *forbidden*
//34 is " *forbidden*
//42 is * *forbidden*
//47 is / *forbidden*
//58 is : *forbidden*
//60 is < *forbidden*
//62 is > *forbidden*
//63 is ? *forbidden*
//92 is \ *forbidden*
//124 is | *forbidden*
//127 is delete *forbidden*
//Below 127 all except *******
private static readonly int[] ForbiddenCharsBelow127 = [34, 42, 47, 58, 60, 62, 63, 92, 124, 127];
//Above 127 none except *******
private static readonly int[] IncludeCharsAbove127 = [128, 138, 142];
//128 is € include
//138 is Š include
//142 is Ž include
//152 through 255 looks fine except 157, 172, 173, 175 *******
private static readonly int[] ForbiddenCharsAbove152 = [157, 172, 173, 175];
private static string CleanDirectoryName(string name)
{
StringBuilder sb = new ();
foreach (char c in name)
{
if (c >= 32 && c < 127 && ForbiddenCharsBelow127.Contains(c) == false)
sb.Append(c);
else if (c > 127 && c < 152 && IncludeCharsAbove127.Contains(c))
sb.Append(c);
else if(c >= 152 && c <= 255 && ForbiddenCharsAbove152.Contains(c) == false)
sb.Append(c);
}
return sb.ToString();
}
/// <summary>
/// Merges another Manga (MangaConnectorIds and Chapters)
/// </summary>
/// <param name="other">The other <see cref="Manga" /> to merge</param>
/// <param name="context"><see cref="MangaContext"/> to use for Database operations</param>
/// <returns>An array of <see cref="MoveFileOrFolderWorker"/> for moving <see cref="Chapter"/> to new Directory</returns>
public BaseWorker[] MergeFrom(Manga other, MangaContext context)
{
context.Mangas.Remove(other);
List<BaseWorker> newJobs = new();
this.MangaConnectorIds = this.MangaConnectorIds
.UnionBy(other.MangaConnectorIds, id => id.MangaConnectorName)
.ToList();
foreach (Chapter otherChapter in other.Chapters)
{
string oldPath = otherChapter.FullArchiveFilePath;
Chapter newChapter = new(this, otherChapter.ChapterNumber, otherChapter.VolumeNumber,
otherChapter.Title);
this.Chapters.Add(newChapter);
string newPath = newChapter.FullArchiveFilePath;
newJobs.Add(new MoveFileOrFolderWorker(newPath, oldPath));
}
return newJobs.ToArray();
}
public override string ToString() => $"{base.ToString()} {Name}";
}
public enum MangaReleaseStatus : byte
{
Continuing = 0,
Completed = 1,
OnHiatus = 2,
Cancelled = 3,
Unreleased = 4
}

View File

@ -1,70 +0,0 @@
using System.ComponentModel.DataAnnotations;
using API.Schema.MangaContext.MangaConnectors;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
namespace API.Schema.MangaContext;
[PrimaryKey("Key")]
public class MangaConnectorId<T> : Identifiable where T : Identifiable
{
[StringLength(64)] [Required] public string ObjId { get; private set; } = null!;
[JsonIgnore] private T? _obj;
[JsonIgnore]
public T Obj
{
get => _lazyLoader.Load(this, ref _obj) ?? throw new InvalidOperationException();
internal set
{
ObjId = value.Key;
_obj = value;
}
}
[StringLength(32)] [Required] public string MangaConnectorName { get; private set; } = null!;
[JsonIgnore] private MangaConnector? _mangaConnector;
[JsonIgnore]
public MangaConnector MangaConnector
{
get => _lazyLoader.Load(this, ref _mangaConnector) ?? throw new InvalidOperationException();
init
{
MangaConnectorName = value.Name;
_mangaConnector = value;
}
}
[StringLength(256)] [Required] public string IdOnConnectorSite { get; init; }
[Url] [StringLength(512)] public string? WebsiteUrl { get; internal init; }
public bool UseForDownload { get; internal set; }
private readonly ILazyLoader _lazyLoader = null!;
public MangaConnectorId(T obj, MangaConnector mangaConnector, string idOnConnectorSite, string? websiteUrl, bool useForDownload = false)
: base(TokenGen.CreateToken(typeof(MangaConnectorId<T>), mangaConnector.Name, idOnConnectorSite))
{
this.Obj = obj;
this.MangaConnector = mangaConnector;
this.IdOnConnectorSite = idOnConnectorSite;
this.WebsiteUrl = websiteUrl;
this.UseForDownload = useForDownload;
}
/// <summary>
/// EF CORE ONLY!!!
/// </summary>
public MangaConnectorId(ILazyLoader lazyLoader, string key, string objId, string mangaConnectorName, string idOnConnectorSite, bool useForDownload, string? websiteUrl)
: base(key)
{
this._lazyLoader = lazyLoader;
this.ObjId = objId;
this.MangaConnectorName = mangaConnectorName;
this.IdOnConnectorSite = idOnConnectorSite;
this.WebsiteUrl = websiteUrl;
this.UseForDownload = useForDownload;
}
public override string ToString() => $"{base.ToString()} {_obj}";
}

View File

@ -1,258 +0,0 @@
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using Newtonsoft.Json.Linq;
namespace API.Schema.MangaContext.MangaConnectors;
public class ComickIo : MangaConnector
{
//https://api.comick.io/docs/
//https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
public ComickIo() : base("ComickIo",
["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"],
["comick.io"],
"https://comick.io/static/icons/unicorn-64.png")
{
this.downloadClient = new HttpDownloadClient();
}
public override (Manga, MangaConnectorId<Manga>)[] SearchManga(string mangaSearchName)
{
Log.Info($"Searching Obj: {mangaSearchName}");
List<string> slugs = new();
int page = 1;
while(page < 50)
{
string requestUrl = $"https://api.comick.fun/v1.0/search/?type=comic&t=false&limit=100&showall=true&" +
$"page={page}&q={mangaSearchName}";
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return [];
}
using StreamReader sr = new (result.result);
JArray data = JArray.Parse(sr.ReadToEnd());
if (data.Count < 1)
break;
slugs.AddRange(data.Select(token => token.Value<string>("slug")!));
page++;
}
Log.Debug($"Search {mangaSearchName} yielded {slugs.Count} slugs. Requesting mangas now...");
List<(Manga, MangaConnectorId<Manga>)> mangas = new ();
foreach (string slug in slugs)
{
if(GetMangaFromId(slug) is { } entry)
mangas.Add(entry);
}
Log.Info($"Search {mangaSearchName} yielded {mangas.Count} results.");
return mangas.ToArray();
}
private readonly Regex _getSlugFromTitleRex = new(@"https?:\/\/comick\.io\/comic\/(.+)(?:\/.*)*");
public override (Manga, MangaConnectorId<Manga>)? GetMangaFromUrl(string url)
{
Match m = _getSlugFromTitleRex.Match(url);
return m.Groups[1].Success ? GetMangaFromId(m.Groups[1].Value) : null;
}
public override (Manga, MangaConnectorId<Manga>)? GetMangaFromId(string mangaIdOnSite)
{
string requestUrl = $"https://api.comick.fun/comic/{mangaIdOnSite}";
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaInfo);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return null;
}
using StreamReader sr = new (result.result);
JToken data = JToken.Parse(sr.ReadToEnd());
return ParseMangaFromJToken(data);
}
public override (Chapter, MangaConnectorId<Chapter>)[] GetChapters(MangaConnectorId<Manga> mangaConnectorId,
string? language = null)
{
Log.Info($"Getting Chapters: {mangaConnectorId.IdOnConnectorSite}");
List<(Chapter, MangaConnectorId<Chapter>)> chapters = new();
int page = 1;
while(page < 50)
{
string requestUrl = $"https://api.comick.fun/comic/{mangaConnectorId.IdOnConnectorSite}/chapters?limit=100&page={page}&lang={language}";
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaInfo);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return [];
}
using StreamReader sr = new (result.result);
JToken data = JToken.Parse(sr.ReadToEnd());
JArray? chaptersArray = data["chapters"] as JArray;
if (chaptersArray is null || chaptersArray.Count < 1)
break;
chapters.AddRange(ParseChapters(mangaConnectorId, chaptersArray));
page++;
}
return chapters.ToArray();
}
private readonly Regex _hidFromUrl = new(@"https?:\/\/comick\.io\/comic\/.+\/([^-]+).*");
internal override string[] GetChapterImageUrls(MangaConnectorId<Chapter> chapterId)
{
Log.Info($"Getting Chapter Image-Urls: {chapterId.Obj}");
if (chapterId.WebsiteUrl is null || !UrlMatchesConnector(chapterId.WebsiteUrl))
{
Log.Debug($"Url is not for Connector. {chapterId.WebsiteUrl}");
return [];
}
Match m = _hidFromUrl.Match(chapterId.WebsiteUrl);
if (!m.Groups[1].Success)
{
Log.Debug($"Could not parse hid from url. {chapterId.WebsiteUrl}");
return [];
}
string hid = m.Groups[1].Value;
string requestUrl = $"https://api.comick.fun/chapter/{hid}/get_images";
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaInfo);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return [];
}
using StreamReader sr = new (result.result);
JArray data = JArray.Parse(sr.ReadToEnd());
return data.Select(token =>
{
string url = $"https://meo.comick.pictures/{token.Value<string>("b2key")}";
return url;
}).ToArray();
}
private (Manga manga, MangaConnectorId<Manga> id) ParseMangaFromJToken(JToken json)
{
string? hid = json["comic"]?.Value<string>("hid");
string? slug = json["comic"]?.Value<string>("slug");
string? name = json["comic"]?.Value<string>("title");
string? description = json["comic"]?.Value<string>("desc");
string? originalLanguage = json["comic"]?.Value<string>("country");
string url = $"https://comick.io/comic/{slug}";
string? coverName = json["comic"]?["md_covers"]?.First?.Value<string>("b2key");
string coverUrl = $"https://meo.comick.pictures/{coverName}";
int? releaseStatusStr = json["comic"]?.Value<int>("status");
MangaReleaseStatus status = releaseStatusStr switch
{
1 => MangaReleaseStatus.Continuing,
2 => MangaReleaseStatus.Completed,
3 => MangaReleaseStatus.Cancelled,
4 => MangaReleaseStatus.OnHiatus,
_ => MangaReleaseStatus.Unreleased
};
uint? year = json["comic"]?.Value<uint?>("year");
JArray? altTitlesArray = json["comic"]?["md_titles"] as JArray;
//Cant let language be null, so fill with whatever.
byte whatever = 0;
List<AltTitle> altTitles = altTitlesArray?
.Select(token => new AltTitle(token.Value<string>("lang")??whatever++.ToString(), token.Value<string>("title")!))
.ToList()!;
JArray? authorsArray = json["authors"] as JArray;
JArray? artistsArray = json["artists"] as JArray;
List<Author> authors = authorsArray?.Concat(artistsArray!)
.Select(token => new Author(token.Value<string>("name")!))
.DistinctBy(a => a.Key)
.ToList()!;
JArray? genreArray = json["comic"]?["md_comic_md_genres"] as JArray;
List<MangaTag> tags = genreArray?
.Select(token => new MangaTag(token["md_genres"]?.Value<string>("name")!))
.ToList()!;
JArray? linksArray = json["comic"]?["links"] as JArray;
List<Link> links = linksArray?
.ToObject<Dictionary<string,string>>()?
.Select(kv =>
{
string fullUrl = kv.Key switch
{
"al" => $"https://anilist.co/manga/{kv.Value}",
"ap" => $"https://www.anime-planet.com/manga/{kv.Value}",
"bw" => $"https://bookwalker.jp/{kv.Value}",
"mu" => $"https://www.mangaupdates.com/series.html?id={kv.Value}",
"nu" => $"https://www.novelupdates.com/series/{kv.Value}",
"mal" => $"https://myanimelist.net/manga/{kv.Value}",
_ => kv.Value
};
string key = kv.Key switch
{
"al" => "AniList",
"ap" => "Anime Planet",
"bw" => "BookWalker",
"mu" => "Obj Updates",
"nu" => "Novel Updates",
"kt" => "Kitsu.io",
"amz" => "Amazon",
"ebj" => "eBookJapan",
"mal" => "MyAnimeList",
"cdj" => "CDJapan",
_ => kv.Key
};
return new Link(key, fullUrl);
}).ToList()!;
if(hid is null)
throw new Exception("hid is null");
if(slug is null)
throw new Exception("slug is null");
if(name is null)
throw new Exception("name is null");
Manga manga = new (name, description??"", coverUrl, status, authors, tags, links, altTitles,
year: year, originalLanguage: originalLanguage);
return (manga, new MangaConnectorId<Manga>(manga, this, hid, url));
}
private List<(Chapter, MangaConnectorId<Chapter>)> ParseChapters(MangaConnectorId<Manga> mcIdManga, JArray chaptersArray)
{
List<(Chapter, MangaConnectorId<Chapter>)> chapters = new ();
foreach (JToken chapter in chaptersArray)
{
string? chapterNum = chapter.Value<string>("chap");
string? volumeNumStr = chapter.Value<string>("vol");
int? volumeNum = volumeNumStr is null ? null : int.Parse(volumeNumStr);
string? title = chapter.Value<string>("title");
string? hid = chapter.Value<string>("hid");
string url = $"https://comick.io/comic/{mcIdManga.IdOnConnectorSite}/{hid}";
if(chapterNum is null || hid is null)
continue;
Chapter ch = new (mcIdManga.Obj, chapterNum, volumeNum, title);
chapters.Add((ch, new (ch, this, hid, url)));
}
return chapters;
}
}

View File

@ -1,54 +0,0 @@
namespace API.Schema.MangaContext.MangaConnectors;
public class Global : MangaConnector
{
private MangaContext context { get; init; }
public Global(MangaContext context) : base("Global", ["all"], [""], "")
{
this.context = context;
}
public override (Manga, MangaConnectorId<Manga>)[] SearchManga(string mangaSearchName)
{
//Get all enabled Connectors
MangaConnector[] enabledConnectors = context.MangaConnectors.Where(c => c.Enabled && c.Name != "Global").ToArray();
//Create Task for each MangaConnector to search simultaneously
Task<(Manga, MangaConnectorId<Manga>)[]>[] tasks =
enabledConnectors.Select(c => new Task<(Manga, MangaConnectorId<Manga>)[]>(() => c.SearchManga(mangaSearchName))).ToArray();
foreach (var task in tasks)
task.Start();
//Wait for all tasks to finish
do
{
Thread.Sleep(50);
}while(tasks.Any(t => t.Status < TaskStatus.RanToCompletion));
//Concatenate all results into one
(Manga, MangaConnectorId<Manga>)[] ret = tasks.Select(t => t.IsCompletedSuccessfully ? t.Result : []).ToArray().SelectMany(i => i).ToArray();
return ret;
}
public override (Manga, MangaConnectorId<Manga>)? GetMangaFromUrl(string url)
{
MangaConnector? mc = context.MangaConnectors.ToArray().FirstOrDefault(c => c.UrlMatchesConnector(url));
return mc?.GetMangaFromUrl(url) ?? null;
}
public override (Manga, MangaConnectorId<Manga>)? GetMangaFromId(string mangaIdOnSite)
{
return null;
}
public override (Chapter, MangaConnectorId<Chapter>)[] GetChapters(MangaConnectorId<Manga> manga,
string? language = null)
{
return manga.MangaConnector.GetChapters(manga, language);
}
internal override string[] GetChapterImageUrls(MangaConnectorId<Chapter> chapterId)
{
return chapterId.MangaConnector.GetChapterImageUrls(chapterId);
}
}

View File

@ -1,75 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using log4net;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.MangaContext.MangaConnectors;
[PrimaryKey("Name")]
public abstract class MangaConnector(string name, string[] supportedLanguages, string[] baseUris, string iconUrl)
{
[JsonIgnore]
[NotMapped]
internal DownloadClient downloadClient { get; init; } = null!;
[JsonIgnore]
[NotMapped]
protected ILog Log { get; init; } = LogManager.GetLogger(name);
[StringLength(32)]
[Required]
public string Name { get; init; } = name;
[StringLength(8)]
[Required]
public string[] SupportedLanguages { get; init; } = supportedLanguages;
[StringLength(2048)]
[Required]
public string IconUrl { get; init; } = iconUrl;
[StringLength(256)]
[Required]
public string[] BaseUris { get; init; } = baseUris;
[Required]
public bool Enabled { get; internal set; } = true;
public abstract (Manga, MangaConnectorId<Manga>)[] SearchManga(string mangaSearchName);
public abstract (Manga, MangaConnectorId<Manga>)? GetMangaFromUrl(string url);
public abstract (Manga, MangaConnectorId<Manga>)? GetMangaFromId(string mangaIdOnSite);
public abstract (Chapter, MangaConnectorId<Chapter>)[] GetChapters(MangaConnectorId<Manga> mangaId,
string? language = null);
internal abstract string[] GetChapterImageUrls(MangaConnectorId<Chapter> chapterId);
public bool UrlMatchesConnector(string url) => BaseUris.Any(baseUri => Regex.IsMatch(url, "https?://" + baseUri + "/.*"));
internal string? SaveCoverImageToCache(MangaConnectorId<Manga> mangaId, int retries = 3)
{
if(retries < 0)
return null;
Regex urlRex = new (@"https?:\/\/((?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]+)\/(?:.+\/)*(.+\.([a-zA-Z]+))");
//https?:\/\/[a-zA-Z0-9-]+\.([a-zA-Z0-9-]+\.[a-zA-Z0-9]+)\/(?:.+\/)*(.+\.([a-zA-Z]+)) for only second level domains
Match match = urlRex.Match(mangaId.Obj.CoverUrl);
string filename = $"{match.Groups[1].Value}-{mangaId.Key}.{match.Groups[3].Value}";
string saveImagePath = Path.Join(TrangaSettings.coverImageCache, filename);
if (File.Exists(saveImagePath))
return saveImagePath;
RequestResult coverResult = downloadClient.MakeRequest(mangaId.Obj.CoverUrl, RequestType.MangaCover, $"https://{match.Groups[1].Value}");
if ((int)coverResult.statusCode < 200 || (int)coverResult.statusCode >= 300)
return SaveCoverImageToCache(mangaId, --retries);
using MemoryStream ms = new();
coverResult.result.CopyTo(ms);
Directory.CreateDirectory(TrangaSettings.coverImageCache);
File.WriteAllBytes(saveImagePath, ms.ToArray());
return saveImagePath;
}
}

View File

@ -1,338 +0,0 @@
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using Newtonsoft.Json.Linq;
namespace API.Schema.MangaContext.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"],
"https://mangadex.org/favicon.ico")
{
this.downloadClient = new HttpDownloadClient();
}
private const int Limit = 100;
public override (Manga, MangaConnectorId<Manga>)[] SearchManga(string mangaSearchName)
{
Log.Info($"Searching Obj: {mangaSearchName}");
List<(Manga, MangaConnectorId<Manga>)> mangas = new ();
int offset = 0;
int total = int.MaxValue;
while(offset < total)
{
string requestUrl =
$"https://api.mangadex.org/manga?limit={Limit}&offset={offset}&title={mangaSearchName}" +
$"&contentRating%5B%5D=safe&contentRating%5B%5D=suggestive&contentRating%5B%5D=erotica" +
$"&includes%5B%5D=manga&includes%5B%5D=cover_art&includes%5B%5D=author&includes%5B%5D=artist&includes%5B%5D=tag'";
offset += Limit;
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaDexFeed);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return [];
}
using StreamReader sr = new (result.result);
JObject jObject = JObject.Parse(sr.ReadToEnd());
if (jObject.Value<string>("result") != "ok")
{
JArray? errors = jObject["errors"] as JArray;
Log.Error($"Request failed: {string.Join(',', errors?.Select(e => e.Value<string>("title")) ?? [])}");
return [];
}
total = jObject.Value<int>("total");
JArray? data = jObject.Value<JArray>("data");
if (data is null)
{
Log.Error("Data was null");
return [];
}
mangas.AddRange(data.Select(ParseMangaFromJToken));
}
Log.Info($"Search {mangaSearchName} yielded {mangas.Count} results.");
return mangas.ToArray();
}
private static readonly Regex GetMangaIdFromUrl = new(@"https?:\/\/mangadex\.org\/title\/([a-z0-9-]+)\/?.*");
public override (Manga, MangaConnectorId<Manga>)? GetMangaFromUrl(string url)
{
Log.Info($"Getting Obj: {url}");
if (!UrlMatchesConnector(url))
{
Log.Debug($"Url is not for Connector. {url}");
return null;
}
Match match = GetMangaIdFromUrl.Match(url);
if (!match.Success || !match.Groups[1].Success)
{
Log.Debug($"Url is not for Connector (Could not retrieve id). {url}");
return null;
}
string id = match.Groups[1].Value;
return GetMangaFromId(id);
}
public override (Manga, MangaConnectorId<Manga>)? GetMangaFromId(string mangaIdOnSite)
{
Log.Info($"Getting Obj: {mangaIdOnSite}");
string requestUrl =
$"https://api.mangadex.org/manga/{mangaIdOnSite}" +
$"?includes%5B%5D=manga&includes%5B%5D=cover_art&includes%5B%5D=author&includes%5B%5D=artist&includes%5B%5D=tag'";
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaDexFeed);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return null;
}
using StreamReader sr = new (result.result);
JObject jObject = JObject.Parse(sr.ReadToEnd());
if (jObject.Value<string>("result") != "ok")
{
JArray? errors = jObject["errors"] as JArray;
Log.Error($"Request failed: {string.Join(',', errors?.Select(e => e.Value<string>("title")) ?? [])}");
return null;
}
JObject? data = jObject["data"] as JObject;
if (data is null)
{
Log.Error("Data was null");
return null;
}
return ParseMangaFromJToken(data);
}
public override (Chapter, MangaConnectorId<Chapter>)[] GetChapters(MangaConnectorId<Manga> manga, string? language = null)
{
Log.Info($"Getting Chapters: {manga.IdOnConnectorSite}");
List<(Chapter, MangaConnectorId<Chapter>)> chapters = new ();
int offset = 0;
int total = int.MaxValue;
while(offset < total)
{
string requestUrl =
$"https://api.mangadex.org/manga/{manga.IdOnConnectorSite}/feed?limit={Limit}&offset={offset}&" +
$"translatedLanguage%5B%5D={language}&" +
$"contentRating%5B%5D=safe&contentRating%5B%5D=suggestive&contentRating%5B%5D=erotica&includeFutureUpdates=0&includes%5B%5D=";
offset += Limit;
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaDexFeed);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return [];
}
using StreamReader sr = new (result.result);
JObject jObject = JObject.Parse(sr.ReadToEnd());
if (jObject.Value<string>("result") != "ok")
{
JArray? errors = jObject["errors"] as JArray;
Log.Error($"Request failed: {string.Join(',', errors?.Select(e => e.Value<string>("title")) ?? [])}");
return [];
}
total = jObject.Value<int>("total");
JArray? data = jObject.Value<JArray>("data");
if (data is null)
{
Log.Error("Data was null");
return [];
}
chapters.AddRange(data.Select(d => ParseChapterFromJToken(manga, d)));
}
Log.Info($"Request for chapters for {manga.Obj.Name} yielded {chapters.Count} results.");
return chapters.ToArray();
}
private static readonly Regex GetChapterIdFromUrl = new(@"https?:\/\/mangadex\.org\/chapter\/([a-z0-9-]+)\/?.*");
internal override string[] GetChapterImageUrls(MangaConnectorId<Chapter> chapterId)
{
Log.Info($"Getting Chapter Image-Urls: {chapterId.Obj}");
if (chapterId.WebsiteUrl is null || !UrlMatchesConnector(chapterId.WebsiteUrl))
{
Log.Debug($"Url is not for Connector. {chapterId.WebsiteUrl}");
return [];
}
Match match = GetChapterIdFromUrl.Match(chapterId.WebsiteUrl);
if (!match.Success || !match.Groups[1].Success)
{
Log.Debug($"Url is not for Connector (Could not retrieve id). {chapterId.WebsiteUrl}");
return [];
}
string id = match.Groups[1].Value;
string requestUrl = $"https://api.mangadex.org/at-home/server/{id}";
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return [];
}
using StreamReader sr = new (result.result);
JObject jObject = JObject.Parse(sr.ReadToEnd());
if (jObject.Value<string>("result") != "ok")
{
JArray? errors = jObject["errors"] as JArray;
Log.Error($"Request failed: {string.Join(',', errors?.Select(e => e.Value<string>("title")) ?? [])}");
return [];
}
string? baseUrl = jObject.Value<string>("baseUrl");
JToken? chapterToken = jObject["chapter"];
string? hash = chapterToken?.Value<string>("hash");
JArray? data = chapterToken?["data"] as JArray;
if (baseUrl is null || hash is null || data is null)
{
Log.Error("Data was null");
return [];
}
IEnumerable<string> urls = data.Select(t => $"{baseUrl}/data/{hash}/{t.Value<string>()}");
return urls.ToArray();
}
private (Manga manga, MangaConnectorId<Manga> id) ParseMangaFromJToken(JToken jToken)
{
string? id = jToken.Value<string>("id");
if(id is null)
throw new Exception("jToken was not in expected format");
JObject? attributes = jToken["attributes"] as JObject;
if(attributes is null)
throw new Exception("jToken was not in expected format");
string? name = attributes["title"]?.Value<string>("en") ?? attributes["title"]?.First?.First?.Value<string>();
string description = attributes["description"]?.Value<string>("en")??attributes["description"]?.First?.First?.Value<string>()??"";
string? status = attributes["status"]?.Value<string>();
uint? year = attributes["year"]?.Value<uint?>();
string? originalLanguage = attributes["originalLanguage"]?.Value<string>();
JArray? altTitlesJArray = attributes.TryGetValue("altTitles", out JToken? altTitlesArray) ? altTitlesArray as JArray : null;
JArray? tagsJArray = attributes.TryGetValue("tags", out JToken? tagsArray) ? tagsArray as JArray : null;
JArray? relationships = jToken["relationships"] as JArray;
if (name is null || status is null || relationships is null)
throw new Exception("jToken was not in expected format");
string? coverFileName = relationships.FirstOrDefault(r => r["type"]?.Value<string>() == "cover_art")?["attributes"]?.Value<string>("fileName");
if(coverFileName is null)
throw new Exception("jToken was not in expected format");
List<Link> links = attributes["links"]?
.ToObject<Dictionary<string,string>>()?
.Select(kv =>
{
//https://api.mangadex.org/docs/3-enumerations/#manga-links-data
string url = kv.Key switch
{
"al" => $"https://anilist.co/manga/{kv.Value}",
"ap" => $"https://www.anime-planet.com/manga/{kv.Value}",
"bw" => $"https://bookwalker.jp/{kv.Value}",
"mu" => $"https://www.mangaupdates.com/series.html?id={kv.Value}",
"nu" => $"https://www.novelupdates.com/series/{kv.Value}",
"mal" => $"https://myanimelist.net/manga/{kv.Value}",
_ => kv.Value
};
string key = kv.Key switch
{
"al" => "AniList",
"ap" => "Anime Planet",
"bw" => "BookWalker",
"mu" => "Obj Updates",
"nu" => "Novel Updates",
"kt" => "Kitsu.io",
"amz" => "Amazon",
"ebj" => "eBookJapan",
"mal" => "MyAnimeList",
"cdj" => "CDJapan",
_ => kv.Key
};
return new Link(key, url);
}).ToList()!;
List<AltTitle> altTitles = (altTitlesJArray??[])
.Select(t =>
{
JObject? j = t as JObject;
JProperty? p = j?.Properties().First();
if (p is null)
return null;
return new AltTitle(p.Name, p.Value.ToString());
}).Where(x => x is not null).ToList()!;
List<MangaTag> tags = (tagsJArray??[])
.Where(t => t.Value<string>("type") == "tag")
.Select(t => t["attributes"]?["name"]?.Value<string>("en")??t["attributes"]?["name"]?.First?.First?.Value<string>())
.Select(str => str is not null ? new MangaTag(str) : null)
.Where(x => x is not null).ToList()!;
List<Author> authors = relationships
.Where(r => r["type"]?.Value<string>() == "author")
.Select(t => t["attributes"]?.Value<string>("name"))
.Select(str => str is not null ? new Author(str) : null)
.Where(x => x is not null).ToList()!;
MangaReleaseStatus releaseStatus = status switch
{
"completed" => MangaReleaseStatus.Completed,
"ongoing" => MangaReleaseStatus.Continuing,
"cancelled" => MangaReleaseStatus.Cancelled,
"hiatus" => MangaReleaseStatus.OnHiatus,
_ => MangaReleaseStatus.Unreleased
};
string websiteUrl = $"https://mangadex.org/title/{id}";
string coverUrl = $"https://uploads.mangadex.org/covers/{id}/{coverFileName}";
Manga manga = new Manga(name, description, coverUrl, releaseStatus, authors, tags, links,altTitles,
null, 0f, year, originalLanguage);
return (manga, new MangaConnectorId<Manga>(manga, this, id, websiteUrl));
}
private (Chapter chapter, MangaConnectorId<Chapter> id) ParseChapterFromJToken(MangaConnectorId<Manga> mcIdManga, JToken jToken)
{
string? id = jToken.Value<string>("id");
JToken? attributes = jToken["attributes"];
string? chapterStr = attributes?.Value<string>("chapter");
string? volumeStr = attributes?.Value<string>("volume");
int? volumeNumber = null;
string? title = attributes?.Value<string>("title");
if(id is null || chapterStr is null)
throw new Exception("jToken was not in expected format");
if(volumeStr is not null)
volumeNumber = int.Parse(volumeStr);
string websiteUrl = $"https://mangadex.org/chapter/{id}";
Chapter chapter = new (mcIdManga.Obj, chapterStr, volumeNumber, title);
return (chapter, new MangaConnectorId<Chapter>(chapter, this, id, websiteUrl));
}
}

View File

@ -1,134 +0,0 @@
using API.Schema.MangaContext.MangaConnectors;
using API.Schema.MangaContext.MetadataFetchers;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.MangaContext;
public class MangaContext(DbContextOptions<MangaContext> options) : TrangaBaseContext<MangaContext>(options)
{
public DbSet<MangaConnector> MangaConnectors { get; set; }
public DbSet<Manga> Mangas { get; set; }
public DbSet<FileLibrary> FileLibraries { get; set; }
public DbSet<Chapter> Chapters { get; set; }
public DbSet<Author> Authors { get; set; }
public DbSet<MangaTag> Tags { get; set; }
public DbSet<MangaConnectorId<Manga>> MangaConnectorToManga { get; set; }
public DbSet<MangaConnectorId<Chapter>> MangaConnectorToChapter { get; set; }
public DbSet<MetadataEntry> MetadataEntries { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
//MangaConnector Types
modelBuilder.Entity<MangaConnector>()
.HasDiscriminator(c => c.Name)
.HasValue<Global>("Global")
.HasValue<MangaDex>("MangaDex")
.HasValue<ComickIo>("ComickIo");
//Manga has many Chapters
modelBuilder.Entity<Manga>()
.HasMany<Chapter>(m => m.Chapters)
.WithOne(c => c.ParentManga)
.HasForeignKey(c => c.ParentMangaId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Manga>()
.Navigation(m => m.Chapters)
.EnableLazyLoading();
modelBuilder.Entity<Chapter>()
.Navigation(c => c.ParentManga)
.EnableLazyLoading();
//Chapter has MangaConnectorIds
modelBuilder.Entity<Chapter>()
.HasMany<MangaConnectorId<Chapter>>(c => c.MangaConnectorIds)
.WithOne(id => id.Obj)
.HasForeignKey(id => id.ObjId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity<MangaConnectorId<Chapter>>()
.HasOne<MangaConnector>(id => id.MangaConnector)
.WithMany()
.HasForeignKey(id => id.MangaConnectorName)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MangaConnectorId<Chapter>>()
.Navigation(entry => entry.MangaConnector)
.EnableLazyLoading();
//Manga owns MangaAltTitles
modelBuilder.Entity<Manga>()
.OwnsMany<AltTitle>(m => m.AltTitles)
.WithOwner();
modelBuilder.Entity<Manga>()
.Navigation(m => m.AltTitles)
.AutoInclude();
//Manga owns Links
modelBuilder.Entity<Manga>()
.OwnsMany<Link>(m => m.Links)
.WithOwner();
modelBuilder.Entity<Manga>()
.Navigation(m => m.Links)
.AutoInclude();
//Manga has many Tags associated with many Obj
modelBuilder.Entity<Manga>()
.HasMany<MangaTag>(m => m.MangaTags)
.WithMany()
.UsingEntity("MangaTagToManga",
l=> l.HasOne(typeof(MangaTag)).WithMany().HasForeignKey("MangaTagIds").HasPrincipalKey(nameof(MangaTag.Tag)),
r => r.HasOne(typeof(Manga)).WithMany().HasForeignKey("MangaIds").HasPrincipalKey(nameof(Manga.Key)),
j => j.HasKey("MangaTagIds", "MangaIds")
);
modelBuilder.Entity<Manga>()
.Navigation(m => m.MangaTags)
.AutoInclude();
//Manga has many Authors associated with many Obj
modelBuilder.Entity<Manga>()
.HasMany<Author>(m => m.Authors)
.WithMany()
.UsingEntity("AuthorToManga",
l=> l.HasOne(typeof(Author)).WithMany().HasForeignKey("AuthorIds").HasPrincipalKey(nameof(Author.Key)),
r => r.HasOne(typeof(Manga)).WithMany().HasForeignKey("MangaIds").HasPrincipalKey(nameof(Manga.Key)),
j => j.HasKey("AuthorIds", "MangaIds")
);
modelBuilder.Entity<Manga>()
.Navigation(m => m.Authors)
.AutoInclude();
//Manga has many MangaIds
modelBuilder.Entity<Manga>()
.HasMany<MangaConnectorId<Manga>>(m => m.MangaConnectorIds)
.WithOne(id => id.Obj)
.HasForeignKey(id => id.ObjId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Manga>()
.Navigation(m => m.MangaConnectorIds)
.EnableLazyLoading();
modelBuilder.Entity<MangaConnectorId<Manga>>()
.HasOne<MangaConnector>(id => id.MangaConnector)
.WithMany()
.HasForeignKey(id => id.MangaConnectorName)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MangaConnectorId<Manga>>()
.Navigation(entry => entry.MangaConnector)
.EnableLazyLoading();
//FileLibrary has many Mangas
modelBuilder.Entity<FileLibrary>()
.HasMany<Manga>()
.WithOne(m => m.Library)
.HasForeignKey(m => m.LibraryId)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<Manga>()
.Navigation(m => m.Library)
.EnableLazyLoading();
modelBuilder.Entity<MetadataFetcher>()
.HasDiscriminator<string>(nameof(MetadataEntry))
.HasValue<MyAnimeList>(nameof(MyAnimeList));
//MetadataEntry
modelBuilder.Entity<MetadataEntry>()
.HasOne<Manga>(entry => entry.Manga)
.WithMany()
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MetadataEntry>()
.HasOne<MetadataFetcher>(entry => entry.MetadataFetcher)
.WithMany()
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@ -1,14 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.MangaContext;
[PrimaryKey("Tag")]
public class MangaTag(string tag)
{
[StringLength(64)]
[Required]
public string Tag { get; init; } = tag;
public override string ToString() => Tag;
}

View File

@ -1,35 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.MangaContext.MetadataFetchers;
[PrimaryKey("MetadataFetcherName", "Identifier")]
public class MetadataEntry
{
[JsonIgnore]
public Manga Manga { get; init; } = null!;
public string MangaId { get; init; }
[JsonIgnore]
public MetadataFetcher MetadataFetcher { get; init; } = null!;
public string MetadataFetcherName { get; init; }
public string Identifier { get; init; }
public MetadataEntry(MetadataFetcher fetcher, Manga manga, string identifier)
{
this.Manga = manga;
this.MangaId = manga.Key;
this.MetadataFetcher = fetcher;
this.MetadataFetcherName = fetcher.Name;
this.Identifier = identifier;
}
/// <summary>
/// EFCORE only!!!!
/// </summary>
internal MetadataEntry(string mangaId, string identifier, string metadataFetcherName)
{
this.MangaId = mangaId;
this.Identifier = identifier;
this.MetadataFetcherName = metadataFetcherName;
}
}

View File

@ -1,35 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace API.Schema.MangaContext.MetadataFetchers;
[PrimaryKey("Name")]
public abstract class MetadataFetcher
{
// ReSharper disable once EntityFramework.ModelValidation.UnlimitedStringLength
public string Name { get; init; }
protected MetadataFetcher()
{
this.Name = this.GetType().Name;
}
/// <summary>
/// EFCORE ONLY!!!
/// </summary>
internal MetadataFetcher(string name)
{
this.Name = name;
}
internal MetadataEntry CreateMetadataEntry(Manga manga, string identifier) =>
new (this, manga, identifier);
public abstract MetadataSearchResult[] SearchMetadataEntry(Manga manga);
public abstract MetadataSearchResult[] SearchMetadataEntry(string searchTerm);
/// <summary>
/// Updates the Manga linked in the MetadataEntry
/// </summary>
public abstract void UpdateMetadata(MetadataEntry metadataEntry, MangaContext dbContext);
}

View File

@ -1,3 +0,0 @@
namespace API.Schema.MangaContext.MetadataFetchers;
public record MetadataSearchResult(string Identifier, string Name, string Url, string? Description = null, string? CoverUrl = null);

View File

@ -1,78 +0,0 @@
using System.Text.RegularExpressions;
using JikanDotNet;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.MangaContext.MetadataFetchers;
public class MyAnimeList : MetadataFetcher
{
private static readonly Jikan Jikan = new ();
private static readonly Regex GetIdFromUrl = new(@"https?:\/\/myanimelist\.net\/manga\/([0-9]+)\/?.*");
public override MetadataSearchResult[] SearchMetadataEntry(Manga manga)
{
if (manga.Links.Any(link => link.LinkProvider.Equals("MyAnimeList", StringComparison.InvariantCultureIgnoreCase)))
{
string url = manga.Links.First(link => link.LinkProvider.Equals("MyAnimeList", StringComparison.InvariantCultureIgnoreCase)).LinkUrl;
Match m = GetIdFromUrl.Match(url);
if (m.Success && m.Groups[1].Success)
{
long id = long.Parse(m.Groups[1].Value);
JikanDotNet.Manga data = Jikan.GetMangaAsync(id).Result.Data;
return [new MetadataSearchResult(id.ToString(), data.Titles.First().Title, data.Url, data.Synopsis)];
}
}
return SearchMetadataEntry(manga.Name);
}
public override MetadataSearchResult[] SearchMetadataEntry(string searchTerm)
{
ICollection<JikanDotNet.Manga> resultData = Jikan.SearchMangaAsync(searchTerm).Result.Data;
if (resultData.Count < 1)
return [];
return resultData.Select(data =>
new MetadataSearchResult(data.MalId.ToString(), data.Titles.First().Title, data.Url, data.Synopsis))
.ToArray();
}
/// <summary>
/// Updates the Manga linked in the MetadataEntry
/// </summary>
/// <param name="metadataEntry"></param>
/// <param name="dbContext"></param>
/// <exception cref="FormatException"></exception>
/// <exception cref="DbUpdateException"></exception>
public override void UpdateMetadata(MetadataEntry metadataEntry, MangaContext dbContext)
{
Manga dbManga = dbContext.Mangas.Find(metadataEntry.MangaId)!;
MangaFull resultData;
try
{
long id = long.Parse(metadataEntry.Identifier);
resultData = Jikan.GetMangaFullDataAsync(id).Result.Data;
}
catch (Exception)
{
throw new FormatException("ID was not in correct format");
}
try
{
dbManga.Name = resultData.Titles.First().Title;
dbManga.Description = resultData.Synopsis;
dbManga.AltTitles.Clear();
dbManga.AltTitles = resultData.Titles.Select(t => new AltTitle(t.Type, t.Title)).ToList();
dbManga.Authors.Clear();
dbManga.Authors = resultData.Authors.Select(a => new Author(a.Name)).ToList();
dbContext.Sync();
}
catch (DbUpdateException e)
{
throw;
}
}
}

View File

@ -1,56 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.NotificationsContext;
[PrimaryKey(nameof(Key))]
public class Notification : Identifiable
{
[Required]
public NotificationUrgency Urgency { get; init; }
[StringLength(128)]
[Required]
public string Title { get; init; }
[StringLength(512)]
[Required]
public string Message { get; init; }
[Required]
public DateTime Date { get; init; }
public bool IsSent { get; internal set; }
public Notification(string title, string message = "", NotificationUrgency urgency = NotificationUrgency.Normal, DateTime? date = null)
: base(TokenGen.CreateToken("Notification"))
{
this.Title = title;
this.Message = message;
this.Urgency = urgency;
this.Date = date ?? DateTime.UtcNow;
this.IsSent = false;
}
/// <summary>
/// EF ONLY!!!
/// </summary>
public Notification(string key, string title, string message, NotificationUrgency urgency, DateTime date, bool isSent)
: base(key)
{
this.Title = title;
this.Message = message;
this.Urgency = urgency;
this.Date = date;
this.IsSent = isSent;
}
public override string ToString() => $"{base.ToString()} {Urgency} {Title} {Message}";
}
public enum NotificationUrgency : byte
{
Low = 1,
Normal = 3,
High = 5
}

View File

@ -1,84 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text;
using log4net;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.NotificationsContext.NotificationConnectors;
[PrimaryKey("Name")]
public class NotificationConnector(string name, string url, Dictionary<string, string> headers, string httpMethod, string body)
{
[StringLength(64)]
[Required]
public string Name { get; init; } = name;
[StringLength(2048)]
[Required]
[Url]
public string Url { get; internal set; } = url;
[Required]
public Dictionary<string, string> Headers { get; internal set; } = headers;
[StringLength(8)]
[Required]
public string HttpMethod { get; internal set; } = httpMethod;
[StringLength(4096)]
[Required]
public string Body { get; internal set; } = body;
[JsonIgnore]
[NotMapped]
private readonly HttpClient Client = new()
{
DefaultRequestHeaders = { { "User-Agent", Tranga.Settings.UserAgent } }
};
[JsonIgnore]
[NotMapped]
protected ILog Log = LogManager.GetLogger(name);
public void SendNotification(string title, string notificationText)
{
Log.Info($"Sending notification: {title} - {notificationText}");
CustomWebhookFormatProvider formatProvider = new (title, notificationText);
string formattedUrl = string.Format(formatProvider, Url);
string formattedBody = string.Format(formatProvider, Body, title, notificationText);
Dictionary<string, string> formattedHeaders = Headers.ToDictionary(h => h.Key,
h => string.Format(formatProvider, h.Value, title, notificationText));
HttpRequestMessage request = new(System.Net.Http.HttpMethod.Parse(HttpMethod), formattedUrl);
foreach (var (key, value) in formattedHeaders)
request.Headers.Add(key, value);
request.Content = new StringContent(formattedBody);
Log.Debug($"Request: {request}");
HttpResponseMessage response = Client.Send(request);
Log.Debug($"Response status code: {response.StatusCode}");
}
private class CustomWebhookFormatProvider(string title, string text) : IFormatProvider
{
public object? GetFormat(Type? formatType)
{
return this;
}
public string Format(string fmt, object arg, IFormatProvider provider)
{
if(arg.GetType() != typeof(string))
return arg.ToString() ?? string.Empty;
StringBuilder sb = new StringBuilder(fmt);
sb.Replace("%title", title);
sb.Replace("%text", text);
return sb.ToString();
}
}
public override string ToString() => $"{GetType().Name} {Name}";
}

View File

@ -1,10 +0,0 @@
using API.Schema.NotificationsContext.NotificationConnectors;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.NotificationsContext;
public class NotificationsContext(DbContextOptions<NotificationsContext> options) : TrangaBaseContext<NotificationsContext>(options)
{
public DbSet<NotificationConnector> NotificationConnectors { get; set; }
public DbSet<Notification> Notifications { get; set; }
}

View File

@ -1,40 +0,0 @@
using log4net;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace API.Schema;
public abstract class TrangaBaseContext<T> : DbContext where T : DbContext
{
private ILog Log { get; init; }
protected TrangaBaseContext(DbContextOptions<T> options) : base(options)
{
this.Log = LogManager.GetLogger(GetType());
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.LogTo(s =>
{
Log.Debug(s);
}, Array.Empty<string>(), LogLevel.Warning, DbContextLoggerOptions.Level | DbContextLoggerOptions.Category | DbContextLoggerOptions.UtcTime);
}
internal (bool success, string? exceptionMessage) Sync()
{
try
{
this.SaveChanges();
return (true, null);
}
catch (Exception e)
{
Log.Error(null, e);
return (false, e.Message);
}
}
public override string ToString() => $"{GetType().Name} {typeof(T).Name}";
}

View File

@ -1,37 +0,0 @@
using System.Security.Cryptography;
using System.Text;
namespace API;
public static class TokenGen
{
private const int MinimumLength = 16;
private const int MaximumLength = 64;
private const string Chars = "abcdefghijklmnopqrstuvwxyz0123456789";
public static string CreateToken(Type t, params string[] identifiers) => CreateToken(t.Name, identifiers);
public static string CreateToken(string prefix, params string[] identifiers)
{
if (prefix.Length + 1 >= MaximumLength - MinimumLength)
throw new ArgumentException("Prefix to long to create Token of meaningful length.");
int tokenLength = MaximumLength - prefix.Length - 1;
if (identifiers.Length == 0)
{
// No identifier, just create a random token
byte[] rng = new byte[tokenLength];
RandomNumberGenerator.Create().GetBytes(rng);
string key = new(rng.Select(b => Chars[b % Chars.Length]).ToArray());
key = string.Join('-', prefix, key);
return key;
}
// Identifier provided, create a token based on the identifier hashed
byte[] hash = MD5.HashData(Encoding.UTF8.GetBytes(string.Join("", identifiers)));
string token = Convert.ToHexStringLower(hash);
return string.Join('-', prefix, token);
}
}

View File

@ -1,202 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using API.Schema.LibraryContext;
using API.Schema.MangaContext;
using API.Schema.MangaContext.MetadataFetchers;
using API.Schema.NotificationsContext;
using API.Workers;
using API.Workers.MaintenanceWorkers;
using log4net;
using log4net.Config;
namespace API;
public static class Tranga
{
// ReSharper disable once InconsistentNaming
private const string TRANGA =
"\n\n" +
" _______ v2\n" +
"|_ _|.----..---.-..-----..-----..---.-.\n" +
" | | | _|| _ || || _ || _ |\n" +
" |___| |__| |___._||__|__||___ ||___._|\n" +
" |_____| \n\n";
public static Thread PeriodicWorkerStarterThread { get; } = new (WorkerStarter);
private static readonly ILog Log = LogManager.GetLogger(typeof(Tranga));
internal static readonly MetadataFetcher[] MetadataFetchers = [new MyAnimeList()];
internal static TrangaSettings Settings = TrangaSettings.Load();
internal static readonly UpdateMetadataWorker UpdateMetadataWorker = new ();
internal static readonly SendNotificationsWorker SendNotificationsWorker = new();
internal static readonly UpdateChaptersDownloadedWorker UpdateChaptersDownloadedWorker = new();
internal static readonly CheckForNewChaptersWorker CheckForNewChaptersWorker = new();
internal static readonly CleanupMangaCoversWorker CleanupMangaCoversWorker = new();
internal static readonly StartNewChapterDownloadsWorker StartNewChapterDownloadsWorker = new();
internal static readonly RemoveOldNotificationsWorker RemoveOldNotificationsWorker = new();
internal static void StartLogger()
{
BasicConfigurator.Configure();
Log.Info("Logger Configured.");
Log.Info(TRANGA);
AddWorker(UpdateMetadataWorker);
AddWorker(SendNotificationsWorker);
AddWorker(UpdateChaptersDownloadedWorker);
AddWorker(CheckForNewChaptersWorker);
AddWorker(CleanupMangaCoversWorker);
AddWorker(StartNewChapterDownloadsWorker);
AddWorker(RemoveOldNotificationsWorker);
}
internal static HashSet<BaseWorker> AllWorkers { get; private set; } = new ();
public static void AddWorker(BaseWorker worker) => AllWorkers.Add(worker);
public static void AddWorkers(IEnumerable<BaseWorker> workers)
{
foreach (BaseWorker baseWorker in workers)
{
AddWorker(baseWorker);
}
}
public static void RemoveWorker(BaseWorker removeWorker)
{
IEnumerable<BaseWorker> baseWorkers = AllWorkers.Where(w => w.DependenciesAndSelf.Any(worker => worker == removeWorker));
foreach (BaseWorker worker in baseWorkers)
{
StopWorker(worker);
AllWorkers.Remove(worker);
}
}
private static readonly Dictionary<BaseWorker, Task<BaseWorker[]>> RunningWorkers = new();
public static BaseWorker[] GetRunningWorkers() => RunningWorkers.Keys.ToArray();
private static readonly HashSet<BaseWorker> StartWorkers = new();
private static void WorkerStarter(object? serviceProviderObj)
{
Log.Info("WorkerStarter Thread running.");
if (serviceProviderObj is null)
{
Log.Error("serviceProviderObj is null");
return;
}
IServiceProvider serviceProvider = (IServiceProvider)serviceProviderObj;
while (true)
{
CheckRunningWorkers();
foreach (BaseWorker baseWorker in AllWorkers.DueWorkers())
StartWorkers.Add(baseWorker);
foreach (BaseWorker worker in StartWorkers.ToArray())
{
if(RunningWorkers.ContainsKey(worker))
continue;
if (worker is BaseWorkerWithContext<MangaContext> mangaContextWorker)
{
mangaContextWorker.SetScope(serviceProvider.CreateScope());
RunningWorkers.Add(mangaContextWorker, mangaContextWorker.DoWork());
}else if (worker is BaseWorkerWithContext<NotificationsContext> notificationContextWorker)
{
notificationContextWorker.SetScope(serviceProvider.CreateScope());
RunningWorkers.Add(notificationContextWorker, notificationContextWorker.DoWork());
}else if (worker is BaseWorkerWithContext<LibraryContext> libraryContextWorker)
{
libraryContextWorker.SetScope(serviceProvider.CreateScope());
RunningWorkers.Add(libraryContextWorker, libraryContextWorker.DoWork());
}else
RunningWorkers.Add(worker, worker.DoWork());
StartWorkers.Remove(worker);
}
Thread.Sleep(Settings.WorkCycleTimeoutMs);
}
}
private static void CheckRunningWorkers()
{
KeyValuePair<BaseWorker, Task<BaseWorker[]>>[] done = RunningWorkers.Where(kv => kv.Value.IsCompleted).ToArray();
if (done.Length < 1)
return;
Log.Info($"Done: {done.Length}");
Log.Debug(string.Join("\n", done.Select(d => d.Key.ToString())));
foreach ((BaseWorker worker, Task<BaseWorker[]> task) in done)
{
RunningWorkers.Remove(worker);
foreach (BaseWorker newWorker in task.Result)
AllWorkers.Add(newWorker);
task.Dispose();
}
}
private static IEnumerable<BaseWorker> DueWorkers(this IEnumerable<BaseWorker> workers)
{
return workers.Where(w =>
{
if (w.State is >= WorkerExecutionState.Running and < WorkerExecutionState.Completed)
return false;
if (w is IPeriodic periodicWorker)
return periodicWorker.IsDue;
return true;
});
}
internal static void MarkWorkerForStart(BaseWorker worker) => StartWorkers.Add(worker);
internal static void StopWorker(BaseWorker worker)
{
StartWorkers.Remove(worker);
worker.Cancel();
RunningWorkers.Remove(worker);
}
internal static bool AddMangaToContext((Manga, MangaConnectorId<Manga>) addManga, MangaContext context, [NotNullWhen(true)]out Manga? manga) => AddMangaToContext(addManga.Item1, addManga.Item2, context, out manga);
internal static bool AddMangaToContext(Manga addManga, MangaConnectorId<Manga> addMcId, MangaContext context, [NotNullWhen(true)]out Manga? manga)
{
manga = context.Mangas.Find(addManga.Key) ?? addManga;
MangaConnectorId<Manga> mcId = context.MangaConnectorToManga.Find(addMcId.Key) ?? addMcId;
mcId.Obj = manga;
IEnumerable<MangaTag> mergedTags = manga.MangaTags.Select(mt =>
{
MangaTag? inDb = context.Tags.Find(mt.Tag);
return inDb ?? mt;
});
manga.MangaTags = mergedTags.ToList();
IEnumerable<Author> mergedAuthors = manga.Authors.Select(ma =>
{
Author? inDb = context.Authors.Find(ma.Key);
return inDb ?? ma;
});
manga.Authors = mergedAuthors.ToList();
if(context.MangaConnectorToManga.Find(addMcId.Key) is null)
context.MangaConnectorToManga.Add(mcId);
if (context.Sync() is { success: false })
return false;
return true;
}
internal static bool AddChapterToContext((Chapter, MangaConnectorId<Chapter>) addChapter, MangaContext context,
[NotNullWhen(true)] out Chapter? chapter) => AddChapterToContext(addChapter.Item1, addChapter.Item2, context, out chapter);
internal static bool AddChapterToContext(Chapter addChapter, MangaConnectorId<Chapter> addChId, MangaContext context, [NotNullWhen(true)] out Chapter? chapter)
{
chapter = context.Chapters.Find(addChapter.Key) ?? addChapter;
MangaConnectorId<Chapter> chId = context.MangaConnectorToChapter.Find(addChId.Key) ?? addChId;
chId.Obj = chapter;
if(context.MangaConnectorToChapter.Find(chId.Key) is null)
context.MangaConnectorToChapter.Add(chId);
if (context.Sync() is { success: false })
return false;
return true;
}
}

View File

@ -1,112 +0,0 @@
using System.Runtime.InteropServices;
using API.MangaDownloadClients;
using Newtonsoft.Json;
namespace API;
public struct TrangaSettings()
{
[JsonIgnore]
public static string workingDirectory => Path.Join(RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/usr/share" : Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "tranga-api");
[JsonIgnore]
public static string settingsFilePath => Path.Join(workingDirectory, "settings.json");
[JsonIgnore]
public static string coverImageCache => Path.Join(workingDirectory, "imageCache");
public string DownloadLocation => RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/Manga" : Path.Join(Directory.GetCurrentDirectory(), "Manga");
[JsonIgnore]
internal static readonly string DefaultUserAgent = $"Tranga/2.0 ({Enum.GetName(Environment.OSVersion.Platform)}; {(Environment.Is64BitOperatingSystem ? "x64" : "")})";
public string UserAgent { get; set; } = DefaultUserAgent;
public int ImageCompression{ get; set; } = 40;
public bool BlackWhiteImages { get; set; } = false;
public string FlareSolverrUrl { get; set; } = string.Empty;
/// <summary>
/// Placeholders:
/// %M Obj Name
/// %V Volume
/// %C Chapter
/// %T Title
/// %A Author (first in list)
/// %I Chapter Internal ID
/// %i Obj Internal ID
/// %Y Year (Obj)
///
/// ?_(...) replace _ with a value from above:
/// Everything inside the braces will only be added if the value of %_ is not null
/// </summary>
public string ChapterNamingScheme { get; set; } = "%M - ?V(Vol.%V )Ch.%C?T( - %T)";
public int WorkCycleTimeoutMs { get; set; } = 20000;
[JsonIgnore]
internal static readonly Dictionary<RequestType, int> DefaultRequestLimits = new ()
{
{RequestType.MangaInfo, 60},
{RequestType.MangaDexFeed, 60},
{RequestType.MangaDexImage, 60},
{RequestType.MangaImage, 240},
{RequestType.MangaCover, 60},
{RequestType.Default, 60}
};
public Dictionary<RequestType, int> RequestLimits { get; set; } = DefaultRequestLimits;
public string DownloadLanguage { get; set; } = "en";
public static TrangaSettings Load()
{
if (!File.Exists(settingsFilePath))
new TrangaSettings().Save();
return JsonConvert.DeserializeObject<TrangaSettings>(File.ReadAllText(settingsFilePath));
}
public void Save()
{
File.WriteAllText(settingsFilePath, JsonConvert.SerializeObject(this));
}
public void SetUserAgent(string value)
{
this.UserAgent = value;
Save();
}
public void SetRequestLimit(RequestType type, int value)
{
this.RequestLimits[type] = value;
Save();
}
public void ResetRequestLimits()
{
this.RequestLimits = DefaultRequestLimits;
Save();
}
public void UpdateImageCompression(int value)
{
this.ImageCompression = value;
Save();
}
public void SetBlackWhiteImageEnabled(bool enabled)
{
this.BlackWhiteImages = enabled;
Save();
}
public void SetChapterNamingScheme(string scheme)
{
this.ChapterNamingScheme = scheme;
Save();
}
public void SetFlareSolverrUrl(string url)
{
this.FlareSolverrUrl = url;
Save();
}
public void SetDownloadLanguage(string language)
{
this.DownloadLanguage = language;
Save();
}
}

View File

@ -1,120 +0,0 @@
using API.Schema;
using log4net;
namespace API.Workers;
public abstract class BaseWorker : Identifiable
{
/// <summary>
/// Workers this Worker depends on being completed before running.
/// </summary>
public BaseWorker[] DependsOn { get; init; }
/// <summary>
/// Dependencies and dependencies of dependencies. See also <see cref="DependsOn"/>.
/// </summary>
public IEnumerable<BaseWorker> AllDependencies => DependsOn.Select(d => d.AllDependencies).SelectMany(x => x);
/// <summary>
/// <see cref="AllDependencies"/> and Self.
/// </summary>
public IEnumerable<BaseWorker> DependenciesAndSelf => AllDependencies.Append(this);
/// <summary>
/// <see cref="DependsOn"/> where <see cref="WorkerExecutionState"/> is less than Completed.
/// </summary>
public IEnumerable<BaseWorker> MissingDependencies => DependsOn.Where(d => d.State < WorkerExecutionState.Completed);
public bool AllDependenciesFulfilled => DependsOn.All(d => d.State >= WorkerExecutionState.Completed);
internal WorkerExecutionState State { get; private set; }
private static readonly CancellationTokenSource CancellationTokenSource = new(TimeSpan.FromMinutes(10));
protected ILog Log { get; init; }
/// <summary>
/// Stops worker, and marks as <see cref="WorkerExecutionState"/>.Cancelled
/// </summary>
public void Cancel()
{
Log.Debug($"Cancelled {this}");
this.State = WorkerExecutionState.Cancelled;
CancellationTokenSource.Cancel();
}
/// <summary>
/// Stops worker, and marks as <see cref="WorkerExecutionState"/>.Failed
/// </summary>
protected void Fail()
{
Log.Debug($"Failed {this}");
this.State = WorkerExecutionState.Failed;
CancellationTokenSource.Cancel();
}
public BaseWorker(IEnumerable<BaseWorker>? dependsOn = null)
{
this.DependsOn = dependsOn?.ToArray() ?? [];
this.Log = LogManager.GetLogger(GetType());
}
/// <summary>
/// Sets States during worker-run.
/// States:
/// <list type="bullet">
/// <item><see cref="WorkerExecutionState"/>.Waiting when waiting for <see cref="MissingDependencies"/></item>
/// <item><see cref="WorkerExecutionState"/>.Running when running</item>
/// <item><see cref="WorkerExecutionState"/>.Completed after finished</item>
/// </list>
/// </summary>
/// <returns>
/// <list type="bullet">
/// <item>If <see cref="BaseWorker"/> has <see cref="MissingDependencies"/>, missing dependencies.</item>
/// <item>If <see cref="MissingDependencies"/> are <see cref="WorkerExecutionState"/>.Running, itself after waiting for dependencies.</item>
/// <item>If <see cref="BaseWorker"/> has run, additional <see cref="BaseWorker"/>.</item>
/// </list>
/// </returns>
public Task<BaseWorker[]> DoWork()
{
Log.Debug($"Checking {this}");
this.State = WorkerExecutionState.Waiting;
BaseWorker[] missingDependenciesThatNeedStarting = MissingDependencies.Where(d => d.State < WorkerExecutionState.Waiting).ToArray();
if(missingDependenciesThatNeedStarting.Any())
return new Task<BaseWorker[]>(() => missingDependenciesThatNeedStarting);
if (MissingDependencies.Any())
return new Task<BaseWorker[]>(WaitForDependencies);
Log.Info($"Running {this}");
DateTime startTime = DateTime.UtcNow;
Task<BaseWorker[]> task = new (DoWorkInternal, CancellationTokenSource.Token);
task.GetAwaiter().OnCompleted(() =>
{
DateTime endTime = DateTime.UtcNow;
Log.Info($"Completed {this}\n\t{endTime.Subtract(startTime).TotalMilliseconds} ms");
this.State = WorkerExecutionState.Completed;
if(this is IPeriodic periodic)
periodic.LastExecution = DateTime.UtcNow;
});
task.Start();
this.State = WorkerExecutionState.Running;
return task;
}
protected abstract BaseWorker[] DoWorkInternal();
private BaseWorker[] WaitForDependencies()
{
Log.Info($"Waiting for {MissingDependencies.Count()} Dependencies {this}:\n\t{string.Join("\n\t", MissingDependencies.Select(d => d.ToString()))}");
while (CancellationTokenSource.IsCancellationRequested == false && MissingDependencies.Any())
{
Thread.Sleep(Tranga.Settings.WorkCycleTimeoutMs);
}
return [this];
}
}
public enum WorkerExecutionState
{
Failed = 0,
Cancelled = 32,
Created = 64,
Waiting = 96,
Running = 128,
Completed = 192
}

View File

@ -1,24 +0,0 @@
using System.Configuration;
using Microsoft.EntityFrameworkCore;
namespace API.Workers;
public abstract class BaseWorkerWithContext<T>(IEnumerable<BaseWorker>? dependsOn = null) : BaseWorker(dependsOn) where T : DbContext
{
protected T DbContext = null!;
private IServiceScope? _scope;
public void SetScope(IServiceScope scope)
{
this._scope = scope;
this.DbContext = scope.ServiceProvider.GetRequiredService<T>();
}
/// <exception cref="ConfigurationErrorsException">Scope has not been set. <see cref="SetScope"/></exception>
public new Task<BaseWorker[]> DoWork()
{
if (DbContext is null)
throw new ConfigurationErrorsException("Scope has not been set.");
return base.DoWork();
}
}

View File

@ -1,9 +0,0 @@
namespace API.Workers;
public interface IPeriodic
{
internal DateTime LastExecution { get; set; }
public TimeSpan Interval { get; set; }
public DateTime NextExecution => LastExecution.Add(Interval);
public bool IsDue => NextExecution <= DateTime.UtcNow;
}

View File

@ -1,185 +0,0 @@
using System.IO.Compression;
using System.Runtime.InteropServices;
using API.MangaDownloadClients;
using API.Schema.MangaContext;
using API.Schema.MangaContext.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.Workers;
public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> chId, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn)
{
internal readonly string MangaConnectorIdId = chId.Key;
protected override BaseWorker[] DoWorkInternal()
{
if (DbContext.MangaConnectorToChapter.Find(MangaConnectorIdId) is not { } MangaConnectorId)
return []; //TODO Exception?
MangaConnector mangaConnector = MangaConnectorId.MangaConnector;
Chapter chapter = MangaConnectorId.Obj;
if (chapter.Downloaded)
{
Log.Info("Chapter was already downloaded.");
return [];
}
string[] imageUrls = mangaConnector.GetChapterImageUrls(MangaConnectorId);
if (imageUrls.Length < 1)
{
Log.Info($"No imageUrls for chapter {chapter}");
return [];
}
string saveArchiveFilePath = chapter.FullArchiveFilePath;
Log.Debug($"Chapter path: {saveArchiveFilePath}");
//Check if Publication Directory already exists
string? directoryPath = Path.GetDirectoryName(saveArchiveFilePath);
if (directoryPath is null)
{
Log.Error($"Directory path could not be found: {saveArchiveFilePath}");
this.Fail();
return [];
}
if (!Directory.Exists(directoryPath))
{
Log.Info($"Creating publication Directory: {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
{
Log.Info($"Archive {saveArchiveFilePath} already existed, but deleting and re-downloading.");
File.Delete(saveArchiveFilePath);
}
//Create a temporary folder to store images
string tempFolder = Directory.CreateTempSubdirectory("trangatemp").FullName;
Log.Debug($"Created temp folder: {tempFolder}");
Log.Info($"Downloading images: {chapter}");
int chapterNum = 0;
//Download all Images to temporary Folder
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)
{
Log.Error($"Failed to download image: {imageUrl}");
return [];
}
}
CopyCoverFromCacheToDownloadLocation(chapter.ParentManga);
Log.Debug($"Creating ComicInfo.xml {chapter}");
File.WriteAllText(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString());
Log.Debug($"Packaging images to archive {chapter}");
//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
chapter.Downloaded = true;
DbContext.Sync();
return [];
}
private void ProcessImage(string imagePath)
{
if (!Tranga.Settings.BlackWhiteImages && Tranga.Settings.ImageCompression == 100)
{
Log.Debug("No processing requested for image");
return;
}
Log.Debug($"Processing image: {imagePath}");
try
{
using Image image = Image.Load(imagePath);
if (Tranga.Settings.BlackWhiteImages)
image.Mutate(i => i.ApplyProcessor(new AdaptiveThresholdProcessor()));
File.Delete(imagePath);
image.SaveAsJpeg(imagePath, new JpegEncoder()
{
Quality = Tranga.Settings.ImageCompression
});
}
catch (Exception e)
{
if (e is UnknownImageFormatException or NotSupportedException)
{
//If the Image-Format is not processable by ImageSharp, we can't modify it.
Log.Debug($"Unable to process {imagePath}: Not supported image format");
}else if (e is InvalidImageContentException)
{
Log.Debug($"Unable to process {imagePath}: Invalid Content");
}
else
{
Log.Error(e);
}
}
}
private void CopyCoverFromCacheToDownloadLocation(Manga manga)
{
//Check if Publication already has a Folder and cover
string publicationFolder = manga.CreatePublicationFolder();
DirectoryInfo dirInfo = new (publicationFolder);
if (dirInfo.EnumerateFiles().Any(info => info.Name.Contains("cover", StringComparison.InvariantCultureIgnoreCase)))
{
Log.Debug($"Cover already exists at {publicationFolder}");
return;
}
//TODO MangaConnector Selection
MangaConnectorId<Manga> mcId = manga.MangaConnectorIds.First();
Log.Info($"Copying cover to {publicationFolder}");
string? fileInCache = manga.CoverFileNameInCache ?? mcId.MangaConnector.SaveCoverImageToCache(mcId);
if (fileInCache is null)
{
Log.Error($"File {fileInCache} does not exist");
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 | OtherRead | OtherWrite);
Log.Debug($"Copied cover from {fileInCache} to {newFilePath}");
}
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, FileAccess.Write, FileShare.None);
requestResult.result.CopyTo(fs);
fs.Close();
ProcessImage(savePath);
return true;
}
public override string ToString() => $"{base.ToString()} {MangaConnectorIdId}";
}

View File

@ -1,24 +0,0 @@
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
namespace API.Workers;
public class DownloadCoverFromMangaconnectorWorker(MangaConnectorId<Manga> mcId, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn)
{
internal readonly string MangaConnectorIdId = mcId.Key;
protected override BaseWorker[] DoWorkInternal()
{
if (DbContext.MangaConnectorToManga.Find(MangaConnectorIdId) is not { } MangaConnectorId)
return []; //TODO Exception?
MangaConnector mangaConnector = MangaConnectorId.MangaConnector;
Manga manga = MangaConnectorId.Obj;
manga.CoverFileNameInCache = mangaConnector.SaveCoverImageToCache(MangaConnectorId);
DbContext.Sync();
return [];
}
public override string ToString() => $"{base.ToString()} {MangaConnectorIdId}";
}

View File

@ -1,35 +0,0 @@
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
namespace API.Workers;
public class RetrieveMangaChaptersFromMangaconnectorWorker(MangaConnectorId<Manga> mcId, string language, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn)
{
internal readonly string MangaConnectorIdId = mcId.Key;
protected override BaseWorker[] DoWorkInternal()
{
if (DbContext.MangaConnectorToManga.Find(MangaConnectorIdId) is not { } MangaConnectorId)
return []; //TODO Exception?
MangaConnector mangaConnector = MangaConnectorId.MangaConnector;
Manga manga = MangaConnectorId.Obj;
// This gets all chapters that are not downloaded
(Chapter, MangaConnectorId<Chapter>)[] allChapters =
mangaConnector.GetChapters(MangaConnectorId, language).DistinctBy(c => c.Item1.Key).ToArray();
int addedChapters = 0;
foreach ((Chapter chapter, MangaConnectorId<Chapter> mcId) newChapter in allChapters)
{
if (Tranga.AddChapterToContext(newChapter, DbContext, out Chapter? addedChapter) == false)
continue;
manga.Chapters.Add(addedChapter);
}
Log.Info($"{manga.Chapters.Count} existing + {addedChapters} new chapters.");
DbContext.Sync();
return [];
}
public override string ToString() => $"{base.ToString()} {MangaConnectorIdId}";
}

View File

@ -1,49 +0,0 @@
namespace API.Workers;
public class MoveFileOrFolderWorker(string toLocation, string fromLocation, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorker(dependsOn)
{
public readonly string FromLocation = fromLocation;
public readonly string ToLocation = toLocation;
protected override BaseWorker[] DoWorkInternal()
{
try
{
FileInfo fi = new (FromLocation);
if (!fi.Exists)
{
Log.Error($"File does not exist at {FromLocation}");
return [];
}
if (File.Exists(ToLocation))//Do not override existing
{
Log.Error($"File already exists at {ToLocation}");
return [];
}
if(fi.Attributes.HasFlag(FileAttributes.Directory))
MoveDirectory(fi, ToLocation);
else
MoveFile(fi, ToLocation);
}
catch (Exception e)
{
Log.Error(e);
}
return [];
}
private void MoveDirectory(FileInfo from, string toLocation)
{
Directory.Move(from.FullName, toLocation);
}
private void MoveFile(FileInfo from, string toLocation)
{
File.Move(from.FullName, toLocation);
}
public override string ToString() => $"{base.ToString()} {FromLocation} {ToLocation}";
}

View File

@ -1,26 +0,0 @@
using API.Schema.MangaContext;
namespace API.Workers;
public class MoveMangaLibraryWorker(Manga manga, FileLibrary toLibrary, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn)
{
internal readonly string MangaId = manga.Key;
internal readonly string LibraryId = toLibrary.Key;
protected override BaseWorker[] DoWorkInternal()
{
if (DbContext.Mangas.Find(MangaId) is not { } manga)
return []; //TODO Exception?
if (DbContext.FileLibraries.Find(LibraryId) is not { } toLibrary)
return []; //TODO Exception?
Dictionary<Chapter, string> oldPath = manga.Chapters.ToDictionary(c => c, c => c.FullArchiveFilePath);
manga.Library = toLibrary;
if (DbContext.Sync() is { success: false })
return [];
return manga.Chapters.Select(c => new MoveFileOrFolderWorker(c.FullArchiveFilePath, oldPath[c])).ToArray<BaseWorker>();
}
public override string ToString() => $"{base.ToString()} {MangaId} {LibraryId}";
}

View File

@ -1,22 +0,0 @@
using API.Schema.MangaContext;
namespace API.Workers;
public class CheckForNewChaptersWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval??TimeSpan.FromMinutes(60);
protected override BaseWorker[] DoWorkInternal()
{
IQueryable<MangaConnectorId<Manga>> connectorIdsManga = DbContext.MangaConnectorToManga.Where(id => id.UseForDownload);
List<BaseWorker> newWorkers = new();
foreach (MangaConnectorId<Manga> mangaConnectorId in connectorIdsManga)
newWorkers.Add(new RetrieveMangaChaptersFromMangaconnectorWorker(mangaConnectorId, Tranga.Settings.DownloadLanguage));
return newWorkers.ToArray();
}
}

View File

@ -1,29 +0,0 @@
using API.Schema.MangaContext;
namespace API.Workers.MaintenanceWorkers;
public class CleanupMangaCoversWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(24);
protected override BaseWorker[] DoWorkInternal()
{
Log.Info("Removing stale files...");
if (!Directory.Exists(TrangaSettings.coverImageCache))
return [];
string[] usedFiles = DbContext.Mangas.Select(m => m.CoverFileNameInCache).Where(s => s != null).ToArray()!;
string[] extraneousFiles = new DirectoryInfo(TrangaSettings.coverImageCache).GetFiles()
.Where(f => usedFiles.Contains(f.FullName) == false)
.Select(f => f.FullName)
.ToArray();
foreach (string path in extraneousFiles)
{
Log.Info($"Deleting {path}");
File.Delete(path);
}
return [];
}
}

View File

@ -1,19 +0,0 @@
using API.Schema.NotificationsContext;
namespace API.Workers.MaintenanceWorkers;
public class RemoveOldNotificationsWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<NotificationsContext>(dependsOn), IPeriodic
{
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(1);
protected override BaseWorker[] DoWorkInternal()
{
IQueryable<Notification> toRemove = DbContext.Notifications.Where(n => n.IsSent || DateTime.UtcNow - n.Date > Interval);
DbContext.RemoveRange(toRemove);
DbContext.Sync();
return [];
}
}

View File

@ -1,29 +0,0 @@
using API.Schema.NotificationsContext;
using API.Schema.NotificationsContext.NotificationConnectors;
namespace API.Workers;
public class SendNotificationsWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<NotificationsContext>(dependsOn), IPeriodic
{
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval??TimeSpan.FromMinutes(1);
protected override BaseWorker[] DoWorkInternal()
{
NotificationConnector[] connectors = DbContext.NotificationConnectors.ToArray();
Notification[] notifications = DbContext.Notifications.Where(n => n.IsSent == false).ToArray();
foreach (Notification notification in notifications)
{
foreach (NotificationConnector connector in connectors)
{
connector.SendNotification(notification.Title, notification.Message);
notification.IsSent = true;
}
}
DbContext.Sync();
return [];
}
}

View File

@ -1,21 +0,0 @@
using API.Schema.MangaContext;
namespace API.Workers;
public class StartNewChapterDownloadsWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromMinutes(1);
protected override BaseWorker[] DoWorkInternal()
{
IQueryable<MangaConnectorId<Chapter>> mangaConnectorIds = DbContext.MangaConnectorToChapter.Where(id => id.Obj.Downloaded == false && id.UseForDownload);
List<BaseWorker> newWorkers = new();
foreach (MangaConnectorId<Chapter> mangaConnectorId in mangaConnectorIds)
newWorkers.Add(new DownloadChapterFromMangaconnectorWorker(mangaConnectorId));
return newWorkers.ToArray();
}
}

View File

@ -1,17 +0,0 @@
using API.Schema.MangaContext;
namespace API.Workers;
public class UpdateChaptersDownloadedWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval??TimeSpan.FromMinutes(60);
protected override BaseWorker[] DoWorkInternal()
{
foreach (Chapter dbContextChapter in DbContext.Chapters)
dbContextChapter.Downloaded = dbContextChapter.CheckDownloaded();
DbContext.Sync();
return [];
}
}

View File

@ -1,31 +0,0 @@
using API.Schema.MangaContext;
using API.Schema.MangaContext.MetadataFetchers;
using Microsoft.EntityFrameworkCore;
namespace API.Workers;
public class UpdateMetadataWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(12);
protected override BaseWorker[] DoWorkInternal()
{
IQueryable<string> mangaIds = DbContext.MangaConnectorToManga
.Where(m => m.UseForDownload)
.Select(m => m.ObjId);
IQueryable<MetadataEntry> metadataEntriesToUpdate = DbContext.MetadataEntries
.Include(e => e.MetadataFetcher)
.Where(e =>
mangaIds.Any(id => id == e.MangaId));
foreach (MetadataEntry metadataEntry in metadataEntriesToUpdate)
metadataEntry.MetadataFetcher.UpdateMetadata(metadataEntry, DbContext);
DbContext.Sync();
return [];
}
}

View File

@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Error",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Error",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

19
CLI/CLI.csproj Normal file
View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console.Cli" Version="0.49.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tranga\Tranga.csproj" />
</ItemGroup>
</Project>

157
CLI/Program.cs Normal file
View File

@ -0,0 +1,157 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using Logging;
using Spectre.Console;
using Spectre.Console.Cli;
using Tranga;
var app = new CommandApp<TrangaCli>();
return app.Run(args);
internal sealed class TrangaCli : Command<TrangaCli.Settings>
{
public sealed class Settings : CommandSettings
{
[Description("Directory to which downloaded Manga are saved")]
[CommandOption("-d|--downloadLocation")]
[DefaultValue(null)]
public string? downloadLocation { get; init; }
[Description("Directory in which application-data is saved")]
[CommandOption("-w|--workingDirectory")]
[DefaultValue(null)]
public string? workingDirectory { get; init; }
[Description("Enables the file-logger")]
[CommandOption("-f")]
[DefaultValue(null)]
public bool? fileLogger { get; init; }
[Description("Path to save logfile to")]
[CommandOption("-l|--fPath")]
[DefaultValue(null)]
public string? fileLoggerPath { get; init; }
[Description("Port on which to run API on")]
[CommandOption("-p|--port")]
[DefaultValue(null)]
public int? apiPort { get; init; }
}
public override int Execute([NotNull] CommandContext context, [NotNull] Settings settings)
{
List<Logger.LoggerType> enabledLoggers = new();
if(settings.fileLogger is true)
enabledLoggers.Add(Logger.LoggerType.FileLogger);
string? logFolderPath = settings.fileLoggerPath ?? "";
Logger logger = new(enabledLoggers.ToArray(), Console.Out, Console.OutputEncoding, logFolderPath);
if(settings.workingDirectory is not null)
TrangaSettings.LoadFromWorkingDirectory(settings.workingDirectory);
else
TrangaSettings.CreateOrUpdate();
if(settings.downloadLocation is not null)
TrangaSettings.CreateOrUpdate(downloadDirectory: settings.downloadLocation);
Tranga.Tranga? api = null;
Thread trangaApi = new Thread(() =>
{
api = new(logger);
});
trangaApi.Start();
HttpClient client = new();
bool exit = false;
while (!exit)
{
string menuSelect = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Menu")
.PageSize(10)
.MoreChoicesText("Up/Down")
.AddChoices(new[]
{
"CustomRequest",
"Log",
"Exit"
}));
switch (menuSelect)
{
case "CustomRequest":
HttpMethod requestMethod = AnsiConsole.Prompt(
new SelectionPrompt<HttpMethod>()
.Title("Request Type")
.AddChoices(new[]
{
HttpMethod.Get,
HttpMethod.Delete,
HttpMethod.Post
}));
string requestPath = AnsiConsole.Prompt(
new TextPrompt<string>("Request Path:"));
List<ValueTuple<string, string>> parameters = new();
while (AnsiConsole.Confirm("Add Parameter?"))
{
string name = AnsiConsole.Ask<string>("Parameter Name:");
string value = AnsiConsole.Ask<string>("Parameter Value:");
parameters.Add(new ValueTuple<string, string>(name, value));
}
string requestString = $"http://localhost:{TrangaSettings.apiPortNumber}/{requestPath}";
if (parameters.Any())
{
requestString += "?";
foreach (ValueTuple<string, string> parameter in parameters)
requestString += $"{parameter.Item1}={parameter.Item2}&";
}
HttpRequestMessage request = new (requestMethod, requestString);
AnsiConsole.WriteLine($"Request: {request.Method} {request.RequestUri}");
HttpResponseMessage response;
if (AnsiConsole.Confirm("Send Request?"))
response = client.Send(request);
else break;
AnsiConsole.WriteLine($"Response: {(int)response.StatusCode} {response.StatusCode}");
AnsiConsole.WriteLine(response.Content.ReadAsStringAsync().Result);
break;
case "Log":
List<string> lines = logger.Tail(10).ToList();
Rows rows = new Rows(lines.Select(line => new Text(line)));
AnsiConsole.Live(rows).Start(context =>
{
bool running = true;
while (running)
{
string[] newLines = logger.GetNewLines();
if (newLines.Length > 0)
{
lines.AddRange(newLines);
rows = new Rows(lines.Select(line => new Text(line)));
context.UpdateTarget(rows);
}
Thread.Sleep(100);
if (AnsiConsole.Console.Input.IsKeyAvailable())
{
AnsiConsole.Console.Input.ReadKey(true); //Do not process input
running = false;
}
}
});
break;
case "Exit":
exit = true;
break;
}
}
if (api is not null)
api.keepRunning = false;
return 0;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

View File

@ -1,460 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<diagram program="umlet" version="15.1">
<zoom_level>10</zoom_level>
<element>
<id>UMLClass</id>
<coordinates>
<x>1160</x>
<y>680</y>
<w>100</w>
<h>40</h>
</coordinates>
<panel_attributes>Manga</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>900</x>
<y>680</y>
<w>140</w>
<h>40</h>
</coordinates>
<panel_attributes>MangaConnector</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>500</x>
<y>800</y>
<w>80</w>
<h>40</h>
</coordinates>
<panel_attributes>/Job/</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>680</x>
<y>800</y>
<w>160</w>
<h>40</h>
</coordinates>
<panel_attributes>/JobWithDownload/</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>680</x>
<y>680</y>
<w>160</w>
<h>40</h>
</coordinates>
<panel_attributes>RetrieveChaptersJob
</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>570</x>
<y>810</y>
<w>130</w>
<h>30</h>
</coordinates>
<panel_attributes>lt=&lt;&lt;-</panel_attributes>
<additional_attributes>10.0;10.0;110.0;10.0</additional_attributes>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>750</x>
<y>710</y>
<w>30</w>
<h>110</h>
</coordinates>
<panel_attributes>lt=&lt;&lt;-</panel_attributes>
<additional_attributes>10.0;90.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>1170</x>
<y>710</y>
<w>30</w>
<h>230</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>10.0;210.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>1030</x>
<y>820</y>
<w>150</w>
<h>140</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>130.0;120.0;70.0;120.0;70.0;10.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>960</x>
<y>710</y>
<w>30</w>
<h>110</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>10.0;90.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>830</x>
<y>810</y>
<w>90</w>
<h>30</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>70.0;10.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>1410</x>
<y>680</y>
<w>100</w>
<h>40</h>
</coordinates>
<panel_attributes>FileLibrary</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>1250</x>
<y>690</y>
<w>180</w>
<h>30</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>160.0;10.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>1410</x>
<y>620</y>
<w>100</w>
<h>40</h>
</coordinates>
<panel_attributes>Link</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>1410</x>
<y>560</y>
<w>100</w>
<h>40</h>
</coordinates>
<panel_attributes>Author</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>1410</x>
<y>500</y>
<w>100</w>
<h>40</h>
</coordinates>
<panel_attributes>MangaTag</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>1200</x>
<y>510</y>
<w>230</w>
<h>190</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>210.0;10.0;10.0;10.0;10.0;170.0</additional_attributes>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>1230</x>
<y>570</y>
<w>200</w>
<h>130</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>180.0;10.0;10.0;10.0;10.0;110.0</additional_attributes>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>1250</x>
<y>630</y>
<w>180</w>
<h>70</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>160.0;10.0;90.0;10.0;90.0;50.0;10.0;50.0</additional_attributes>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>1410</x>
<y>440</y>
<w>100</w>
<h>40</h>
</coordinates>
<panel_attributes>AltTitle</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>1170</x>
<y>450</y>
<w>260</w>
<h>250</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>240.0;10.0;10.0;10.0;10.0;230.0</additional_attributes>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>1380</x>
<y>800</y>
<w>160</w>
<h>40</h>
</coordinates>
<panel_attributes>MangaMetadataEntry</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>1230</x>
<y>710</y>
<w>170</w>
<h>130</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>150.0;110.0;10.0;110.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>1650</x>
<y>800</y>
<w>140</w>
<h>40</h>
</coordinates>
<panel_attributes>MetadataFetcher</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>1530</x>
<y>810</y>
<w>140</w>
<h>30</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>120.0;10.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>UMLUseCase</id>
<coordinates>
<x>1660</x>
<y>680</y>
<w>120</w>
<h>40</h>
</coordinates>
<panel_attributes>Path</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>1500</x>
<y>690</y>
<w>180</w>
<h>30</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>160.0;10.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>900</x>
<y>800</y>
<w>140</w>
<h>40</h>
</coordinates>
<panel_attributes>MangaConnectorID</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>1030</x>
<y>690</y>
<w>150</w>
<h>140</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>130.0;10.0;70.0;10.0;70.0;120.0;10.0;120.0</additional_attributes>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>1160</x>
<y>920</y>
<w>100</w>
<h>40</h>
</coordinates>
<panel_attributes>Chapter
</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>460</x>
<y>680</y>
<w>160</w>
<h>40</h>
</coordinates>
<panel_attributes>UpdateChapters
DownloadedJob</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>530</x>
<y>710</y>
<w>30</w>
<h>110</h>
</coordinates>
<panel_attributes>lt=&lt;&lt;-</panel_attributes>
<additional_attributes>10.0;90.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>1970</x>
<y>640</y>
<w>110</w>
<h>40</h>
</coordinates>
<panel_attributes>lw=2
Komga</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>1970</x>
<y>710</y>
<w>110</w>
<h>40</h>
</coordinates>
<panel_attributes>lw=2
Kavita</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>UMLPackage</id>
<coordinates>
<x>1930</x>
<y>600</y>
<w>190</w>
<h>170</h>
</coordinates>
<panel_attributes>Library</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>1770</x>
<y>690</y>
<w>180</w>
<h>30</h>
</coordinates>
<panel_attributes>lt=-</panel_attributes>
<additional_attributes>160.0;10.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>710</x>
<y>910</y>
<w>100</w>
<h>30</h>
</coordinates>
<panel_attributes>/Identifiable/</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>750</x>
<y>830</y>
<w>30</w>
<h>100</h>
</coordinates>
<panel_attributes>lt=&lt;&lt;-</panel_attributes>
<additional_attributes>10.0;80.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>530</x>
<y>830</y>
<w>200</w>
<h>120</h>
</coordinates>
<panel_attributes>lt=&lt;&lt;-</panel_attributes>
<additional_attributes>180.0;100.0;10.0;100.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>750</x>
<y>930</y>
<w>480</w>
<h>110</h>
</coordinates>
<panel_attributes>lt=&lt;&lt;-</panel_attributes>
<additional_attributes>10.0;10.0;10.0;90.0;460.0;90.0;460.0;30.0</additional_attributes>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>800</x>
<y>670</y>
<w>380</w>
<h>280</h>
</coordinates>
<panel_attributes>lt=&lt;&lt;-</panel_attributes>
<additional_attributes>10.0;260.0;260.0;260.0;260.0;10.0;360.0;10.0</additional_attributes>
</element>
</diagram>

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
ARG DOTNET=9.0
ARG DOTNET=8.0
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:$DOTNET AS base
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime:$DOTNET AS base
WORKDIR /publish
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
@ -16,7 +16,9 @@ FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:$DOTNET AS build-env
WORKDIR /src
COPY Tranga.sln /src
COPY API/API.csproj /src/API/API.csproj
COPY CLI/CLI.csproj /src/CLI/CLI.csproj
COPY Logging/Logging.csproj /src/Logging/Logging.csproj
COPY Tranga/Tranga.csproj /src/Tranga/Tranga.csproj
RUN dotnet restore /src/Tranga.sln
COPY . /src/
@ -38,5 +40,5 @@ USER $UNAME
WORKDIR /publish
COPY --chown=1000:1000 --from=build-env /publish .
USER 0
ENTRYPOINT ["dotnet", "/publish/API.dll"]
CMD [""]
ENTRYPOINT ["dotnet", "/publish/Tranga.dll"]
CMD ["-f", "-c", "-l", "/usr/share/tranga-api/logs"]

32
Logging/FileLogger.cs Normal file
View File

@ -0,0 +1,32 @@
using System.Text;
namespace Logging;
public class FileLogger : LoggerBase
{
internal string logFilePath { get; }
private const int MaxNumberOfLogFiles = 5;
public FileLogger(string logFilePath, Encoding? encoding = null) : base (encoding)
{
this.logFilePath = logFilePath;
DirectoryInfo dir = Directory.CreateDirectory(new FileInfo(logFilePath).DirectoryName!);
//Remove oldest logfile if more than MaxNumberOfLogFiles
for (int fileCount = dir.EnumerateFiles().Count(); fileCount > MaxNumberOfLogFiles - 1; fileCount--) //-1 because we create own logfile later
File.Delete(dir.EnumerateFiles().MinBy(file => file.LastWriteTime)!.FullName);
}
protected override void Write(LogMessage logMessage)
{
try
{
File.AppendAllText(logFilePath, logMessage.formattedMessage);
}
catch (Exception)
{
// ignored
}
}
}

View File

@ -0,0 +1,17 @@
using System.Text;
namespace Logging;
public class FormattedConsoleLogger : LoggerBase
{
private readonly TextWriter _stdOut;
public FormattedConsoleLogger(TextWriter stdOut, Encoding? encoding = null) : base(encoding)
{
this._stdOut = stdOut;
}
protected override void Write(LogMessage message)
{
this._stdOut.Write(message.formattedMessage);
}
}

23
Logging/LogMessage.cs Normal file
View File

@ -0,0 +1,23 @@
namespace Logging;
public readonly struct LogMessage
{
public DateTime logTime { get; }
public string caller { get; }
public string value { get; }
public string formattedMessage => ToString();
public LogMessage(DateTime messageTime, string caller, string value)
{
this.logTime = messageTime;
this.caller = caller;
this.value = value;
}
public override string ToString()
{
string dateTimeString = $"{logTime.ToShortDateString()} {logTime.ToLongTimeString()}.{logTime.Millisecond,-3}";
string name = caller.Split(new char[] { '.', '+' }).Last();
return $"[{dateTimeString}] {name.Substring(0, name.Length >= 13 ? 13 : name.Length),13} | {value}";
}
}

80
Logging/Logger.cs Normal file
View File

@ -0,0 +1,80 @@
using System.Runtime.InteropServices;
using System.Text;
namespace Logging;
public class Logger : TextWriter
{
private static readonly string LogDirectoryPath = RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
? "/var/log/tranga-api"
: Path.Join(Directory.GetCurrentDirectory(), "logs");
public string? logFilePath => _fileLogger?.logFilePath;
public override Encoding Encoding { get; }
public enum LoggerType
{
FileLogger,
ConsoleLogger
}
private readonly FileLogger? _fileLogger;
private readonly FormattedConsoleLogger? _formattedConsoleLogger;
private readonly MemoryLogger _memoryLogger;
public Logger(LoggerType[] enabledLoggers, TextWriter? stdOut, Encoding? encoding, string? logFolderPath)
{
this.Encoding = encoding ?? Encoding.UTF8;
DateTime now = DateTime.Now;
if(enabledLoggers.Contains(LoggerType.FileLogger) && (logFolderPath is null || logFolderPath == ""))
{
string filePath = Path.Join(LogDirectoryPath,
$"{now.ToShortDateString()}_{now.Hour}-{now.Minute}-{now.Second}.log");
_fileLogger = new FileLogger(filePath, encoding);
}else if (enabledLoggers.Contains(LoggerType.FileLogger) && logFolderPath is not null)
_fileLogger = new FileLogger(Path.Join(logFolderPath, $"{now.ToShortDateString()}_{now.Hour}-{now.Minute}-{now.Second}.log") , encoding);
if (enabledLoggers.Contains(LoggerType.ConsoleLogger) && stdOut is not null)
{
_formattedConsoleLogger = new FormattedConsoleLogger(stdOut, encoding);
}
else if (enabledLoggers.Contains(LoggerType.ConsoleLogger) && stdOut is null)
{
_formattedConsoleLogger = null;
throw new ArgumentException($"stdOut can not be null for LoggerType {LoggerType.ConsoleLogger}");
}
_memoryLogger = new MemoryLogger(encoding);
WriteLine(GetType().ToString(), $"Logfile: {logFilePath}");
}
public void WriteLine(string caller, string? value)
{
value = value is null ? Environment.NewLine : string.Concat(value, Environment.NewLine);
Write(caller, value);
}
public void Write(string caller, string? value)
{
if (value is null)
return;
_fileLogger?.Write(caller, value);
_formattedConsoleLogger?.Write(caller, value);
_memoryLogger.Write(caller, value);
}
public string[] Tail(uint? lines)
{
return _memoryLogger.Tail(lines);
}
public string[] GetNewLines()
{
return _memoryLogger.GetNewLines();
}
public string[] GetLog()
{
return _memoryLogger.GetLogMessages();
}
}

25
Logging/LoggerBase.cs Normal file
View File

@ -0,0 +1,25 @@
using System.Text;
namespace Logging;
public abstract class LoggerBase : TextWriter
{
public override Encoding Encoding { get; }
public LoggerBase(Encoding? encoding = null)
{
this.Encoding = encoding ?? Encoding.ASCII;
}
public void Write(string caller, string? value)
{
if (value is null)
return;
LogMessage message = new (DateTime.Now, caller, value);
Write(message);
}
protected abstract void Write(LogMessage message);
}

10
Logging/Logging.csproj Normal file
View File

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12</LangVersion>
</PropertyGroup>
</Project>

74
Logging/MemoryLogger.cs Normal file
View File

@ -0,0 +1,74 @@
using System.Text;
namespace Logging;
public class MemoryLogger : LoggerBase
{
private readonly SortedList<DateTime, LogMessage> _logMessages = new();
private int _lastLogMessageIndex = 0;
public MemoryLogger(Encoding? encoding = null) : base(encoding)
{
}
protected override void Write(LogMessage value)
{
lock (_logMessages)
{
_logMessages.Add(DateTime.Now, value);
}
}
public string[] GetLogMessages()
{
return Tail(Convert.ToUInt32(_logMessages.Count));
}
public string[] Tail(uint? length)
{
int retLength;
if (length is null || length > _logMessages.Count)
retLength = _logMessages.Count;
else
retLength = (int)length;
string[] ret = new string[retLength];
for (int retIndex = 0; retIndex < ret.Length; retIndex++)
{
lock (_logMessages)
{
ret[retIndex] = _logMessages.GetValueAtIndex(_logMessages.Count - retLength + retIndex).ToString();
}
}
_lastLogMessageIndex = _logMessages.Count - 1;
return ret;
}
public string[] GetNewLines()
{
int logMessageCount = _logMessages.Count;
List<string> ret = new();
int retIndex = 0;
for (; retIndex < logMessageCount - _lastLogMessageIndex; retIndex++)
{
try
{
lock(_logMessages)
{
ret.Add(_logMessages.GetValueAtIndex(_lastLogMessageIndex + retIndex).ToString());
}
}
catch (NullReferenceException)//Called when LogMessage has not finished writing
{
break;
}
}
_lastLogMessageIndex = _lastLogMessageIndex + retIndex;
return ret.ToArray();
}
}

195
README.md
View File

@ -1,74 +1,80 @@
<span id="readme-top"></span>
<!-- PROJECT LOGO -->
<br />
<div align="center">
<h1 align="center">Tranga v2</h1>
<h3 align="center">Tranga v2</h3>
<p align="center">
Automatic Manga and Metadata downloader
</p>
![GitHub License](https://img.shields.io/github/license/C9glax/tranga)
<table>
<tr>
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga/master?label=master"></th>
<td><img alt="Last Run" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Fc9glax%2Ftranga%2Factions%2Fworkflows%2Fdocker-image-master.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td>
</tr>
<tr>
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga/cuttingedge?label=cuttingedge"></th>
<td><img alt="Last Run" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Fc9glax%2Ftranga%2Factions%2Fworkflows%2Fdocker-image-cuttingedge.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td>
</tr>
<tr>
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga/postgres-Server-V2?label=postgres-Server-V2"></th>
<td><img alt="Last Run" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Fc9glax%2Ftranga%2Factions%2Fworkflows%2Fdocker-image-serverv2.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td>
</tr>
</table>
<p align="center">
This is the API for <a href="https://github.com/C9Glax/tranga-website">Tranga-Website</a>
</p>
</div>
<!-- TABLE OF CONTENTS -->
<details>
<summary>Table of Contents</summary>
<ol>
<li>
<a href="#about-the-project">About The Project</a>
<ul>
<li><a href="#built-with">Built With</a></li>
</ul>
</li>
<li>
<a href="#getting-started">Getting Started</a>
<ul>
<li><a href="#prerequisites">Usage</a></li>
<li><a href="#prerequisites">Prerequisites</a></li>
</ul>
</li>
<li><a href="#roadmap">Roadmap</a></li>
<li><a href="#contributing">Contributing</a></li>
<li><a href="#license">License</a></li>
<li><a href="#acknowledgments">Acknowledgments</a></li>
</ol>
</details>
<!-- ABOUT THE PROJECT -->
## About The Project
Tranga can download Chapters and Metadata from "Scanlation" sites such as
- [MangaDex.org](https://mangadex.org/) (Multilingual)
- [Comick.io](https://comick.io/)
- [Manganato.com](https://manganato.com/) (en)
- [Mangasee.com](https://mangasee123.com/) (en)
- [MangaKatana.com](https://mangakatana.com) (en)
- [Mangaworld.bz](https://www.mangaworld.bz/) (it)
- [Bato.to](https://bato.to/v3x) (en)
- [Manga4Life](https://manga4life.com) (en)
- [ManhuaPlus](https://manhuaplus.org/) (en)
- [MangaHere](https://www.mangahere.cc/) (en) (Their covers aren't scrapeable.)
- [Weebcentral](https://weebcentral.com) (en)
- ❓ Open an [issue](https://github.com/C9Glax/tranga/issues/new?assignees=&labels=New+Connector&projects=&template=new_connector.yml&title=%5BNew+Connector%5D%3A+)
and trigger a library-scan with [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/).
Notifications can be sent to your devices using [Gotify](https://gotify.net/), [LunaSea](https://www.lunasea.app/) or [Ntfy](https://ntfy.sh/
), or any other service that can use REST Webhooks.
).
## What this program does and does *not* do
### What this does and doesn't do
DOES: Download Images from a Website.<br />
DOES: Create Archives.<br />
Tranga (this git-repo) will open a port (standard 6531) and listen for requests to add Jobs to Monitor and/or download specific Manga.
The configuration is all done through HTTP-Requests. [Documentation](docs/API_Calls_v2.md)
### how:
_**For a web-frontend use [tranga-website](https://github.com/C9Glax/tranga-website).**_
Tranga (this repository) is a REST-API and worker in one. Tranga provides REST-Endpoints to configure workers (Jobs).
Requests include searches for Manga, creating and starting Jobs such as downloading available chapters.
For available endpoints check `<hostedInstance>/swagger`
This project downloads the images for a Manga from the specified Scanlation-Website and packages them with some metadata - from that same website - in a .cbz-archive (per chapter).
It does this on an interval, and checks for any Chapters (.cbz-Archive) not already existing in your specified Download-Location. (If you rename or move files, it will download those again)
Tranga can (if configured) trigger a scan in Komga or Kavita, however the directory in which the Manga reside has to be available to both Tranga and Komga/Kavita.
This repository *does not* include a frontend. A frontend can take many forms, such as a website:
The project doesn't manage metadata, and doesn't curate, change or enhance any information that isn't available on the selected Scanlation-Site.
It will blindly use whatever is scrapes (yes this is a glorified Web-scraper).
[tranga-website](https://github.com/C9Glax/tranga-website)
When downloading a chapter (meaning the images that make-up the manga) from a Website, Tranga will
additionally try and scrape Metadata from the same website ~~or enhance it from third-party sources~~
([tbd issue](https://github.com/C9Glax/tranga/issues/280)).
Downloaded images can be jpeg-compressed and/or made black and white to save on diskspace
(measured at least a 50% reduction in size, without a significant loss of quality).
Tranga will then package the contents of each chapter in a `.cbz`-archive and place it in a common folder per Manga.
If specified, Tranga will then notify library-Managers such as [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/) to trigger a scan for new
chapters. Tranga can also send notifications to your devices via third-party services such as [Gotify](https://gotify.net/), [Ntfy](https://ntfy.sh/),
or any other REST Webhook.
## Screenshots
This repository has no frontend, however checkout [tranga-website](https://github.com/C9Glax/tranga-website) for a default!
## Inspiration:
### Inspiration:
Because [Kaizoku](https://github.com/oae/kaizoku) was relying on [mangal](https://github.com/metafates/mangal) and mangal
hasn't received bugfixes for its issues with Titles not showing up, or throwing errors because of illegal characters,
@ -78,24 +84,15 @@ That is why I wanted to create my own project, in a language I understand, and t
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Endpoint Documentation
### Built With
Endpoints are documented in Swagger. Just spin up an instance, and go to `http://<url>/swagger`.
## Built With
- ASP.NET
- Entity Framework Core
- [PostgreSQL](https://www.postgresql.org/about/licence/)
- [Ngpsql](https://github.com/npgsql/npgsql/blob/main/LICENSE)
- [Swagger](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/LICENSE)
- [Newtonsoft.Json](https://github.com/JamesNK/Newtonsoft.Json/blob/master/LICENSE.md)
- .NET-Core
- Newtonsoft.JSON
- [PuppeteerSharp](https://www.puppeteersharp.com/)
- [Html Agility Pack (HAP)](https://html-agility-pack.net/)
- [Soenneker.Utils.String.NeedlemanWunsch](https://github.com/soenneker/soenneker.utils.string.needlemanwunsch)
- [Sixlabors.ImageSharp](https://docs-v2.sixlabors.com/articles/imagesharp/index.html#license)
- [PuppeteerSharp](https://github.com/hardkoded/puppeteer-sharp/blob/master/LICENSE)
- [Html Agility Pack (HAP)](https://github.com/zzzprojects/html-agility-pack/blob/master/LICENSE)
- [Soenneker.Utils.String.NeedlemanWunsch](https://github.com/soenneker/soenneker.utils.string.needlemanwunsch/blob/main/LICENSE)
- [Jikan](https://jikan.moe/)
- [Jikan.Net](https://github.com/Ervie/jikan.net)
- [zstd-wrapper](https://github.com/oleg-st/ZstdSharp) [zstd](https://github.com/facebook/zstd)
- 💙 Blåhaj 🦈
<p align="right">(<a href="#readme-top">back to top</a>)</p>
@ -115,82 +112,60 @@ Endpoints are documented in Swagger. Just spin up an instance, and go to `http:/
### Docker
Built for AMD64 (and ARM64, maybe, if it feels like it).
An example `docker-compose.yaml` is provided. Mount `/Manga` to wherever you want your chapters (`.cbz`-Archives)
downloaded (where Komga/Kavita can access them for example).
The file also includes [tranga-website](https://github.com/C9Glax/tranga-website) as frontend. For its configuration refer to the
[Tranga-Website Repository](https://github.com/C9Glax/tranga-website) README.
Download [docker-compose.yaml](https://git.bernloehr.eu/glax/Tranga/src/branch/master/docker-compose.yaml) and configure to your needs.
Mount `/Manga` to wherever you want your chapters (`.cbz`-Archives) downloaded (where Komga/Kavita can access them).
The `docker-compose` also includes [tranga-website](https://github.com/C9Glax/tranga-website) as frontend. For its configuration refer to the repo README.
For compatibility do not execute the compose as root (which you should not do anyways...) but as user that can
access the folder. Permission conflicts with Komga and Kavita should thus be limited.
access the folder.
### Bare-Metal
### Prerequisites
While not supported/currently built, Tranga should also run Bare-Metal without issue.
#### To Build
[.NET-Core 8.0 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
#### To Run
[.NET-Core 8.0 Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) scroll down a bit, should be on the right the second item.
Configuration-Files will be stored per OS:
- Linux `/usr/share/tranga-api`
- Windows `%appdata%/tranga-api`
See the [open issues](https://github.com/C9Glax/tranga/issues) for a full list of proposed features (and known issues).
Downloads (default) are stored in - but this can be configured in `settings.json` (which will be generated on first after first launch):
- Linux `/Manga`
- Windows `%currentDirectory%/Downloads`
<p align="right">(<a href="#readme-top">back to top</a>)</p>
#### Prerequisits
[.NET-Core 9.0](https://dotnet.microsoft.com/en-us/download/dotnet/9.0)
<!-- CONTRIBUTING -->
## Contributing
If you want to contribute, please feel free to fork and create a Pull-Request!
The following is copy & pasted:
General rules:
- Strongly-type your variables. This improves readability.
```csharp
var xyz = Object.GetSomething(); //Do not do this. What type is xyz (without looking at Method returns etc.)?
Manga[] zyx = Object.GetAnotherThing(); //I can now easily see that zyx is an Array.
```
Tranga is using a code-first Entity-Framework Core approach. If you modify the db-table structure you need to create a migration.
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
**A broad overview of where is what:**<br />
If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement".
Don't forget to give the project a star! Thanks again!
![Image](DB-Layout.png)
1. Fork the Project
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the Branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
- `Program.cs` Configuration for ASP.NET, Swagger (also in `NamedSwaggerGenOptions.cs`)
- `Tranga.cs` Worker-Logic
- `Schema/` Entity-Framework
- `Schema/Jobs/` + Logic for Jobs
- `Schema/**/` + Logic for **
- `Schema/Contexts/` EF configuration
- `MangaDownloadClients/` Networking-Clients for Scraping
- `Controllers/` ASP.NET Controllers (Endpoints)
- `APIEndpointRecords/` Records for API-Requests with specific Request-Types (Body)
<p align="right">(<a href="#readme-top">back to top</a>)</p>
If you want to add a new Website-Connector: <br />
1. Copy one of the existing connectors, or start from scratch and inherit from `API.Schema.MangaConnectors.MangaConnector`.
2. Add the new Connector as Object-Instance in `Program.cs` to the MangaConnector-Array `connectors`.
3. In `PgsqlContext.cs` add the Discriminator for the Connector (the value is the name of the connector, as defined
in the constructor).
4. In `Program.cs` add a new Object to the Array.
### How to test locally
In the Project root a `docker-compose.local.yaml` file will compile the code and create the container(s).
<!-- LICENSE -->
## License
Distributed under the GNU GPLv3 License. See [LICENSE.txt](https://github.com/C9Glax/tranga/blob/master/LICENSE.txt) for more information.
Distributed under the GNU GPLv3 License. See `LICENSE.txt` for more information.
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- ACKNOWLEDGMENTS -->
## Acknowledgments
* [Choose an Open Source License](https://choosealicense.com)
* [Font Awesome](https://fontawesome.com)
* [Best-README-Template](https://github.com/othneildrew/Best-README-Template/tree/master)
* [Shields.io](https://shields.io/)
<p align="right">(<a href="#readme-top">back to top</a>)</p>

View File

@ -1,6 +1,10 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{EDB07E7B-351F-4FCC-9AEF-777838E5551E}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tranga", ".\Tranga\Tranga.csproj", "{545E81B9-D96B-4C8F-A97F-2C02414DE566}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logging", "Logging\Logging.csproj", "{415BE889-BB7D-426F-976F-8D977876A462}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CLI", "CLI\CLI.csproj", "{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -8,9 +12,17 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{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
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Debug|Any CPU.Build.0 = Debug|Any CPU
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.ActiveCfg = Release|Any CPU
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.Build.0 = Release|Any CPU
{415BE889-BB7D-426F-976F-8D977876A462}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{415BE889-BB7D-426F-976F-8D977876A462}.Debug|Any CPU.Build.0 = Debug|Any CPU
{415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.ActiveCfg = Release|Any CPU
{415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.Build.0 = Release|Any CPU
{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
EndGlobalSection
EndGlobal

View File

@ -1,11 +1,8 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=altnames/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=authorsartists/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=comick/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Gotify/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=jikan/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=jjob/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=kitsu/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Komga/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=lunasea/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=mangakatana/@EntryIndexedValue">True</s:Boolean>
@ -13,6 +10,5 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=Mangasee/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Mangaworld/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Ntfy/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=solverr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Taskmanager/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Tranga/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

156
Tranga/Chapter.cs Normal file
View File

@ -0,0 +1,156 @@
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using static System.IO.UnixFileMode;
namespace Tranga;
/// <summary>
/// Has to be Part of a publication
/// Includes the Chapter-Name, -VolumeNumber, -ChapterNumber, the location of the chapter on the internet and the saveName of the local file.
/// </summary>
public readonly struct Chapter : IComparable
{
// ReSharper disable once MemberCanBePrivate.Global
public Manga parentManga { get; }
public string? name { get; }
public float volumeNumber { get; }
public float chapterNumber { get; }
public string url { get; }
// ReSharper disable once MemberCanBePrivate.Global
public string fileName { get; }
public string? id { get; }
private static readonly Regex LegalCharacters = new (@"([A-z]*[0-9]* *\.*-*,*\]*\[*'*\'*\)*\(*~*!*)*");
private static readonly Regex IllegalStrings = new(@"(Vol(ume)?|Ch(apter)?)\.?", RegexOptions.IgnoreCase);
public Chapter(Manga parentManga, string? name, string? volumeNumber, string chapterNumber, string url, string? id = null)
: this(parentManga, name, float.Parse(volumeNumber??"0", GlobalBase.numberFormatDecimalPoint),
float.Parse(chapterNumber, GlobalBase.numberFormatDecimalPoint), url, id)
{
}
public Chapter(Manga parentManga, string? name, float? volumeNumber, float chapterNumber, string url, string? id = null)
{
this.parentManga = parentManga;
this.name = name;
this.volumeNumber = volumeNumber??0;
this.chapterNumber = chapterNumber;
this.url = url;
this.id = id;
string chapterVolNumStr = $"Vol.{this.volumeNumber} Ch.{chapterNumber}";
if (name is not null && name.Length > 0)
{
string chapterName = IllegalStrings.Replace(string.Concat(LegalCharacters.Matches(name)), "");
this.fileName = $"{chapterVolNumStr} - {chapterName}";
}
else
this.fileName = chapterVolNumStr;
}
public override string ToString()
{
return $"Chapter {parentManga.sortName} {parentManga.internalId} {chapterNumber} {name}";
}
public override bool Equals(object? obj)
{
if (obj is not Chapter)
return false;
return CompareTo(obj) == 0;
}
public int CompareTo(object? obj)
{
if(obj is not Chapter otherChapter)
throw new ArgumentException($"{obj} can not be compared to {this}");
return volumeNumber.CompareTo(otherChapter.volumeNumber) switch
{
<0 => -1,
>0 => 1,
_ => chapterNumber.CompareTo(otherChapter.chapterNumber)
};
}
/// <summary>
/// Checks if a chapter-archive is already present
/// </summary>
/// <returns>true if chapter is present</returns>
internal bool CheckChapterIsDownloaded()
{
string mangaDirectory = Path.Join(TrangaSettings.downloadLocation, parentManga.folderName);
if (!Directory.Exists(mangaDirectory))
return false;
FileInfo? mangaArchive = null;
string markerPath = Path.Join(mangaDirectory, $".{id}");
if (this.id is not null && File.Exists(markerPath))
{
if(File.Exists(File.ReadAllText(markerPath)))
mangaArchive = new FileInfo(File.ReadAllText(markerPath));
else
File.Delete(markerPath);
}
if(mangaArchive is null)
{
FileInfo[] archives = new DirectoryInfo(mangaDirectory).GetFiles("*.cbz");
Regex volChRex = new(@"(?:Vol(?:ume)?\.([0-9]+)\D*)?Ch(?:apter)?\.([0-9]+(?:\.[0-9]+)*)");
Chapter t = this;
mangaArchive = archives.FirstOrDefault(archive =>
{
Match m = volChRex.Match(archive.Name);
if (m.Groups[1].Success)
return m.Groups[1].Value == t.volumeNumber.ToString(GlobalBase.numberFormatDecimalPoint) &&
m.Groups[2].Value == t.chapterNumber.ToString(GlobalBase.numberFormatDecimalPoint);
else
return m.Groups[2].Value == t.chapterNumber.ToString(GlobalBase.numberFormatDecimalPoint);
});
}
string correctPath = GetArchiveFilePath();
if(mangaArchive is not null && mangaArchive.FullName != correctPath)
mangaArchive.MoveTo(correctPath, true);
return (mangaArchive is not null);
}
public void CreateChapterMarker()
{
if (this.id is null)
return;
string path = Path.Join(TrangaSettings.downloadLocation, parentManga.folderName, $".{id}");
File.WriteAllText(path, GetArchiveFilePath());
File.SetAttributes(path, FileAttributes.Hidden);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(path, UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute | OtherRead | OtherExecute);
}
/// <summary>
/// Creates full file path of chapter-archive
/// </summary>
/// <returns>Filepath</returns>
internal string GetArchiveFilePath()
{
return Path.Join(TrangaSettings.downloadLocation, parentManga.folderName, $"{parentManga.folderName} - {this.fileName}.cbz");
}
/// <summary>
/// Creates a string containing XML of publication and chapter.
/// See ComicInfo.xml
/// </summary>
/// <returns>XML-string</returns>
internal string GetComicInfoXmlString()
{
XElement comicInfo = new XElement("ComicInfo",
new XElement("Tags", string.Join(',', parentManga.tags)),
new XElement("LanguageISO", parentManga.originalLanguage),
new XElement("Title", this.name),
new XElement("Writer", string.Join(',', parentManga.authors)),
new XElement("Volume", this.volumeNumber),
new XElement("Number", this.chapterNumber)
);
return comicInfo.ToString();
}
}

Some files were not shown because too many files have changed in this diff Show More