64 Commits

Author SHA1 Message Date
c5689557b3 Fix MangaConnectorId Chapters Cascade 2025-07-22 22:34:38 +02:00
c044532564 Add Maintenance Controller
- CleanupNoDownloadManga
2025-07-22 21:53:27 +02:00
383258ac87 Add Query to get similar Manga by name 2025-07-22 21:53:14 +02:00
de36ce9c16 Fix MangaDex null 2025-07-22 21:09:31 +02:00
eba79abf51 Fix merging of Manga
Fix ComickIo empty lists
2025-07-22 20:24:53 +02:00
ae20ad47a8 Additional Query to get only downloading Manga 2025-07-22 18:04:49 +02:00
691506b2d8 Fix Merge of Chapters 2025-07-22 18:00:50 +02:00
356a22d72e Fix Merge of Manga 2025-07-22 17:59:25 +02:00
5d8f203a35 Add Queries for MangaConnectorIds 2025-07-22 15:56:57 +02:00
17995d1603 Fix DownloadChapterFromMangaconnectorWorker trying to download even if Library is not set 2025-07-22 13:49:18 +02:00
d2f9ab64aa Fix return of GET MangaConnector 2025-07-21 19:52:03 +02:00
d79dd8c3d5 Fix CancellationToken Source crashing all Workers after 10 Minutes of runtime 2025-07-21 19:32:29 +02:00
2b527e15b0 Fix NotificationConnectors 2025-07-21 16:39:18 +02:00
305b9d900c NamedSwaggerGenOptions.cs 2025-07-21 14:01:59 +02:00
413fb0e69e More Logging 2025-07-21 13:47:21 +02:00
64b89d4537 Do not use a Thread to Periodically check for Due workers.
Each Periodic Worker has it's own Thread that waits for execution.
2025-07-21 13:45:39 +02:00
fab70501ce Fix Concurrency of DownloadClient LastExecutedRateLimit 2025-07-21 13:06:35 +02:00
0a1021b488 Add UpdateCoversWorker 2025-07-21 12:45:29 +02:00
18c6f64405 Download Cover when adding Manga 2025-07-21 12:38:00 +02:00
5202bfe782 Remove non-periodic workers after they finish 2025-07-21 12:35:18 +02:00
8408407a8e Search for Manga on different MangaConnector 2025-07-21 12:32:20 +02:00
a091250195 Context Load Navigations and Collections 2025-07-21 12:24:54 +02:00
a352495866 GET Workers return IDs 2025-07-21 11:53:23 +02:00
3a46d0fd24 Disable LazyLoading
Remove MangaConnectors from Database
2025-07-21 11:42:21 +02:00
6034937c23 Indent TrangaSettings 2025-07-21 11:15:17 +02:00
454f468fd4 Move Configuration of Workers to separate method 2025-07-20 19:19:06 +02:00
1ee442ea2e Rename methods for workers from old Job 2025-07-20 19:12:28 +02:00
6b4317834d StartNewChapterDownloadsWorker interval 1 minute 2025-07-03 23:06:35 +02:00
88fef8417c Fix request path 2025-07-03 23:04:32 +02:00
eb9fc08b2d Fix Scope/Context for Workers 2025-07-03 23:02:37 +02:00
9743bb6e8e Fix Worker-Cycle:
Periodic set last execution,
Print Running Worker-Names when done
2025-07-03 22:48:06 +02:00
e8d612557f Fix TrangaBaseContext.Sync 2025-07-03 22:39:06 +02:00
cf2dbeaf6a Fix RemoveOldNotificationsWorker.cs: RemoveRange 2025-07-03 21:57:07 +02:00
84940c414c Add Migrations
Add RemoveOldNotificationsWorker.cs
2025-07-03 21:55:38 +02:00
ea627081b8 Add default Tranga-Workers 2025-07-03 21:17:08 +02:00
a90a6fb200 Enable Manga Downloading 2025-07-03 21:11:33 +02:00
c3a0bb03e9 SettingsController set download language 2025-07-03 20:53:20 +02:00
f8ccd2d69e Tranga WorkerCycle 2025-07-03 20:51:06 +02:00
ad224190a2 UpdateMetadataWorker.cs 2025-07-03 20:38:29 +02:00
f05f2cc8e0 ToString overrides 2025-07-03 20:38:18 +02:00
d6f0630a99 StartNewChapterDownloadsWorker.cs 2025-07-03 20:21:48 +02:00
0ac4c23ac9 SendNotificationsWorker, CleanupMangaCoversWorker, UpdateChaptersDownloadedWorker add optional interval parameter 2025-07-03 20:14:18 +02:00
d6847d769e CheckForNewChaptersWorker 2025-07-03 20:11:18 +02:00
f6f5e21151 Move AddMangaToContext to Tranga.cs 2025-07-03 19:44:11 +02:00
da3b5078af SendNotificationsWorker.cs 2025-07-03 19:43:23 +02:00
681d56710a TrangaSettings as static field in Tranga instead of Static class 2025-07-03 17:30:58 +02:00
6f5823596a Tranga CheckRunning Workers 2025-07-02 22:34:12 +02:00
8a06ed648c BaseWorker Logging 2025-07-02 22:34:00 +02:00
4dcd6ee035 DbContext never null 2025-07-02 22:17:51 +02:00
e327e93163 BaseWorker, BaseWorkerWithContext DoWork, call: Scope setting
TrangaBaseContext Sync return with success state and exception message
2025-07-02 22:15:34 +02:00
6cd836540a IPeriodic non-generic 2025-07-02 21:12:47 +02:00
91c91e4989 Refactor Controllers
SettingsController.cs

SearchController.cs

QueryController.cs

NotificationConnectorController.cs

MetadataFetcherController.cs

MangaConnectorController.cs

FileLibraryController

LibraryConnectors

WorkerController
2025-07-02 21:12:47 +02:00
57bb87120a WIP 2025-07-02 02:26:02 +02:00
07880fedb5 Create TrangaBaseContext for common OnConfiguring implementation of Contexts 2025-07-01 23:00:47 +02:00
f1d3203ae1 Notifications-Identifiable 2025-07-01 22:35:44 +02:00
314700aa8e Add TODO to remove migrations after some time 2025-06-30 22:05:49 +02:00
b299cc9109 Merge branch 'Jikan' into JobQueue-Sortable
# Conflicts:
#	API/Schema/Contexts/PgsqlContext.cs
2025-06-30 22:03:22 +02:00
7e9ba7090a Manga and Chapters are shared across Connectors 2025-06-30 22:01:10 +02:00
ea73d03b8f WIP: Manga can be linked to multiple Connectors
- PgsqlContext Adjustment
2025-06-30 14:42:24 +02:00
e9d9bebcd7 WIP: Manga can be linked to multiple Connectors 2025-06-30 14:24:17 +02:00
e5937d2654 Job is IComparable<Job> 2025-06-30 12:58:00 +02:00
7c9e0eddf9 Metadata-Site Search (Interactive linking) 2025-06-29 21:13:05 +02:00
ae0c6c8240 Change PrimaryKey of MetadataEntry to Fetcher + Identifier 2025-06-29 20:43:21 +02:00
2eb0d941b2 MetadataFetching:
- Jikan (MAL) linking, fetching/updating
2025-06-28 22:59:35 +02:00
98 changed files with 1961 additions and 3413 deletions

View File

@@ -1,13 +1,13 @@
name: Bug Report name: Bug Report
description: File a bug report description: File a bug report
title: "[Tranga broke]: <title>" title: "[It broke]: "
labels: ["bug"] labels: ["bug"]
body: body:
- type: textarea - type: textarea
attributes: attributes:
label: What is broken? label: What is broken?
description: What happened? How did we get here? description: What happened? How did we get here?
placeholder: Tell me what you expected to happen, and what happened instead. placeholder: The place where you tell me what you expected to happen, and what happened instead.
validations: validations:
required: true required: true
- type: textarea - type: textarea
@@ -18,4 +18,4 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: Additional stuff label: Additional stuff
description: Screenshots, anything you think might help description: Screenshots, anything you think might help

View File

@@ -1,6 +1,6 @@
name: New Connector Request name: New Connector Request
description: Request a new site to be added description: Request a new site to be added
title: "[New Connector]: <title>" title: "[New Connector]: "
labels: ["New Connector"] labels: ["New Connector"]
body: body:
- type: input - type: input
@@ -9,12 +9,15 @@ body:
placeholder: https:// placeholder: https://
validations: validations:
required: true required: true
- 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.
options:
- label: The Website is freely accessible.
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Anything else? label: Anything else?
validations: validations:
required: false required: false
- type: markdown
attributes:
value: |
If you want implement this read [Contributing](https://github.com/C9Glax/tranga#contributing). Thank you!

View File

@@ -1,31 +0,0 @@
name: New Connector Implementation
description: New Connector
title: "<title>"
labels: ["New Connector"]
body:
- type: markdown
attributes:
value: |
Thank you for contributing. Please make sure you have read [Contributing](https://github.com/C9Glax/tranga#contributing).
Just for the sake of completion:
- type: checkboxes
id: Contributing
attributes:
label: Contributing
description: I have checked
options:
- label: Formatting (if not done yet, mark this PR as draft)
required: false
- label: I have read https://github.com/C9Glax/tranga#if-you-want-to-add-a-new-website-connector
required: true
- type: input
attributes:
label: Existing Issue
placeholder: #<Issue number>
validations:
required: false
- type: textarea
attributes:
label: Anything else?
validations:
required: false

View File

@@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
# https://github.com/docker/setup-qemu-action#usage # https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU - name: Set up QEMU
@@ -22,7 +22,7 @@ jobs:
# https://github.com/marketplace/actions/docker-setup-buildx # https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v3.11.1 uses: docker/setup-buildx-action@v3.10.0
# https://github.com/docker/login-action#docker-hub # https://github.com/docker/login-action#docker-hub
- name: Login to Docker Hub - name: Login to Docker Hub
@@ -33,12 +33,12 @@ jobs:
# https://github.com/docker/build-push-action#multi-platform-image # https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push API - name: Build and push API
uses: docker/build-push-action@v6.18.0 uses: docker/build-push-action@v6.15.0
with: with:
context: ./ context: ./
file: ./Dockerfile file: ./Dockerfile
#platforms: linux/amd64,linux/amd64/v2,linux/amd64/v3,linux/arm64,linux/arm/v7 #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/amd64/v2,linux/amd64/v3,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64
pull: true pull: true
push: true push: true
tags: | tags: |

View File

@@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
# https://github.com/docker/setup-qemu-action#usage # https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU - name: Set up QEMU
@@ -22,7 +22,7 @@ jobs:
# https://github.com/marketplace/actions/docker-setup-buildx # https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v3.11.1 uses: docker/setup-buildx-action@v3.10.0
# https://github.com/docker/login-action#docker-hub # https://github.com/docker/login-action#docker-hub
- name: Login to Docker Hub - name: Login to Docker Hub
@@ -33,7 +33,7 @@ jobs:
# https://github.com/docker/build-push-action#multi-platform-image # https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push API - name: Build and push API
uses: docker/build-push-action@v6.18.0 uses: docker/build-push-action@v6.15.0
with: with:
context: ./ context: ./
file: ./Dockerfile file: ./Dockerfile

View File

@@ -2,7 +2,7 @@ name: Docker Image CI
on: on:
push: push:
branches: [ "testing" ] branches: [ "postgres-Server-V2" ]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
# https://github.com/docker/setup-qemu-action#usage # https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU - name: Set up QEMU
@@ -22,7 +22,7 @@ jobs:
# https://github.com/marketplace/actions/docker-setup-buildx # https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v3.11.1 uses: docker/setup-buildx-action@v3.10.0
# https://github.com/docker/login-action#docker-hub # https://github.com/docker/login-action#docker-hub
- name: Login to Docker Hub - name: Login to Docker Hub
@@ -33,13 +33,13 @@ jobs:
# https://github.com/docker/build-push-action#multi-platform-image # https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push API - name: Build and push API
uses: docker/build-push-action@v6.18.0 uses: docker/build-push-action@v6.15.0
with: with:
context: ./ context: ./
file: ./Dockerfile file: ./Dockerfile
#platforms: linux/amd64,linux/amd64/v2,linux/amd64/v3,linux/arm64,linux/arm/v7 #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/amd64/v2,linux/amd64/v3,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64
pull: true pull: true
push: true push: true
tags: | tags: |
glax/tranga-api:testing glax/tranga-api:Server-V2

View File

@@ -11,32 +11,30 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" /> <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.2" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
<PackageReference Include="JikanDotNet" Version="2.9.1" /> <PackageReference Include="JikanDotNet" Version="2.9.1" />
<PackageReference Include="log4net" Version="3.2.0" /> <PackageReference Include="log4net" Version="3.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="9.0.5" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql" Version="9.0.3" /> <PackageReference Include="Npgsql" Version="9.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" /> <PackageReference Include="PuppeteerSharp" Version="20.1.3" />
<PackageReference Include="Soenneker.Utils.String.NeedlemanWunsch" Version="3.0.978" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> <PackageReference Include="Soenneker.Utils.String.NeedlemanWunsch" Version="3.0.929" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" />
<PackageReference Include="System.Drawing.Common" Version="9.0.8" /> <PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="8.1.0" />
<PackageReference Include="System.Drawing.Common" Version="9.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Migrations\Manga\" /> <Folder Include="Migrations\Manga\" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Include="Log4Net.config.xml" CopyToOutputDirectory="Always" CopyToPublishDirectory="Always" />
</ItemGroup>
</Project> </Project>

View File

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

View File

@@ -0,0 +1,16 @@
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

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

View File

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,13 @@
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,18 +0,0 @@
using SixLabors.ImageSharp;
namespace API;
public struct Constants
{
public const string TRANGA =
"\n\n" +
" _______ v2\n" +
"|_ _|.----..---.-..-----..-----..---.-.\n" +
" | | | _|| _ || || _ || _ |\n" +
" |___| |__| |___._||__|__||___ ||___._|\n" +
" |_____| \n\n";
public static readonly Size ImageSmSize = new (225, 320);
public static readonly Size ImageMdSize = new (450, 640);
public static readonly Size ImageLgSize = new (900, 1280);
}

View File

@@ -1,24 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace API.Controllers.DTOs;
/// <summary>
/// <see cref="API.Schema.MangaContext.AltTitle"/> DTO
/// </summary>
public sealed record AltTitle(string Language, string Title)
{
/// <summary>
/// Language of the Title
/// </summary>
[Required]
[Description("Language of the Title")]
public string Language { get; init; } = Language;
/// <summary>
/// Title
/// </summary>
[Required]
[Description("Title")]
public string Title { get; init; } = Title;
}

View File

@@ -1,17 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace API.Controllers.DTOs;
/// <summary>
/// The <see cref="API.Schema.MangaContext.Author"/> DTO
/// </summary>
public sealed record Author(string Key, string Name) : Identifiable(Key)
{
/// <summary>
/// Name of the Author.
/// </summary>
[Required]
[Description("Name of the Author.")]
public string Name { get; init; } = Name;
}

View File

@@ -1,52 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace API.Controllers.DTOs;
/// <summary>
/// <see cref="API.Schema.MangaContext.Chapter"/> DTO
/// </summary>
public sealed record Chapter(string Key, string MangaId, int? Volume, string ChapterNumber, string? Title, IEnumerable<MangaConnectorId> MangaConnectorIds, bool Downloaded) : Identifiable(Key)
{
/// <summary>
/// Identifier of the Manga this Chapter belongs to
/// </summary>
[Required]
[Description("Identifier of the Manga this Chapter belongs to")]
public string MangaId { get; init; } = MangaId;
/// <summary>
/// Volume number
/// </summary>
[Required]
[Description("Volume number")]
public int? Volume { get; init; } = Volume;
/// <summary>
/// Chapter number
/// </summary>
[Required]
[Description("Chapter number")]
public string ChapterNumber { get; init; } = ChapterNumber;
/// <summary>
/// Title of the Chapter
/// </summary>
[Required]
[Description("Title of the Chapter")]
public string? Title { get; init; } = Title;
/// <summary>
/// Whether Chapter is Downloaded (on disk)
/// </summary>
[Required]
[Description("Whether Chapter is Downloaded (on disk)")]
public bool Downloaded { get; init; } = Downloaded;
/// <summary>
/// Ids of the Manga on MangaConnectors
/// </summary>
[Required]
[Description("Ids of the Manga on MangaConnectors")]
public IEnumerable<MangaConnectorId> MangaConnectorIds { get; init; } = MangaConnectorIds;
}

View File

@@ -1,21 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace API.Controllers.DTOs;
public sealed record FileLibrary(string Key, string BasePath, string LibraryName) : Identifiable(Key)
{
/// <summary>
/// The directory Path of the library
/// </summary>
[Required]
[Description("The directory Path of the library")]
public string BasePath { get; internal set; } = BasePath;
/// <summary>
/// The Name of the library
/// </summary>
[Required]
[Description("The Name of the library")]
public string LibraryName { get; internal set; } = LibraryName;
}

View File

@@ -1,18 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace API.Controllers.DTOs;
/// <summary>
/// <see cref="API.Schema.Identifiable"/>
/// </summary>
public record Identifiable(string Key)
{
/// <summary>
/// Unique Identifier of the DTO
/// </summary>
[Required]
[Description("Unique Identifier of the DTO")]
[StringLength(TokenGen.MaximumLength, MinimumLength = TokenGen.MinimumLength)]
public string Key { get; init; } = Key;
}

View File

@@ -1,23 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using API.Schema.LibraryContext.LibraryConnectors;
namespace API.Controllers.DTOs;
public record LibraryConnector(string Key, string BaseUrl, LibraryType Type) : Identifiable(Key)
{
/// <summary>
/// The Url of the Library instance
/// </summary>
[Required]
[Url]
[Description("The Url of the Library instance")]
public string BaseUrl { get; init;} = BaseUrl;
/// <summary>
/// The <see cref="LibraryType"/>
/// </summary>
[Required]
[Description("The Library Type")]
public LibraryType Type { get; init; } = Type;
}

View File

@@ -1,25 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace API.Controllers.DTOs;
/// <summary>
/// <see cref="API.Schema.MangaContext.Link"/> DTO
/// </summary>
public sealed record Link(string Key, string Provider, string Url) : Identifiable(Key)
{
/// <summary>
/// Name of the Provider
/// </summary>
[Required]
[Description("Name of the Provider")]
public string Provider { get; init; } = Provider;
/// <summary>
/// Url
/// </summary>
[Required]
[Description("Url")]
public string Url { get; init; } = Url;
}

View File

@@ -1,73 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using API.Schema.MangaContext;
namespace API.Controllers.DTOs;
/// <summary>
/// <see cref="Schema.MangaContext.Manga"/> DTO
/// </summary>
public sealed record Manga(string Key, string Name, string Description, MangaReleaseStatus ReleaseStatus, IEnumerable<MangaConnectorId> MangaConnectorIds, float IgnoreChaptersBefore, uint? Year, string? OriginalLanguage, IEnumerable<string> ChapterIds, IEnumerable<Author> Authors, IEnumerable<string> Tags, IEnumerable<Link> Links, IEnumerable<AltTitle> AltTitles, string? FileLibraryId)
: MinimalManga(Key, Name, Description, ReleaseStatus, MangaConnectorIds)
{
/// <summary>
/// Chapter cutoff for Downloads (Chapters before this will not be downloaded)
/// </summary>
[Required]
[Description("Chapter cutoff for Downloads (Chapters before this will not be downloaded)")]
public float IgnoreChaptersBefore { get; init; } = IgnoreChaptersBefore;
/// <summary>
/// Release Year
/// </summary>
[Description("Release Year")]
public uint? Year { get; init; } = Year;
/// <summary>
/// Release Language
/// </summary>
[Description("Release Language")]
public string? OriginalLanguage { get; init; } = OriginalLanguage;
/// <summary>
/// Keys of ChapterDTOs
/// </summary>
[Required]
[Description("Keys of ChapterDTOs")]
public IEnumerable<string> ChapterIds { get; init; } = ChapterIds;
/// <summary>
/// Author-names
/// </summary>
[Required]
[Description("Author-names")]
public IEnumerable<Author> Authors { get; init; } = Authors;
/// <summary>
/// Manga Tags
/// </summary>
[Required]
[Description("Manga Tags")]
public IEnumerable<string> Tags { get; init; } = Tags;
/// <summary>
/// Links for more Metadata
/// </summary>
[Required]
[Description("Links for more Metadata")]
public IEnumerable<Link> Links { get; init; } = Links;
/// <summary>
/// Alt Titles of Manga
/// </summary>
[Required]
[Description("Alt Titles of Manga")]
public IEnumerable<AltTitle> AltTitles { get; init; } = AltTitles;
/// <summary>
/// Id of the Library the Manga gets downloaded to
/// </summary>
[Required]
[Description("Id of the Library the Manga gets downloaded to")]
public string? FileLibraryId { get; init; } = FileLibraryId;
}

View File

@@ -1,28 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace API.Controllers.DTOs;
public sealed record MangaConnector(string Name, bool Enabled, string IconUrl, string[] SupportedLanguages) : Identifiable(Name)
{
/// <summary>
/// Whether Connector is used for Searches and Downloads
/// </summary>
[Required]
[Description("Whether Connector is used for Searches and Downloads")]
public bool Enabled { get; init; } = Enabled;
/// <summary>
/// Languages supported by the Connector
/// </summary>
[Required]
[Description("Languages supported by the Connector")]
public string[] SupportedLanguages { get; init; } = SupportedLanguages;
/// <summary>
/// Url of the Website Icon
/// </summary>
[Required]
[Description("Url of the Website Icon")]
public string IconUrl { get; init; } = IconUrl;
}

View File

@@ -1,38 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using API.Schema.MangaContext;
namespace API.Controllers.DTOs;
/// <summary>
/// <see cref="MangaConnectorId{T}"/> DTO
/// </summary>
public sealed record MangaConnectorId(string Key, string MangaConnectorName, string ForeignKey, string? WebsiteUrl, bool UseForDownload) : Identifiable(Key)
{
/// <summary>
/// Name of the Connector
/// </summary>
[Required]
[Description("Name of the Connector")]
public string MangaConnectorName { get; init; } = MangaConnectorName;
/// <summary>
/// Key of the referenced DTO
/// </summary>
[Required]
[Description("Key of the referenced DTO")]
public string ForeignKey { get; init; } = ForeignKey;
/// <summary>
/// Website Link for reference, if any
/// </summary>
[Description("Website Link for reference, if any")]
public string? WebsiteUrl { get; init; } = WebsiteUrl;
/// <summary>
/// Whether this Link is used for downloads
/// </summary>
[Required]
[Description("Whether this Link is used for downloads")]
public bool UseForDownload { get; init; } = UseForDownload;
}

View File

@@ -1,39 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using API.Schema.MangaContext;
namespace API.Controllers.DTOs;
/// <summary>
/// Shortened Version of <see cref="Manga"/>
/// </summary>
public record MinimalManga(string Key, string Name, string Description, MangaReleaseStatus ReleaseStatus, IEnumerable<MangaConnectorId> MangaConnectorIds) : Identifiable(Key)
{
/// <summary>
/// Name of the Manga
/// </summary>
[Required]
[Description("Name of the Manga")]
public string Name { get; init; } = Name;
/// <summary>
/// Description of the Manga
/// </summary>
[Required]
[Description("Description of the Manga")]
public string Description { get; init; } = Description;
/// <summary>
/// ReleaseStatus of the Manga
/// </summary>
[Required]
[Description("ReleaseStatus of the Manga")]
public MangaReleaseStatus ReleaseStatus { get; init; } = ReleaseStatus;
/// <summary>
/// Ids of the Manga on MangaConnectors
/// </summary>
[Required]
[Description("Ids of the Manga on MangaConnectors")]
public IEnumerable<MangaConnectorId> MangaConnectorIds { get; init; } = MangaConnectorIds;
}

View File

@@ -1,46 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace API.Controllers.DTOs;
public record NotificationConnector(string Name, string Url, string HttpMethod, string Body, Dictionary<string, string> Headers) : Identifiable(Name)
{
/// <summary>
/// The Name of the Notification Connector
/// </summary>
[Required]
[Description("The Name of the Notification Connector")]
public string Name { get; init; } = Name;
/// <summary>
/// The Url of the Instance
/// </summary>
/// <remarks>Formatting placeholders: "%title" and "%text" will be replaced when notifications are sent</remarks>
[Required]
[Url]
[Description("The Url of the Instance")]
public string Url { get; internal set; } = Url;
/// <summary>
/// The HTTP Request Method to use for notifications
/// </summary>
[Required]
[Description("The HTTP Request Method to use for notifications")]
public string HttpMethod { get; internal set; } = HttpMethod;
/// <summary>
/// The Request Body to use to send notifications
/// </summary>
/// <remarks>Formatting placeholders: "%title" and "%text" will be replaced when notifications are sent</remarks>
[Required]
[Description("The Request Body to use to send notifications")]
public string Body { get; internal set; } = Body;
/// <summary>
/// The Request Headers to use to send notifications
/// </summary>
/// <remarks>Formatting placeholders: "%title" and "%text" will be replaced when notifications are sent</remarks>
[Required]
[Description("The Request Headers to use to send notifications")]
public Dictionary<string, string> Headers { get; internal set; } = Headers;
}

View File

@@ -1,33 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using API.Workers;
namespace API.Controllers.DTOs;
/// <summary>
/// <see cref="IPeriodic"/> DTO (<seealso cref="Worker"/> <seealso cref="BaseWorker"/>)
/// </summary>
public sealed record PeriodicWorker(string Key, IEnumerable<string> Dependencies, IEnumerable<string> MissingDependencies, bool DependenciesFulfilled, WorkerExecutionState State, DateTime LastExecution, TimeSpan Interval, DateTime NextExecution)
: Worker(Key, Dependencies, MissingDependencies, DependenciesFulfilled, State)
{
/// <summary>
/// Timestamp when Worker executed last.
/// </summary>
[Required]
[Description("Timestamp when Worker executed last.")]
public DateTime LastExecution { get; init; } = LastExecution;
/// <summary>
/// Interval at which Worker runs.
/// </summary>
[Required]
[Description("Interval at which Worker runs.")]
public TimeSpan Interval { get; init; } = Interval;
/// <summary>
/// Timestamp when Worker is scheduled to execute next.
/// </summary>
[Required]
[Description("Timestamp when Worker is scheduled to execute next.")]
public DateTime NextExecution { get; init; } = LastExecution;
}

View File

@@ -1,39 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using API.Workers;
namespace API.Controllers.DTOs;
/// <summary>
/// <see cref="BaseWorker"/> DTO
/// </summary>
public record Worker(string Key, IEnumerable<string> Dependencies, IEnumerable<string> MissingDependencies, bool DependenciesFulfilled, WorkerExecutionState State) : Identifiable(Key)
{
/// <summary>
/// Workers this worker depends on having ran.
/// </summary>
[Required]
[Description("Workers this worker depends on having ran.")]
public IEnumerable<string> Dependencies { get; init; } = Dependencies;
/// <summary>
/// Workers that have not yet ran, that need to run for this Worker to run.
/// </summary>
[Required]
[Description("Workers that have not yet ran, that need to run for this Worker to run.")]
public IEnumerable<string> MissingDependencies { get; init; } = MissingDependencies;
/// <summary>
/// Worker can run.
/// </summary>
[Required]
[Description("Worker can run.")]
public bool DependenciesFulfilled { get; init; } = DependenciesFulfilled;
/// <summary>
/// Execution state of the Worker.
/// </summary>
[Required]
[Description("Execution state of the Worker.")]
public WorkerExecutionState State { get; init; } = State;
}

View File

@@ -1,12 +1,7 @@
using API.Controllers.Requests; using API.Schema.MangaContext;
using API.Schema.MangaContext;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
using FileLibrary = API.Controllers.DTOs.FileLibrary;
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
namespace API.Controllers; namespace API.Controllers;
@@ -17,21 +12,14 @@ namespace API.Controllers;
public class FileLibraryController(MangaContext context) : Controller public class FileLibraryController(MangaContext context) : Controller
{ {
/// <summary> /// <summary>
/// Returns all <see cref="DTOs.FileLibrary"/> /// Returns all <see cref="FileLibrary"/>
/// </summary> /// </summary>
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="500">Error during Database Operation</response>
[HttpGet] [HttpGet]
[ProducesResponseType<List<FileLibrary>>(Status200OK, "application/json")] [ProducesResponseType<FileLibrary[]>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)] public IActionResult GetFileLibraries()
public async Task<Results<Ok<List<FileLibrary>>, InternalServerError>> GetFileLibraries ()
{ {
if (await context.FileLibraries.ToListAsync(HttpContext.RequestAborted) is not { } result) return Ok(context.FileLibraries.ToArray());
return TypedResults.InternalServerError();
List<FileLibrary> fileLibraries = result.Select(f => new FileLibrary(f.Key, f.BasePath, f.LibraryName)).ToList();
return TypedResults.Ok(fileLibraries);
} }
/// <summary> /// <summary>
@@ -42,13 +30,13 @@ public class FileLibraryController(MangaContext context) : Controller
/// <response code="404"><see cref="FileLibrary"/> with <paramref name="FileLibraryId"/> not found.</response> /// <response code="404"><see cref="FileLibrary"/> with <paramref name="FileLibraryId"/> not found.</response>
[HttpGet("{FileLibraryId}")] [HttpGet("{FileLibraryId}")]
[ProducesResponseType<FileLibrary>(Status200OK, "application/json")] [ProducesResponseType<FileLibrary>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok<FileLibrary>, NotFound<string>>> GetFileLibrary (string FileLibraryId) public IActionResult GetFileLibrary(string FileLibraryId)
{ {
if(await context.FileLibraries.FirstOrDefaultAsync(l => l.Key == FileLibraryId, HttpContext.RequestAborted) is not { } library) if (context.FileLibraries.Find(FileLibraryId) is not { } library)
return TypedResults.NotFound(nameof(FileLibraryId)); return NotFound();
return TypedResults.Ok(new FileLibrary(library.Key, library.BasePath, library.LibraryName)); return Ok(library);
} }
/// <summary> /// <summary>
@@ -61,19 +49,19 @@ public class FileLibraryController(MangaContext context) : Controller
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpPatch("{FileLibraryId}/ChangeBasePath")] [HttpPatch("{FileLibraryId}/ChangeBasePath")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Ok, NotFound<string>, InternalServerError<string>>> ChangeLibraryBasePath (string FileLibraryId, [FromBody]string newBasePath) public IActionResult ChangeLibraryBasePath(string FileLibraryId, [FromBody]string newBasePath)
{ {
if(await context.FileLibraries.FirstOrDefaultAsync(l => l.Key == FileLibraryId, HttpContext.RequestAborted) is not { } library) if (context.FileLibraries.Find(FileLibraryId) is not { } library)
return TypedResults.NotFound(nameof(FileLibraryId)); return NotFound();
//TODO Path check //TODO Path check
library.BasePath = newBasePath; library.BasePath = newBasePath;
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) if(context.Sync() is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, result.exceptionMessage);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
@@ -86,40 +74,40 @@ public class FileLibraryController(MangaContext context) : Controller
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpPatch("{FileLibraryId}/ChangeName")] [HttpPatch("{FileLibraryId}/ChangeName")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Ok, NotFound<string>, InternalServerError<string>>> ChangeLibraryName (string FileLibraryId, [FromBody] string newName) public IActionResult ChangeLibraryName(string FileLibraryId, [FromBody] string newName)
{ {
if(await context.FileLibraries.FirstOrDefaultAsync(l => l.Key == FileLibraryId, HttpContext.RequestAborted) is not { } library) if (context.FileLibraries.Find(FileLibraryId) is not { } library)
return TypedResults.NotFound(nameof(FileLibraryId)); return NotFound();
//TODO Name check //TODO Name check
library.LibraryName = newName; library.LibraryName = newName;
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) if(context.Sync() is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, result.exceptionMessage);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
/// Creates new <see cref="FileLibrary"/> /// Creates new <see cref="FileLibraryId"/>
/// </summary> /// </summary>
/// <param name="requestData">New <see cref="FileLibrary"/> to add</param> /// <param name="library">New <see cref="FileLibrary"/> to add</param>
/// <response code="200">Key of new Library</response> /// <response code="200"></response>
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpPut] [HttpPut]
[ProducesResponseType<string>(Status201Created, "text/plain")] [ProducesResponseType(Status201Created)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Created<string>, InternalServerError<string>>> CreateNewLibrary ([FromBody]CreateLibraryRecord requestData) public IActionResult CreateNewLibrary([FromBody]FileLibrary library)
{ {
//TODO Parameter check //TODO Parameter check
Schema.MangaContext.FileLibrary library = new Schema.MangaContext.FileLibrary(requestData.BasePath, requestData.LibraryName);
context.FileLibraries.Add(library); context.FileLibraries.Add(library);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) if(context.Sync() is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Created();
return TypedResults.Created(string.Empty, library.Key);
} }
/// <summary> /// <summary>
@@ -127,21 +115,20 @@ public class FileLibraryController(MangaContext context) : Controller
/// </summary> /// </summary>
/// <param name="FileLibraryId"><see cref="FileLibrary"/>.Key</param> /// <param name="FileLibraryId"><see cref="FileLibrary"/>.Key</param>
/// <response code="200"></response> /// <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> /// <response code="500">Error during Database Operation</response>
[HttpDelete("{FileLibraryId}")] [HttpDelete("{FileLibraryId}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Ok, NotFound<string>, InternalServerError<string>>> DeleteLocalLibrary (string FileLibraryId) public IActionResult DeleteLocalLibrary(string FileLibraryId)
{ {
if(await context.FileLibraries.FirstOrDefaultAsync(l => l.Key == FileLibraryId, HttpContext.RequestAborted) is not { } library) if (context.FileLibraries.Find(FileLibraryId) is not { } library)
return TypedResults.NotFound(nameof(FileLibraryId)); return NotFound();
context.FileLibraries.Remove(library); context.FileLibraries.Remove(library);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) if(context.Sync() is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, result.exceptionMessage);
return TypedResults.Ok(); return Ok();
} }
} }

View File

@@ -1,13 +1,8 @@
using API.Controllers.Requests; using API.Schema.LibraryContext;
using API.Schema.LibraryContext;
using API.Schema.LibraryContext.LibraryConnectors; using API.Schema.LibraryContext.LibraryConnectors;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
using LibraryConnector = API.Controllers.DTOs.LibraryConnector;
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
namespace API.Controllers; namespace API.Controllers;
@@ -18,20 +13,16 @@ namespace API.Controllers;
public class LibraryConnectorController(LibraryContext context) : Controller public class LibraryConnectorController(LibraryContext context) : Controller
{ {
/// <summary> /// <summary>
/// Gets all configured <see cref="DTOs.LibraryConnector"/> /// Gets all configured <see cref="LibraryConnector"/>
/// </summary> /// </summary>
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="500">Error during Database Operation</response>
[HttpGet] [HttpGet]
[ProducesResponseType<List<LibraryConnector>>(Status200OK, "application/json")] [ProducesResponseType<LibraryConnector[]>(Status200OK, "application/json")]
public async Task<Results<Ok<List<LibraryConnector>>, InternalServerError>> GetAllConnectors () public IActionResult GetAllConnectors()
{ {
if (await context.LibraryConnectors.ToListAsync(HttpContext.RequestAborted) is not { } connectors) LibraryConnector[] connectors = context.LibraryConnectors.ToArray();
return TypedResults.InternalServerError();
return Ok(connectors);
List<LibraryConnector> libraryConnectors = connectors.Select(c => new LibraryConnector(c.Key, c.BaseUrl, c.LibraryType)).ToList();
return TypedResults.Ok(libraryConnectors);
} }
/// <summary> /// <summary>
@@ -42,39 +33,32 @@ public class LibraryConnectorController(LibraryContext context) : Controller
/// <response code="404"><see cref="LibraryConnector"/> with <paramref name="LibraryConnectorId"/> not found.</response> /// <response code="404"><see cref="LibraryConnector"/> with <paramref name="LibraryConnectorId"/> not found.</response>
[HttpGet("{LibraryConnectorId}")] [HttpGet("{LibraryConnectorId}")]
[ProducesResponseType<LibraryConnector>(Status200OK, "application/json")] [ProducesResponseType<LibraryConnector>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok<LibraryConnector>, NotFound<string>>> GetConnector (string LibraryConnectorId) public IActionResult GetConnector(string LibraryConnectorId)
{ {
if (await context.LibraryConnectors.FirstOrDefaultAsync(l => l.Key == LibraryConnectorId) is not { } connector) if (context.LibraryConnectors.Find(LibraryConnectorId) is not { } connector)
return TypedResults.NotFound(nameof(LibraryConnectorId)); return NotFound();
return TypedResults.Ok(new LibraryConnector(connector.Key, connector.BaseUrl, connector.LibraryType)); return Ok(connector);
} }
/// <summary> /// <summary>
/// Creates a new <see cref="LibraryConnector"/> /// Creates a new <see cref="LibraryConnector"/>
/// </summary> /// </summary>
/// <param name="requestData"></param> /// <param name="libraryConnector"></param>
/// <response code="201"></response> /// <response code="201"></response>
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpPut] [HttpPut]
[ProducesResponseType<string>(Status201Created, "text/plain")] [ProducesResponseType(Status201Created)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Created<string>, InternalServerError<string>>> CreateConnector ([FromBody]CreateLibraryConnectorRecord requestData) public IActionResult CreateConnector([FromBody]LibraryConnector libraryConnector)
{ {
//TODO verify data
API.Schema.LibraryContext.LibraryConnectors.LibraryConnector connector = requestData.LibraryType switch
{
LibraryType.Kavita => new Kavita(requestData.Url, requestData.Username, requestData.Password),
LibraryType.Komga => new Komga(requestData.Url, requestData.Username, requestData.Password),
_ => throw new NotImplementedException()
};
context.LibraryConnectors.Add(connector); context.LibraryConnectors.Add(libraryConnector);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) if(context.Sync() is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, result.exceptionMessage);
return TypedResults.Created(string.Empty, connector.Key); return Created();
} }
/// <summary> /// <summary>
@@ -82,21 +66,21 @@ public class LibraryConnectorController(LibraryContext context) : Controller
/// </summary> /// </summary>
/// <param name="LibraryConnectorId">ToFileLibrary-Connector-ID</param> /// <param name="LibraryConnectorId">ToFileLibrary-Connector-ID</param>
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="LibraryConnector"/> with <paramref name="LibraryConnectorId"/> not found.</response> /// <response code="404"><see cref="LibraryConnector"/> with <<paramref name="LibraryConnectorId"/> not found.</response>
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpDelete("{LibraryConnectorId}")] [HttpDelete("{LibraryConnectorId}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Ok, NotFound<string>, InternalServerError<string>>> DeleteConnector (string LibraryConnectorId) public IActionResult DeleteConnector(string LibraryConnectorId)
{ {
if (await context.LibraryConnectors.FirstOrDefaultAsync(l => l.Key == LibraryConnectorId) is not { } connector) if (context.LibraryConnectors.Find(LibraryConnectorId) is not { } connector)
return TypedResults.NotFound(nameof(LibraryConnectorId)); return NotFound();
context.LibraryConnectors.Remove(connector); context.LibraryConnectors.Remove(connector);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) if(context.Sync() is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, result.exceptionMessage);
return TypedResults.Ok(); return Ok();
} }
} }

View File

@@ -1,7 +1,6 @@
using API.MangaConnectors; using API.MangaConnectors;
using API.Schema.MangaContext; using API.Schema.MangaContext;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
@@ -22,18 +21,16 @@ public class MaintenanceController(MangaContext mangaContext) : Controller
[HttpPost("CleanupNoDownloadManga")] [HttpPost("CleanupNoDownloadManga")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Ok, InternalServerError<string>>> CleanupNoDownloadManga() public IActionResult CleanupNoDownloadManga()
{ {
if (await mangaContext.Mangas Manga[] noDownloads = mangaContext.Mangas
.Include(m => m.MangaConnectorIds) .Include(m => m.MangaConnectorIds)
.Where(m => !m.MangaConnectorIds.Any(id => id.UseForDownload)) .Where(m => !m.MangaConnectorIds.Any(id => id.UseForDownload))
.ToArrayAsync(HttpContext.RequestAborted) is not { } noDownloads) .ToArray();
return TypedResults.InternalServerError("Could not fetch Manga");
mangaContext.Mangas.RemoveRange(noDownloads); mangaContext.Mangas.RemoveRange(noDownloads);
if(await mangaContext.Sync(HttpContext.RequestAborted) is { success: false } result) if(mangaContext.Sync() is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, result.exceptionMessage);
return TypedResults.Ok(); return Ok();
} }
} }

View File

@@ -1,7 +1,6 @@
using API.Controllers.DTOs; using API.MangaConnectors;
using API.Schema.MangaContext; using API.Schema.MangaContext;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
@@ -14,85 +13,77 @@ namespace API.Controllers;
public class MangaConnectorController(MangaContext context) : Controller public class MangaConnectorController(MangaContext context) : Controller
{ {
/// <summary> /// <summary>
/// Get all <see cref="API.MangaConnectors.MangaConnector"/> (Scanlation-Sites) /// Get all <see cref="MangaConnector"/> (Scanlation-Sites)
/// </summary> /// </summary>
/// <response code="200">Names of <see cref="API.MangaConnectors.MangaConnector"/> (Scanlation-Sites)</response> /// <response code="200">Names of <see cref="MangaConnector"/> (Scanlation-Sites)</response>
[HttpGet] [HttpGet]
[ProducesResponseType<List<MangaConnector>>(Status200OK, "application/json")] [ProducesResponseType<MangaConnector[]>(Status200OK, "application/json")]
public Ok<List<MangaConnector>> GetConnectors() public IActionResult GetConnectors()
{ {
return TypedResults.Ok(Tranga.MangaConnectors return Ok(Tranga.MangaConnectors.ToArray());
.Select(c => new MangaConnector(c.Name, c.Enabled, c.IconUrl, c.SupportedLanguages))
.ToList());
} }
/// <summary> /// <summary>
/// Returns the <see cref="API.MangaConnectors.MangaConnector"/> (Scanlation-Sites) with the requested Name /// Returns the <see cref="MangaConnector"/> (Scanlation-Sites) with the requested Name
/// </summary> /// </summary>
/// <param name="MangaConnectorName"><see cref="API.MangaConnectors.MangaConnector"/>.Name</param> /// <param name="MangaConnectorName"><see cref="MangaConnector"/>.Name</param>
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="MangaConnector"/> (Scanlation-Sites) with Name not found.</response> /// <response code="404"><see cref="MangaConnector"/> (Scanlation-Sites) with Name not found.</response>
[HttpGet("{MangaConnectorName}")] [HttpGet("{MangaConnectorName}")]
[ProducesResponseType<MangaConnector>(Status200OK, "application/json")] [ProducesResponseType<MangaConnector>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public Results<Ok<MangaConnector>, NotFound<string>> GetConnector(string MangaConnectorName) public IActionResult GetConnector(string MangaConnectorName)
{ {
if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals(MangaConnectorName, StringComparison.InvariantCultureIgnoreCase)) is not { } connector) if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals(MangaConnectorName, StringComparison.InvariantCultureIgnoreCase)) is not { } connector)
return TypedResults.NotFound(nameof(MangaConnectorName)); return NotFound();
return TypedResults.Ok(new MangaConnector(connector.Name, connector.Enabled, connector.IconUrl, connector.SupportedLanguages)); return Ok(connector);
} }
/// <summary> /// <summary>
/// Get all enabled <see cref="API.MangaConnectors.MangaConnector"/> (Scanlation-Sites) /// Get all enabled <see cref="MangaConnector"/> (Scanlation-Sites)
/// </summary> /// </summary>
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet("Enabled")] [HttpGet("Enabled")]
[ProducesResponseType<List<MangaConnector>>(Status200OK, "application/json")] [ProducesResponseType<MangaConnector[]>(Status200OK, "application/json")]
public Ok<List<MangaConnector>> GetEnabledConnectors() public IActionResult GetEnabledConnectors()
{ {
return TypedResults.Ok(Tranga.MangaConnectors return Ok(Tranga.MangaConnectors.Where(c => c.Enabled).ToArray());
.Where(c => c.Enabled)
.Select(c => new MangaConnector(c.Name, c.Enabled, c.IconUrl, c.SupportedLanguages))
.ToList());
} }
/// <summary> /// <summary>
/// Get all disabled <see cref="API.MangaConnectors.MangaConnector"/> (Scanlation-Sites) /// Get all disabled <see cref="MangaConnector"/> (Scanlation-Sites)
/// </summary> /// </summary>
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet("Disabled")] [HttpGet("Disabled")]
[ProducesResponseType<List<MangaConnector>>(Status200OK, "application/json")] [ProducesResponseType<MangaConnector[]>(Status200OK, "application/json")]
public Ok<List<MangaConnector>> GetDisabledConnectors() public IActionResult GetDisabledConnectors()
{ {
return TypedResults.Ok(Tranga.MangaConnectors return Ok(Tranga.MangaConnectors.Where(c => c.Enabled == false).ToArray());
.Where(c => c.Enabled == false)
.Select(c => new MangaConnector(c.Name, c.Enabled, c.IconUrl, c.SupportedLanguages))
.ToList());
} }
/// <summary> /// <summary>
/// Enabled or disables <see cref="API.MangaConnectors.MangaConnector"/> (Scanlation-Sites) with Name /// Enabled or disables <see cref="MangaConnector"/> (Scanlation-Sites) with Name
/// </summary> /// </summary>
/// <param name="MangaConnectorName"><see cref="API.MangaConnectors.MangaConnector"/>.Name</param> /// <param name="MangaConnectorName"><see cref="MangaConnector"/>.Name</param>
/// <param name="Enabled">Set true to enable, false to disable</param> /// <param name="Enabled">Set true to enable, false to disable</param>
/// <response code="200"></response> /// <response code="202"></response>
/// <response code="404"><see cref="API.MangaConnectors.MangaConnector"/> (Scanlation-Sites) with Name not found.</response> /// <response code="404"><see cref="MangaConnector"/> (Scanlation-Sites) with Name not found.</response>
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpPatch("{MangaConnectorName}/SetEnabled/{Enabled}")] [HttpPatch("{MangaConnectorName}/SetEnabled/{Enabled}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status202Accepted)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Ok, NotFound<string>, InternalServerError<string>>> SetEnabled(string MangaConnectorName, bool Enabled) public IActionResult SetEnabled(string MangaConnectorName, bool Enabled)
{ {
if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals(MangaConnectorName, StringComparison.InvariantCultureIgnoreCase)) is not { } connector) if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals(MangaConnectorName, StringComparison.InvariantCultureIgnoreCase)) is not { } connector)
return TypedResults.NotFound(nameof(MangaConnectorName)); return NotFound();
connector.Enabled = Enabled; connector.Enabled = Enabled;
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) if(context.Sync() is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, result.exceptionMessage);
return TypedResults.Ok(); return Accepted();
} }
} }

View File

@@ -1,19 +1,16 @@
using API.Controllers.DTOs; using API.MangaConnectors;
using API.Schema.MangaContext; using API.Schema.MangaContext;
using API.Workers; using API.Workers;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Transforms;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
using AltTitle = API.Controllers.DTOs.AltTitle;
using Author = API.Controllers.DTOs.Author;
using Chapter = API.Controllers.DTOs.Chapter;
using Link = API.Controllers.DTOs.Link;
using Manga = API.Controllers.DTOs.Manga;
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
namespace API.Controllers; namespace API.Controllers;
@@ -23,116 +20,45 @@ namespace API.Controllers;
[Route("v{v:apiVersion}/[controller]")] [Route("v{v:apiVersion}/[controller]")]
public class MangaController(MangaContext context) : Controller public class MangaController(MangaContext context) : Controller
{ {
/// <summary> /// <summary>
/// Returns all cached <see cref="DTOs.Manga"/> /// Returns all cached <see cref="Manga"/>
/// </summary> /// </summary>
/// <response code="200"><see cref="MinimalManga"/> exert of <see cref="Schema.MangaContext.Manga"/>. Use <see cref="GetManga"/> for more information</response> /// <response code="200"><see cref="Manga"/> Keys/IDs</response>
/// <response code="500">Error during Database Operation</response>
[HttpGet] [HttpGet]
[ProducesResponseType<List<MinimalManga>>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<List<MinimalManga>>, InternalServerError>> GetAllManga ()
{
if (await context.Mangas.Include(m => m.MangaConnectorIds).ToArrayAsync(HttpContext.RequestAborted) is not
{ } result)
return TypedResults.InternalServerError();
return TypedResults.Ok(result.Select(m =>
{
IEnumerable<MangaConnectorId> ids = m.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
return new MinimalManga(m.Key, m.Name, m.Description, m.ReleaseStatus, ids);
}).ToList());
}
/// <summary>
/// Returns all cached <see cref="Schema.MangaContext.Manga"/>.Keys
/// </summary>
/// <response code="200"><see cref="Schema.MangaContext.Manga"/> Keys/IDs</response>
/// <response code="500">Error during Database Operation</response>
[HttpGet("Keys")]
[ProducesResponseType<string[]>(Status200OK, "application/json")] [ProducesResponseType<string[]>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)] public IActionResult GetAllManga()
public async Task<Results<Ok<string[]>, InternalServerError>> GetAllMangaKeys ()
{ {
if (await context.Mangas.Select(m => m.Key).ToArrayAsync(HttpContext.RequestAborted) is not { } result) string[] ret = context.Mangas.Select(m => m.Key).ToArray();
return TypedResults.InternalServerError(); return Ok(ret);
return TypedResults.Ok(result);
} }
/// <summary> /// <summary>
/// Returns all <see cref="Schema.MangaContext.Manga"/> that are being downloaded from at least one <see cref="API.MangaConnectors.MangaConnector"/> /// Returns all cached <see cref="Manga"/> with <paramref name="MangaIds"/>
/// </summary> /// </summary>
/// <response code="200"><see cref="MinimalManga"/> exert of <see cref="Schema.MangaContext.Manga"/>. Use <see cref="GetManga"/> for more information</response> /// <param name="MangaIds">Array of <<see cref="Manga"/>.Key</param>
/// <response code="500">Error during Database Operation</response> /// <response code="200"></response>
[HttpGet("Downloading")]
[ProducesResponseType<MinimalManga[]>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<List<MinimalManga>>, InternalServerError>> GetMangaDownloading()
{
if (await context.Mangas
.Include(m => m.MangaConnectorIds)
.Where(m => m.MangaConnectorIds.Any(id => id.UseForDownload))
.ToArrayAsync(HttpContext.RequestAborted) is not { } result)
return TypedResults.InternalServerError();
return TypedResults.Ok(result.Select(m =>
{
IEnumerable<MangaConnectorId> ids = m.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
return new MinimalManga(m.Key, m.Name, m.Description, m.ReleaseStatus, ids);
}).ToList());
}
/// <summary>
/// Returns all cached <see cref="Schema.MangaContext.Manga"/> with <paramref name="MangaIds"/>
/// </summary>
/// <param name="MangaIds">Array of <see cref="Schema.MangaContext.Manga"/>.Key</param>
/// <response code="200"><see cref="DTOs.Manga"/></response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("WithIDs")] [HttpPost("WithIDs")]
[ProducesResponseType<List<Manga>>(Status200OK, "application/json")] [ProducesResponseType<Manga[]>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)] public IActionResult GetManga([FromBody]string[] MangaIds)
public async Task<Results<Ok<List<Manga>>, InternalServerError>> GetMangaWithIds ([FromBody]string[] MangaIds)
{ {
if (await context.MangaIncludeAll() Manga[] ret = context.MangaIncludeAll().Where(m => MangaIds.Contains(m.Key)).ToArray();
.Where(m => MangaIds.Contains(m.Key)) return Ok(ret);
.ToArrayAsync(HttpContext.RequestAborted) is not { } result)
return TypedResults.InternalServerError();
return TypedResults.Ok(result.Select(m =>
{
IEnumerable<MangaConnectorId> ids = m.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
IEnumerable<Author> authors = m.Authors.Select(a => new Author(a.Key, a.AuthorName));
IEnumerable<string> tags = m.MangaTags.Select(t => t.Tag);
IEnumerable<Link> links = m.Links.Select(l => new Link(l.Key, l.LinkProvider, l.LinkUrl));
IEnumerable<AltTitle> altTitles = m.AltTitles.Select(a => new AltTitle(a.Language, a.Title));
return new Manga(m.Key, m.Name, m.Description, m.ReleaseStatus, ids, m.IgnoreChaptersBefore, m.Year, m.OriginalLanguage, m.ChapterIds, authors, tags, links, altTitles, m.LibraryId);
}).ToList());
} }
/// <summary> /// <summary>
/// Return <see cref="Schema.MangaContext.Manga"/> with <paramref name="MangaId"/> /// Return <see cref="Manga"/> with <paramref name="MangaId"/>
/// </summary> /// </summary>
/// <param name="MangaId"><see cref="Schema.MangaContext.Manga"/>.Key</param> /// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response> /// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
[HttpGet("{MangaId}")] [HttpGet("{MangaId}")]
[ProducesResponseType<Manga>(Status200OK, "application/json")] [ProducesResponseType<Manga>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok<Manga>, NotFound<string>>> GetManga (string MangaId) public IActionResult GetManga(string MangaId)
{ {
if (await context.MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) if (context.MangaIncludeAll().FirstOrDefault(m => m.Key == MangaId) is not { } manga)
return TypedResults.NotFound(nameof(MangaId)); return NotFound(nameof(MangaId));
return Ok(manga);
IEnumerable<MangaConnectorId> ids = manga.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
IEnumerable<Author> authors = manga.Authors.Select(a => new Author(a.Key, a.AuthorName));
IEnumerable<string> tags = manga.MangaTags.Select(t => t.Tag);
IEnumerable<Link> links = manga.Links.Select(l => new Link(l.Key, l.LinkProvider, l.LinkUrl));
IEnumerable<AltTitle> altTitles = manga.AltTitles.Select(a => new AltTitle(a.Language, a.Title));
Manga result = new (manga.Key, manga.Name, manga.Description, manga.ReleaseStatus, ids, manga.IgnoreChaptersBefore, manga.Year, manga.OriginalLanguage, manga.ChapterIds, authors, tags, links, altTitles, manga.LibraryId);
return TypedResults.Ok(result);
} }
/// <summary> /// <summary>
@@ -144,18 +70,18 @@ public class MangaController(MangaContext context) : Controller
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpDelete("{MangaId}")] [HttpDelete("{MangaId}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Ok, NotFound<string>, InternalServerError<string>>> DeleteManga (string MangaId) public IActionResult DeleteManga(string MangaId)
{ {
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) if (context.Mangas.Find(MangaId) is not { } manga)
return TypedResults.NotFound(nameof(MangaId)); return NotFound(nameof(MangaId));
context.Mangas.Remove(manga); context.Mangas.Remove(manga);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) if(context.Sync() is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, result.exceptionMessage);
return TypedResults.Ok(); return Ok();
} }
@@ -167,71 +93,82 @@ public class MangaController(MangaContext context) : Controller
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaIdFrom"/> or <paramref name="MangaIdInto"/> not found</response> /// <response code="404"><see cref="Manga"/> with <paramref name="MangaIdFrom"/> or <paramref name="MangaIdInto"/> not found</response>
[HttpPatch("{MangaIdFrom}/MergeInto/{MangaIdInto}")] [HttpPatch("{MangaIdFrom}/MergeInto/{MangaIdInto}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType<byte[]>(Status200OK,"image/jpeg")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok, NotFound<string>>> MergeIntoManga (string MangaIdFrom, string MangaIdInto) public IActionResult MergeIntoManga(string MangaIdFrom, string MangaIdInto)
{ {
if (await context.MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == MangaIdFrom, HttpContext.RequestAborted) is not { } from) if (context.Mangas.Find(MangaIdFrom) is not { } from)
return TypedResults.NotFound(nameof(MangaIdFrom)); return NotFound(nameof(MangaIdFrom));
if (await context.MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == MangaIdInto, HttpContext.RequestAborted) is not { } into) if (context.Mangas.Find(MangaIdInto) is not { } into)
return TypedResults.NotFound(nameof(MangaIdInto)); return NotFound(nameof(MangaIdInto));
foreach (CollectionEntry collectionEntry in context.Entry(from).Collections)
collectionEntry.Load();
context.Entry(from).Navigation(nameof(Manga.Library)).Load();
foreach (CollectionEntry collectionEntry in context.Entry(into).Collections)
collectionEntry.Load();
context.Entry(into).Navigation(nameof(Manga.Library)).Load();
BaseWorker[] newJobs = into.MergeFrom(from, context); BaseWorker[] newJobs = into.MergeFrom(from, context);
Tranga.AddWorkers(newJobs); Tranga.AddWorkers(newJobs);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
/// Returns Cover of <see cref="Manga"/> with <paramref name="MangaId"/> /// Returns Cover of <see cref="Manga"/> with <paramref name="MangaId"/>
/// </summary> /// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param> /// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <param name="CoverSize">Size of the cover returned /// <param name="width">If <paramref name="width"/> is provided, <paramref name="height"/> needs to also be provided</param>
/// <br /> - <see cref="CoverSize.Small"/> <see cref="Constants.ImageSmSize"/> /// <param name="height">If <paramref name="height"/> is provided, <paramref name="width"/> needs to also be provided</param>
/// <br /> - <see cref="CoverSize.Medium"/> <see cref="Constants.ImageMdSize"/>
/// <br /> - <see cref="CoverSize.Large"/> <see cref="Constants.ImageLgSize"/>
/// </param>
/// <response code="200">JPEG Image</response> /// <response code="200">JPEG Image</response>
/// <response code="204">Cover not loaded</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="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
/// <response code="503">Retry later, downloading cover</response> /// <response code="503">Retry later, downloading cover</response>
[HttpGet("{MangaId}/Cover/{CoverSize?}")] [HttpGet("{MangaId}/Cover")]
[ProducesResponseType<FileContentResult>(Status200OK,"image/jpeg")] [ProducesResponseType<byte[]>(Status200OK,"image/jpeg")]
[ProducesResponseType(Status204NoContent)] [ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status400BadRequest)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status503ServiceUnavailable)] [ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
public async Task<Results<FileContentHttpResult, NoContent, BadRequest, NotFound<string>, StatusCodeHttpResult>> GetCover (string MangaId, CoverSize? CoverSize = null) public IActionResult GetCover(string MangaId, [FromQuery]int? width, [FromQuery]int? height)
{ {
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) if (context.Mangas.Find(MangaId) is not { } manga)
return TypedResults.NotFound(nameof(MangaId)); return NotFound(nameof(MangaId));
string cache = CoverSize switch if (!System.IO.File.Exists(manga.CoverFileNameInCache))
{
MangaController.CoverSize.Small => TrangaSettings.coverImageCacheSmall,
MangaController.CoverSize.Medium => TrangaSettings.coverImageCacheMedium,
MangaController.CoverSize.Large => TrangaSettings.coverImageCacheLarge,
_ => TrangaSettings.coverImageCacheOriginal
};
if (await manga.GetCoverImage(cache, HttpContext.RequestAborted) is not { } data)
{ {
if (Tranga.GetRunningWorkers().Any(worker => worker is DownloadCoverFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId)) 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}"); Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}");
return TypedResults.StatusCode(Status503ServiceUnavailable); return StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000);
} }
return TypedResults.NoContent(); 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)));
} }
DateTime lastModified = data.fileInfo.LastWriteTime; using MemoryStream ms = new();
EntityTagHeaderValue entityTagHeaderValue = EntityTagHeaderValue.Parse($"\"{lastModified.Ticks}\""); image.Save(ms, new JpegEncoder(){Quality = 100});
if(HttpContext.Request.Headers.ETag.Equals(entityTagHeaderValue.Tag.Value)) DateTime lastModified = new FileInfo(manga.CoverFileNameInCache).LastWriteTime;
return TypedResults.StatusCode(Status304NotModified);
HttpContext.Response.Headers.CacheControl = "public"; HttpContext.Response.Headers.CacheControl = "public";
return TypedResults.Bytes(data.stream.ToArray(), "image/jpeg", lastModified: new DateTimeOffset(lastModified), entityTag: entityTagHeaderValue); return File(ms.GetBuffer(), "image/jpeg", new DateTimeOffset(lastModified), EntityTagHeaderValue.Parse($"\"{lastModified.Ticks}\""));
} }
public enum CoverSize { Original, Large, Medium, Small }
/// <summary> /// <summary>
/// Returns all <see cref="Chapter"/> of <see cref="Manga"/> with <paramref name="MangaId"/> /// Returns all <see cref="Chapter"/> of <see cref="Manga"/> with <paramref name="MangaId"/>
@@ -240,20 +177,17 @@ public class MangaController(MangaContext context) : Controller
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response> /// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
[HttpGet("{MangaId}/Chapters")] [HttpGet("{MangaId}/Chapters")]
[ProducesResponseType<List<Chapter>>(Status200OK, "application/json")] [ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok<List<Chapter>>, NotFound<string>>> GetChapters(string MangaId) public IActionResult GetChapters(string MangaId)
{ {
if (await context.Mangas.Include(m => m.Chapters).ThenInclude(c => c.MangaConnectorIds).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) if (context.Mangas.Find(MangaId) is not { } manga)
return TypedResults.NotFound(nameof(MangaId)); return NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.Select(c => context.Entry(manga).Collection(m => m.Chapters).Load();
{
IEnumerable<MangaConnectorId> ids = c.MangaConnectorIds.Select(id => Chapter[] chapters = manga.Chapters.ToArray();
new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); return Ok(chapters);
return new Chapter(c.Key, c.ParentMangaId, c.VolumeNumber, c.ChapterNumber, c.Title, ids, c.Downloaded);
}).ToList();
return TypedResults.Ok(chapters);
} }
/// <summary> /// <summary>
@@ -266,22 +200,19 @@ public class MangaController(MangaContext context) : Controller
[HttpGet("{MangaId}/Chapters/Downloaded")] [HttpGet("{MangaId}/Chapters/Downloaded")]
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")] [ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)] [ProducesResponseType(Status204NoContent)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok<List<Chapter>>, NoContent, NotFound<string>>> GetChaptersDownloaded(string MangaId) public IActionResult GetChaptersDownloaded(string MangaId)
{ {
if (await context.Mangas.Include(m => m.Chapters).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) if (context.Mangas.Find(MangaId) is not { } manga)
return TypedResults.NotFound(nameof(MangaId)); return NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.Where(c => c.Downloaded).Select(c => context.Entry(manga).Collection(m => m.Chapters).Load();
{
IEnumerable<MangaConnectorId> ids = c.MangaConnectorIds.Select(id => List<Chapter> chapters = manga.Chapters.Where(c => c.Downloaded).ToList();
new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
return new Chapter(c.Key, c.ParentMangaId, c.VolumeNumber, c.ChapterNumber, c.Title, ids, c.Downloaded);
}).ToList();
if (chapters.Count == 0) if (chapters.Count == 0)
return TypedResults.NoContent(); return NoContent();
return TypedResults.Ok(chapters); return Ok(chapters);
} }
/// <summary> /// <summary>
@@ -292,28 +223,25 @@ public class MangaController(MangaContext context) : Controller
/// <response code="204">No available chapters</response> /// <response code="204">No available chapters</response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found.</response> /// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found.</response>
[HttpGet("{MangaId}/Chapters/NotDownloaded")] [HttpGet("{MangaId}/Chapters/NotDownloaded")]
[ProducesResponseType<List<Chapter>>(Status200OK, "application/json")] [ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)] [ProducesResponseType(Status204NoContent)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok<List<Chapter>>, NoContent, NotFound<string>>> GetChaptersNotDownloaded(string MangaId) public IActionResult GetChaptersNotDownloaded(string MangaId)
{ {
if (await context.Mangas.Include(m => m.Chapters).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) if (context.Mangas.Find(MangaId) is not { } manga)
return TypedResults.NotFound(nameof(MangaId)); return NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.Where(c => c.Downloaded == false).Select(c => context.Entry(manga).Collection(m => m.Chapters).Load();
{
IEnumerable<MangaConnectorId> ids = c.MangaConnectorIds.Select(id => List<Chapter> chapters = manga.Chapters.Where(c => c.Downloaded == false).ToList();
new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
return new Chapter(c.Key, c.ParentMangaId, c.VolumeNumber, c.ChapterNumber, c.Title, ids, c.Downloaded);
}).ToList();
if (chapters.Count == 0) if (chapters.Count == 0)
return TypedResults.NoContent(); return NoContent();
return TypedResults.Ok(chapters); return Ok(chapters);
} }
/// <summary> /// <summary>
/// Returns the latest <see cref="Chapter"/> of requested <see cref="Manga"/> available on <see cref="API.MangaConnectors.MangaConnector"/> /// Returns the latest <see cref="Chapter"/> of requested <see cref="Manga"/> available on <see cref="MangaConnector"/>
/// </summary> /// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param> /// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <response code="200"></response> /// <response code="200"></response>
@@ -322,41 +250,38 @@ public class MangaController(MangaContext context) : Controller
/// <response code="412">Could not retrieve the maximum chapter-number</response> /// <response code="412">Could not retrieve the maximum chapter-number</response>
/// <response code="503">Retry after timeout, updating value</response> /// <response code="503">Retry after timeout, updating value</response>
[HttpGet("{MangaId}/Chapter/LatestAvailable")] [HttpGet("{MangaId}/Chapter/LatestAvailable")]
[ProducesResponseType<int>(Status200OK, "application/json")] [ProducesResponseType<Chapter>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)] [ProducesResponseType(Status204NoContent)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status500InternalServerError)] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
[ProducesResponseType(Status503ServiceUnavailable)] [ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
public async Task<Results<Ok<Chapter>, NoContent, InternalServerError, NotFound<string>, StatusCodeHttpResult>> GetLatestChapter(string MangaId) public IActionResult GetLatestChapter(string MangaId)
{ {
if (await context.Mangas.Include(m => m.Chapters).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) if (context.Mangas.Find(MangaId) is not { } manga)
return TypedResults.NotFound(nameof(MangaId)); return NotFound(nameof(MangaId));
List<API.Schema.MangaContext.Chapter> chapters = manga.Chapters.ToList(); context.Entry(manga).Collection(m => m.Chapters).Load();
List<Chapter> chapters = manga.Chapters.ToList();
if (chapters.Count == 0) if (chapters.Count == 0)
{ {
if (Tranga.GetRunningWorkers().Any(worker => if (Tranga.GetRunningWorkers().Any(worker => worker is RetrieveMangaChaptersFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId && w.State < WorkerExecutionState.Completed))
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}"); Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}");
return TypedResults.StatusCode(Status503ServiceUnavailable); return StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2/ 1000);
} }else
else return Ok(0);
return TypedResults.NoContent();
} }
API.Schema.MangaContext.Chapter? max = chapters.Max(); Chapter? max = chapters.Max();
if (max is null) if (max is null)
return TypedResults.InternalServerError(); return StatusCode(Status500InternalServerError, "Max chapter could not be found");
foreach (CollectionEntry collectionEntry in context.Entry(max).Collections) foreach (CollectionEntry collectionEntry in context.Entry(max).Collections)
await collectionEntry.LoadAsync(HttpContext.RequestAborted); collectionEntry.Load();
context.Entry(max).Navigation(nameof(Chapter.ParentManga)).Load();
IEnumerable<MangaConnectorId> ids = max.MangaConnectorIds.Select(id => return Ok(max);
new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
return TypedResults.Ok(new Chapter(max.Key, max.ParentMangaId, max.VolumeNumber, max.ChapterNumber, max.Title,ids, max.Downloaded));
} }
/// <summary> /// <summary>
@@ -371,34 +296,36 @@ public class MangaController(MangaContext context) : Controller
[HttpGet("{MangaId}/Chapter/LatestDownloaded")] [HttpGet("{MangaId}/Chapter/LatestDownloaded")]
[ProducesResponseType<Chapter>(Status200OK, "application/json")] [ProducesResponseType<Chapter>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)] [ProducesResponseType(Status204NoContent)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status412PreconditionFailed)] [ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
[ProducesResponseType(Status503ServiceUnavailable)] [ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
public async Task<Results<Ok<Chapter>, NoContent, NotFound<string>, StatusCodeHttpResult>> GetLatestChapterDownloaded(string MangaId) public IActionResult GetLatestChapterDownloaded(string MangaId)
{ {
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) if (context.Mangas.Find(MangaId) is not { } manga)
return TypedResults.NotFound(nameof(MangaId)); return NotFound(nameof(MangaId));
await context.Entry(manga).Collection(m => m.Chapters).LoadAsync(HttpContext.RequestAborted); context.Entry(manga).Collection(m => m.Chapters).Load();
List<API.Schema.MangaContext.Chapter> chapters = manga.Chapters.ToList(); List<Chapter> chapters = manga.Chapters.ToList();
if (chapters.Count == 0) if (chapters.Count == 0)
{ {
if (Tranga.GetRunningWorkers().Any(worker => worker is RetrieveMangaChaptersFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId && w.State < WorkerExecutionState.Completed)) 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}"); Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}");
return TypedResults.StatusCode(Status503ServiceUnavailable); return StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2/ 1000);
}else }else
return TypedResults.NoContent(); return NoContent();
} }
API.Schema.MangaContext.Chapter? max = chapters.Max(); Chapter? max = chapters.Max();
if (max is null) if (max is null)
return TypedResults.StatusCode(Status412PreconditionFailed); return StatusCode(Status412PreconditionFailed, "Max chapter could not be found");
IEnumerable<MangaConnectorId> ids = max.MangaConnectorIds.Select(id => foreach (CollectionEntry collectionEntry in context.Entry(max).Collections)
new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); collectionEntry.Load();
return TypedResults.Ok(new Chapter(max.Key, max.ParentMangaId, max.VolumeNumber, max.ChapterNumber, max.Title,ids, max.Downloaded)); context.Entry(max).Navigation(nameof(Chapter.ParentManga)).Load();
return Ok(max);
} }
/// <summary> /// <summary>
@@ -410,19 +337,19 @@ public class MangaController(MangaContext context) : Controller
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found.</response> /// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found.</response>
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpPatch("{MangaId}/IgnoreChaptersBefore")] [HttpPatch("{MangaId}/IgnoreChaptersBefore")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status202Accepted)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Ok, NotFound<string>, InternalServerError<string>>> IgnoreChaptersBefore(string MangaId, [FromBody]float chapterThreshold) public IActionResult IgnoreChaptersBefore(string MangaId, [FromBody]float chapterThreshold)
{ {
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) if (context.Mangas.Find(MangaId) is not { } manga)
return TypedResults.NotFound(nameof(MangaId)); return NotFound();
manga.IgnoreChaptersBefore = chapterThreshold; manga.IgnoreChaptersBefore = chapterThreshold;
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) if(context.Sync() is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, result.exceptionMessage);
return TypedResults.Ok(); return Accepted();
} }
/// <summary> /// <summary>
@@ -433,35 +360,36 @@ public class MangaController(MangaContext context) : Controller
/// <response code="202">Folder is going to be moved</response> /// <response code="202">Folder is going to be moved</response>
/// <response code="404"><paramref name="MangaId"/> or <paramref name="LibraryId"/> not found</response> /// <response code="404"><paramref name="MangaId"/> or <paramref name="LibraryId"/> not found</response>
[HttpPost("{MangaId}/ChangeLibrary/{LibraryId}")] [HttpPost("{MangaId}/ChangeLibrary/{LibraryId}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status202Accepted)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok, NotFound<string>>> ChangeLibrary(string MangaId, string LibraryId) public IActionResult ChangeLibrary(string MangaId, string LibraryId)
{ {
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) if (context.Mangas.Find(MangaId) is not { } manga)
return TypedResults.NotFound(nameof(MangaId)); return NotFound(nameof(MangaId));
if (await context.FileLibraries.FirstOrDefaultAsync(l => l.Key == LibraryId, HttpContext.RequestAborted) is not { } library) if(context.FileLibraries.Find(LibraryId) is not { } library)
return TypedResults.NotFound(nameof(LibraryId)); return NotFound(nameof(LibraryId));
if(manga.LibraryId == library.Key) foreach (CollectionEntry collectionEntry in context.Entry(manga).Collections)
return TypedResults.Ok(); collectionEntry.Load();
context.Entry(manga).Navigation(nameof(Manga.Library)).Load();
MoveMangaLibraryWorker moveLibrary = new(manga, library); MoveMangaLibraryWorker moveLibrary = new(manga, library);
Tranga.AddWorkers([moveLibrary]); Tranga.AddWorkers([moveLibrary]);
return TypedResults.Ok(); return Accepted();
} }
/// <summary> /// <summary>
/// (Un-)Marks <see cref="Manga"/> as requested for Download from <see cref="API.MangaConnectors.MangaConnector"/> /// (Un-)Marks <see cref="Manga"/> as requested for Download from <see cref="MangaConnector"/>
/// </summary> /// </summary>
/// <param name="MangaId"><see cref="Manga"/> with <paramref name="MangaId"/></param> /// <param name="MangaId"><see cref="Manga"/> with <paramref name="MangaId"/></param>
/// <param name="MangaConnectorName"><see cref="API.MangaConnectors.MangaConnector"/> with <paramref name="MangaConnectorName"/></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> /// <param name="IsRequested">true to mark as requested, false to mark as not-requested</param>
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><paramref name="MangaId"/> or <paramref name="MangaConnectorName"/> not found</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="API.MangaConnectors.MangaConnector"/>, so nothing changed</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="API.MangaConnectors.MangaConnector"/> yet. Search for <see cref="Manga"/> on <see cref="API.MangaConnectors.MangaConnector"/> first (to create a <see cref="MangaConnectorId{T}"/>).</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> /// <response code="500">Error during Database Operation</response>
[HttpPost("{MangaId}/SetAsDownloadFrom/{MangaConnectorName}/{IsRequested}")] [HttpPost("{MangaId}/SetAsDownloadFrom/{MangaConnectorName}/{IsRequested}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
@@ -469,50 +397,51 @@ public class MangaController(MangaContext context) : Controller
[ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")] [ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
[ProducesResponseType<string>(Status428PreconditionRequired, "text/plain")] [ProducesResponseType<string>(Status428PreconditionRequired, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Ok, NotFound<string>, StatusCodeHttpResult, InternalServerError<string>>> MarkAsRequested(string MangaId, string MangaConnectorName, bool IsRequested) public IActionResult MarkAsRequested(string MangaId, string MangaConnectorName, bool IsRequested)
{ {
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } _) if (context.Mangas.Find(MangaId) is null)
return TypedResults.NotFound(nameof(MangaId)); return NotFound(nameof(MangaId));
if(!Tranga.TryGetMangaConnector(MangaConnectorName, out API.MangaConnectors.MangaConnector? _)) if(!Tranga.TryGetMangaConnector(MangaConnectorName, out MangaConnector? mangaConnector))
return TypedResults.NotFound(nameof(MangaConnectorName)); return NotFound(nameof(MangaConnectorName));
if (context.MangaConnectorToManga if (context.MangaConnectorToManga
.FirstOrDefault(id => id.MangaConnectorName == MangaConnectorName && id.ObjId == MangaId) .FirstOrDefault(id => id.MangaConnectorName == MangaConnectorName && id.ObjId == MangaId)
is not { } mcId) is not { } mcId)
{ {
if(IsRequested) if(IsRequested)
return TypedResults.StatusCode(Status428PreconditionRequired); return StatusCode(Status428PreconditionRequired, "Don't know how to download this Manga from MangaConnector");
else else
return TypedResults.StatusCode(Status412PreconditionFailed); return StatusCode(Status412PreconditionFailed, "Not linked anyways.");
} }
mcId.UseForDownload = IsRequested; mcId.UseForDownload = IsRequested;
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) if(context.Sync() is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, result.exceptionMessage);
DownloadCoverFromMangaconnectorWorker downloadCover = new(mcId); DownloadCoverFromMangaconnectorWorker downloadCover = new(mcId);
RetrieveMangaChaptersFromMangaconnectorWorker retrieveChapters = new(mcId, Tranga.Settings.DownloadLanguage); RetrieveMangaChaptersFromMangaconnectorWorker retrieveChapters = new(mcId, Tranga.Settings.DownloadLanguage);
Tranga.AddWorkers([downloadCover, retrieveChapters]); Tranga.AddWorkers([downloadCover, retrieveChapters]);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
/// Initiate a search for <see cref="API.Schema.MangaContext.Manga"/> on a different <see cref="API.MangaConnectors.MangaConnector"/> /// Initiate a search for <see cref="Manga"/> on a different <see cref="MangaConnector"/>
/// </summary> /// </summary>
/// <param name="MangaId"><see cref="API.Schema.MangaContext.Manga"/> with <paramref name="MangaId"/></param> /// <param name="MangaId"><see cref="Manga"/> with <paramref name="MangaId"/></param>
/// <param name="MangaConnectorName"><see cref="API.MangaConnectors.MangaConnector"/>.Name</param> /// <param name="MangaConnectorName"><see cref="MangaConnector"/>.Name</param>
/// <response code="200"><see cref="MinimalManga"/> exert of <see cref="Schema.MangaContext.Manga"/></response> /// <response code="200"></response>
/// <response code="404"><see cref="API.MangaConnectors.MangaConnector"/> with Name not found</response> /// <response code="404"><see cref="MangaConnector"/> with Name not found</response>
/// <response code="412"><see cref="API.MangaConnectors.MangaConnector"/> with Name is disabled</response> /// <response code="412"><see cref="MangaConnector"/> with Name is disabled</response>
[HttpPost("{MangaId}/SearchOn/{MangaConnectorName}")] [HttpPost("{MangaId}/SearchOn/{MangaConnectorName}")]
[ProducesResponseType<List<MinimalManga>>(Status200OK, "application/json")] [ProducesResponseType<Manga[]>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status406NotAcceptable)] [ProducesResponseType(Status406NotAcceptable)]
public async Task<Results<Ok<List<MinimalManga>>, NotFound<string>, StatusCodeHttpResult>> SearchOnDifferentConnector (string MangaId, string MangaConnectorName) public IActionResult SearchOnDifferentConnector(string MangaId, string MangaConnectorName)
{ {
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) if (context.Mangas.Find(MangaId) is not { } manga)
return TypedResults.NotFound(nameof(MangaId)); return NotFound(nameof(MangaId));
return new SearchController(context).SearchManga(MangaConnectorName, manga.Name); return new SearchController(context).SearchManga(MangaConnectorName, manga.Name);
} }
@@ -523,27 +452,14 @@ public class MangaController(MangaContext context) : Controller
/// <param name="AuthorId"><see cref="Author"/>.Key</param> /// <param name="AuthorId"><see cref="Author"/>.Key</param>
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="Author"/> with <paramref name="AuthorId"/></response> /// <response code="404"><see cref="Author"/> with <paramref name="AuthorId"/></response>
/// /// <response code="500">Error during Database Operation</response>
[HttpGet("WithAuthorId/{AuthorId}")] [HttpGet("WithAuthorId/{AuthorId}")]
[ProducesResponseType<List<Manga>>(Status200OK, "application/json")] [ProducesResponseType<Manga[]>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] public IActionResult GetMangaWithAuthorIds(string AuthorId)
public async Task<Results<Ok<List<Manga>>, NotFound<string>, InternalServerError>> GetMangaWithAuthorIds (string AuthorId)
{ {
if (await context.Authors.FirstOrDefaultAsync(a => a.Key == AuthorId, HttpContext.RequestAborted) is not { } _) if (context.Authors.Find(AuthorId) is not { } author)
return TypedResults.NotFound(nameof(AuthorId)); return NotFound();
if (await context.MangaIncludeAll().Where(m => m.Authors.Any(a => a.Key == AuthorId)).ToListAsync(HttpContext.RequestAborted) is not { } result) return Ok(context.Mangas.Where(m => m.Authors.Contains(author)));
return TypedResults.InternalServerError();
return TypedResults.Ok(result.Select(m =>
{
IEnumerable<MangaConnectorId> ids = m.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
IEnumerable<Author> authors = m.Authors.Select(a => new Author(a.Key, a.AuthorName));
IEnumerable<string> tags = m.MangaTags.Select(t => t.Tag);
IEnumerable<Link> links = m.Links.Select(l => new Link(l.Key, l.LinkProvider, l.LinkUrl));
IEnumerable<AltTitle> altTitles = m.AltTitles.Select(a => new AltTitle(a.Language, a.Title));
return new Manga(m.Key, m.Name, m.Description, m.ReleaseStatus, ids, m.IgnoreChaptersBefore, m.Year, m.OriginalLanguage, m.ChapterIds, authors, tags, links, altTitles, m.LibraryId);
}).ToList());
} }
/// <summary> /// <summary>
@@ -552,28 +468,13 @@ public class MangaController(MangaContext context) : Controller
/// <param name="Tag"><see cref="Tag"/>.Tag</param> /// <param name="Tag"><see cref="Tag"/>.Tag</param>
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="Tag"/> not found</response> /// <response code="404"><see cref="Tag"/> not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpGet("WithTag/{Tag}")] [HttpGet("WithTag/{Tag}")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")] [ProducesResponseType<Manga[]>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] public IActionResult GetMangasWithTag(string Tag)
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<List<Manga>>, NotFound<string>, InternalServerError>> GetMangasWithTag (string Tag)
{ {
if (await context.Tags.FirstOrDefaultAsync(t => t.Tag == Tag, HttpContext.RequestAborted) is not { } tag) if (context.Tags.Find(Tag) is not { } tag)
return TypedResults.NotFound(nameof(Tag)); return NotFound();
return Ok(context.Mangas.Where(m => m.MangaTags.Contains(tag)));
if (await context.MangaIncludeAll().Where(m => m.MangaTags.Any(t => t.Tag.Equals(tag))) .ToListAsync(HttpContext.RequestAborted) is not { } result)
return TypedResults.InternalServerError();
return TypedResults.Ok(result.Select(m =>
{
IEnumerable<MangaConnectorId> ids = m.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
IEnumerable<Author> authors = m.Authors.Select(a => new Author(a.Key, a.AuthorName));
IEnumerable<string> tags = m.MangaTags.Select(t => t.Tag);
IEnumerable<Link> links = m.Links.Select(l => new Link(l.Key, l.LinkProvider, l.LinkUrl));
IEnumerable<AltTitle> altTitles = m.AltTitles.Select(a => new AltTitle(a.Language, a.Title));
return new Manga(m.Key, m.Name, m.Description, m.ReleaseStatus, ids, m.IgnoreChaptersBefore, m.Year, m.OriginalLanguage, m.ChapterIds, authors, tags, links, altTitles, m.LibraryId);
}).ToList());
} }
} }

View File

@@ -1,10 +1,8 @@
using API.Schema.MangaContext; using API.Schema.MangaContext;
using API.Schema.MangaContext.MetadataFetchers; using API.Schema.MangaContext.MetadataFetchers;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.EntityFrameworkCore;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
@@ -20,26 +18,21 @@ public class MetadataFetcherController(MangaContext context) : Controller
/// </summary> /// </summary>
/// <response code="200">Names of <see cref="MetadataFetcher"/> (Metadata-Sites)</response> /// <response code="200">Names of <see cref="MetadataFetcher"/> (Metadata-Sites)</response>
[HttpGet] [HttpGet]
[ProducesResponseType<List<string>>(Status200OK, "application/json")] [ProducesResponseType<string[]>(Status200OK, "application/json")]
public Ok<List<string>> GetConnectors () public IActionResult GetConnectors()
{ {
return TypedResults.Ok(Tranga.MetadataFetchers.Select(m => m.Name).ToList()); return Ok(Tranga.MetadataFetchers.Select(m => m.Name).ToArray());
} }
/// <summary> /// <summary>
/// Returns all <see cref="MetadataEntry"/> /// Returns all <see cref="MetadataEntry"/>
/// </summary> /// </summary>
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="500">Error during Database Operation</response>
[HttpGet("Links")] [HttpGet("Links")]
[ProducesResponseType<List<MetadataEntry>>(Status200OK, "application/json")] [ProducesResponseType<MetadataEntry[]>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)] public IActionResult GetLinkedEntries()
public async Task<Results<Ok<List<MetadataEntry>>, InternalServerError>> GetLinkedEntries ()
{ {
if (await context.MetadataEntries.ToListAsync() is not { } result) return Ok(context.MetadataEntries.ToArray());
return TypedResults.InternalServerError();
return TypedResults.Ok(result);
} }
/// <summary> /// <summary>
@@ -54,16 +47,16 @@ public class MetadataFetcherController(MangaContext context) : Controller
[HttpPost("{MetadataFetcherName}/SearchManga/{MangaId}")] [HttpPost("{MetadataFetcherName}/SearchManga/{MangaId}")]
[ProducesResponseType<MetadataSearchResult[]>(Status200OK, "application/json")] [ProducesResponseType<MetadataSearchResult[]>(Status200OK, "application/json")]
[ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status400BadRequest)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok<List<MetadataSearchResult>>, BadRequest, NotFound<string>>> SearchMangaMetadata(string MangaId, string MetadataFetcherName, [FromBody (EmptyBodyBehavior = EmptyBodyBehavior.Allow)]string? searchTerm = null) public IActionResult SearchMangaMetadata(string MangaId, string MetadataFetcherName, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)]string? searchTerm = null)
{ {
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) if(context.Mangas.Find(MangaId) is not { } manga)
return TypedResults.NotFound(nameof(MangaId)); return NotFound();
if(Tranga.MetadataFetchers.FirstOrDefault(f => f.Name == MetadataFetcherName) is not { } fetcher) if(Tranga.MetadataFetchers.FirstOrDefault(f => f.Name == MetadataFetcherName) is not { } fetcher)
return TypedResults.BadRequest(); return BadRequest();
MetadataSearchResult[] searchResults = searchTerm is null ? fetcher.SearchMetadataEntry(manga) : fetcher.SearchMetadataEntry(searchTerm); MetadataSearchResult[] searchResults = searchTerm is null ? fetcher.SearchMetadataEntry(manga) : fetcher.SearchMetadataEntry(searchTerm);
return TypedResults.Ok(searchResults.ToList()); return Ok(searchResults);
} }
/// <summary> /// <summary>
@@ -79,21 +72,21 @@ public class MetadataFetcherController(MangaContext context) : Controller
[HttpPost("{MetadataFetcherName}/Link/{MangaId}")] [HttpPost("{MetadataFetcherName}/Link/{MangaId}")]
[ProducesResponseType<MetadataEntry>(Status200OK, "application/json")] [ProducesResponseType<MetadataEntry>(Status200OK, "application/json")]
[ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status400BadRequest)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Ok<MetadataEntry>, BadRequest, NotFound<string>, InternalServerError<string>>> LinkMangaMetadata (string MangaId, string MetadataFetcherName, [FromBody]string Identifier) public IActionResult LinkMangaMetadata(string MangaId, string MetadataFetcherName, [FromBody]string Identifier)
{ {
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) if(context.Mangas.Find(MangaId) is not { } manga)
return TypedResults.NotFound(nameof(MangaId)); return NotFound();
if(Tranga.MetadataFetchers.FirstOrDefault(f => f.Name == MetadataFetcherName) is not { } fetcher) if(Tranga.MetadataFetchers.FirstOrDefault(f => f.Name == MetadataFetcherName) is not { } fetcher)
return TypedResults.BadRequest(); return BadRequest();
MetadataEntry entry = fetcher.CreateMetadataEntry(manga, Identifier); MetadataEntry entry = fetcher.CreateMetadataEntry(manga, Identifier);
context.MetadataEntries.Add(entry); context.MetadataEntries.Add(entry);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) if(context.Sync() is { } errorMessage)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, errorMessage);
return TypedResults.Ok(entry); return Ok(entry);
} }
/// <summary> /// <summary>
@@ -107,23 +100,22 @@ public class MetadataFetcherController(MangaContext context) : Controller
[HttpPost("{MetadataFetcherName}/Unlink/{MangaId}")] [HttpPost("{MetadataFetcherName}/Unlink/{MangaId}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status400BadRequest)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status412PreconditionFailed)] [ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Ok, BadRequest, NotFound<string>, InternalServerError<string>, StatusCodeHttpResult>> UnlinkMangaMetadata (string MangaId, string MetadataFetcherName) public IActionResult UnlinkMangaMetadata(string MangaId, string MetadataFetcherName)
{ {
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } _) if(context.Mangas.Find(MangaId) is null)
return TypedResults.NotFound(nameof(MangaId)); return NotFound();
if(Tranga.MetadataFetchers.FirstOrDefault(f => f.Name == MetadataFetcherName) is null) if(Tranga.MetadataFetchers.FirstOrDefault(f => f.Name == MetadataFetcherName) is null)
return TypedResults.BadRequest(); return BadRequest();
if (context.MetadataEntries.FirstOrDefault(e => if(context.MetadataEntries.FirstOrDefault(e => e.MangaId == MangaId && e.MetadataFetcherName == MetadataFetcherName) is not { } entry)
e.MangaId == MangaId && e.MetadataFetcherName == MetadataFetcherName) is not { } entry) return StatusCode(Status412PreconditionFailed, "No entry found");
return TypedResults.StatusCode(Status412PreconditionFailed);
context.Remove(entry); context.Remove(entry);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) if(context.Sync() is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, result.exceptionMessage);
return TypedResults.Ok(); return Ok();
} }
} }

View File

@@ -1,11 +1,9 @@
using System.Text; using System.Text;
using API.Controllers.DTOs; using API.APIEndpointRecords;
using API.Controllers.Requests;
using API.Schema.NotificationsContext; using API.Schema.NotificationsContext;
using API.Schema.NotificationsContext.NotificationConnectors;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
@@ -21,18 +19,12 @@ public class NotificationConnectorController(NotificationsContext context) : Con
/// Gets all configured <see cref="NotificationConnector"/> /// Gets all configured <see cref="NotificationConnector"/>
/// </summary> /// </summary>
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="500">Error during Database Operation</response>
[HttpGet] [HttpGet]
[ProducesResponseType<List<NotificationConnector>>(Status200OK, "application/json")] [ProducesResponseType<NotificationConnector[]>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)] public IActionResult GetAllConnectors()
public async Task<Results<Ok<List<NotificationConnector>>, InternalServerError>> GetAllConnectors ()
{ {
if(await context.NotificationConnectors.ToListAsync(HttpContext.RequestAborted) is not { } result)
return TypedResults.InternalServerError(); return Ok(context.NotificationConnectors.ToArray());
List<NotificationConnector> notificationConnectors = result.Select(n => new NotificationConnector(n.Name, n.Url, n.HttpMethod, n.Body, n.Headers)).ToList();
return TypedResults.Ok(notificationConnectors);
} }
/// <summary> /// <summary>
@@ -43,15 +35,13 @@ public class NotificationConnectorController(NotificationsContext context) : Con
/// <response code="404"><see cref="NotificationConnector"/> with <paramref name="Name"/> not found</response> /// <response code="404"><see cref="NotificationConnector"/> with <paramref name="Name"/> not found</response>
[HttpGet("{Name}")] [HttpGet("{Name}")]
[ProducesResponseType<NotificationConnector>(Status200OK, "application/json")] [ProducesResponseType<NotificationConnector>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok<NotificationConnector>, NotFound<string>>> GetConnector (string Name) public IActionResult GetConnector(string Name)
{ {
if (await context.NotificationConnectors.FirstOrDefaultAsync(c => c.Name == Name, HttpContext.RequestAborted) is not { } connector) if(context.NotificationConnectors.Find(Name) is not { } connector)
return TypedResults.NotFound(nameof(Name)); return NotFound();
NotificationConnector notificationConnector = new NotificationConnector(connector.Name, connector.Url, connector.HttpMethod, connector.Body, connector.Headers); return Ok(connector);
return TypedResults.Ok(notificationConnector);
} }
/// <summary> /// <summary>
@@ -61,20 +51,16 @@ public class NotificationConnectorController(NotificationsContext context) : Con
/// <response code="200">ID of the new <see cref="NotificationConnector"/></response> /// <response code="200">ID of the new <see cref="NotificationConnector"/></response>
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpPut] [HttpPut]
[ProducesResponseType<string>(Status201Created, "text/plain")] [ProducesResponseType<string>(Status200OK, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Created<string>, InternalServerError<string>>> CreateConnector ([FromBody]CreateNotificationConnectorRecord requestData) public IActionResult CreateConnector([FromBody]NotificationConnector notificationConnector)
{ {
// TODO validate data context.NotificationConnectors.Add(notificationConnector);
API.Schema.NotificationsContext.NotificationConnectors.NotificationConnector newConnector = context.Notifications.Add(new ("Added new Notification Connector!", notificationConnector.Name, NotificationUrgency.High));
new(requestData.Name, requestData.Url, requestData.Headers, requestData.HttpMethod, requestData.Body);
context.NotificationConnectors.Add(newConnector); if(context.Sync() is { success: false } result)
context.Notifications.Add(new ("Added new Notification Connector!", newConnector.Name, NotificationUrgency.High)); return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Ok(notificationConnector.Name);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
return TypedResults.Created(string.Empty, newConnector.Name);
} }
/// <summary> /// <summary>
@@ -84,17 +70,18 @@ public class NotificationConnectorController(NotificationsContext context) : Con
/// <response code="200">ID of the new <see cref="NotificationConnector"/></response> /// <response code="200">ID of the new <see cref="NotificationConnector"/></response>
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpPut("Gotify")] [HttpPut("Gotify")]
[ProducesResponseType<string>(Status201Created, "text/plain")] [ProducesResponseType<string>(Status200OK, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Created<string>, InternalServerError<string>>> CreateGotifyConnector ([FromBody]CreateGotifyConnectorRecord createGotifyConnectorData) public IActionResult CreateGotifyConnector([FromBody]GotifyRecord gotifyData)
{ {
//TODO Validate Data //TODO Validate Data
CreateNotificationConnectorRecord gotifyConnector = new (createGotifyConnectorData.Name,
createGotifyConnectorData.Url, NotificationConnector gotifyConnector = new (gotifyData.Name,
gotifyData.Endpoint,
new Dictionary<string, string>() { { "X-Gotify-Key", gotifyData.AppToken } },
"POST", "POST",
$"{{\"message\": \"%text\", \"title\": \"%title\", \"Priority\": {createGotifyConnectorData.Priority}}}", $"{{\"message\": \"%text\", \"title\": \"%title\", \"Priority\": {gotifyData.Priority}}}");
new () { { "X-Gotify-Key", createGotifyConnectorData.AppToken } }); return CreateConnector(gotifyConnector);
return await CreateConnector(gotifyConnector);
} }
/// <summary> /// <summary>
@@ -104,20 +91,24 @@ public class NotificationConnectorController(NotificationsContext context) : Con
/// <response code="200">ID of the new <see cref="NotificationConnector"/></response> /// <response code="200">ID of the new <see cref="NotificationConnector"/></response>
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpPut("Ntfy")] [HttpPut("Ntfy")]
[ProducesResponseType<string>(Status201Created, "text/plain")] [ProducesResponseType<string>(Status200OK, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Created<string>, InternalServerError<string>>> CreateNtfyConnector ([FromBody]CreateNtfyConnectorRecord createNtfyConnectorRecord) public IActionResult CreateNtfyConnector([FromBody]NtfyRecord ntfyRecord)
{ {
//TODO Validate Data //TODO Validate Data
string authHeader = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{createNtfyConnectorRecord.Username}:{createNtfyConnectorRecord.Password}"));
string authHeader = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ntfyRecord.Username}:{ntfyRecord.Password}"));
string auth = Convert.ToBase64String(Encoding.UTF8.GetBytes(authHeader)).Replace("=",""); string auth = Convert.ToBase64String(Encoding.UTF8.GetBytes(authHeader)).Replace("=","");
CreateNotificationConnectorRecord ntfyConnector = new (createNtfyConnectorRecord.Name, NotificationConnector ntfyConnector = new (ntfyRecord.Name,
$"{createNtfyConnectorRecord.Url}?auth={auth}", $"{ntfyRecord.Endpoint}?auth={auth}",
new Dictionary<string, string>()
{
{"Authorization", auth}
},
"POST", "POST",
$"{{\"message\": \"%text\", \"title\": \"%title\", \"Priority\": {createNtfyConnectorRecord.Priority} \"Topic\": \"{createNtfyConnectorRecord.Topic}\"}}", $"{{\"message\": \"%text\", \"title\": \"%title\", \"Priority\": {ntfyRecord.Priority} \"Topic\": \"{ntfyRecord.Topic}\"}}");
new () {{"Authorization", auth}}); return CreateConnector(ntfyConnector);
return await CreateConnector(ntfyConnector);
} }
/// <summary> /// <summary>
@@ -127,17 +118,18 @@ public class NotificationConnectorController(NotificationsContext context) : Con
/// <response code="200">ID of the new <see cref="NotificationConnector"/></response> /// <response code="200">ID of the new <see cref="NotificationConnector"/></response>
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpPut("Pushover")] [HttpPut("Pushover")]
[ProducesResponseType<string>(Status201Created, "text/plain")] [ProducesResponseType<string>(Status200OK, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Created<string>, InternalServerError<string>>> CreatePushoverConnector ([FromBody]CreatePushoverConnectorRecord createPushoverConnectorRecord) public IActionResult CreatePushoverConnector([FromBody]PushoverRecord pushoverRecord)
{ {
//TODO Validate Data //TODO Validate Data
CreateNotificationConnectorRecord pushoverConnector = new (createPushoverConnectorRecord.Name,
NotificationConnector pushoverConnector = new (pushoverRecord.Name,
$"https://api.pushover.net/1/messages.json", $"https://api.pushover.net/1/messages.json",
new Dictionary<string, string>(),
"POST", "POST",
$"{{\"token\": \"{createPushoverConnectorRecord.AppToken}\", \"user\": \"{createPushoverConnectorRecord.Username}\", \"message:\":\"%text\", \"%title\" }}", $"{{\"token\": \"{pushoverRecord.AppToken}\", \"user\": \"{pushoverRecord.User}\", \"message:\":\"%text\", \"%title\" }}");
new ()); return CreateConnector(pushoverConnector);
return await CreateConnector(pushoverConnector);
} }
/// <summary> /// <summary>
@@ -149,17 +141,17 @@ public class NotificationConnectorController(NotificationsContext context) : Con
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpDelete("{Name}")] [HttpDelete("{Name}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Ok, NotFound<string>, InternalServerError<string>>> DeleteConnector (string Name) public IActionResult DeleteConnector(string Name)
{ {
if (await context.NotificationConnectors.FirstOrDefaultAsync(c => c.Name == Name, HttpContext.RequestAborted) is not { } connector) if(context.NotificationConnectors.Find(Name) is not { } connector)
return TypedResults.NotFound(nameof(Name)); return NotFound();
context.NotificationConnectors.Remove(connector); context.NotificationConnectors.Remove(connector);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result) if(context.Sync() is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage); return StatusCode(Status500InternalServerError, result.exceptionMessage);
return TypedResults.Ok(); return Ok();
} }
} }

View File

@@ -1,14 +1,9 @@
using API.Controllers.DTOs; using API.MangaConnectors;
using API.Schema.MangaContext; using API.Schema.MangaContext;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Soenneker.Utils.String.NeedlemanWunsch; using Soenneker.Utils.String.NeedlemanWunsch;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
using Author = API.Controllers.DTOs.Author;
using Chapter = API.Controllers.DTOs.Chapter;
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
namespace API.Controllers; namespace API.Controllers;
@@ -26,13 +21,13 @@ public class QueryController(MangaContext context) : Controller
/// <response code="404"><see cref="Author"/> with <paramref name="AuthorId"/> not found</response> /// <response code="404"><see cref="Author"/> with <paramref name="AuthorId"/> not found</response>
[HttpGet("Author/{AuthorId}")] [HttpGet("Author/{AuthorId}")]
[ProducesResponseType<Author>(Status200OK, "application/json")] [ProducesResponseType<Author>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok<Author>, NotFound<string>>> GetAuthor (string AuthorId) public IActionResult GetAuthor(string AuthorId)
{ {
if (await context.Authors.FirstOrDefaultAsync(a => a.Key == AuthorId, HttpContext.RequestAborted) is not { } author) if (context.Authors.Find(AuthorId) is not { } author)
return TypedResults.NotFound(nameof(AuthorId)); return NotFound();
return TypedResults.Ok(new Author(author.Key, author.AuthorName)); return Ok(author);
} }
/// <summary> /// <summary>
@@ -43,15 +38,13 @@ public class QueryController(MangaContext context) : Controller
/// <response code="404"><see cref="Chapter"/> with <paramref name="ChapterId"/> not found</response> /// <response code="404"><see cref="Chapter"/> with <paramref name="ChapterId"/> not found</response>
[HttpGet("Chapter/{ChapterId}")] [HttpGet("Chapter/{ChapterId}")]
[ProducesResponseType<Chapter>(Status200OK, "application/json")] [ProducesResponseType<Chapter>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok<Chapter>, NotFound<string>>> GetChapter (string ChapterId) public IActionResult GetChapter(string ChapterId)
{ {
if (await context.Chapters.FirstOrDefaultAsync(c => c.Key == ChapterId, HttpContext.RequestAborted) is not { } chapter) if (context.Chapters.Find(ChapterId) is not { } chapter)
return TypedResults.NotFound(nameof(ChapterId)); return NotFound();
IEnumerable<MangaConnectorId> ids = chapter.MangaConnectorIds.Select(id => return Ok(chapter);
new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
return TypedResults.Ok(new Chapter(chapter.Key, chapter.ParentMangaId, chapter.VolumeNumber, chapter.ChapterNumber, chapter.Title,ids, chapter.Downloaded));
} }
/// <summary> /// <summary>
@@ -61,46 +54,49 @@ public class QueryController(MangaContext context) : Controller
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="MangaConnectorId{Manga}"/> with <paramref name="MangaConnectorIdId"/> not found</response> /// <response code="404"><see cref="MangaConnectorId{Manga}"/> with <paramref name="MangaConnectorIdId"/> not found</response>
[HttpGet("Manga/MangaConnectorId/{MangaConnectorIdId}")] [HttpGet("Manga/MangaConnectorId/{MangaConnectorIdId}")]
[ProducesResponseType<MangaConnectorId>(Status200OK, "application/json")] [ProducesResponseType<MangaConnectorId<Manga>>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok<MangaConnectorId>, NotFound<string>>> GetMangaMangaConnectorId (string MangaConnectorIdId) public IActionResult GetMangaMangaConnectorId(string MangaConnectorIdId)
{ {
if (await context.MangaConnectorToManga.FirstOrDefaultAsync(c => c.Key == MangaConnectorIdId, HttpContext.RequestAborted) is not { } mcIdManga) if(context.MangaConnectorToManga.Find(MangaConnectorIdId) is not { } mcIdManga)
return TypedResults.NotFound(nameof(MangaConnectorIdId)); return NotFound();
MangaConnectorId result = new (mcIdManga.Key, mcIdManga.MangaConnectorName, mcIdManga.ObjId, mcIdManga.WebsiteUrl, mcIdManga.UseForDownload);
return TypedResults.Ok(result); return Ok(mcIdManga);
} }
/// <summary> /// <summary>
/// Returns <see cref="Schema.MangaContext.Manga"/> with names similar to <see cref="Schema.MangaContext.Manga"/> (identified by <paramref name="MangaId"/>) /// Returns all <see cref="Manga"/> that are being downloaded from at least one <see cref="MangaConnector"/>
/// </summary> /// </summary>
/// <param name="MangaId">Key of <see cref="Schema.MangaContext.Manga"/></param>
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="Schema.MangaContext.Manga"/> with <paramref name="MangaId"/> not found</response> [HttpGet("Manga/Downloading")]
/// <response code="500">Error during Database Operation</response> [ProducesResponseType<Manga[]>(Status200OK, "application/json")]
[HttpGet("Manga/{MangaId}/SimilarName")] public IActionResult GetMangaDownloading()
[ProducesResponseType<List<string>>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<List<string>>, NotFound<string>, InternalServerError>> GetSimilarManga (string MangaId)
{ {
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga) Manga[] ret = context.MangaIncludeAll()
return TypedResults.NotFound(nameof(MangaId)); .Where(m => m.MangaConnectorIds.Any(id => id.UseForDownload))
.ToArray();
return Ok(ret);
}
/// <summary>
/// Returns <see cref="Manga"/> with names similar to <see cref="Manga"/> (identified by <paramref name="MangaId"/>
/// </summary>
/// <param name="MangaId">Key of <see cref="Manga"/></param>
/// <response code="200"></response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
[HttpGet("Manga/{MangaId}/SimilarName")]
[ProducesResponseType<string[]>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetSimilarManga(string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound();
string name = manga.Name; string name = manga.Name;
Dictionary<string, string> mangaNames = context.Mangas.Where(m => m.Key != MangaId).ToDictionary(m => m.Key, m => m.Name);
if (await context.Mangas.Where(m => m.Key != MangaId) string[] similarIds = mangaNames
.ToDictionaryAsync(m => m.Key, m => m.Name, HttpContext.RequestAborted) is not { } mangaNames)
return TypedResults.InternalServerError();
List<string> similarIds = mangaNames
.Where(kv => NeedlemanWunschStringUtil.CalculateSimilarityPercentage(name, kv.Value) > 0.8) .Where(kv => NeedlemanWunschStringUtil.CalculateSimilarityPercentage(name, kv.Value) > 0.8)
.Select(kv => kv.Key) .Select(kv => kv.Key).ToArray();
.ToList(); return Ok(similarIds);
return TypedResults.Ok(similarIds);
} }
/// <summary> /// <summary>
@@ -110,15 +106,13 @@ public class QueryController(MangaContext context) : Controller
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="MangaConnectorId{Chapter}"/> with <paramref name="MangaConnectorIdId"/> not found</response> /// <response code="404"><see cref="MangaConnectorId{Chapter}"/> with <paramref name="MangaConnectorIdId"/> not found</response>
[HttpGet("Chapter/MangaConnectorId/{MangaConnectorIdId}")] [HttpGet("Chapter/MangaConnectorId/{MangaConnectorIdId}")]
[ProducesResponseType<MangaConnectorId>(Status200OK, "application/json")] [ProducesResponseType<MangaConnectorId<Chapter>>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public async Task<Results<Ok<MangaConnectorId>, NotFound<string>>> GetChapterMangaConnectorId (string MangaConnectorIdId) public IActionResult GetChapterMangaConnectorId(string MangaConnectorIdId)
{ {
if (await context.MangaConnectorToManga.FirstOrDefaultAsync(c => c.Key == MangaConnectorIdId, HttpContext.RequestAborted) is not { } mcIdChapter) if(context.MangaConnectorToChapter.Find(MangaConnectorIdId) is not { } mcIdChapter)
return TypedResults.NotFound(nameof(MangaConnectorIdId)); return NotFound();
MangaConnectorId result = new(mcIdChapter.Key, mcIdChapter.MangaConnectorName, mcIdChapter.ObjId, mcIdChapter.WebsiteUrl, mcIdChapter.UseForDownload); return Ok(mcIdChapter);
return TypedResults.Ok(result);
} }
} }

View File

@@ -1,37 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace API.Controllers.Requests;
public record CreateGotifyConnectorRecord(string Name, string Url, string AppToken, int Priority)
{
/// <summary>
/// The Name of the Notification Connector
/// </summary>
[Required]
[Description("The Name of the Notification Connector")]
public string Name { get; init; } = Name;
/// <summary>
/// The Url of the Instance
/// </summary>
/// <remarks>Formatting placeholders: "%title" and "%text" will be replaced when notifications are sent</remarks>
[Required]
[Url]
[Description("The Url of the Instance")]
public string Url { get; internal set; } = Url;
/// <summary>
/// The Apptoken used for authentication
/// </summary>
[Required]
[Description("The Apptoken used for authentication")]
public string AppToken { get; init; } = AppToken;
/// <summary>
/// The Priority of Notifications
/// </summary>
[Required]
[Description("The Priority of Notifications")]
public int Priority { get; init; } = Priority;
}

View File

@@ -1,37 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using API.Schema.LibraryContext.LibraryConnectors;
namespace API.Controllers.Requests;
public sealed record CreateLibraryConnectorRecord(LibraryType LibraryType, string Url, string Username, string Password)
{
/// <summary>
/// The <see cref="LibraryType"/>
/// </summary>
[Required]
[Description("The Library Type")]
public LibraryType LibraryType { get; init; } = LibraryType;
/// <summary>
/// The Url of the Library instance
/// </summary>
[Required]
[Url]
[Description("The Url of the Library instance")]
public string Url { get; init; } = Url;
/// <summary>
/// The Username to authenticate to the Library instance
/// </summary>
[Required]
[Description("The Username to authenticate to the Library instance")]
public string Username { get; init; } = Username;
/// <summary>
/// The Password to authenticate to the Library instance
/// </summary>
[Required]
[Description("The Password to authenticate to the Library instance")]
public string Password { get; init; } = Password;
}

View File

@@ -1,21 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace API.Controllers.Requests;
public sealed record CreateLibraryRecord(string BasePath, string LibraryName)
{
/// <summary>
/// The directory Path of the library
/// </summary>
[Required]
[Description("The directory Path of the library")]
public string BasePath { get; init; } = BasePath;
/// <summary>
/// The Name of the library
/// </summary>
[Required]
[Description("The Name of the library")]
public string LibraryName { get; init; } = LibraryName;
}

View File

@@ -1,46 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace API.Controllers.Requests;
public record CreateNotificationConnectorRecord(string Name, string Url, string HttpMethod, string Body, Dictionary<string, string> Headers)
{
/// <summary>
/// The Name of the Notification Connector
/// </summary>
[Required]
[Description("The Name of the Notification Connector")]
public string Name { get; init; } = Name;
/// <summary>
/// The Url of the Instance
/// </summary>
/// <remarks>Formatting placeholders: "%title" and "%text" will be replaced when notifications are sent</remarks>
[Required]
[Url]
[Description("The Url of the Instance")]
public string Url { get; internal set; } = Url;
/// <summary>
/// The HTTP Request Method to use for notifications
/// </summary>
[Required]
[Description("The HTTP Request Method to use for notifications")]
public string HttpMethod { get; internal set; } = HttpMethod;
/// <summary>
/// The Request Body to use to send notifications
/// </summary>
/// <remarks>Formatting placeholders: "%title" and "%text" will be replaced when notifications are sent</remarks>
[Required]
[Description("The Request Body to use to send notifications")]
public string Body { get; internal set; } = Body;
/// <summary>
/// The Request Headers to use to send notifications
/// </summary>
/// <remarks>Formatting placeholders: "%title" and "%text" will be replaced when notifications are sent</remarks>
[Required]
[Description("The Request Headers to use to send notifications")]
public Dictionary<string, string> Headers { get; internal set; } = Headers;
}

View File

@@ -1,51 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace API.Controllers.Requests;
public record CreateNtfyConnectorRecord(string Name, string Url, string Username, string Password, string Topic, int Priority)
{
/// <summary>
/// The Name of the Notification Connector
/// </summary>
[Required]
[Description("The Name of the Notification Connector")]
public string Name { get; init; } = Name;
/// <summary>
/// The Url of the Instance
/// </summary>
/// <remarks>Formatting placeholders: "%title" and "%text" will be replaced when notifications are sent</remarks>
[Required]
[Url]
[Description("The Url of the Instance")]
public string Url { get; internal set; } = Url;
/// <summary>
/// The Priority of Notifications
/// </summary>
[Required]
[Description("The Priority of Notifications")]
public int Priority { get; init; } = Priority;
/// <summary>
/// The Username used for authentication
/// </summary>
[Required]
[Description("The Username used for authentication")]
public string Username { get; init; } = Username;
/// <summary>
/// The Password used for authentication
/// </summary>
[Required]
[Description("The Password used for authentication")]
public string Password { get; init; } = Password;
/// <summary>
/// The Topic of Notifications
/// </summary>
[Required]
[Description("The Topic of Notifications")]
public string Topic { get; init; } = Topic;
}

View File

@@ -1,28 +0,0 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
namespace API.Controllers.Requests;
public record CreatePushoverConnectorRecord(string Name, string AppToken, string Username)
{
/// <summary>
/// The Name of the Notification Connector
/// </summary>
[Required]
[Description("The Name of the Notification Connector")]
public string Name { get; init; } = Name;
/// <summary>
/// The Apptoken used for authentication
/// </summary>
[Required]
[Description("The Apptoken used for authentication")]
public string AppToken { get; init; } = AppToken;
/// <summary>
/// The Username used for authentication
/// </summary>
[Required]
[Description("The Username used for authentication")]
public string Username { get; init; } = Username;
}

View File

@@ -1,11 +1,8 @@
using API.Controllers.DTOs; using API.MangaConnectors;
using API.Schema.MangaContext; using API.Schema.MangaContext;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
using Manga = API.Schema.MangaContext.Manga;
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
namespace API.Controllers; namespace API.Controllers;
@@ -16,64 +13,58 @@ namespace API.Controllers;
public class SearchController(MangaContext context) : Controller public class SearchController(MangaContext context) : Controller
{ {
/// <summary> /// <summary>
/// Initiate a search for a <see cref="Schema.MangaContext.Manga"/> on <see cref="MangaConnector"/> with searchTerm /// Initiate a search for a <see cref="Manga"/> on <see cref="MangaConnector"/> with searchTerm
/// </summary> /// </summary>
/// <param name="MangaConnectorName"><see cref="MangaConnector"/>.Name</param> /// <param name="MangaConnectorName"><see cref="MangaConnector"/>.Name</param>
/// <param name="Query">searchTerm</param> /// <param name="Query">searchTerm</param>
/// <response code="200"><see cref="MinimalManga"/> exert of <see cref="Schema.MangaContext.Manga"/></response> /// <response code="200"></response>
/// <response code="404"><see cref="MangaConnector"/> with Name not found</response> /// <response code="404"><see cref="MangaConnector"/> with Name not found</response>
/// <response code="412"><see cref="MangaConnector"/> with Name is disabled</response> /// <response code="412"><see cref="MangaConnector"/> with Name is disabled</response>
[HttpGet("{MangaConnectorName}/{Query}")] [HttpGet("{MangaConnectorName}/{Query}")]
[ProducesResponseType<List<MinimalManga>>(Status200OK, "application/json")] [ProducesResponseType<Manga[]>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status406NotAcceptable)] [ProducesResponseType(Status406NotAcceptable)]
public Results<Ok<List<MinimalManga>>, NotFound<string>, StatusCodeHttpResult> SearchManga (string MangaConnectorName, string Query) public IActionResult SearchManga(string MangaConnectorName, string Query)
{ {
if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals(MangaConnectorName, StringComparison.InvariantCultureIgnoreCase)) is not { } connector) if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals(MangaConnectorName, StringComparison.InvariantCultureIgnoreCase)) is not { } connector)
return TypedResults.NotFound(nameof(MangaConnectorName)); return NotFound();
if (connector.Enabled is false) if (connector.Enabled is false)
return TypedResults.StatusCode(Status412PreconditionFailed); return StatusCode(Status412PreconditionFailed);
(Manga manga, MangaConnectorId<Manga> id)[] mangas = connector.SearchManga(Query); (Manga, MangaConnectorId<Manga>)[] mangas = connector.SearchManga(Query);
List<Manga> retMangas = new();
IEnumerable<(Manga manga, MangaConnectorId<Manga> id)> addedManga = mangas.Where(kv => context.AddMangaToContext(kv, HttpContext.RequestAborted).GetAwaiter().GetResult()); foreach ((Manga manga, MangaConnectorId<Manga> mcId) manga in mangas)
IEnumerable<MinimalManga> result = addedManga.Select(manga => manga.manga).Select(m =>
{ {
IEnumerable<MangaConnectorId> ids = m.MangaConnectorIds.Select(id => if(Tranga.AddMangaToContext(manga, context, out Manga? add))
new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload)); retMangas.Add(add);
return new MinimalManga(m.Key, m.Name, m.Description, m.ReleaseStatus, ids); }
});
return TypedResults.Ok(result.ToList()); return Ok(retMangas.ToArray());
} }
/// <summary> /// <summary>
/// Returns <see cref="Schema.MangaContext.Manga"/> from the <see cref="MangaConnector"/> associated with <paramref name="url"/> /// Returns <see cref="Manga"/> from the <see cref="MangaConnector"/> associated with <paramref name="url"/>
/// </summary> /// </summary>
/// <param name="url"></param> /// <param name="url"></param>
/// <response code="200"><see cref="MinimalManga"/> exert of <see cref="Schema.MangaContext.Manga"/>.</response> /// <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="404"><see cref="Manga"/> not found</response>
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpPost("Url")] [HttpPost("Url")]
[ProducesResponseType<MinimalManga>(Status200OK, "application/json")] [ProducesResponseType<Manga>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")] [ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<MinimalManga>, NotFound<string>, InternalServerError<string>>> GetMangaFromUrl ([FromBody]string url) public IActionResult GetMangaFromUrl([FromBody]string url)
{ {
if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals("Global", StringComparison.InvariantCultureIgnoreCase)) is not { } connector) if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals("Global", StringComparison.InvariantCultureIgnoreCase)) is not { } connector)
return TypedResults.InternalServerError("Could not find Global Connector."); return StatusCode(Status500InternalServerError, "Could not find Global Connector.");
if(connector.GetMangaFromUrl(url) is not ({ } m, not null) manga) if(connector.GetMangaFromUrl(url) is not { } manga)
return TypedResults.NotFound("Could not retrieve Manga"); return NotFound();
if(await context.AddMangaToContext(manga, HttpContext.RequestAborted) == false) if(Tranga.AddMangaToContext(manga, context, out Manga? add) == false)
return TypedResults.InternalServerError("Could not add Manga to context"); return StatusCode(Status500InternalServerError);
IEnumerable<MangaConnectorId> ids = m.MangaConnectorIds.Select(id => return Ok(add);
new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
MinimalManga result = new (m.Key, m.Name, m.Description, m.ReleaseStatus, ids);
return TypedResults.Ok(result);
} }
} }

View File

@@ -1,6 +1,5 @@
using API.MangaDownloadClients; using API.MangaDownloadClients;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
@@ -18,9 +17,9 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet] [HttpGet]
[ProducesResponseType<TrangaSettings>(Status200OK, "application/json")] [ProducesResponseType<TrangaSettings>(Status200OK, "application/json")]
public Ok<TrangaSettings> GetSettings() public IActionResult GetSettings()
{ {
return TypedResults.Ok(Tranga.Settings); return Ok(Tranga.Settings);
} }
/// <summary> /// <summary>
@@ -29,9 +28,9 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet("UserAgent")] [HttpGet("UserAgent")]
[ProducesResponseType<string>(Status200OK, "text/plain")] [ProducesResponseType<string>(Status200OK, "text/plain")]
public Ok<string> GetUserAgent() public IActionResult GetUserAgent()
{ {
return TypedResults.Ok(Tranga.Settings.UserAgent); return Ok(Tranga.Settings.UserAgent);
} }
/// <summary> /// <summary>
@@ -40,11 +39,11 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpPatch("UserAgent")] [HttpPatch("UserAgent")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public Ok SetUserAgent([FromBody]string userAgent) public IActionResult SetUserAgent([FromBody]string userAgent)
{ {
//TODO Validate //TODO Validate
Tranga.Settings.SetUserAgent(userAgent); Tranga.Settings.SetUserAgent(userAgent);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
@@ -53,10 +52,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpDelete("UserAgent")] [HttpDelete("UserAgent")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public Ok ResetUserAgent() public IActionResult ResetUserAgent()
{ {
Tranga.Settings.SetUserAgent(TrangaSettings.DefaultUserAgent); Tranga.Settings.SetUserAgent(TrangaSettings.DefaultUserAgent);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
@@ -65,9 +64,9 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet("RequestLimits")] [HttpGet("RequestLimits")]
[ProducesResponseType<Dictionary<RequestType,int>>(Status200OK, "application/json")] [ProducesResponseType<Dictionary<RequestType,int>>(Status200OK, "application/json")]
public Ok<Dictionary<RequestType,int>> GetRequestLimits() public IActionResult GetRequestLimits()
{ {
return TypedResults.Ok(Tranga.Settings.RequestLimits); return Ok(Tranga.Settings.RequestLimits);
} }
/// <summary> /// <summary>
@@ -76,9 +75,9 @@ public class SettingsController() : Controller
/// <remarks><h1>NOT IMPLEMENTED</h1></remarks> /// <remarks><h1>NOT IMPLEMENTED</h1></remarks>
[HttpPatch("RequestLimits")] [HttpPatch("RequestLimits")]
[ProducesResponseType(Status501NotImplemented)] [ProducesResponseType(Status501NotImplemented)]
public StatusCodeHttpResult SetRequestLimits() public IActionResult SetRequestLimits()
{ {
return TypedResults.StatusCode(Status501NotImplemented); return StatusCode(501);
} }
/// <summary> /// <summary>
@@ -91,12 +90,12 @@ public class SettingsController() : Controller
[HttpPatch("RequestLimits/{RequestType}")] [HttpPatch("RequestLimits/{RequestType}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status400BadRequest)]
public Results<Ok, BadRequest> SetRequestLimit(RequestType RequestType, [FromBody]int requestLimit) public IActionResult SetRequestLimit(RequestType RequestType, [FromBody]int requestLimit)
{ {
if (requestLimit <= 0) if (requestLimit <= 0)
return TypedResults.BadRequest(); return BadRequest();
Tranga.Settings.SetRequestLimit(RequestType, requestLimit); Tranga.Settings.SetRequestLimit(RequestType, requestLimit);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
@@ -105,10 +104,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpDelete("RequestLimits/{RequestType}")] [HttpDelete("RequestLimits/{RequestType}")]
[ProducesResponseType<string>(Status200OK)] [ProducesResponseType<string>(Status200OK)]
public Ok ResetRequestLimits(RequestType RequestType) public IActionResult ResetRequestLimits(RequestType RequestType)
{ {
Tranga.Settings.SetRequestLimit(RequestType, TrangaSettings.DefaultRequestLimits[RequestType]); Tranga.Settings.SetRequestLimit(RequestType, TrangaSettings.DefaultRequestLimits[RequestType]);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
@@ -117,10 +116,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpDelete("RequestLimits")] [HttpDelete("RequestLimits")]
[ProducesResponseType<string>(Status200OK)] [ProducesResponseType<string>(Status200OK)]
public Ok ResetRequestLimits() public IActionResult ResetRequestLimits()
{ {
Tranga.Settings.ResetRequestLimits(); Tranga.Settings.ResetRequestLimits();
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
@@ -129,9 +128,9 @@ public class SettingsController() : Controller
/// <response code="200">JPEG ImageCompression-level as Integer</response> /// <response code="200">JPEG ImageCompression-level as Integer</response>
[HttpGet("ImageCompressionLevel")] [HttpGet("ImageCompressionLevel")]
[ProducesResponseType<int>(Status200OK, "text/plain")] [ProducesResponseType<int>(Status200OK, "text/plain")]
public Ok<int> GetImageCompression() public IActionResult GetImageCompression()
{ {
return TypedResults.Ok(Tranga.Settings.ImageCompression); return Ok(Tranga.Settings.ImageCompression);
} }
/// <summary> /// <summary>
@@ -143,12 +142,12 @@ public class SettingsController() : Controller
[HttpPatch("ImageCompressionLevel/{level}")] [HttpPatch("ImageCompressionLevel/{level}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status400BadRequest)]
public Results<Ok, BadRequest> SetImageCompression(int level) public IActionResult SetImageCompression(int level)
{ {
if (level < 1 || level > 100) if (level < 1 || level > 100)
return TypedResults.BadRequest(); return BadRequest();
Tranga.Settings.UpdateImageCompression(level); Tranga.Settings.UpdateImageCompression(level);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
@@ -157,9 +156,9 @@ public class SettingsController() : Controller
/// <response code="200">True if enabled</response> /// <response code="200">True if enabled</response>
[HttpGet("BWImages")] [HttpGet("BWImages")]
[ProducesResponseType<bool>(Status200OK, "text/plain")] [ProducesResponseType<bool>(Status200OK, "text/plain")]
public Ok<bool> GetBwImagesToggle() public IActionResult GetBwImagesToggle()
{ {
return TypedResults.Ok(Tranga.Settings.BlackWhiteImages); return Ok(Tranga.Settings.BlackWhiteImages);
} }
/// <summary> /// <summary>
@@ -169,10 +168,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpPatch("BWImages/{enabled}")] [HttpPatch("BWImages/{enabled}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public Ok SetBwImagesToggle(bool enabled) public IActionResult SetBwImagesToggle(bool enabled)
{ {
Tranga.Settings.SetBlackWhiteImageEnabled(enabled); Tranga.Settings.SetBlackWhiteImageEnabled(enabled);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
@@ -195,9 +194,9 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet("ChapterNamingScheme")] [HttpGet("ChapterNamingScheme")]
[ProducesResponseType<string>(Status200OK, "text/plain")] [ProducesResponseType<string>(Status200OK, "text/plain")]
public Ok<string> GetCustomNamingScheme() public IActionResult GetCustomNamingScheme()
{ {
return TypedResults.Ok(Tranga.Settings.ChapterNamingScheme); return Ok(Tranga.Settings.ChapterNamingScheme);
} }
/// <summary> /// <summary>
@@ -218,12 +217,12 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpPatch("ChapterNamingScheme")] [HttpPatch("ChapterNamingScheme")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public Ok SetCustomNamingScheme([FromBody]string namingScheme) public IActionResult SetCustomNamingScheme([FromBody]string namingScheme)
{ {
//TODO Move old Chapters //TODO Move old Chapters
Tranga.Settings.SetChapterNamingScheme(namingScheme); Tranga.Settings.SetChapterNamingScheme(namingScheme);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
@@ -233,10 +232,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpPost("FlareSolverr/Url")] [HttpPost("FlareSolverr/Url")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public Ok SetFlareSolverrUrl([FromBody]string flareSolverrUrl) public IActionResult SetFlareSolverrUrl([FromBody]string flareSolverrUrl)
{ {
Tranga.Settings.SetFlareSolverrUrl(flareSolverrUrl); Tranga.Settings.SetFlareSolverrUrl(flareSolverrUrl);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
@@ -245,10 +244,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpDelete("FlareSolverr/Url")] [HttpDelete("FlareSolverr/Url")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public Ok ClearFlareSolverrUrl() public IActionResult ClearFlareSolverrUrl()
{ {
Tranga.Settings.SetFlareSolverrUrl(string.Empty); Tranga.Settings.SetFlareSolverrUrl(string.Empty);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
@@ -259,12 +258,12 @@ public class SettingsController() : Controller
[HttpPost("FlareSolverr/Test")] [HttpPost("FlareSolverr/Test")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType(Status500InternalServerError)] [ProducesResponseType(Status500InternalServerError)]
public Results<Ok, InternalServerError> TestFlareSolverrReachable() public IActionResult TestFlareSolverrReachable()
{ {
const string knownProtectedUrl = "https://prowlarr.servarr.com/v1/ping"; const string knownProtectedUrl = "https://prowlarr.servarr.com/v1/ping";
FlareSolverrDownloadClient client = new(); FlareSolverrDownloadClient client = new();
RequestResult result = client.MakeRequestInternal(knownProtectedUrl); RequestResult result = client.MakeRequestInternal(knownProtectedUrl);
return (int)result.statusCode >= 200 && (int)result.statusCode < 300 ? TypedResults.Ok() : TypedResults.InternalServerError(); return (int)result.statusCode >= 200 && (int)result.statusCode < 300 ? Ok() : StatusCode(500, result.statusCode);
} }
/// <summary> /// <summary>
@@ -273,9 +272,9 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet("DownloadLanguage")] [HttpGet("DownloadLanguage")]
[ProducesResponseType<string>(Status200OK, "text/plain")] [ProducesResponseType<string>(Status200OK, "text/plain")]
public Ok<string> GetDownloadLanguage() public IActionResult GetDownloadLanguage()
{ {
return TypedResults.Ok(Tranga.Settings.DownloadLanguage); return Ok(Tranga.Settings.DownloadLanguage);
} }
/// <summary> /// <summary>
@@ -284,10 +283,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpPatch("DownloadLanguage/{Language}")] [HttpPatch("DownloadLanguage/{Language}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public Ok SetDownloadLanguage(string Language) public IActionResult SetDownloadLanguage(string Language)
{ {
//TODO Validation //TODO Validation
Tranga.Settings.SetDownloadLanguage(Language); Tranga.Settings.SetDownloadLanguage(Language);
return TypedResults.Ok(); return Ok();
} }
} }

View File

@@ -1,7 +1,6 @@
using API.Controllers.DTOs; using API.APIEndpointRecords;
using API.Workers; using API.Workers;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
@@ -11,30 +10,29 @@ namespace API.Controllers;
[ApiVersion(2)] [ApiVersion(2)]
[ApiController] [ApiController]
[Route("v{version:apiVersion}/[controller]")] [Route("v{version:apiVersion}/[controller]")]
public class WorkerController : Controller public class WorkerController() : Controller
{ {
/// <summary>
/// Returns all <see cref="BaseWorker"/>
/// </summary>
/// <response code="200"><see cref="Worker"/></response>
[HttpGet]
[ProducesResponseType<List<Worker>>(Status200OK, "application/json")]
public Ok<List<Worker>> GetWorkers()
{
IEnumerable<Worker> result = Tranga.GetRunningWorkers().Select(w =>
new Worker(w.Key, w.AllDependencies.Select(d => d.Key), w.MissingDependencies.Select(d => d.Key), w.AllDependenciesFulfilled, w.State));
return TypedResults.Ok(result.ToList());
}
/// <summary> /// <summary>
/// Returns all <see cref="BaseWorker"/>.Keys /// Returns all <see cref="BaseWorker"/>.Keys
/// </summary> /// </summary>
/// <response code="200"></response> /// <response code="200"><see cref="BaseWorker"/> Keys/IDs</response>
[HttpGet("Keys")] [HttpGet]
[ProducesResponseType<string[]>(Status200OK, "application/json")] [ProducesResponseType<string[]>(Status200OK, "application/json")]
public Ok<List<string>> GetWorkerIds() public IActionResult GetAllWorkers()
{ {
return TypedResults.Ok(Tranga.GetRunningWorkers().Select(w => w.Key).ToList()); return Ok(Tranga.GetRunningWorkers().Select(w => w.Key).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 GetWorkers([FromBody]string[] WorkerIds)
{
return Ok(Tranga.GetRunningWorkers().Where(worker => WorkerIds.Contains(worker.Key)).ToArray());
} }
/// <summary> /// <summary>
@@ -43,12 +41,10 @@ public class WorkerController : Controller
/// <param name="State">Requested <see cref="WorkerExecutionState"/></param> /// <param name="State">Requested <see cref="WorkerExecutionState"/></param>
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet("State/{State}")] [HttpGet("State/{State}")]
[ProducesResponseType<List<Worker>>(Status200OK, "application/json")] [ProducesResponseType<BaseWorker[]>(Status200OK, "application/json")]
public Ok<List<Worker>> GetWorkersInState(WorkerExecutionState State) public IActionResult GetWorkersInState(WorkerExecutionState State)
{ {
IEnumerable<Worker> result = Tranga.GetRunningWorkers().Where(worker => worker.State == State).Select(w => return Ok(Tranga.GetRunningWorkers().Where(worker => worker.State == State).ToArray());
new Worker(w.Key, w.AllDependencies.Select(d => d.Key), w.MissingDependencies.Select(d => d.Key), w.AllDependenciesFulfilled, w.State));
return TypedResults.Ok(result.ToList());
} }
/// <summary> /// <summary>
@@ -58,16 +54,13 @@ public class WorkerController : Controller
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response> /// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response>
[HttpGet("{WorkerId}")] [HttpGet("{WorkerId}")]
[ProducesResponseType<Worker>(Status200OK, "application/json")] [ProducesResponseType<BaseWorker>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public Results<Ok<Worker>, NotFound<string>> GetWorker(string WorkerId) public IActionResult GetWorker(string WorkerId)
{ {
if(Tranga.GetRunningWorkers().FirstOrDefault(w => w.Key == WorkerId) is not { } w) if(Tranga.GetRunningWorkers().FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return TypedResults.NotFound(nameof(WorkerId)); return NotFound(nameof(WorkerId));
return Ok(worker);
Worker result = new (w.Key, w.AllDependencies.Select(d => d.Key), w.MissingDependencies.Select(d => d.Key), w.AllDependenciesFulfilled, w.State);
return TypedResults.Ok(result);
} }
/// <summary> /// <summary>
@@ -78,13 +71,13 @@ public class WorkerController : Controller
/// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response> /// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response>
[HttpDelete("{WorkerId}")] [HttpDelete("{WorkerId}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
public Results<Ok, NotFound<string>> DeleteWorker(string WorkerId) public IActionResult DeleteWorker(string WorkerId)
{ {
if(Tranga.GetRunningWorkers().FirstOrDefault(w => w.Key == WorkerId) is not { } worker) if(Tranga.GetRunningWorkers().FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return TypedResults.NotFound(nameof(WorkerId)); return NotFound(nameof(WorkerId));
Tranga.StopWorker(worker); Tranga.StopWorker(worker);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
@@ -96,18 +89,18 @@ public class WorkerController : Controller
/// <response code="412"><see cref="BaseWorker"/> was already running</response> /// <response code="412"><see cref="BaseWorker"/> was already running</response>
[HttpPost("{WorkerId}/Start")] [HttpPost("{WorkerId}/Start")]
[ProducesResponseType(Status202Accepted)] [ProducesResponseType(Status202Accepted)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] [ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status412PreconditionFailed)] [ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
public Results<Ok, NotFound<string>, StatusCodeHttpResult> StartWorker(string WorkerId) public IActionResult StartWorker(string WorkerId)
{ {
if(Tranga.GetRunningWorkers().FirstOrDefault(w => w.Key == WorkerId) is not { } worker) if(Tranga.GetRunningWorkers().FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return TypedResults.NotFound(nameof(WorkerId)); return NotFound(nameof(WorkerId));
if (worker.State >= WorkerExecutionState.Waiting) if (worker.State >= WorkerExecutionState.Waiting)
return TypedResults.StatusCode(Status412PreconditionFailed); return StatusCode(Status412PreconditionFailed, "Already running");
Tranga.StartWorker(worker); Tranga.StartWorker(worker);
return TypedResults.Ok(); return Ok();
} }
/// <summary> /// <summary>
@@ -116,20 +109,18 @@ public class WorkerController : Controller
/// <param name="WorkerId"><see cref="BaseWorker"/>.Key</param> /// <param name="WorkerId"><see cref="BaseWorker"/>.Key</param>
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response> /// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response>
/// <response code="412"><see cref="BaseWorker"/> was already not running</response> /// <response code="208"><see cref="BaseWorker"/> was not running</response>
[HttpPost("{WorkerId}/Stop")] [HttpPost("{WorkerId}/Stop")]
[ProducesResponseType(Status202Accepted)] [ProducesResponseType(Status501NotImplemented)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")] public IActionResult StopWorker(string WorkerId)
[ProducesResponseType(Status412PreconditionFailed)]
public Results<Ok, NotFound<string>, StatusCodeHttpResult> StopWorker(string WorkerId)
{ {
if(Tranga.GetRunningWorkers().FirstOrDefault(w => w.Key == WorkerId) is not { } worker) if(Tranga.GetRunningWorkers().FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return TypedResults.NotFound(nameof(WorkerId)); return NotFound(nameof(WorkerId));
if(worker.State is < WorkerExecutionState.Running or >= WorkerExecutionState.Completed) if(worker.State is < WorkerExecutionState.Running or >= WorkerExecutionState.Completed)
return TypedResults.StatusCode(Status412PreconditionFailed); return StatusCode(Status208AlreadyReported, "Not running");
Tranga.StopWorker(worker); Tranga.StopWorker(worker);
return TypedResults.Ok(); return Ok();
} }
} }

View File

@@ -1,23 +0,0 @@
<log4net>
<appender name="ConsoleAppender" type="log4net.Appender.ConsoleAppender">
<layout type="log4net.Layout.PatternLayout">
<conversionPattern value="%date [%thread] %-5level %logger - %message%newline" />
</layout>
</appender>
<appender name="RollingLogFileAppender" type="log4net.Appender.RollingFileAppender">
<file value="/usr/share/tranga-api/log/Tranga.log" />
<appendToFile value="true" />
<rollingStyle value="Composite" />
<datePattern value="yyyyMMdd" />
<maxSizeRollBackups value="4" />
<maximumFileSize value="256MB" />
<layout type="log4net.Layout.PatternLayout">
<conversionPattern value="%date [%thread] %-5level %logger - %message%newline" />
</layout>
</appender>
<root>
<level value="DEBUG" />
<appender-ref ref="ConsoleAppender" />
<appender-ref ref="RollingLogFileAppender" />
</root>
</log4net>

View File

@@ -4,7 +4,7 @@ namespace API.MangaConnectors;
public class Global : MangaConnector public class Global : MangaConnector
{ {
public Global() : base("Global", ["all"], [""], "https://avatars.githubusercontent.com/u/13404778") public Global() : base("Global", ["all"], [""], "")
{ {
} }

View File

@@ -6,23 +6,33 @@ using API.Schema.MangaContext;
using log4net; using log4net;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json; using Newtonsoft.Json;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
namespace API.MangaConnectors; namespace API.MangaConnectors;
[PrimaryKey("Name")] [PrimaryKey("Name")]
public abstract class MangaConnector(string name, string[] supportedLanguages, string[] baseUris, string iconUrl) public abstract class MangaConnector(string name, string[] supportedLanguages, string[] baseUris, string iconUrl)
{ {
[NotMapped] internal DownloadClient downloadClient { get; init; } = null!; [JsonIgnore]
[NotMapped]
internal DownloadClient downloadClient { get; init; } = null!;
[NotMapped] protected ILog Log { get; init; } = LogManager.GetLogger(name); [JsonIgnore]
[NotMapped]
protected ILog Log { get; init; } = LogManager.GetLogger(name);
[StringLength(32)] public string Name { get; init; } = name; [StringLength(32)]
[StringLength(8)] public string[] SupportedLanguages { get; init; } = supportedLanguages; [Required]
[StringLength(2048)] public string IconUrl { get; init; } = iconUrl; public string Name { get; init; } = name;
[StringLength(256)] public string[] BaseUris { get; init; } = baseUris; [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 bool Enabled { get; internal set; } = true;
public abstract (Manga, MangaConnectorId<Manga>)[] SearchManga(string mangaSearchName); public abstract (Manga, MangaConnectorId<Manga>)[] SearchManga(string mangaSearchName);
@@ -46,46 +56,21 @@ public abstract class MangaConnector(string name, string[] supportedLanguages, s
Regex urlRex = new (@"https?:\/\/((?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]+)\/(?:.+\/)*(.+\.([a-zA-Z]+))"); 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 //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); Match match = urlRex.Match(mangaId.Obj.CoverUrl);
string filename = $"{match.Groups[1].Value}-{mangaId.ObjId}.{mangaId.MangaConnectorName}.{match.Groups[3].Value}"; string filename = $"{match.Groups[1].Value}-{mangaId.Key}.{match.Groups[3].Value}";
string saveImagePath = Path.Join(TrangaSettings.coverImageCacheOriginal, filename); string saveImagePath = Path.Join(TrangaSettings.coverImageCache, filename);
if (File.Exists(saveImagePath)) if (File.Exists(saveImagePath))
return filename; return saveImagePath;
RequestResult coverResult = downloadClient.MakeRequest(mangaId.Obj.CoverUrl, RequestType.MangaCover, $"https://{match.Groups[1].Value}"); RequestResult coverResult = downloadClient.MakeRequest(mangaId.Obj.CoverUrl, RequestType.MangaCover, $"https://{match.Groups[1].Value}");
if ((int)coverResult.statusCode < 200 || (int)coverResult.statusCode >= 300) if ((int)coverResult.statusCode < 200 || (int)coverResult.statusCode >= 300)
return SaveCoverImageToCache(mangaId, --retries); return SaveCoverImageToCache(mangaId, --retries);
try using MemoryStream ms = new();
{ coverResult.result.CopyTo(ms);
using MemoryStream ms = new(); Directory.CreateDirectory(TrangaSettings.coverImageCache);
coverResult.result.CopyTo(ms); File.WriteAllBytes(saveImagePath, ms.ToArray());
byte[] imageBytes = ms.ToArray();
Directory.CreateDirectory(TrangaSettings.coverImageCacheOriginal); return saveImagePath;
File.WriteAllBytes(saveImagePath, imageBytes);
using Image image = Image.Load(imageBytes);
Directory.CreateDirectory(TrangaSettings.coverImageCacheLarge);
using Image large = image.Clone(x => x.Resize(new ResizeOptions
{ Size = Constants.ImageLgSize, Mode = ResizeMode.Max }));
large.SaveAsJpeg(Path.Join(TrangaSettings.coverImageCacheLarge, filename), new (){ Quality = 40 });
Directory.CreateDirectory(TrangaSettings.coverImageCacheMedium);
using Image medium = image.Clone(x => x.Resize(new ResizeOptions
{ Size = Constants.ImageMdSize, Mode = ResizeMode.Max }));
medium.SaveAsJpeg(Path.Join(TrangaSettings.coverImageCacheMedium, filename), new (){ Quality = 40 });
Directory.CreateDirectory(TrangaSettings.coverImageCacheSmall);
using Image small = image.Clone(x => x.Resize(new ResizeOptions
{ Size = Constants.ImageSmSize, Mode = ResizeMode.Max }));
small.SaveAsJpeg(Path.Join(TrangaSettings.coverImageCacheSmall, filename), new (){ Quality = 40 });
}
catch (Exception e)
{
Log.Error(e);
}
return filename;
} }
} }

View File

@@ -324,7 +324,7 @@ public class MangaDex : MangaConnector
{ {
string? id = jToken.Value<string>("id"); string? id = jToken.Value<string>("id");
JToken? attributes = jToken["attributes"]; JToken? attributes = jToken["attributes"];
string? chapterStr = attributes?.Value<string>("chapter") ?? "0"; string? chapterStr = attributes?.Value<string>("chapter");
string? volumeStr = attributes?.Value<string>("volume"); string? volumeStr = attributes?.Value<string>("volume");
int? volumeNumber = null; int? volumeNumber = null;
string? title = attributes?.Value<string>("title"); string? title = attributes?.Value<string>("title");

View File

@@ -0,0 +1,151 @@
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using HtmlAgilityPack;
using log4net;
using PuppeteerSharp;
namespace API.MangaDownloadClients;
internal class ChromiumDownloadClient : DownloadClient
{
private static IBrowser? _browser;
private readonly HttpDownloadClient _httpDownloadClient;
private readonly Thread _closeStalePagesThread;
private readonly List<KeyValuePair<IPage, DateTime>> _openPages = new ();
private static async Task<IBrowser> StartBrowser(ILog log)
{
return await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
Args = new [] {
"--disable-gpu",
"--disable-dev-shm-usage",
"--disable-setuid-sandbox",
"--no-sandbox"},
Timeout = 30000
}, new LoggerFactory([new Provider(log)]));
}
public ChromiumDownloadClient()
{
_httpDownloadClient = new();
if(_browser is null)
_browser = StartBrowser(Log).Result;
_closeStalePagesThread = new Thread(CheckStalePages);
_closeStalePagesThread.Start();
}
private void CheckStalePages()
{
while (true)
{
Thread.Sleep(TimeSpan.FromHours(1));
Log.Debug("Removing stale pages");
foreach ((IPage key, DateTime _) in _openPages.Where(kv => kv.Value.Subtract(DateTime.Now) > TimeSpan.FromHours(1)))
{
Log.Debug($"Closing {key.Url}");
key.CloseAsync().Wait();
}
}
}
private readonly Regex _imageUrlRex = new(@"https?:\/\/.*\.(?:p?jpe?g|gif|a?png|bmp|avif|webp)(\?.*)?");
internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null)
{
Log.Debug($"Requesting {url}");
return _imageUrlRex.IsMatch(url)
? _httpDownloadClient.MakeRequestInternal(url, referrer)
: MakeRequestBrowser(url, referrer, clickButton);
}
private RequestResult MakeRequestBrowser(string url, string? referrer = null, string? clickButton = null)
{
if (_browser is null)
return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null);
IPage page = _browser.NewPageAsync().Result;
_openPages.Add(new(page, DateTime.Now));
page.SetExtraHttpHeadersAsync(new() { { "Referer", referrer } });
page.DefaultTimeout = 30000;
IResponse response;
try
{
response = page.GoToAsync(url, WaitUntilNavigation.Networkidle0).Result;
Log.Debug($"Page loaded. {url}");
}
catch (Exception e)
{
Log.Info($"Could not load Page {url}\n{e.Message}");
page.CloseAsync();
_openPages.Remove(_openPages.Find(i => i.Key == page));
return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null);
}
Stream stream = Stream.Null;
HtmlDocument? document = null;
if (response.Headers.TryGetValue("Content-Type", out string? content))
{
if (content.Contains("text/html"))
{
if (clickButton is not null && page.QuerySelectorAsync(clickButton).Result is not null)
page.ClickAsync(clickButton).Wait();
string htmlString = page.GetContentAsync().Result;
stream = new MemoryStream(Encoding.Default.GetBytes(htmlString));
document = new ();
document.LoadHtml(htmlString);
}else if (content.Contains("image"))
{
stream = new MemoryStream(response.BufferAsync().Result);
}
}
else
{
page.CloseAsync().Wait();
_openPages.Remove(_openPages.Find(i => i.Key == page));
return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null);
}
page.CloseAsync().Wait();
_openPages.Remove(_openPages.Find(i => i.Key == page));
return new RequestResult(response.Status, document, stream, false, "");
}
private class Provider(ILog log) : ILoggerProvider
{
public void Dispose()
{
}
public ILogger CreateLogger(string categoryName)
{
return new ChromiumLogger(log);
}
}
private class ChromiumLogger(ILog log) : ILogger
{
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
string message = formatter.Invoke(state, exception);
switch(logLevel)
{
case LogLevel.Critical: log.Fatal(message); break;
case LogLevel.Error: log.Error(message); break;
case LogLevel.Warning: log.Warn(message); break;
case LogLevel.Information: log.Info(message); break;
case LogLevel.Debug: log.Debug(message); break;
default: log.Info(message); break;
}
}
public bool IsEnabled(LogLevel logLevel) => true;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
return null;
}
}
}

View File

@@ -14,22 +14,18 @@ public abstract class DownloadClient
this.Log = LogManager.GetLogger(GetType()); this.Log = LogManager.GetLogger(GetType());
} }
// TODO Requests still go too fast across threads!
public RequestResult MakeRequest(string url, RequestType requestType, string? referrer = null, string? clickButton = null) public RequestResult MakeRequest(string url, RequestType requestType, string? referrer = null, string? clickButton = null)
{ {
Log.Debug($"Requesting {requestType} {url}"); Log.Debug($"Requesting {requestType} {url}");
// If we don't have a RequestLimit set for a Type, use the default one
if (!Tranga.Settings.RequestLimits.ContainsKey(requestType)) if (!Tranga.Settings.RequestLimits.ContainsKey(requestType))
requestType = RequestType.Default; {
return new RequestResult(HttpStatusCode.NotAcceptable, null, Stream.Null);
}
int rateLimit = Tranga.Settings.RequestLimits[requestType]; int rateLimit = Tranga.Settings.UserAgent == TrangaSettings.DefaultUserAgent
// TODO this probably needs a better check whether the useragent matches... ? TrangaSettings.DefaultRequestLimits[requestType]
// If the UserAgent is the default one, do not exceed the default request-limits. : Tranga.Settings.RequestLimits[requestType];
if (Tranga.Settings.UserAgent == TrangaSettings.DefaultUserAgent && rateLimit > TrangaSettings.DefaultRequestLimits[requestType])
rateLimit = TrangaSettings.DefaultRequestLimits[requestType];
// Apply the delay
TimeSpan timeBetweenRequests = TimeSpan.FromMinutes(1).Divide(rateLimit); TimeSpan timeBetweenRequests = TimeSpan.FromMinutes(1).Divide(rateLimit);
DateTime now = DateTime.Now; DateTime now = DateTime.Now;
LastExecutedRateLimit.TryAdd(requestType, now.Subtract(timeBetweenRequests)); LastExecutedRateLimit.TryAdd(requestType, now.Subtract(timeBetweenRequests));
@@ -38,12 +34,11 @@ public abstract class DownloadClient
Log.Debug($"Request limit {requestType} {rateLimit}/Minute timeBetweenRequests: {timeBetweenRequests:ss'.'fffff} Timeout: {rateLimitTimeout:ss'.'fffff}"); Log.Debug($"Request limit {requestType} {rateLimit}/Minute timeBetweenRequests: {timeBetweenRequests:ss'.'fffff} Timeout: {rateLimitTimeout:ss'.'fffff}");
if (rateLimitTimeout > TimeSpan.Zero) if (rateLimitTimeout > TimeSpan.Zero)
{
Thread.Sleep(rateLimitTimeout); Thread.Sleep(rateLimitTimeout);
}
// Make the request
RequestResult result = MakeRequestInternal(url, referrer, clickButton); RequestResult result = MakeRequestInternal(url, referrer, clickButton);
// Update the time the last request was made
LastExecutedRateLimit[requestType] = DateTime.UtcNow; LastExecutedRateLimit[requestType] = DateTime.UtcNow;
Log.Debug($"Result {url}: {result}"); Log.Debug($"Result {url}: {result}");
return result; return result;

View File

@@ -1,71 +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("20250901194308_KeyLengthChange")]
partial class KeyLengthChange
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.LibraryContext.LibraryConnectors.LibraryConnector", b =>
{
b.Property<string>("Key")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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,36 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations.Library
{
/// <inheritdoc />
public partial class KeyLengthChange : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "LibraryConnectors",
type: "character varying(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "LibraryConnectors",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
}
}
}

View File

@@ -16,7 +16,7 @@ namespace API.Migrations.Library
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.8") .HasAnnotation("ProductVersion", "9.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -24,8 +24,7 @@ namespace API.Migrations.Library
modelBuilder.Entity("API.Schema.LibraryContext.LibraryConnectors.LibraryConnector", b => modelBuilder.Entity("API.Schema.LibraryContext.LibraryConnectors.LibraryConnector", b =>
{ {
b.Property<string>("Key") b.Property<string>("Key")
.HasMaxLength(64) .HasColumnType("text");
.HasColumnType("character varying(64)");
b.Property<string>("Auth") b.Property<string>("Auth")
.IsRequired() .IsRequired()

View File

@@ -1,535 +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("20250901194144_KeyLengthChange")]
partial class KeyLengthChange
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.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("MangaConnector");
b.HasDiscriminator<string>("Name").HasValue("MangaConnector");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.MangaContext.Author", b =>
{
b.Property<string>("Key")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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("ObjId");
b.ToTable("MangaConnectorToChapter");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Manga>", b =>
{
b.Property<string>("Key")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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("ObjId");
b.ToTable("MangaConnectorToManga");
});
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("character varying(64)");
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("character varying(64)");
b.Property<string>("MangaIds")
.HasColumnType("character varying(64)");
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("character varying(64)");
b.HasKey("MangaTagIds", "MangaIds");
b.HasIndex("MangaIds");
b.ToTable("MangaTagToManga");
});
modelBuilder.Entity("API.MangaConnectors.ComickIo", b =>
{
b.HasBaseType("API.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("ComickIo");
});
modelBuilder.Entity("API.MangaConnectors.Global", b =>
{
b.HasBaseType("API.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Global");
});
modelBuilder.Entity("API.MangaConnectors.MangaDex", b =>
{
b.HasBaseType("API.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")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b1.Property<string>("Language")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b1.Property<string>("MangaKey")
.IsRequired()
.HasColumnType("character varying(64)");
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")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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("character varying(64)");
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.Chapter", "Obj")
.WithMany("MangaConnectorIds")
.HasForeignKey("ObjId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Obj");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Manga>", b =>
{
b.HasOne("API.Schema.MangaContext.Manga", "Obj")
.WithMany("MangaConnectorIds")
.HasForeignKey("ObjId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
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,258 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations.Manga
{
/// <inheritdoc />
public partial class KeyLengthChange : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "MangaId",
table: "MetadataEntries",
type: "character varying(64)",
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "MangaIds",
table: "MangaTagToManga",
type: "character varying(64)",
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "Mangas",
type: "character varying(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "MangaConnectorToManga",
type: "character varying(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "MangaConnectorToChapter",
type: "character varying(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "MangaKey",
table: "Link",
type: "character varying(64)",
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "Link",
type: "character varying(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "FileLibraries",
type: "character varying(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "Chapters",
type: "character varying(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "MangaIds",
table: "AuthorToManga",
type: "character varying(64)",
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "AuthorIds",
table: "AuthorToManga",
type: "character varying(64)",
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "Authors",
type: "character varying(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "MangaKey",
table: "AltTitle",
type: "character varying(64)",
nullable: false,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "AltTitle",
type: "character varying(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "MangaId",
table: "MetadataEntries",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)");
migrationBuilder.AlterColumn<string>(
name: "MangaIds",
table: "MangaTagToManga",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)");
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "Mangas",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "MangaConnectorToManga",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "MangaConnectorToChapter",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
migrationBuilder.AlterColumn<string>(
name: "MangaKey",
table: "Link",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)");
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "Link",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "FileLibraries",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "Chapters",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
migrationBuilder.AlterColumn<string>(
name: "MangaIds",
table: "AuthorToManga",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)");
migrationBuilder.AlterColumn<string>(
name: "AuthorIds",
table: "AuthorToManga",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)");
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "Authors",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
migrationBuilder.AlterColumn<string>(
name: "MangaKey",
table: "AltTitle",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)");
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "AltTitle",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
}
}
}

View File

@@ -16,7 +16,7 @@ namespace API.Migrations.Manga
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.8") .HasAnnotation("ProductVersion", "9.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -57,8 +57,7 @@ namespace API.Migrations.Manga
modelBuilder.Entity("API.Schema.MangaContext.Author", b => modelBuilder.Entity("API.Schema.MangaContext.Author", b =>
{ {
b.Property<string>("Key") b.Property<string>("Key")
.HasMaxLength(64) .HasColumnType("text");
.HasColumnType("character varying(64)");
b.Property<string>("AuthorName") b.Property<string>("AuthorName")
.IsRequired() .IsRequired()
@@ -73,8 +72,7 @@ namespace API.Migrations.Manga
modelBuilder.Entity("API.Schema.MangaContext.Chapter", b => modelBuilder.Entity("API.Schema.MangaContext.Chapter", b =>
{ {
b.Property<string>("Key") b.Property<string>("Key")
.HasMaxLength(64) .HasColumnType("text");
.HasColumnType("character varying(64)");
b.Property<string>("ChapterNumber") b.Property<string>("ChapterNumber")
.IsRequired() .IsRequired()
@@ -111,8 +109,7 @@ namespace API.Migrations.Manga
modelBuilder.Entity("API.Schema.MangaContext.FileLibrary", b => modelBuilder.Entity("API.Schema.MangaContext.FileLibrary", b =>
{ {
b.Property<string>("Key") b.Property<string>("Key")
.HasMaxLength(64) .HasColumnType("text");
.HasColumnType("character varying(64)");
b.Property<string>("BasePath") b.Property<string>("BasePath")
.IsRequired() .IsRequired()
@@ -132,8 +129,7 @@ namespace API.Migrations.Manga
modelBuilder.Entity("API.Schema.MangaContext.Manga", b => modelBuilder.Entity("API.Schema.MangaContext.Manga", b =>
{ {
b.Property<string>("Key") b.Property<string>("Key")
.HasMaxLength(64) .HasColumnType("text");
.HasColumnType("character varying(64)");
b.Property<string>("CoverFileNameInCache") b.Property<string>("CoverFileNameInCache")
.HasMaxLength(512) .HasMaxLength(512)
@@ -185,8 +181,7 @@ namespace API.Migrations.Manga
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Chapter>", b => modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Chapter>", b =>
{ {
b.Property<string>("Key") b.Property<string>("Key")
.HasMaxLength(64) .HasColumnType("text");
.HasColumnType("character varying(64)");
b.Property<string>("IdOnConnectorSite") b.Property<string>("IdOnConnectorSite")
.IsRequired() .IsRequired()
@@ -220,8 +215,7 @@ namespace API.Migrations.Manga
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Manga>", b => modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Manga>", b =>
{ {
b.Property<string>("Key") b.Property<string>("Key")
.HasMaxLength(64) .HasColumnType("text");
.HasColumnType("character varying(64)");
b.Property<string>("IdOnConnectorSite") b.Property<string>("IdOnConnectorSite")
.IsRequired() .IsRequired()
@@ -273,7 +267,7 @@ namespace API.Migrations.Manga
b.Property<string>("MangaId") b.Property<string>("MangaId")
.IsRequired() .IsRequired()
.HasColumnType("character varying(64)"); .HasColumnType("text");
b.HasKey("MetadataFetcherName", "Identifier"); b.HasKey("MetadataFetcherName", "Identifier");
@@ -304,10 +298,10 @@ namespace API.Migrations.Manga
modelBuilder.Entity("AuthorToManga", b => modelBuilder.Entity("AuthorToManga", b =>
{ {
b.Property<string>("AuthorIds") b.Property<string>("AuthorIds")
.HasColumnType("character varying(64)"); .HasColumnType("text");
b.Property<string>("MangaIds") b.Property<string>("MangaIds")
.HasColumnType("character varying(64)"); .HasColumnType("text");
b.HasKey("AuthorIds", "MangaIds"); b.HasKey("AuthorIds", "MangaIds");
@@ -322,7 +316,7 @@ namespace API.Migrations.Manga
.HasColumnType("character varying(64)"); .HasColumnType("character varying(64)");
b.Property<string>("MangaIds") b.Property<string>("MangaIds")
.HasColumnType("character varying(64)"); .HasColumnType("text");
b.HasKey("MangaTagIds", "MangaIds"); b.HasKey("MangaTagIds", "MangaIds");
@@ -380,8 +374,7 @@ namespace API.Migrations.Manga
b.OwnsMany("API.Schema.MangaContext.AltTitle", "AltTitles", b1 => b.OwnsMany("API.Schema.MangaContext.AltTitle", "AltTitles", b1 =>
{ {
b1.Property<string>("Key") b1.Property<string>("Key")
.HasMaxLength(64) .HasColumnType("text");
.HasColumnType("character varying(64)");
b1.Property<string>("Language") b1.Property<string>("Language")
.IsRequired() .IsRequired()
@@ -390,7 +383,7 @@ namespace API.Migrations.Manga
b1.Property<string>("MangaKey") b1.Property<string>("MangaKey")
.IsRequired() .IsRequired()
.HasColumnType("character varying(64)"); .HasColumnType("text");
b1.Property<string>("Title") b1.Property<string>("Title")
.IsRequired() .IsRequired()
@@ -410,8 +403,7 @@ namespace API.Migrations.Manga
b.OwnsMany("API.Schema.MangaContext.Link", "Links", b1 => b.OwnsMany("API.Schema.MangaContext.Link", "Links", b1 =>
{ {
b1.Property<string>("Key") b1.Property<string>("Key")
.HasMaxLength(64) .HasColumnType("text");
.HasColumnType("character varying(64)");
b1.Property<string>("LinkProvider") b1.Property<string>("LinkProvider")
.IsRequired() .IsRequired()
@@ -425,7 +417,7 @@ namespace API.Migrations.Manga
b1.Property<string>("MangaKey") b1.Property<string>("MangaKey")
.IsRequired() .IsRequired()
.HasColumnType("character varying(64)"); .HasColumnType("text");
b1.HasKey("Key"); b1.HasKey("Key");

View File

@@ -1,92 +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("20250901194327_KeyLengthChange")]
partial class KeyLengthChange
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.NotificationsContext.Notification", b =>
{
b.Property<string>("Key")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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,36 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations.Notifications
{
/// <inheritdoc />
public partial class KeyLengthChange : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "Notifications",
type: "character varying(64)",
maxLength: 64,
nullable: false,
oldClrType: typeof(string),
oldType: "text");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Key",
table: "Notifications",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(64)",
oldMaxLength: 64);
}
}
}

View File

@@ -18,7 +18,7 @@ namespace API.Migrations.Notifications
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.8") .HasAnnotation("ProductVersion", "9.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
@@ -27,8 +27,7 @@ namespace API.Migrations.Notifications
modelBuilder.Entity("API.Schema.NotificationsContext.Notification", b => modelBuilder.Entity("API.Schema.NotificationsContext.Notification", b =>
{ {
b.Property<string>("Key") b.Property<string>("Key")
.HasMaxLength(64) .HasColumnType("text");
.HasColumnType("character varying(64)");
b.Property<DateTime>("Date") b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");

View File

@@ -1,5 +1,6 @@
using System.Reflection; using System.Reflection;
using API; using API;
using API.MangaConnectors;
using API.Schema.LibraryContext; using API.Schema.LibraryContext;
using API.Schema.MangaContext; using API.Schema.MangaContext;
using API.Schema.NotificationsContext; using API.Schema.NotificationsContext;
@@ -75,7 +76,7 @@ builder.Services.AddControllers().AddNewtonsoftJson(opts =>
opts.SerializerSettings.Converters.Add(new StringEnumConverter()); opts.SerializerSettings.Converters.Add(new StringEnumConverter());
opts.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; opts.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
}); });
builder.Services.AddScoped<ILog>(_ => LogManager.GetLogger("API")); builder.Services.AddScoped<ILog>(opts => LogManager.GetLogger("API"));
builder.WebHost.UseUrls("http://*:6531"); builder.WebHost.UseUrls("http://*:6531");
@@ -111,7 +112,7 @@ using (IServiceScope scope = app.Services.CreateScope())
if (!context.FileLibraries.Any()) if (!context.FileLibraries.Any())
context.FileLibraries.Add(new FileLibrary(Tranga.Settings.DownloadLocation, "Default FileLibrary")); context.FileLibraries.Add(new FileLibrary(Tranga.Settings.DownloadLocation, "Default FileLibrary"));
await context.Sync(CancellationToken.None); context.Sync();
} }
using (IServiceScope scope = app.Services.CreateScope()) using (IServiceScope scope = app.Services.CreateScope())
@@ -123,7 +124,7 @@ using (IServiceScope scope = app.Services.CreateScope())
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=)"}; 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.Notifications.Add(new Notification("Tranga Started", emojis[Random.Shared.Next(0, emojis.Length - 1)], NotificationUrgency.High));
await context.Sync(CancellationToken.None); context.Sync();
} }
using (IServiceScope scope = app.Services.CreateScope()) using (IServiceScope scope = app.Services.CreateScope())
@@ -131,11 +132,11 @@ using (IServiceScope scope = app.Services.CreateScope())
LibraryContext context = scope.ServiceProvider.GetRequiredService<LibraryContext>(); LibraryContext context = scope.ServiceProvider.GetRequiredService<LibraryContext>();
context.Database.Migrate(); context.Database.Migrate();
await context.Sync(CancellationToken.None); context.Sync();
} }
Tranga.SetServiceProvider(app.Services); Tranga.SetServiceProvider(app.Services);
Tranga.StartLogger(new FileInfo("Log4Net.config.xml")); Tranga.StartLogger();
Tranga.AddDefaultWorkers(); Tranga.AddDefaultWorkers();
app.UseCors("AllowAll"); app.UseCors("AllowAll");

View File

@@ -1,4 +1,3 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace API.Schema; namespace API.Schema;
@@ -6,18 +5,16 @@ namespace API.Schema;
[PrimaryKey("Key")] [PrimaryKey("Key")]
public abstract class Identifiable public abstract class Identifiable
{ {
protected Identifiable() public Identifiable()
{ {
this.Key = TokenGen.CreateToken(this.GetType()); this.Key = TokenGen.CreateToken(this.GetType());
} }
protected Identifiable(string key) public Identifiable(string key)
{ {
this.Key = key; this.Key = key;
} }
[Required]
[StringLength(TokenGen.MaximumLength, MinimumLength = TokenGen.MinimumLength)]
public string Key { get; init; } public string Key { get; init; }
public override string ToString() => Key; public override string ToString() => Key;

View File

@@ -9,10 +9,19 @@ namespace API.Schema.LibraryContext.LibraryConnectors;
[PrimaryKey("Key")] [PrimaryKey("Key")]
public abstract class LibraryConnector : Identifiable public abstract class LibraryConnector : Identifiable
{ {
[Required]
public LibraryType LibraryType { get; init; } public LibraryType LibraryType { get; init; }
[StringLength(256)] [Url] public string BaseUrl { get; init; } [StringLength(256)]
[StringLength(256)] public string Auth { get; init; } [Required]
[NotMapped] protected ILog Log { get; init; } [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) protected LibraryConnector(LibraryType libraryType, string baseUrl, string auth)
: base() : base()

View File

@@ -7,6 +7,7 @@ namespace API.Schema.MangaContext;
public class Author(string authorName) : Identifiable(TokenGen.CreateToken(typeof(Author), authorName)) public class Author(string authorName) : Identifiable(TokenGen.CreateToken(typeof(Author), authorName))
{ {
[StringLength(128)] [StringLength(128)]
[Required]
public string AuthorName { get; init; } = authorName; public string AuthorName { get; init; } = authorName;
public override string ToString() => $"{base.ToString()} {AuthorName}"; public override string ToString() => $"{base.ToString()} {AuthorName}";

View File

@@ -4,27 +4,28 @@ using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Xml.Linq; using System.Xml.Linq;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.MangaContext; namespace API.Schema.MangaContext;
[PrimaryKey("Key")] [PrimaryKey("Key")]
public class Chapter : Identifiable, IComparable<Chapter> public class Chapter : Identifiable, IComparable<Chapter>
{ {
[StringLength(64)] public string ParentMangaId { get; init; } = null!; [StringLength(64)] [Required] public string ParentMangaId { get; init; } = null!;
public Manga ParentManga = null!; [JsonIgnore] public Manga ParentManga = null!;
[NotMapped] public Dictionary<string, string> IdsOnMangaConnectors => [NotMapped] public Dictionary<string, string> IdsOnMangaConnectors =>
MangaConnectorIds.ToDictionary(id => id.MangaConnectorName, id => id.IdOnConnectorSite); MangaConnectorIds.ToDictionary(id => id.MangaConnectorName, id => id.IdOnConnectorSite);
public ICollection<MangaConnectorId<Chapter>> MangaConnectorIds = null!; [JsonIgnore] public ICollection<MangaConnectorId<Chapter>> MangaConnectorIds = null!;
public int? VolumeNumber { get; private set; } public int? VolumeNumber { get; private set; }
[StringLength(10)] public string ChapterNumber { get; private set; } [StringLength(10)] [Required] public string ChapterNumber { get; private set; }
[StringLength(256)] public string? Title { get; private set; } [StringLength(256)] public string? Title { get; private set; }
[StringLength(256)] public string FileName { get; private set; } [StringLength(256)] [Required] public string FileName { get; private set; }
public bool Downloaded { get; internal set; } [Required] public bool Downloaded { get; internal set; }
[NotMapped] public string FullArchiveFilePath => Path.Join(ParentManga.FullDirectoryPath, FileName); [NotMapped] public string FullArchiveFilePath => Path.Join(ParentManga.FullDirectoryPath, FileName);
public Chapter(Manga parentManga, string chapterNumber, public Chapter(Manga parentManga, string chapterNumber,
@@ -66,27 +67,11 @@ public class Chapter : Identifiable, IComparable<Chapter>
}; };
} }
/// <summary> /// <summary>
/// Checks the filesystem if an archive at the ArchiveFilePath exists /// Checks the filesystem if an archive at the ArchiveFilePath exists
/// </summary> /// </summary>
/// <param name="context"></param>
/// <param name="token"></param>
/// <returns>True if archive exists on disk</returns> /// <returns>True if archive exists on disk</returns>
/// <exception cref="KeyNotFoundException">Unable to load Chapter, Parent or Library</exception> public bool CheckDownloaded() => File.Exists(FullArchiveFilePath);
public async Task<bool> CheckDownloaded(MangaContext context, CancellationToken? token = null)
{
if(await context.Chapters
.Include(c => c.ParentManga)
.ThenInclude(p => p.Library)
.FirstOrDefaultAsync(c => c.Key == this.Key, token??CancellationToken.None) is not { } chapter)
throw new KeyNotFoundException("Unable to find chapter");
//TODO Log here
this.Downloaded = File.Exists(chapter.FullArchiveFilePath);
await context.Sync(token??CancellationToken.None);
return this.Downloaded;
}
/// Placeholders: /// Placeholders:
/// %M Obj Name /// %M Obj Name
@@ -156,20 +141,6 @@ public class Chapter : Identifiable, IComparable<Chapter>
return stringBuilder.ToString(); return stringBuilder.ToString();
} }
public class ChapterComparer : IComparer<Chapter>
{
public int Compare(Chapter? x, Chapter? y)
{
if (x is null && y is null)
return 0;
if(x is null)
return -1;
if (y is null)
return 1;
return CompareChapterNumbers(x.ChapterNumber, y.ChapterNumber);
}
}
private static int CompareChapterNumbers(string ch1, string ch2) private static int CompareChapterNumbers(string ch1, string ch2)
{ {
int[] ch1Arr = ch1.Split('.').Select(c => int.TryParse(c, out int result) ? result : -1).ToArray(); int[] ch1Arr = ch1.Split('.').Select(c => int.TryParse(c, out int result) ? result : -1).ToArray();

View File

@@ -7,9 +7,13 @@ namespace API.Schema.MangaContext;
public class FileLibrary(string basePath, string libraryName) public class FileLibrary(string basePath, string libraryName)
: Identifiable(TokenGen.CreateToken(typeof(FileLibrary), basePath)) : Identifiable(TokenGen.CreateToken(typeof(FileLibrary), basePath))
{ {
[StringLength(256)] public string BasePath { get; internal set; } = basePath; [StringLength(256)]
[Required]
public string BasePath { get; internal set; } = basePath;
[StringLength(512)] public string LibraryName { get; internal set; } = libraryName; [StringLength(512)]
[Required]
public string LibraryName { get; internal set; } = libraryName;
public override string ToString() => $"{base.ToString()} {LibraryName} - {BasePath}"; public override string ToString() => $"{base.ToString()} {LibraryName} - {BasePath}";
} }

View File

@@ -7,8 +7,10 @@ namespace API.Schema.MangaContext;
public class Link(string linkProvider, string linkUrl) : Identifiable(TokenGen.CreateToken(typeof(Link), linkProvider, linkUrl)) public class Link(string linkProvider, string linkUrl) : Identifiable(TokenGen.CreateToken(typeof(Link), linkProvider, linkUrl))
{ {
[StringLength(64)] [StringLength(64)]
[Required]
public string LinkProvider { get; init; } = linkProvider; public string LinkProvider { get; init; } = linkProvider;
[StringLength(2048)] [StringLength(2048)]
[Required]
[Url] [Url]
public string LinkUrl { get; init; } = linkUrl; public string LinkUrl { get; init; } = linkUrl;

View File

@@ -4,10 +4,8 @@ using System.Runtime.InteropServices;
using System.Text; using System.Text;
using API.Workers; using API.Workers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SixLabors.ImageSharp; using Microsoft.EntityFrameworkCore.Infrastructure;
using SixLabors.ImageSharp.Formats.Jpeg; using Newtonsoft.Json;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Transforms;
using static System.IO.UnixFileMode; using static System.IO.UnixFileMode;
namespace API.Schema.MangaContext; namespace API.Schema.MangaContext;
@@ -15,31 +13,34 @@ namespace API.Schema.MangaContext;
[PrimaryKey("Key")] [PrimaryKey("Key")]
public class Manga : Identifiable public class Manga : Identifiable
{ {
[StringLength(512)] public string Name { get; internal set; } [StringLength(512)] [Required] public string Name { get; internal set; }
[Required] public string Description { get; internal set; } [Required] public string Description { get; internal set; }
[Url] [StringLength(512)] public string CoverUrl { get; internal set; } [JsonIgnore] [Url] [StringLength(512)] public string CoverUrl { get; internal set; }
public MangaReleaseStatus ReleaseStatus { get; internal set; } [Required] public MangaReleaseStatus ReleaseStatus { get; internal set; }
[StringLength(64)] public string? LibraryId { get; private set; } [StringLength(64)] public string? LibraryId { get; private set; }
public FileLibrary? Library = null!; [JsonIgnore] public FileLibrary? Library = null!;
public ICollection<Author> Authors { get; internal set; } = null!; public ICollection<Author> Authors { get; internal set; } = null!;
public ICollection<MangaTag> MangaTags { get; internal set; } = null!; public ICollection<MangaTag> MangaTags { get; internal set; } = null!;
public ICollection<Link> Links { get; internal set; } = null!; public ICollection<Link> Links { get; internal set; } = null!;
public ICollection<AltTitle> AltTitles { get; internal set; } = null!; public ICollection<AltTitle> AltTitles { get; internal set; } = null!;
public float IgnoreChaptersBefore { get; internal set; } [Required] public float IgnoreChaptersBefore { get; internal set; }
[StringLength(1024)] [Required] public string DirectoryName { get; private set; } [StringLength(1024)] [Required] public string DirectoryName { get; private set; }
[StringLength(512)] public string? CoverFileNameInCache { get; internal set; } [JsonIgnore] [StringLength(512)] public string? CoverFileNameInCache { get; internal set; }
public uint? Year { get; internal init; } public uint? Year { get; internal init; }
[StringLength(8)] public string? OriginalLanguage { get; internal init; } [StringLength(8)] public string? OriginalLanguage { get; internal init; }
[NotMapped] public string? FullDirectoryPath => Library is not null ? Path.Join(Library.BasePath, DirectoryName) : null; [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(); [NotMapped] public ICollection<string> ChapterIds => Chapters.Select(c => c.Key).ToList();
public ICollection<Chapter> Chapters = null!; [JsonIgnore] public ICollection<Chapter> Chapters = null!;
[NotMapped] public Dictionary<string, string> IdsOnMangaConnectors => MangaConnectorIds.ToDictionary(id => id.MangaConnectorName, id => id.IdOnConnectorSite); [NotMapped] public Dictionary<string, string> IdsOnMangaConnectors =>
MangaConnectorIds.ToDictionary(id => id.MangaConnectorName, id => id.IdOnConnectorSite);
[NotMapped] public ICollection<string> MangaConnectorIdsIds => MangaConnectorIds.Select(id => id.Key).ToList(); [NotMapped] public ICollection<string> MangaConnectorIdsIds => MangaConnectorIds.Select(id => id.Key).ToList();
public ICollection<MangaConnectorId<Manga>> MangaConnectorIds = null!; [JsonIgnore] public ICollection<MangaConnectorId<Manga>> MangaConnectorIds = null!;
public Manga(string name, string description, string coverUrl, MangaReleaseStatus releaseStatus, public Manga(string name, string description, string coverUrl, MangaReleaseStatus releaseStatus,
ICollection<Author> authors, ICollection<MangaTag> mangaTags, ICollection<Link> links, ICollection<AltTitle> altTitles, ICollection<Author> authors, ICollection<MangaTag> mangaTags, ICollection<Link> links, ICollection<AltTitle> altTitles,
@@ -159,18 +160,6 @@ public class Manga : Identifiable
return newJobs.ToArray(); return newJobs.ToArray();
} }
public async Task<(MemoryStream stream, FileInfo fileInfo)?> GetCoverImage(string cachePath, CancellationToken ct)
{
string fullPath = Path.Join(cachePath, CoverFileNameInCache);
if (!File.Exists(fullPath))
return null;
FileInfo fileInfo = new(fullPath);
MemoryStream stream = new (await File.ReadAllBytesAsync(fullPath, ct));
return (stream, fileInfo);
}
public override string ToString() => $"{base.ToString()} {Name}"; public override string ToString() => $"{base.ToString()} {Name}";
} }

View File

@@ -1,18 +1,19 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using API.MangaConnectors; using API.MangaConnectors;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.MangaContext; namespace API.Schema.MangaContext;
[PrimaryKey("Key")] [PrimaryKey("Key")]
public class MangaConnectorId<T> : Identifiable where T : Identifiable public class MangaConnectorId<T> : Identifiable where T : Identifiable
{ {
public T Obj = null!; [StringLength(64)] [Required] public string ObjId { get; internal set; }
[StringLength(64)] public string ObjId { get; internal set; } [JsonIgnore] public T Obj = null!;
[StringLength(32)] public string MangaConnectorName { get; private set; } [StringLength(32)] [Required] public string MangaConnectorName { get; private set; }
[StringLength(256)] public string IdOnConnectorSite { get; init; } [StringLength(256)] [Required] public string IdOnConnectorSite { get; init; }
[Url] [StringLength(512)] public string? WebsiteUrl { get; internal init; } [Url] [StringLength(512)] public string? WebsiteUrl { get; internal init; }
public bool UseForDownload { get; internal set; } public bool UseForDownload { get; internal set; }

View File

@@ -104,24 +104,22 @@ public class MangaContext(DbContextOptions<MangaContext> options) : TrangaBaseCo
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
} }
public async Task<Manga?> FindMangaLike(Manga other, CancellationToken token) public Manga? FindMangaLike(Manga other)
{ {
if (await MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == other.Key, token) is { } f) if (MangaIncludeAll().FirstOrDefault(m => m.Key == other.Key) is { } f)
return f; return f;
return await MangaIncludeAll() return MangaIncludeAll()
.FirstOrDefaultAsync(m => .FirstOrDefault(m => m.Links.Any(l => l.Key == other.Key) ||
m.Links.Any(l => l.Key == other.Key) || m.AltTitles.Any(t => other.AltTitles.Select(ot => ot.Title)
m.AltTitles.Any(t => other.AltTitles.Select(ot => ot.Title).Any(s => s.Equals(t.Title))), token); .Any(s => s.Equals(t.Title))));
} }
public IIncludableQueryable<Manga, ICollection<MangaConnectorId<Manga>>> MangaIncludeAll() => public IIncludableQueryable<Manga, ICollection<MangaConnectorId<Manga>>> MangaIncludeAll() => Mangas.Include(m => m.Library)
Mangas .Include(m => m.Authors)
.Include(m => m.Library) .Include(m => m.MangaTags)
.Include(m => m.Authors) .Include(m => m.Links)
.Include(m => m.MangaTags) .Include(m => m.AltTitles)
.Include(m => m.Links) .Include(m => m.Chapters)
.Include(m => m.AltTitles) .Include(m => m.MangaConnectorIds);
.Include(m => m.Chapters)
.Include(m => m.MangaConnectorIds);
} }

View File

@@ -32,6 +32,4 @@ public class MetadataEntry
this.Identifier = identifier; this.Identifier = identifier;
this.MetadataFetcherName = metadataFetcherName; this.MetadataFetcherName = metadataFetcherName;
} }
public override string ToString() => $"{GetType().FullName} {MangaId} {MetadataFetcherName}";
} }

View File

@@ -31,5 +31,5 @@ public abstract class MetadataFetcher
/// <summary> /// <summary>
/// Updates the Manga linked in the MetadataEntry /// Updates the Manga linked in the MetadataEntry
/// </summary> /// </summary>
public abstract Task UpdateMetadata(MetadataEntry metadataEntry, MangaContext dbContext, CancellationToken token); public abstract void UpdateMetadata(MetadataEntry metadataEntry, MangaContext dbContext);
} }

View File

@@ -43,49 +43,42 @@ public class MyAnimeList : MetadataFetcher
/// </summary> /// </summary>
/// <param name="metadataEntry"></param> /// <param name="metadataEntry"></param>
/// <param name="dbContext"></param> /// <param name="dbContext"></param>
/// <param name="token"></param>
/// <exception cref="FormatException"></exception> /// <exception cref="FormatException"></exception>
/// <exception cref="DbUpdateException"></exception> /// <exception cref="DbUpdateException"></exception>
public override async Task UpdateMetadata(MetadataEntry metadataEntry, MangaContext dbContext, CancellationToken token) public override void UpdateMetadata(MetadataEntry metadataEntry, MangaContext dbContext)
{ {
Manga? dbManga = metadataEntry.Manga; //Might be null! Manga dbManga = dbContext.Mangas.Find(metadataEntry.MangaId)!;
if (dbManga is null)
{
if (await dbContext.Mangas.FirstOrDefaultAsync(m => m.Key == metadataEntry.MangaId, token) is not
{ } update)
throw new DbUpdateException("Manga not found");
dbManga = update;
}
// Load all collections (tags, links, authors)...
foreach (CollectionEntry collectionEntry in dbContext.Entry(dbManga).Collections) foreach (CollectionEntry collectionEntry in dbContext.Entry(dbManga).Collections)
{ collectionEntry.Load();
if(!collectionEntry.IsLoaded) dbContext.Entry(dbManga).Navigation(nameof(Manga.Library)).Load();
await collectionEntry.LoadAsync(token);
}
await dbContext.Entry(dbManga).Reference(m => m.Library).LoadAsync(token);
MangaFull resultData; MangaFull resultData;
try try
{ {
long id = long.Parse(metadataEntry.Identifier); long id = long.Parse(metadataEntry.Identifier);
if(await Jikan.GetMangaFullDataAsync(id, token) is not { } response) resultData = Jikan.GetMangaFullDataAsync(id).Result.Data;
throw new DbUpdateException("Manga Data not found");
resultData = response.Data;
} }
catch (Exception) catch (Exception)
{ {
throw new FormatException("ID was not in correct format"); throw new FormatException("ID was not in correct format");
} }
dbManga.Name = resultData.Titles.First().Title; try
dbManga.Description = resultData.Synopsis; {
dbManga.AltTitles.Clear(); dbManga.Name = resultData.Titles.First().Title;
dbManga.AltTitles = resultData.Titles.Select(t => new AltTitle(t.Type, t.Title)).ToList(); dbManga.Description = resultData.Synopsis;
dbManga.Authors.Clear(); dbManga.AltTitles.Clear();
dbManga.Authors = resultData.Authors.Select(a => new Author(a.Name)).ToList(); 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();
await dbContext.Sync(token); dbContext.Sync();
}
catch (DbUpdateException e)
{
throw;
}
} }
} }

View File

@@ -10,22 +10,36 @@ namespace API.Schema.NotificationsContext.NotificationConnectors;
[PrimaryKey("Name")] [PrimaryKey("Name")]
public class NotificationConnector(string name, string url, Dictionary<string, string> headers, string httpMethod, string body) public class NotificationConnector(string name, string url, Dictionary<string, string> headers, string httpMethod, string body)
{ {
[StringLength(64)] public string Name { get; init; } = name; [StringLength(64)]
[Required]
public string Name { get; init; } = name;
[StringLength(2048)] [Url] public string Url { get; internal set; } = url; [StringLength(2048)]
[Required]
[Url]
public string Url { get; internal set; } = url;
[Required] public Dictionary<string, string> Headers { get; internal set; } = headers; [Required]
public Dictionary<string, string> Headers { get; internal set; } = headers;
[StringLength(8)] public string HttpMethod { get; internal set; } = httpMethod; [StringLength(8)]
[Required]
public string HttpMethod { get; internal set; } = httpMethod;
[StringLength(4096)] public string Body { get; internal set; } = body; [StringLength(4096)]
[Required]
public string Body { get; internal set; } = body;
[NotMapped] private readonly HttpClient Client = new() [JsonIgnore]
[NotMapped]
private readonly HttpClient Client = new()
{ {
DefaultRequestHeaders = { { "User-Agent", Tranga.Settings.UserAgent } } DefaultRequestHeaders = { { "User-Agent", Tranga.Settings.UserAgent } }
}; };
[JsonIgnore] protected ILog Log = LogManager.GetLogger(name); [JsonIgnore]
[NotMapped]
protected ILog Log = LogManager.GetLogger(name);
public void SendNotification(string title, string notificationText) public void SendNotification(string title, string notificationText)
{ {

View File

@@ -22,11 +22,11 @@ public abstract class TrangaBaseContext<T> : DbContext where T : DbContext
}, Array.Empty<string>(), LogLevel.Warning, DbContextLoggerOptions.Level | DbContextLoggerOptions.Category | DbContextLoggerOptions.UtcTime); }, Array.Empty<string>(), LogLevel.Warning, DbContextLoggerOptions.Level | DbContextLoggerOptions.Category | DbContextLoggerOptions.UtcTime);
} }
internal async Task<(bool success, string? exceptionMessage)> Sync(CancellationToken token) internal (bool success, string? exceptionMessage) Sync()
{ {
try try
{ {
await this.SaveChangesAsync(token); this.SaveChanges();
return (true, null); return (true, null);
} }
catch (Exception e) catch (Exception e)

View File

@@ -5,8 +5,8 @@ namespace API;
public static class TokenGen public static class TokenGen
{ {
public const int MinimumLength = 16; private const int MinimumLength = 16;
public const int MaximumLength = 64; private const int MaximumLength = 64;
private const string Chars = "abcdefghijklmnopqrstuvwxyz0123456789"; private const string Chars = "abcdefghijklmnopqrstuvwxyz0123456789";
public static string CreateToken(Type t, params string[] identifiers) => CreateToken(t.Name, identifiers); public static string CreateToken(Type t, params string[] identifiers) => CreateToken(t.Name, identifiers);

View File

@@ -15,6 +15,16 @@ namespace API;
public static class Tranga public static class Tranga
{ {
// ReSharper disable once InconsistentNaming
private const string TRANGA =
"\n\n" +
" _______ v2\n" +
"|_ _|.----..---.-..-----..-----..---.-.\n" +
" | | | _|| _ || || _ || _ |\n" +
" |___| |__| |___._||__|__||___ ||___._|\n" +
" |_____| \n\n";
private static IServiceProvider? ServiceProvider; private static IServiceProvider? ServiceProvider;
private static readonly ILog Log = LogManager.GetLogger(typeof(Tranga)); private static readonly ILog Log = LogManager.GetLogger(typeof(Tranga));
@@ -31,11 +41,11 @@ public static class Tranga
internal static readonly RemoveOldNotificationsWorker RemoveOldNotificationsWorker = new(); internal static readonly RemoveOldNotificationsWorker RemoveOldNotificationsWorker = new();
internal static readonly UpdateCoversWorker UpdateCoversWorker = new(); internal static readonly UpdateCoversWorker UpdateCoversWorker = new();
internal static void StartLogger(FileInfo loggerConfigFile) internal static void StartLogger()
{ {
XmlConfigurator.ConfigureAndWatch(loggerConfigFile); BasicConfigurator.Configure();
Log.Info("Logger Configured."); Log.Info("Logger Configured.");
Log.Info(Constants.TRANGA); Log.Info(TRANGA);
} }
internal static void AddDefaultWorkers() internal static void AddDefaultWorkers()
@@ -115,12 +125,6 @@ public static class Tranga
return; return;
} }
Action afterWorkCallback = AfterWork(worker, callback); Action afterWorkCallback = AfterWork(worker, callback);
while (RunningWorkers.Count > Settings.MaxConcurrentWorkers)
{
Log.Warn($"{worker}: Max worker concurrency reached ({Settings.MaxConcurrentWorkers})! Waiting {Settings.WorkCycleTimeoutMs}ms...");
Thread.Sleep(Settings.WorkCycleTimeoutMs);
}
if (worker is BaseWorkerWithContext<MangaContext> mangaContextWorker) if (worker is BaseWorkerWithContext<MangaContext> mangaContextWorker)
{ {
@@ -141,24 +145,6 @@ public static class Tranga
private static Action AfterWork(BaseWorker worker, Action? callback = null) => () => private static Action AfterWork(BaseWorker worker, Action? callback = null) => () =>
{ {
Log.Debug($"AfterWork {worker}"); Log.Debug($"AfterWork {worker}");
if (RunningWorkers.TryGetValue(worker, out Task<BaseWorker[]>? task))
{
Log.Debug($"Waiting for Children to exit {worker}");
task.Wait();
if (task.IsCompleted)
{
try
{
BaseWorker[] newWorkers = task.Result;
Log.Debug($"{worker} created {newWorkers.Length} new Workers.");
AddWorkers(newWorkers);
}
catch (Exception e)
{
Log.Error(e);
}
}else Log.Warn($"Children failed: {worker}");
}
RunningWorkers.Remove(worker, out _); RunningWorkers.Remove(worker, out _);
callback?.Invoke(); callback?.Invoke();
}; };
@@ -172,14 +158,12 @@ public static class Tranga
RunningWorkers.Remove(worker, out _); RunningWorkers.Remove(worker, out _);
} }
internal static async Task<bool> AddMangaToContext(this MangaContext context, (Manga, MangaConnectorId<Manga>) addManga, CancellationToken token) => internal static bool AddMangaToContext((Manga, MangaConnectorId<Manga>) addManga, MangaContext context, [NotNullWhen(true)]out Manga? manga) => AddMangaToContext(addManga.Item1, addManga.Item2, context, out manga);
await AddMangaToContext(context, addManga.Item1, addManga.Item2, token);
internal static async Task<bool> AddMangaToContext(this MangaContext context, Manga addManga, MangaConnectorId<Manga> addMcId, internal static bool AddMangaToContext(Manga addManga, MangaConnectorId<Manga> addMcId, MangaContext context, [NotNullWhen(true)]out Manga? manga)
CancellationToken token)
{ {
context.ChangeTracker.Clear(); context.ChangeTracker.Clear();
Manga? manga = await context.FindMangaLike(addManga, token); manga = context.FindMangaLike(addManga);
if (manga is not null) if (manga is not null)
{ {
foreach (MangaConnectorId<Manga> mcId in addManga.MangaConnectorIds) foreach (MangaConnectorId<Manga> mcId in addManga.MangaConnectorIds)
@@ -193,8 +177,6 @@ public static class Tranga
manga.AltTitles = manga.AltTitles.UnionBy(addManga.AltTitles, altTitle => altTitle.Key).ToList(); manga.AltTitles = manga.AltTitles.UnionBy(addManga.AltTitles, altTitle => altTitle.Key).ToList();
manga.Chapters = manga.Chapters.UnionBy(addManga.Chapters, chapter => chapter.Key).ToList(); manga.Chapters = manga.Chapters.UnionBy(addManga.Chapters, chapter => chapter.Key).ToList();
manga.MangaConnectorIds = manga.MangaConnectorIds.UnionBy(addManga.MangaConnectorIds, id => id.MangaConnectorName).ToList(); manga.MangaConnectorIds = manga.MangaConnectorIds.UnionBy(addManga.MangaConnectorIds, id => id.MangaConnectorName).ToList();
addManga = manga;
} }
else else
{ {
@@ -216,7 +198,7 @@ public static class Tranga
context.Mangas.Add(manga); context.Mangas.Add(manga);
} }
if (await context.Sync(token) is { success: false }) if (context.Sync() is { success: false })
return false; return false;
DownloadCoverFromMangaconnectorWorker downloadCoverWorker = new (addMcId); DownloadCoverFromMangaconnectorWorker downloadCoverWorker = new (addMcId);
@@ -224,4 +206,28 @@ public static class Tranga
return true; 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.Where(ch => ch.Key == addChapter.Key)
.Include(ch => ch.ParentManga)
.Include(ch => ch.MangaConnectorIds)
.FirstOrDefault();
if (chapter is not null)
{
chapter.MangaConnectorIds = chapter.MangaConnectorIds.UnionBy(addChapter.MangaConnectorIds, id => id.Key).ToList();
}
else
{
context.Chapters.Add(addChapter);
chapter = addChapter;
}
if (context.Sync() is { success: false })
return false;
return true;
}
} }

View File

@@ -11,11 +11,8 @@ public struct TrangaSettings()
public static string workingDirectory => Path.Join(RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/usr/share" : Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "tranga-api"); public static string workingDirectory => Path.Join(RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/usr/share" : Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "tranga-api");
[JsonIgnore] [JsonIgnore]
public static string settingsFilePath => Path.Join(workingDirectory, "settings.json"); public static string settingsFilePath => Path.Join(workingDirectory, "settings.json");
[JsonIgnore] public static string coverImageCache => Path.Join(workingDirectory, "imageCache"); [JsonIgnore]
[JsonIgnore] public static string coverImageCacheOriginal => Path.Join(coverImageCache, "original"); public static string coverImageCache => Path.Join(workingDirectory, "imageCache");
[JsonIgnore] public static string coverImageCacheLarge => Path.Join(coverImageCache, "large");
[JsonIgnore] public static string coverImageCacheMedium => Path.Join(coverImageCache, "medium");
[JsonIgnore] public static string coverImageCacheSmall => Path.Join(coverImageCache, "small");
public string DownloadLocation => RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/Manga" : Path.Join(Directory.GetCurrentDirectory(), "Manga"); public string DownloadLocation => RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/Manga" : Path.Join(Directory.GetCurrentDirectory(), "Manga");
[JsonIgnore] [JsonIgnore]
internal static readonly string DefaultUserAgent = $"Tranga/2.0 ({Enum.GetName(Environment.OSVersion.Platform)}; {(Environment.Is64BitOperatingSystem ? "x64" : "")})"; internal static readonly string DefaultUserAgent = $"Tranga/2.0 ({Enum.GetName(Environment.OSVersion.Platform)}; {(Environment.Is64BitOperatingSystem ? "x64" : "")})";
@@ -42,20 +39,16 @@ public struct TrangaSettings()
[JsonIgnore] [JsonIgnore]
internal static readonly Dictionary<RequestType, int> DefaultRequestLimits = new () internal static readonly Dictionary<RequestType, int> DefaultRequestLimits = new ()
{ {
{RequestType.MangaInfo, 360}, {RequestType.MangaInfo, 60},
{RequestType.MangaDexFeed, 360}, {RequestType.MangaDexFeed, 60},
{RequestType.MangaDexImage, 60}, {RequestType.MangaDexImage, 60},
{RequestType.MangaImage, 240}, {RequestType.MangaImage, 240},
{RequestType.MangaCover, 60}, {RequestType.MangaCover, 60},
{RequestType.Default, 360} {RequestType.Default, 60}
}; };
public Dictionary<RequestType, int> RequestLimits { get; set; } = DefaultRequestLimits; public Dictionary<RequestType, int> RequestLimits { get; set; } = DefaultRequestLimits;
public string DownloadLanguage { get; set; } = "en"; public string DownloadLanguage { get; set; } = "en";
public int MaxConcurrentDownloads { get; set; } = 5;
public int MaxConcurrentWorkers { get; set; } = 10;
public static TrangaSettings Load() public static TrangaSettings Load()
{ {
@@ -116,10 +109,4 @@ public struct TrangaSettings()
this.DownloadLanguage = language; this.DownloadLanguage = language;
Save(); Save();
} }
public void SetMaxConcurrentDownloads(int value)
{
this.MaxConcurrentDownloads = value;
Save();
}
} }

View File

@@ -8,23 +8,22 @@ public abstract class BaseWorker : Identifiable
/// <summary> /// <summary>
/// Workers this Worker depends on being completed before running. /// Workers this Worker depends on being completed before running.
/// </summary> /// </summary>
private BaseWorker[] DependsOn { get; init; } public BaseWorker[] DependsOn { get; init; }
/// <summary> /// <summary>
/// Dependencies and dependencies of dependencies. See also <see cref="DependsOn"/>. /// Dependencies and dependencies of dependencies. See also <see cref="DependsOn"/>.
/// </summary> /// </summary>
internal IEnumerable<BaseWorker> AllDependencies => DependsOn.Select(d => d.AllDependencies).SelectMany(x => x); public IEnumerable<BaseWorker> AllDependencies => DependsOn.Select(d => d.AllDependencies).SelectMany(x => x);
/// <summary> /// <summary>
/// <see cref="AllDependencies"/> and Self. /// <see cref="AllDependencies"/> and Self.
/// </summary> /// </summary>
internal IEnumerable<BaseWorker> DependenciesAndSelf => AllDependencies.Append(this); public IEnumerable<BaseWorker> DependenciesAndSelf => AllDependencies.Append(this);
/// <summary> /// <summary>
/// <see cref="DependsOn"/> where <see cref="WorkerExecutionState"/> is less than Completed. /// <see cref="DependsOn"/> where <see cref="WorkerExecutionState"/> is less than Completed.
/// </summary> /// </summary>
internal IEnumerable<BaseWorker> MissingDependencies => DependsOn.Where(d => d.State < WorkerExecutionState.Completed); public IEnumerable<BaseWorker> MissingDependencies => DependsOn.Where(d => d.State < WorkerExecutionState.Completed);
public bool AllDependenciesFulfilled => DependsOn.All(d => d.State >= WorkerExecutionState.Completed); public bool AllDependenciesFulfilled => DependsOn.All(d => d.State >= WorkerExecutionState.Completed);
internal WorkerExecutionState State { get; private set; } internal WorkerExecutionState State { get; private set; }
private CancellationTokenSource _cancellationTokenSource = new (); private CancellationTokenSource? CancellationTokenSource = null;
protected CancellationToken CancellationToken => _cancellationTokenSource.Token;
protected ILog Log { get; init; } protected ILog Log { get; init; }
/// <summary> /// <summary>
@@ -34,7 +33,7 @@ public abstract class BaseWorker : Identifiable
{ {
Log.Debug($"Cancelled {this}"); Log.Debug($"Cancelled {this}");
this.State = WorkerExecutionState.Cancelled; this.State = WorkerExecutionState.Cancelled;
_cancellationTokenSource.Cancel(); CancellationTokenSource?.Cancel();
} }
/// <summary> /// <summary>
@@ -44,10 +43,10 @@ public abstract class BaseWorker : Identifiable
{ {
Log.Debug($"Failed {this}"); Log.Debug($"Failed {this}");
this.State = WorkerExecutionState.Failed; this.State = WorkerExecutionState.Failed;
_cancellationTokenSource.Cancel(); CancellationTokenSource?.Cancel();
} }
protected BaseWorker(IEnumerable<BaseWorker>? dependsOn = null) public BaseWorker(IEnumerable<BaseWorker>? dependsOn = null)
{ {
this.DependsOn = dependsOn?.ToArray() ?? []; this.DependsOn = dependsOn?.ToArray() ?? [];
this.Log = LogManager.GetLogger(GetType()); this.Log = LogManager.GetLogger(GetType());
@@ -73,23 +72,24 @@ public abstract class BaseWorker : Identifiable
{ {
// Start the worker // Start the worker
Log.Debug($"Checking {this}"); Log.Debug($"Checking {this}");
_cancellationTokenSource = new(TimeSpan.FromMinutes(10)); this.CancellationTokenSource = new(TimeSpan.FromMinutes(10));
State = WorkerExecutionState.Waiting; this.State = WorkerExecutionState.Waiting;
// Wait for dependencies, start them if necessary // Wait for dependencies, start them if necessary
BaseWorker[] missingDependenciesThatNeedStarting = MissingDependencies.Where(d => d.State < WorkerExecutionState.Waiting).ToArray(); BaseWorker[] missingDependenciesThatNeedStarting = MissingDependencies.Where(d => d.State < WorkerExecutionState.Waiting).ToArray();
if(missingDependenciesThatNeedStarting.Any()) if(missingDependenciesThatNeedStarting.Any())
return new (() => missingDependenciesThatNeedStarting); return new Task<BaseWorker[]>(() => missingDependenciesThatNeedStarting);
if (MissingDependencies.Any()) if (MissingDependencies.Any())
return new (WaitForDependencies); return new Task<BaseWorker[]>(WaitForDependencies);
// Run the actual work // Run the actual work
Log.Info($"Running {this}"); Log.Info($"Running {this}");
DateTime startTime = DateTime.UtcNow; DateTime startTime = DateTime.UtcNow;
State = WorkerExecutionState.Running; Task<BaseWorker[]> task = new (DoWorkInternal, CancellationTokenSource.Token);
Task<BaseWorker[]> task = DoWorkInternal();
task.GetAwaiter().OnCompleted(Finish(startTime, callback)); task.GetAwaiter().OnCompleted(Finish(startTime, callback));
this.State = WorkerExecutionState.Running;
task.Start();
return task; return task;
} }
@@ -103,12 +103,12 @@ public abstract class BaseWorker : Identifiable
callback?.Invoke(); callback?.Invoke();
}; };
protected abstract Task<BaseWorker[]> DoWorkInternal(); protected abstract BaseWorker[] DoWorkInternal();
private BaseWorker[] WaitForDependencies() private BaseWorker[] WaitForDependencies()
{ {
Log.Info($"Waiting for {MissingDependencies.Count()} Dependencies {this}:\n\t{string.Join("\n\t", MissingDependencies.Select(d => d.ToString()))}"); 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()) while (CancellationTokenSource?.IsCancellationRequested == false && MissingDependencies.Any())
{ {
Thread.Sleep(Tranga.Settings.WorkCycleTimeoutMs); Thread.Sleep(Tranga.Settings.WorkCycleTimeoutMs);
} }

View File

@@ -3,7 +3,6 @@ using System.Runtime.InteropServices;
using API.MangaConnectors; using API.MangaConnectors;
using API.MangaDownloadClients; using API.MangaDownloadClients;
using API.Schema.MangaContext; using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.ChangeTracking;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Formats.Jpeg;
@@ -13,49 +12,28 @@ using static System.IO.UnixFileMode;
namespace API.Workers; namespace API.Workers;
/// <summary>
/// Downloads single chapter for Manga from Mangaconnector
/// </summary>
/// <param name="chId"></param>
/// <param name="dependsOn"></param>
public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> chId, IEnumerable<BaseWorker>? dependsOn = null) public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> chId, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn) : BaseWorkerWithContext<MangaContext>(dependsOn)
{ {
internal readonly string MangaConnectorIdId = chId.Key; internal readonly string MangaConnectorIdId = chId.Key;
protected override async Task<BaseWorker[]> DoWorkInternal() protected override BaseWorker[] DoWorkInternal()
{ {
Log.Debug($"Downloading chapter for MangaConnectorId {MangaConnectorIdId}..."); if (DbContext.MangaConnectorToChapter.Find(MangaConnectorIdId) is not { } mangaConnectorId)
// Getting MangaConnector info
if (await DbContext.MangaConnectorToChapter
.Include(id => id.Obj)
.ThenInclude(c => c.ParentManga)
.ThenInclude(m => m.Library)
.FirstOrDefaultAsync(c => c.Key == MangaConnectorIdId, CancellationToken) is not { } mangaConnectorId)
{
Log.Error("Could not get MangaConnectorId.");
return []; //TODO Exception? return []; //TODO Exception?
}
// Check if Chapter already exists...
if (await mangaConnectorId.Obj.CheckDownloaded(DbContext, CancellationToken))
{
Log.Warn("Chapter already exists!");
return [];
}
if (!Tranga.TryGetMangaConnector(mangaConnectorId.MangaConnectorName, out MangaConnector? mangaConnector)) if (!Tranga.TryGetMangaConnector(mangaConnectorId.MangaConnectorName, out MangaConnector? mangaConnector))
{
Log.Error("Could not get MangaConnector.");
return []; //TODO Exception? return []; //TODO Exception?
}
Log.Debug($"Downloading chapter for MangaConnectorId {mangaConnectorId}...");
DbContext.Entry(mangaConnectorId).Navigation(nameof(MangaConnectorId<Chapter>.Obj)).Load();
Chapter chapter = mangaConnectorId.Obj; Chapter chapter = mangaConnectorId.Obj;
if (chapter.Downloaded) if (chapter.Downloaded)
{ {
Log.Info("Chapter was already downloaded."); Log.Info("Chapter was already downloaded.");
return []; return [];
} }
DbContext.Entry(chapter).Navigation(nameof(Chapter.ParentManga)).Load();
DbContext.Entry(chapter.ParentManga).Navigation(nameof(Manga.Library)).Load();
if (chapter.ParentManga.LibraryId is null) if (chapter.ParentManga.LibraryId is null)
{ {
Log.Info($"Library is not set for {chapter.ParentManga} {chapter}"); Log.Info($"Library is not set for {chapter.ParentManga} {chapter}");
@@ -66,9 +44,6 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
if (imageUrls.Length < 1) if (imageUrls.Length < 1)
{ {
Log.Info($"No imageUrls for chapter {chapter}"); Log.Info($"No imageUrls for chapter {chapter}");
mangaConnectorId.UseForDownload = false; // Do not try to download from this again
if(await DbContext.Sync(CancellationToken) is { success: false } result)
Log.Error(result.exceptionMessage);
return []; return [];
} }
string saveArchiveFilePath = chapter.FullArchiveFilePath; string saveArchiveFilePath = chapter.FullArchiveFilePath;
@@ -117,12 +92,13 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
} }
} }
await CopyCoverFromCacheToDownloadLocation(chapter.ParentManga); CopyCoverFromCacheToDownloadLocation(chapter.ParentManga);
Log.Debug($"Creating ComicInfo.xml {chapter}"); Log.Debug($"Creating ComicInfo.xml {chapter}");
foreach (CollectionEntry collectionEntry in DbContext.Entry(chapter.ParentManga).Collections) foreach (CollectionEntry collectionEntry in DbContext.Entry(chapter.ParentManga).Collections)
await collectionEntry.LoadAsync(CancellationToken); collectionEntry.Load();
await File.WriteAllTextAsync(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString(), CancellationToken); DbContext.Entry(chapter.ParentManga).Navigation(nameof(Manga.Library)).Load();
File.WriteAllText(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString());
Log.Debug($"Packaging images to archive {chapter}"); Log.Debug($"Packaging images to archive {chapter}");
//ZIP-it and ship-it //ZIP-it and ship-it
@@ -130,12 +106,9 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(saveArchiveFilePath, UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute | OtherRead | OtherExecute); File.SetUnixFileMode(saveArchiveFilePath, UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute | OtherRead | OtherExecute);
Directory.Delete(tempFolder, true); //Cleanup Directory.Delete(tempFolder, true); //Cleanup
DbContext.Entry(chapter).Property(c => c.Downloaded).CurrentValue = true;
if(await DbContext.Sync(CancellationToken) is { success: false } e)
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
Log.Debug($"Downloaded chapter {chapter}."); chapter.Downloaded = true;
DbContext.Sync();
return []; return [];
} }
@@ -178,7 +151,7 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
} }
} }
private async Task CopyCoverFromCacheToDownloadLocation(Manga manga) private void CopyCoverFromCacheToDownloadLocation(Manga manga)
{ {
//Check if Publication already has a Folder and cover //Check if Publication already has a Folder and cover
string publicationFolder = manga.CreatePublicationFolder(); string publicationFolder = manga.CreatePublicationFolder();
@@ -190,7 +163,7 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
} }
//TODO MangaConnector Selection //TODO MangaConnector Selection
await DbContext.Entry(manga).Collection(m => m.MangaConnectorIds).LoadAsync(CancellationToken); DbContext.Entry(manga).Collection(m => m.MangaConnectorIds).Load();
MangaConnectorId<Manga> mangaConnectorId = manga.MangaConnectorIds.First(); MangaConnectorId<Manga> mangaConnectorId = manga.MangaConnectorIds.First();
if (!Tranga.TryGetMangaConnector(mangaConnectorId.MangaConnectorName, out MangaConnector? mangaConnector)) if (!Tranga.TryGetMangaConnector(mangaConnectorId.MangaConnectorName, out MangaConnector? mangaConnector))
{ {
@@ -199,20 +172,19 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
} }
Log.Info($"Copying cover to {publicationFolder}"); Log.Info($"Copying cover to {publicationFolder}");
await DbContext.Entry(mangaConnectorId).Navigation(nameof(MangaConnectorId<Manga>.Obj)).LoadAsync(CancellationToken); DbContext.Entry(mangaConnectorId).Navigation(nameof(MangaConnectorId<Manga>.Obj)).Load();
string? coverFileNameInCache = manga.CoverFileNameInCache ?? mangaConnector.SaveCoverImageToCache(mangaConnectorId); string? fileInCache = manga.CoverFileNameInCache ?? mangaConnector.SaveCoverImageToCache(mangaConnectorId);
if (coverFileNameInCache is null) if (fileInCache is null)
{ {
Log.Error($"File {coverFileNameInCache} does not exist"); Log.Error($"File {fileInCache} does not exist");
return; return;
} }
string fullCoverPath = Path.Join(TrangaSettings.coverImageCacheOriginal, coverFileNameInCache); string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" );
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(coverFileNameInCache).Split('.')[^1]}" ); File.Copy(fileInCache, newFilePath, true);
File.Copy(fullCoverPath, newFilePath, true);
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | UserRead | UserWrite | OtherRead | OtherWrite); File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | UserRead | UserWrite | OtherRead | OtherWrite);
Log.Debug($"Copied cover from {fullCoverPath} to {newFilePath}"); Log.Debug($"Copied cover from {fileInCache} to {newFilePath}");
} }
private bool DownloadImage(string imageUrl, string savePath) private bool DownloadImage(string imageUrl, string savePath)

View File

@@ -1,45 +1,25 @@
using API.MangaConnectors; using API.MangaConnectors;
using API.Schema.MangaContext; using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
namespace API.Workers; namespace API.Workers;
/// <summary>
/// Downloads the cover for Manga from Mangaconnector
/// </summary>
public class DownloadCoverFromMangaconnectorWorker(MangaConnectorId<Manga> mcId, IEnumerable<BaseWorker>? dependsOn = null) public class DownloadCoverFromMangaconnectorWorker(MangaConnectorId<Manga> mcId, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn) : BaseWorkerWithContext<MangaContext>(dependsOn)
{ {
internal readonly string MangaConnectorIdId = mcId.Key; internal readonly string MangaConnectorIdId = mcId.Key;
protected override async Task<BaseWorker[]> DoWorkInternal() protected override BaseWorker[] DoWorkInternal()
{ {
Log.Debug($"Getting Cover for MangaConnectorId {MangaConnectorIdId}..."); if (DbContext.MangaConnectorToManga.Find(MangaConnectorIdId) is not { } mangaConnectorId)
// Getting MangaConnector info
if (await DbContext.MangaConnectorToManga
.Include(id => id.Obj)
.FirstOrDefaultAsync(c => c.Key == MangaConnectorIdId, CancellationToken) is not { } mangaConnectorId)
{
Log.Error("Could not get MangaConnectorId.");
return []; //TODO Exception? return []; //TODO Exception?
}
if (!Tranga.TryGetMangaConnector(mangaConnectorId.MangaConnectorName, out MangaConnector? mangaConnector)) if (!Tranga.TryGetMangaConnector(mangaConnectorId.MangaConnectorName, out MangaConnector? mangaConnector))
{
Log.Error("Could not get MangaConnector.");
return []; //TODO Exception? return []; //TODO Exception?
}
Log.Debug($"Getting Cover for MangaConnectorId {mangaConnectorId}...");
string? coverFileName = mangaConnector.SaveCoverImageToCache(mangaConnectorId);
if (coverFileName is null)
{
Log.Error($"Could not get Cover for MangaConnectorId {mangaConnectorId}.");
return [];
}
DbContext.Entry(mangaConnectorId.Obj).Property(m => m.CoverFileNameInCache).CurrentValue = coverFileName;
if(await DbContext.Sync(CancellationToken) is { success: false } e)
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
DbContext.Entry(mangaConnectorId).Navigation(nameof(MangaConnectorId<Manga>.Obj)).Load();
Manga manga = mangaConnectorId.Obj;
manga.CoverFileNameInCache = mangaConnector.SaveCoverImageToCache(mangaConnectorId);
DbContext.Sync();
return []; return [];
} }

View File

@@ -1,80 +1,37 @@
using API.MangaConnectors; using API.MangaConnectors;
using API.Schema.MangaContext; using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
namespace API.Workers; namespace API.Workers;
/// <summary>
/// Retrieves the metadata of available chapters on the Mangaconnector
/// </summary>
/// <param name="mcId"></param>
/// <param name="language"></param>
/// <param name="dependsOn"></param>
public class RetrieveMangaChaptersFromMangaconnectorWorker(MangaConnectorId<Manga> mcId, string language, IEnumerable<BaseWorker>? dependsOn = null) public class RetrieveMangaChaptersFromMangaconnectorWorker(MangaConnectorId<Manga> mcId, string language, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn) : BaseWorkerWithContext<MangaContext>(dependsOn)
{ {
internal readonly string MangaConnectorIdId = mcId.Key; internal readonly string MangaConnectorIdId = mcId.Key;
protected override async Task<BaseWorker[]> DoWorkInternal() protected override BaseWorker[] DoWorkInternal()
{ {
Log.Debug($"Getting Chapters for MangaConnectorId {MangaConnectorIdId}..."); if (DbContext.MangaConnectorToManga.Find(MangaConnectorIdId) is not { } mangaConnectorId)
// Getting MangaConnector info
if (await DbContext.MangaConnectorToManga
.Include(id => id.Obj)
.ThenInclude(m => m.Chapters)
.ThenInclude(ch => ch.MangaConnectorIds)
.FirstOrDefaultAsync(c => c.Key == MangaConnectorIdId, CancellationToken) is not { } mangaConnectorId)
{
Log.Error("Could not get MangaConnectorId.");
return []; //TODO Exception? return []; //TODO Exception?
}
if (!Tranga.TryGetMangaConnector(mangaConnectorId.MangaConnectorName, out MangaConnector? mangaConnector)) if (!Tranga.TryGetMangaConnector(mangaConnectorId.MangaConnectorName, out MangaConnector? mangaConnector))
{
Log.Error("Could not get MangaConnector.");
return []; //TODO Exception? return []; //TODO Exception?
}
Log.Debug($"Getting Chapters for MangaConnectorId {mangaConnectorId}...");
DbContext.Entry(mangaConnectorId).Navigation(nameof(MangaConnectorId<Manga>.Obj)).Load();
Manga manga = mangaConnectorId.Obj; Manga manga = mangaConnectorId.Obj;
DbContext.Entry(manga).Collection(m => m.Chapters).Load();
// Retrieve available Chapters from Connector // This gets all chapters that are not downloaded
(Chapter chapter, MangaConnectorId<Chapter> chapterId)[] allChapters = (Chapter, MangaConnectorId<Chapter>)[] allChapters =
mangaConnector.GetChapters(mangaConnectorId, language).DistinctBy(c => c.Item1.Key).ToArray(); mangaConnector.GetChapters(mangaConnectorId, language).DistinctBy(c => c.Item1.Key).ToArray();
Log.Debug($"Got {allChapters.Length} chapters from connector.");
// Filter for new Chapters
List<(Chapter chapter, MangaConnectorId<Chapter> chapterId)> newChapters = allChapters.Where<(Chapter chapter, MangaConnectorId<Chapter> chapterId)>(ch =>
manga.Chapters.All(c => c.Key != ch.chapter.Key)).ToList();
Log.Debug($"Got {newChapters.Count} new chapters.");
// Add Chapters to Manga int addedChapters = 0;
manga.Chapters = manga.Chapters.Union(newChapters.Select(ch => ch.chapter)).ToList(); foreach ((Chapter chapter, MangaConnectorId<Chapter> mcId) newChapter in allChapters)
// Filter for new ChapterIds
List<MangaConnectorId<Chapter>> existingChapterIds = manga.Chapters.SelectMany(c => c.MangaConnectorIds).ToList();
List<MangaConnectorId<Chapter>> newIds = allChapters.Select(ch => ch.chapterId)
.Where(newCh => !existingChapterIds.Any(existing =>
existing.MangaConnectorName == newCh.MangaConnectorName &&
existing.IdOnConnectorSite == newCh.IdOnConnectorSite))
.ToList();
// Match tracked entities of Chapters
foreach (MangaConnectorId<Chapter> newId in newIds)
newId.Obj = manga.Chapters.First(ch => ch.Key == newId.ObjId);
Log.Debug($"Got {newIds.Count} new download-Ids.");
// Add new ChapterIds to Database
DbContext.MangaConnectorToChapter.AddRange(newIds);
// If Manga is marked for Download from Connector, mark the new Chapters as UseForDownload
if (mangaConnectorId.UseForDownload)
{ {
foreach ((Chapter _, MangaConnectorId<Chapter> chapterId) in newChapters) if (Tranga.AddChapterToContext(newChapter, DbContext, out Chapter? addedChapter) == false)
{ continue;
chapterId.UseForDownload = mangaConnectorId.UseForDownload; manga.Chapters.Add(addedChapter);
}
} }
Log.Info($"{manga.Chapters.Count} existing + {addedChapters} new chapters.");
if(await DbContext.Sync(CancellationToken) is { success: false } e) DbContext.Sync();
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
return []; return [];
} }

View File

@@ -6,7 +6,7 @@ public class MoveFileOrFolderWorker(string toLocation, string fromLocation, IEnu
public readonly string FromLocation = fromLocation; public readonly string FromLocation = fromLocation;
public readonly string ToLocation = toLocation; public readonly string ToLocation = toLocation;
protected override Task<BaseWorker[]> DoWorkInternal() protected override BaseWorker[] DoWorkInternal()
{ {
try try
{ {
@@ -14,13 +14,13 @@ public class MoveFileOrFolderWorker(string toLocation, string fromLocation, IEnu
if (!fi.Exists) if (!fi.Exists)
{ {
Log.Error($"File does not exist at {FromLocation}"); Log.Error($"File does not exist at {FromLocation}");
return new Task<BaseWorker[]>(() => []); return [];
} }
if (File.Exists(ToLocation))//Do not override existing if (File.Exists(ToLocation))//Do not override existing
{ {
Log.Error($"File already exists at {ToLocation}"); Log.Error($"File already exists at {ToLocation}");
return new Task<BaseWorker[]>(() => []); return [];
} }
if(fi.Attributes.HasFlag(FileAttributes.Directory)) if(fi.Attributes.HasFlag(FileAttributes.Directory))
MoveDirectory(fi, ToLocation); MoveDirectory(fi, ToLocation);
@@ -32,7 +32,7 @@ public class MoveFileOrFolderWorker(string toLocation, string fromLocation, IEnu
Log.Error(e); Log.Error(e);
} }
return new Task<BaseWorker[]>(() => []); return [];
} }
private void MoveDirectory(FileInfo from, string toLocation) private void MoveDirectory(FileInfo from, string toLocation)

View File

@@ -1,44 +1,28 @@
using API.Schema.MangaContext; using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
namespace API.Workers; namespace API.Workers;
/// <summary>
/// Moves a Manga to a different Library
/// </summary>
public class MoveMangaLibraryWorker(Manga manga, FileLibrary toLibrary, IEnumerable<BaseWorker>? dependsOn = null) public class MoveMangaLibraryWorker(Manga manga, FileLibrary toLibrary, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn) : BaseWorkerWithContext<MangaContext>(dependsOn)
{ {
internal readonly string MangaId = manga.Key; internal readonly string MangaId = manga.Key;
internal readonly string LibraryId = toLibrary.Key; internal readonly string LibraryId = toLibrary.Key;
protected override async Task<BaseWorker[]> DoWorkInternal() protected override BaseWorker[] DoWorkInternal()
{ {
Log.Debug("Moving Manga..."); if (DbContext.Mangas.Find(MangaId) is not { } manga)
// Get Manga (with Chapters and Library)
if (await DbContext.Mangas
.Include(m => m.Chapters)
.Include(m => m.Library)
.FirstOrDefaultAsync(m => m.Key == MangaId, CancellationToken) is not { } manga)
{
Log.Error("Could not find Manga.");
return []; //TODO Exception? return []; //TODO Exception?
} if (DbContext.FileLibraries.Find(LibraryId) is not { } toLibrary)
// Get new Library
if (await DbContext.FileLibraries.FirstOrDefaultAsync(l => l.Key == LibraryId, CancellationToken) is not { } toLibrary)
{
Log.Error("Could not find Library.");
return []; //TODO Exception? return []; //TODO Exception?
}
// Save old Path (to later move chapters) DbContext.Entry(manga).Collection(m => m.Chapters).Load();
DbContext.Entry(manga).Navigation(nameof(Manga.Library)).Load();
Dictionary<Chapter, string> oldPath = manga.Chapters.ToDictionary(c => c, c => c.FullArchiveFilePath); Dictionary<Chapter, string> oldPath = manga.Chapters.ToDictionary(c => c, c => c.FullArchiveFilePath);
// Set new Path
manga.Library = toLibrary; manga.Library = toLibrary;
if (await DbContext.Sync(CancellationToken) is { success: false }) if (DbContext.Sync() is { success: false })
return []; return [];
// Create Jobs to move chapters from old to new Path
return manga.Chapters.Select(c => new MoveFileOrFolderWorker(c.FullArchiveFilePath, oldPath[c])).ToArray<BaseWorker>(); return manga.Chapters.Select(c => new MoveFileOrFolderWorker(c.FullArchiveFilePath, oldPath[c])).ToArray<BaseWorker>();
} }

View File

@@ -3,27 +3,22 @@ using Microsoft.EntityFrameworkCore;
namespace API.Workers; namespace API.Workers;
/// <summary>
/// Creates Jobs to update available Chapters for all Manga that are marked for Download
/// </summary>
public class CheckForNewChaptersWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null) public class CheckForNewChaptersWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic : BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{ {
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch; public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval??TimeSpan.FromMinutes(60); public TimeSpan Interval { get; set; } = interval??TimeSpan.FromMinutes(60);
protected override async Task<BaseWorker[]> DoWorkInternal() protected override BaseWorker[] DoWorkInternal()
{ {
Log.Debug("Checking for new chapters..."); IQueryable<MangaConnectorId<Manga>> connectorIdsManga = DbContext.MangaConnectorToManga
List<MangaConnectorId<Manga>> connectorIdsManga = await DbContext.MangaConnectorToManga
.Include(id => id.Obj) .Include(id => id.Obj)
.Where(id => id.UseForDownload) .Where(id => id.UseForDownload);
.ToListAsync(CancellationToken);
Log.Debug($"Creating {connectorIdsManga.Count} update jobs...");
List<BaseWorker> newWorkers = connectorIdsManga.Select(id => new RetrieveMangaChaptersFromMangaconnectorWorker(id, Tranga.Settings.DownloadLanguage))
.ToList<BaseWorker>();
List<BaseWorker> newWorkers = new();
foreach (MangaConnectorId<Manga> mangaConnectorId in connectorIdsManga)
newWorkers.Add(new RetrieveMangaChaptersFromMangaconnectorWorker(mangaConnectorId, Tranga.Settings.DownloadLanguage));
return newWorkers.ToArray(); return newWorkers.ToArray();
} }

View File

@@ -8,25 +8,14 @@ public class CleanupMangaCoversWorker(TimeSpan? interval = null, IEnumerable<Bas
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch; public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(24); public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(24);
protected override Task<BaseWorker[]> DoWorkInternal() protected override BaseWorker[] DoWorkInternal()
{ {
Log.Info("Removing stale files..."); 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[] usedFiles = DbContext.Mangas.Select(m => m.CoverFileNameInCache).Where(s => s != null).ToArray()!;
CleanupImageCache(usedFiles, TrangaSettings.coverImageCacheOriginal); string[] extraneousFiles = new DirectoryInfo(TrangaSettings.coverImageCache).GetFiles()
CleanupImageCache(usedFiles, TrangaSettings.coverImageCacheLarge); .Where(f => usedFiles.Contains(f.FullName) == false)
CleanupImageCache(usedFiles, TrangaSettings.coverImageCacheMedium);
CleanupImageCache(usedFiles, TrangaSettings.coverImageCacheSmall);
return new Task<BaseWorker[]>(() => []);
}
private void CleanupImageCache(string[] retainFilenames, string imageCachePath)
{
DirectoryInfo directory = new(imageCachePath);
if (!directory.Exists)
return;
string[] extraneousFiles = directory
.GetFiles()
.Where(f => retainFilenames.Contains(f.Name) == false)
.Select(f => f.FullName) .Select(f => f.FullName)
.ToArray(); .ToArray();
foreach (string path in extraneousFiles) foreach (string path in extraneousFiles)
@@ -34,5 +23,7 @@ public class CleanupMangaCoversWorker(TimeSpan? interval = null, IEnumerable<Bas
Log.Info($"Deleting {path}"); Log.Info($"Deleting {path}");
File.Delete(path); File.Delete(path);
} }
return [];
} }
} }

View File

@@ -1,27 +1,18 @@
using API.Schema.NotificationsContext; using API.Schema.NotificationsContext;
using Microsoft.EntityFrameworkCore;
namespace API.Workers.MaintenanceWorkers; namespace API.Workers.MaintenanceWorkers;
/// <summary>
/// Removes sent notifications from database
/// </summary>
public class RemoveOldNotificationsWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null) public class RemoveOldNotificationsWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<NotificationsContext>(dependsOn), IPeriodic : BaseWorkerWithContext<NotificationsContext>(dependsOn), IPeriodic
{ {
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch; public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(1); public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(1);
protected override async Task<BaseWorker[]> DoWorkInternal() protected override BaseWorker[] DoWorkInternal()
{ {
Log.Debug("Removing old notifications..."); IQueryable<Notification> toRemove = DbContext.Notifications.Where(n => n.IsSent || DateTime.UtcNow - n.Date > Interval);
List<Notification> toRemove = await DbContext.Notifications.Where(n => n.IsSent).ToListAsync(CancellationToken);
Log.Debug($"Removing {toRemove.Count} old notifications...");
DbContext.RemoveRange(toRemove); DbContext.RemoveRange(toRemove);
DbContext.Sync();
if(await DbContext.Sync(CancellationToken) is { success: false } e)
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
return []; return [];
} }

View File

@@ -1,41 +1,28 @@
using API.Schema.NotificationsContext; using API.Schema.NotificationsContext;
using API.Schema.NotificationsContext.NotificationConnectors; using API.Schema.NotificationsContext.NotificationConnectors;
using Microsoft.EntityFrameworkCore;
namespace API.Workers; namespace API.Workers;
/// <summary>
/// Send notifications to NotificationConnectors
/// </summary>
/// <param name="interval"></param>
/// <param name="dependsOn"></param>
public class SendNotificationsWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null) public class SendNotificationsWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<NotificationsContext>(dependsOn), IPeriodic : BaseWorkerWithContext<NotificationsContext>(dependsOn), IPeriodic
{ {
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch; public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval??TimeSpan.FromMinutes(1); public TimeSpan Interval { get; set; } = interval??TimeSpan.FromMinutes(1);
protected override async Task<BaseWorker[]> DoWorkInternal() protected override BaseWorker[] DoWorkInternal()
{ {
Log.Debug("Sending notifications..."); NotificationConnector[] connectors = DbContext.NotificationConnectors.ToArray();
List<NotificationConnector> connectors = await DbContext.NotificationConnectors.ToListAsync(CancellationToken); Notification[] notifications = DbContext.Notifications.Where(n => n.IsSent == false).ToArray();
List<Notification> unsentNotifications = await DbContext.Notifications.Where(n => n.IsSent == false).ToListAsync(CancellationToken);
Log.Debug($"Sending {unsentNotifications.Count} notifications to {connectors.Count} connectors..."); foreach (Notification notification in notifications)
unsentNotifications.ForEach(notification =>
{ {
connectors.ForEach(connector => foreach (NotificationConnector connector in connectors)
{ {
connector.SendNotification(notification.Title, notification.Message); connector.SendNotification(notification.Title, notification.Message);
DbContext.Entry(notification).Property(n => n.IsSent).CurrentValue = true; notification.IsSent = true;
}); }
}); }
Log.Debug("Notifications sent."); DbContext.Sync();
if(await DbContext.Sync(CancellationToken) is { success: false } e)
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
return []; return [];
} }

View File

@@ -3,36 +3,21 @@ using Microsoft.EntityFrameworkCore;
namespace API.Workers; namespace API.Workers;
/// <summary>
/// Create new Workers for Chapters on Manga marked for Download, that havent been downloaded yet.
/// </summary>
public class StartNewChapterDownloadsWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null) public class StartNewChapterDownloadsWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic : BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{ {
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch; public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromMinutes(1); public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromMinutes(1);
protected override async Task<BaseWorker[]> DoWorkInternal() protected override BaseWorker[] DoWorkInternal()
{ {
Log.Debug("Checking for missing chapters..."); IQueryable<MangaConnectorId<Chapter>> mangaConnectorIds = DbContext.MangaConnectorToChapter
// Get missing chapters
List<MangaConnectorId<Chapter>> missingChapters = await DbContext.MangaConnectorToChapter
.Include(id => id.Obj) .Include(id => id.Obj)
.Where(id => id.Obj.Downloaded == false && id.UseForDownload) .Where(id => id.Obj.Downloaded == false && id.UseForDownload);
.ToListAsync(CancellationToken);
Log.Debug($"Found {missingChapters.Count} missing downloads."); List<BaseWorker> newWorkers = new();
foreach (MangaConnectorId<Chapter> mangaConnectorId in mangaConnectorIds)
// Maximum Concurrent workers newWorkers.Add(new DownloadChapterFromMangaconnectorWorker(mangaConnectorId));
int downloadWorkers = Tranga.GetRunningWorkers().Count(w => w.GetType() == typeof(DownloadChapterFromMangaconnectorWorker));
int amountNewWorkers = Math.Min(Tranga.Settings.MaxConcurrentDownloads, Tranga.Settings.MaxConcurrentDownloads - downloadWorkers);
Log.Debug($"{downloadWorkers} running download Workers. {amountNewWorkers} new download Workers.");
IEnumerable<MangaConnectorId<Chapter>> newDownloadChapters = missingChapters.OrderBy(ch => ch.Obj, new Chapter.ChapterComparer()).Take(amountNewWorkers);
// Create new jobs
List<BaseWorker> newWorkers = newDownloadChapters.Select(mcId => new DownloadChapterFromMangaconnectorWorker(mcId)).ToList<BaseWorker>();
return newWorkers.ToArray(); return newWorkers.ToArray();
} }

View File

@@ -3,34 +3,17 @@ using Microsoft.EntityFrameworkCore;
namespace API.Workers; namespace API.Workers;
/// <summary>
/// Updates the database to reflect changes made on disk
/// </summary>
public class UpdateChaptersDownloadedWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null) public class UpdateChaptersDownloadedWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic : BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{ {
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch; public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval??TimeSpan.FromMinutes(60); public TimeSpan Interval { get; set; } = interval??TimeSpan.FromMinutes(60);
protected override async Task<BaseWorker[]> DoWorkInternal() protected override BaseWorker[] DoWorkInternal()
{ {
Log.Debug("Checking chapter files..."); foreach (Chapter dbContextChapter in DbContext.Chapters.Include(c => c.ParentManga))
List<Chapter> chapters = await DbContext.Chapters.ToListAsync(CancellationToken); dbContextChapter.Downloaded = dbContextChapter.CheckDownloaded();
Log.Debug($"Checking {chapters.Count} chapters...");
foreach (Chapter chapter in chapters)
{
try
{
chapter.Downloaded = await chapter.CheckDownloaded(DbContext, CancellationToken);
}
catch (Exception exception)
{
Log.Error(exception);
}
}
if(await DbContext.Sync(CancellationToken) is { success: false } e) DbContext.Sync();
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
return []; return [];
} }
} }

View File

@@ -1,13 +1,7 @@
using API.Schema.MangaContext; using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
namespace API.Workers; namespace API.Workers;
/// <summary>
/// Creates Workers to update covers for Manga
/// </summary>
/// <param name="interval"></param>
/// <param name="dependsOn"></param>
public class UpdateCoversWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null) public class UpdateCoversWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic : BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{ {
@@ -15,10 +9,11 @@ public class UpdateCoversWorker(TimeSpan? interval = null, IEnumerable<BaseWorke
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch; public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(6); public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(6);
protected override async Task<BaseWorker[]> DoWorkInternal() protected override BaseWorker[] DoWorkInternal()
{ {
List<MangaConnectorId<Manga>> manga = await DbContext.MangaConnectorToManga.Where(mcId => mcId.UseForDownload).ToListAsync(CancellationToken); List<BaseWorker> workers = new();
List<BaseWorker> newWorkers = manga.Select(m => new DownloadCoverFromMangaconnectorWorker(m)).ToList<BaseWorker>(); foreach (MangaConnectorId<Manga> mangaConnectorId in DbContext.MangaConnectorToManga)
return newWorkers.ToArray(); workers.Add(new DownloadCoverFromMangaconnectorWorker(mangaConnectorId));
return workers.ToArray();
} }
} }

View File

@@ -4,11 +4,6 @@ using Microsoft.EntityFrameworkCore;
namespace API.Workers; namespace API.Workers;
/// <summary>
/// Updates Metadata for all Manga
/// </summary>
/// <param name="interval"></param>
/// <param name="dependsOn"></param>
public class UpdateMetadataWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null) public class UpdateMetadataWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic : BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{ {
@@ -16,29 +11,20 @@ public class UpdateMetadataWorker(TimeSpan? interval = null, IEnumerable<BaseWor
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch; public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(12); public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(12);
protected override async Task<BaseWorker[]> DoWorkInternal() protected override BaseWorker[] DoWorkInternal()
{ {
Log.Debug("Updating metadata..."); IQueryable<string> mangaIds = DbContext.MangaConnectorToManga
// Get MetadataEntries of Manga marked for download .Where(m => m.UseForDownload)
List<MetadataEntry> metadataEntriesToUpdate = await DbContext.MangaConnectorToManga .Select(m => m.ObjId);
.Where(m => m.UseForDownload) // Get marked Manga IQueryable<MetadataEntry> metadataEntriesToUpdate = DbContext.MetadataEntries
.Join( .Include(e => e.MetadataFetcher)
DbContext.MetadataEntries.Include(e => e.MetadataFetcher).Include(e => e.Manga), .Where(e =>
mcId => mcId.ObjId, mangaIds.Any(id => id == e.MangaId));
e => e.MangaId,
(mcId, e) => e) // return MetadataEntry
.ToListAsync(CancellationToken);
Log.Debug($"Updating metadata of {metadataEntriesToUpdate.Count} manga...");
foreach (MetadataEntry metadataEntry in metadataEntriesToUpdate) foreach (MetadataEntry metadataEntry in metadataEntriesToUpdate)
{ metadataEntry.MetadataFetcher.UpdateMetadata(metadataEntry, DbContext);
Log.Debug($"Updating metadata of {metadataEntry}...");
await metadataEntry.MetadataFetcher.UpdateMetadata(metadataEntry, DbContext, CancellationToken);
}
Log.Debug("Updated metadata.");
if(await DbContext.Sync(CancellationToken) is { success: false } e) DbContext.Sync();
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
return []; return [];
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -4,8 +4,8 @@
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>930</x> <x>1160</x>
<y>590</y> <y>680</y>
<w>100</w> <w>100</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -15,8 +15,8 @@
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>670</x> <x>900</x>
<y>590</y> <y>680</y>
<w>140</w> <w>140</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -26,53 +26,53 @@
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>250</x> <x>500</x>
<y>710</y> <y>800</y>
<w>120</w> <w>80</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
<panel_attributes>/BaseWorker/</panel_attributes> <panel_attributes>/Job/</panel_attributes>
<additional_attributes/> <additional_attributes/>
</element> </element>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>450</x> <x>680</x>
<y>710</y> <y>800</y>
<w>160</w> <w>160</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
<panel_attributes>/BaseWorkerWithContext/</panel_attributes> <panel_attributes>/JobWithDownload/</panel_attributes>
<additional_attributes/> <additional_attributes/>
</element> </element>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>440</x> <x>680</x>
<y>590</y> <y>680</y>
<w>180</w> <w>160</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
<panel_attributes>DownloadChapterFrom <panel_attributes>RetrieveChaptersJob
MangaConnectorWorker</panel_attributes> </panel_attributes>
<additional_attributes/> <additional_attributes/>
</element> </element>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>360</x> <x>570</x>
<y>720</y> <y>810</y>
<w>110</w> <w>130</w>
<h>30</h> <h>30</h>
</coordinates> </coordinates>
<panel_attributes>lt=&lt;&lt;-</panel_attributes> <panel_attributes>lt=&lt;&lt;-</panel_attributes>
<additional_attributes>10.0;10.0;90.0;10.0</additional_attributes> <additional_attributes>10.0;10.0;110.0;10.0</additional_attributes>
</element> </element>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>520</x> <x>750</x>
<y>620</y> <y>710</y>
<w>30</w> <w>30</w>
<h>110</h> <h>110</h>
</coordinates> </coordinates>
@@ -82,8 +82,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>940</x> <x>1170</x>
<y>620</y> <y>710</y>
<w>30</w> <w>30</w>
<h>230</h> <h>230</h>
</coordinates> </coordinates>
@@ -93,8 +93,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>800</x> <x>1030</x>
<y>730</y> <y>820</y>
<w>150</w> <w>150</w>
<h>140</h> <h>140</h>
</coordinates> </coordinates>
@@ -104,8 +104,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>730</x> <x>960</x>
<y>620</y> <y>710</y>
<w>30</w> <w>30</w>
<h>110</h> <h>110</h>
</coordinates> </coordinates>
@@ -115,8 +115,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>600</x> <x>830</x>
<y>720</y> <y>810</y>
<w>90</w> <w>90</w>
<h>30</h> <h>30</h>
</coordinates> </coordinates>
@@ -126,8 +126,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>1180</x> <x>1410</x>
<y>590</y> <y>680</y>
<w>100</w> <w>100</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -137,8 +137,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>1020</x> <x>1250</x>
<y>600</y> <y>690</y>
<w>180</w> <w>180</w>
<h>30</h> <h>30</h>
</coordinates> </coordinates>
@@ -148,8 +148,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>1180</x> <x>1410</x>
<y>530</y> <y>620</y>
<w>100</w> <w>100</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -159,8 +159,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>1180</x> <x>1410</x>
<y>470</y> <y>560</y>
<w>100</w> <w>100</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -170,8 +170,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>1180</x> <x>1410</x>
<y>410</y> <y>500</y>
<w>100</w> <w>100</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -181,8 +181,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>970</x> <x>1200</x>
<y>420</y> <y>510</y>
<w>230</w> <w>230</w>
<h>190</h> <h>190</h>
</coordinates> </coordinates>
@@ -192,8 +192,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>1000</x> <x>1230</x>
<y>480</y> <y>570</y>
<w>200</w> <w>200</w>
<h>130</h> <h>130</h>
</coordinates> </coordinates>
@@ -203,8 +203,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>1020</x> <x>1250</x>
<y>540</y> <y>630</y>
<w>180</w> <w>180</w>
<h>70</h> <h>70</h>
</coordinates> </coordinates>
@@ -214,8 +214,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>1180</x> <x>1410</x>
<y>350</y> <y>440</y>
<w>100</w> <w>100</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -225,8 +225,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>940</x> <x>1170</x>
<y>360</y> <y>450</y>
<w>260</w> <w>260</w>
<h>250</h> <h>250</h>
</coordinates> </coordinates>
@@ -236,8 +236,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>1150</x> <x>1380</x>
<y>710</y> <y>800</y>
<w>160</w> <w>160</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -247,8 +247,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>1000</x> <x>1230</x>
<y>620</y> <y>710</y>
<w>170</w> <w>170</w>
<h>130</h> <h>130</h>
</coordinates> </coordinates>
@@ -258,8 +258,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>1400</x> <x>1650</x>
<y>710</y> <y>800</y>
<w>140</w> <w>140</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -269,19 +269,19 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>1300</x> <x>1530</x>
<y>720</y> <y>810</y>
<w>120</w> <w>140</w>
<h>30</h> <h>30</h>
</coordinates> </coordinates>
<panel_attributes>lt=-</panel_attributes> <panel_attributes>lt=-</panel_attributes>
<additional_attributes>100.0;10.0;10.0;10.0</additional_attributes> <additional_attributes>120.0;10.0;10.0;10.0</additional_attributes>
</element> </element>
<element> <element>
<id>UMLUseCase</id> <id>UMLUseCase</id>
<coordinates> <coordinates>
<x>1410</x> <x>1660</x>
<y>590</y> <y>680</y>
<w>120</w> <w>120</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -291,19 +291,19 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>1270</x> <x>1500</x>
<y>600</y> <y>690</y>
<w>160</w> <w>180</w>
<h>30</h> <h>30</h>
</coordinates> </coordinates>
<panel_attributes>lt=-</panel_attributes> <panel_attributes>lt=-</panel_attributes>
<additional_attributes>140.0;10.0;10.0;10.0</additional_attributes> <additional_attributes>160.0;10.0;10.0;10.0</additional_attributes>
</element> </element>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>670</x> <x>900</x>
<y>710</y> <y>800</y>
<w>140</w> <w>140</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -313,8 +313,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>800</x> <x>1030</x>
<y>600</y> <y>690</y>
<w>150</w> <w>150</w>
<h>140</h> <h>140</h>
</coordinates> </coordinates>
@@ -324,8 +324,8 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>930</x> <x>1160</x>
<y>830</y> <y>920</y>
<w>100</w> <w>100</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -336,20 +336,20 @@ MangaConnectorWorker</panel_attributes>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>230</x> <x>460</x>
<y>590</y> <y>680</y>
<w>160</w> <w>160</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
<panel_attributes>MoveFileOrFolder <panel_attributes>UpdateChapters
Worker</panel_attributes> DownloadedJob</panel_attributes>
<additional_attributes/> <additional_attributes/>
</element> </element>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>300</x> <x>530</x>
<y>620</y> <y>710</y>
<w>30</w> <w>30</w>
<h>110</h> <h>110</h>
</coordinates> </coordinates>
@@ -359,8 +359,8 @@ Worker</panel_attributes>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>1630</x> <x>1970</x>
<y>550</y> <y>640</y>
<w>110</w> <w>110</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -371,8 +371,8 @@ Komga</panel_attributes>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>1630</x> <x>1970</x>
<y>620</y> <y>710</y>
<w>110</w> <w>110</w>
<h>40</h> <h>40</h>
</coordinates> </coordinates>
@@ -383,8 +383,8 @@ Kavita</panel_attributes>
<element> <element>
<id>UMLPackage</id> <id>UMLPackage</id>
<coordinates> <coordinates>
<x>1570</x> <x>1930</x>
<y>510</y> <y>600</y>
<w>190</w> <w>190</w>
<h>170</h> <h>170</h>
</coordinates> </coordinates>
@@ -394,21 +394,21 @@ Kavita</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>1520</x> <x>1770</x>
<y>600</y> <y>690</y>
<w>70</w> <w>180</w>
<h>30</h> <h>30</h>
</coordinates> </coordinates>
<panel_attributes>lt=-</panel_attributes> <panel_attributes>lt=-</panel_attributes>
<additional_attributes>50.0;10.0;10.0;10.0</additional_attributes> <additional_attributes>160.0;10.0;10.0;10.0</additional_attributes>
</element> </element>
<element> <element>
<id>UMLClass</id> <id>UMLClass</id>
<coordinates> <coordinates>
<x>480</x> <x>710</x>
<y>820</y> <y>910</y>
<w>100</w> <w>100</w>
<h>40</h> <h>30</h>
</coordinates> </coordinates>
<panel_attributes>/Identifiable/</panel_attributes> <panel_attributes>/Identifiable/</panel_attributes>
<additional_attributes/> <additional_attributes/>
@@ -416,8 +416,8 @@ Kavita</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>520</x> <x>750</x>
<y>740</y> <y>830</y>
<w>30</w> <w>30</w>
<h>100</h> <h>100</h>
</coordinates> </coordinates>
@@ -427,8 +427,8 @@ Kavita</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>300</x> <x>530</x>
<y>740</y> <y>830</y>
<w>200</w> <w>200</w>
<h>120</h> <h>120</h>
</coordinates> </coordinates>
@@ -438,92 +438,23 @@ Kavita</panel_attributes>
<element> <element>
<id>Relation</id> <id>Relation</id>
<coordinates> <coordinates>
<x>520</x> <x>750</x>
<y>850</y> <y>930</y>
<w>480</w> <w>480</w>
<h>100</h>
</coordinates>
<panel_attributes>lt=&lt;&lt;-</panel_attributes>
<additional_attributes>10.0;10.0;10.0;80.0;460.0;80.0;460.0;20.0</additional_attributes>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>570</x>
<y>610</y>
<w>380</w>
<h>250</h>
</coordinates>
<panel_attributes>lt=&lt;&lt;-</panel_attributes>
<additional_attributes>10.0;230.0;340.0;230.0;340.0;10.0;360.0;10.0</additional_attributes>
</element>
<element>
<id>UMLPackage</id>
<coordinates>
<x>0</x>
<y>510</y>
<w>630</w>
<h>460</h>
</coordinates>
<panel_attributes>Workers
--</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>UMLClass</id>
<coordinates>
<x>40</x>
<y>710</y>
<w>120</w>
<h>40</h>
</coordinates>
<panel_attributes>/IPeriodicWorker/</panel_attributes>
<additional_attributes/>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>150</x>
<y>720</y>
<w>120</w>
<h>30</h>
</coordinates>
<panel_attributes>lt=&lt;&lt;-</panel_attributes>
<additional_attributes>100.0;10.0;10.0;10.0</additional_attributes>
</element>
<element>
<id>Relation</id>
<coordinates>
<x>90</x>
<y>620</y>
<w>30</w>
<h>110</h> <h>110</h>
</coordinates> </coordinates>
<panel_attributes>lt=&lt;&lt;-</panel_attributes> <panel_attributes>lt=&lt;&lt;-</panel_attributes>
<additional_attributes>10.0;90.0;10.0;10.0</additional_attributes> <additional_attributes>10.0;10.0;10.0;90.0;460.0;90.0;460.0;30.0</additional_attributes>
</element> </element>
<element> <element>
<id>UMLClass</id> <id>Relation</id>
<coordinates> <coordinates>
<x>20</x> <x>800</x>
<y>590</y> <y>670</y>
<w>160</w> <w>380</w>
<h>40</h> <h>280</h>
</coordinates> </coordinates>
<panel_attributes>CheckForNewChapters <panel_attributes>lt=&lt;&lt;-</panel_attributes>
Worker</panel_attributes> <additional_attributes>10.0;260.0;260.0;260.0;260.0;10.0;360.0;10.0</additional_attributes>
<additional_attributes/>
</element>
<element>
<id>UMLPackage</id>
<coordinates>
<x>660</x>
<y>290</y>
<w>710</w>
<h>680</h>
</coordinates>
<panel_attributes>MangaContext
--</panel_attributes>
<additional_attributes/>
</element> </element>
</diagram> </diagram>

View File

@@ -1,8 +1,17 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
ARG DOTNET=9.0 ARG DOTNET=9.0
FROM mcr.microsoft.com/dotnet/aspnet:$DOTNET AS base FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:$DOTNET AS base
WORKDIR /publish WORKDIR /publish
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV XDG_CONFIG_HOME=/tmp/.chromium
ENV XDG_CACHE_HOME=/tmp/.chromium
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y chromium \
&& apt-get autopurge -y \
&& apt-get autoclean -y
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:$DOTNET AS build-env FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:$DOTNET AS build-env
WORKDIR /src WORKDIR /src
@@ -14,7 +23,7 @@ RUN dotnet restore /src/Tranga.sln
COPY . /src/ COPY . /src/
RUN dotnet publish -c Release --property:OutputPath=/publish -maxcpucount:1 RUN dotnet publish -c Release --property:OutputPath=/publish -maxcpucount:1
FROM base AS runtime FROM --platform=$TARGETPLATFORM base AS runtime
EXPOSE 6531 EXPOSE 6531
ARG UNAME=tranga ARG UNAME=tranga
ARG UID=1000 ARG UID=1000

View File

@@ -1,4 +1,674 @@
Tranga  GNU GENERAL PUBLIC LICENSE
Docker-Container to monitor (Manga) Scanlation-Sites for download new chapters. Version 3, 29 June 2007
Copyright (C) 2025 C9Glax
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -18,12 +18,8 @@
<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> <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>
<tr> <tr>
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga/testing?label=testing"></th> <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-testing.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td> <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>
<tr>
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga/oldstable?label=oldstable"></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-oldstable.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td>
</tr> </tr>
</table> </table>
@@ -44,21 +40,22 @@ Notifications can be sent to your devices using [Gotify](https://gotify.net/), [
## What this program does and does *not* do ## What this program does and does *not* do
*DOES*: Download Images from a Website.<br /> DOES: Download Images from a Website.<br />
*DOES*: Create Archives with those images.<br /> DOES: Create Archives.<br />
_**how**?_ ### how:
Tranga (this repository) is a REST-API and worker in one. Tranga provides REST-Endpoints to configure workers (Jobs). 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. Requests include searches for Manga, creating and starting Jobs such as downloading available chapters.
For available endpoints check `http(s)://<hostedInstance>/swagger` For available endpoints check `<hostedInstance>/swagger`
**This repository** _**does not**_ include a frontend. A frontend can take many forms, such as a website: This repository *does not* include a frontend. A frontend can take many forms, such as a website:
[tranga-website](https://github.com/C9Glax/tranga-website) [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 When downloading a chapter (meaning the images that make-up the manga) from a Website, Tranga will
additionally try and fetch Metadata from the same website or enhance it from third-party sources. 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 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). (measured at least a 50% reduction in size, without a significant loss of quality).
@@ -88,7 +85,7 @@ Endpoints are documented in Swagger. Just spin up an instance, and go to `http:/
## Built With ## Built With
- ASP.NET - ASP.NET
- Entity Framework Core (EF Core) - Entity Framework Core
- [PostgreSQL](https://www.postgresql.org/about/licence/) - [PostgreSQL](https://www.postgresql.org/about/licence/)
- [Ngpsql](https://github.com/npgsql/npgsql/blob/main/LICENSE) - [Ngpsql](https://github.com/npgsql/npgsql/blob/main/LICENSE)
- [Swagger](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/LICENSE) - [Swagger](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/LICENSE)
@@ -140,7 +137,7 @@ Downloads (default) are stored in - but this can be configured in `settings.json
- Linux `/Manga` - Linux `/Manga`
- Windows `%currentDirectory%/Downloads` - Windows `%currentDirectory%/Downloads`
### Prerequisits #### Prerequisits
[.NET-Core 9.0](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) [.NET-Core 9.0](https://dotnet.microsoft.com/en-us/download/dotnet/9.0)
@@ -149,67 +146,34 @@ Downloads (default) are stored in - but this can be configured in `settings.json
If you want to contribute, please feel free to fork and create a Pull-Request! If you want to contribute, please feel free to fork and create a Pull-Request!
### General rules General rules:
- Strongly-type your variables. This improves readability.
- Strong-type your variables. This improves readability.
- **DO**
```csharp
Manga[] zyx = Object.GetAnotherThing(); //I can see that zyx is an Array, without digging through more code
```
- **DO _NOT_**
```csharp
var xyz = Object.GetSomething(); //What is xyz? An Array? A string? An object?
```
- Indent your `if` and `for` blocks
- **DO**
```csharp
if(true)
return false;
```
- **DO _NOT_**
```csharp
if(true) return false;
```
<details>
<summary>Because try reading this</summary>
```csharp
if (s.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || s.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) return s;
```
</details>
- When using shorthand, _this_ improves readability for longer lines (at some point just use if-else...):
```csharp ```csharp
bool retVal = xyz is true var xyz = Object.GetSomething(); //Do not do this. What type is xyz (without looking at Method returns etc.)?
? false Manga[] zyx = Object.GetAnotherThing(); //I can now easily see that zyx is an Array.
: true;
```
```csharp
bool retVal = xyz?
?? abc?
?? true;
``` ```
Tranga is using a code-first Entity-Framework Core approach. If you modify the db-table structure you need to create a migration.
### Database and EF Core **A broad overview of where is what:**<br />
Tranga is using a **code-first** EF-Core approach. If you modify the database(context) structure you need to create a migration.
### A broad overview of where is what:
![Image](DB-Layout.png) ![Image](DB-Layout.png)
- `Program.cs` Configuration for ASP.NET, Swagger (also in `NamedSwaggerGenOptions.cs`) - `Program.cs` Configuration for ASP.NET, Swagger (also in `NamedSwaggerGenOptions.cs`)
- `Tranga.cs` Worker-Logic - `Tranga.cs` Worker-Logic
- `Schema/**` Entity-Framework Schema Definitions - `Schema/` Entity-Framework
- `MangaDownloadClients/**` Networking-Clients for Scraping - `Schema/Jobs/` + Logic for Jobs
- `Controllers/**` ASP.NET Controllers (Endpoints) - `Schema/**/` + Logic for **
- `Schema/Contexts/` EF configuration
##### If you want to add a new Website-Connector: - `MangaDownloadClients/` Networking-Clients for Scraping
- `Controllers/` ASP.NET Controllers (Endpoints)
- `APIEndpointRecords/` Records for API-Requests with specific Request-Types (Body)
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`. 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 `Tranga.cs` to the MangaConnector-Array `connectors`. 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 ### How to test locally

View File

@@ -28,9 +28,8 @@ services:
- "5432:5432" - "5432:5432"
environment: environment:
- POSTGRES_PASSWORD=postgres - POSTGRES_PASSWORD=postgres
- POSTGRES_USER=postgres
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready", "-U", "postgres"] test: ["CMD-SHELL", "pg_isready"]
interval: 30s interval: 30s
timeout: 60s timeout: 60s
retries: 5 retries: 5

View File

@@ -39,9 +39,8 @@ services:
- "5432:5432" - "5432:5432"
environment: environment:
- POSTGRES_PASSWORD=postgres - POSTGRES_PASSWORD=postgres
- POSTGRES_USER=postgres
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready", "-U", "postgres"] test: ["CMD-SHELL", "pg_isready"]
interval: 30s interval: 30s
timeout: 60s timeout: 60s
retries: 5 retries: 5