37 Commits

Author SHA1 Message Date
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
105 changed files with 2383 additions and 3936 deletions

View File

@@ -1,13 +1,13 @@
name: Bug Report
description: File a bug report
title: "[Tranga broke]: <title>"
title: "[It broke]: "
labels: ["bug"]
body:
- type: textarea
attributes:
label: What is broken?
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:
required: true
- type: textarea

View File

@@ -1,6 +1,6 @@
name: New Connector Request
description: Request a new site to be added
title: "[New Connector]: <title>"
title: "[New Connector]: "
labels: ["New Connector"]
body:
- type: input
@@ -9,12 +9,15 @@ body:
placeholder: https://
validations:
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
attributes:
label: Anything else?
validations:
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:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
# https://github.com/docker/setup-qemu-action#usage
- name: Set up QEMU
@@ -22,7 +22,7 @@ jobs:
# https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker 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
- name: Login to Docker Hub
@@ -33,12 +33,12 @@ jobs:
# https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push API
uses: docker/build-push-action@v6.18.0
uses: docker/build-push-action@v6.15.0
with:
context: ./
file: ./Dockerfile
#platforms: linux/amd64,linux/amd64/v2,linux/amd64/v3,linux/arm64,linux/arm/v7
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/arm64
pull: true
push: true
tags: |

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,39 +0,0 @@
using API.MangaConnectors;
using API.Schema.MangaContext;
using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using static Microsoft.AspNetCore.Http.StatusCodes;
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class MaintenanceController(MangaContext mangaContext) : Controller
{
/// <summary>
/// Removes all <see cref="Manga"/> not marked for Download on any <see cref="MangaConnector"/>
/// </summary>
/// <response code="200"></response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("CleanupNoDownloadManga")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public async Task<Results<Ok, InternalServerError<string>>> CleanupNoDownloadManga()
{
if (await mangaContext.Mangas
.Include(m => m.MangaConnectorIds)
.Where(m => !m.MangaConnectorIds.Any(id => id.UseForDownload))
.ToArrayAsync(HttpContext.RequestAborted) is not { } noDownloads)
return TypedResults.InternalServerError("Could not fetch Manga");
mangaContext.Mangas.RemoveRange(noDownloads);
if(await mangaContext.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
return TypedResults.Ok();
}
}

View File

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

View File

@@ -1,19 +1,14 @@
using API.Controllers.DTOs;
using API.Schema.MangaContext;
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
using API.Workers;
using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.Net.Http.Headers;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Transforms;
using static Microsoft.AspNetCore.Http.StatusCodes;
using AltTitle = API.Controllers.DTOs.AltTitle;
using Author = API.Controllers.DTOs.Author;
using Chapter = API.Controllers.DTOs.Chapter;
using Link = API.Controllers.DTOs.Link;
using Manga = API.Controllers.DTOs.Manga;
// ReSharper disable InconsistentNaming
namespace API.Controllers;
@@ -23,116 +18,45 @@ namespace API.Controllers;
[Route("v{v:apiVersion}/[controller]")]
public class MangaController(MangaContext context) : Controller
{
/// <summary>
/// Returns all cached <see cref="DTOs.Manga"/>
/// Returns all cached <see cref="Manga"/>
/// </summary>
/// <response code="200"><see cref="MinimalManga"/> exert of <see cref="Schema.MangaContext.Manga"/>. Use <see cref="GetManga"/> for more information</response>
/// <response code="500">Error during Database Operation</response>
/// <response code="200"></response>
[HttpGet]
[ProducesResponseType<List<MinimalManga>>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<List<MinimalManga>>, InternalServerError>> GetAllManga ()
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult 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());
Manga[] ret = context.Mangas.ToArray();
return Ok(ret);
}
/// <summary>
/// Returns all cached <see cref="Schema.MangaContext.Manga"/>.Keys
/// Returns all cached <see cref="Manga"/> with <paramref name="MangaIds"/>
/// </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(Status500InternalServerError)]
public async Task<Results<Ok<string[]>, InternalServerError>> GetAllMangaKeys ()
{
if (await context.Mangas.Select(m => m.Key).ToArrayAsync(HttpContext.RequestAborted) is not { } result)
return TypedResults.InternalServerError();
return TypedResults.Ok(result);
}
/// <summary>
/// Returns all <see cref="Schema.MangaContext.Manga"/> that are being downloaded from at least one <see cref="API.MangaConnectors.MangaConnector"/>
/// </summary>
/// <response code="200"><see cref="MinimalManga"/> exert of <see cref="Schema.MangaContext.Manga"/>. Use <see cref="GetManga"/> for more information</response>
/// <response code="500">Error during Database Operation</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>
/// <param name="MangaIds">Array of <<see cref="Manga"/>.Key</param>
/// <response code="200"></response>
[HttpPost("WithIDs")]
[ProducesResponseType<List<Manga>>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<List<Manga>>, InternalServerError>> GetMangaWithIds ([FromBody]string[] MangaIds)
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult GetManga([FromBody]string[] MangaIds)
{
if (await context.MangaIncludeAll()
.Where(m => MangaIds.Contains(m.Key))
.ToArrayAsync(HttpContext.RequestAborted) is not { } result)
return TypedResults.InternalServerError();
return TypedResults.Ok(result.Select(m =>
{
IEnumerable<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());
Manga[] ret = context.Mangas.Where(m => MangaIds.Contains(m.Key)).ToArray();
return Ok(ret);
}
/// <summary>
/// Return <see cref="Schema.MangaContext.Manga"/> with <paramref name="MangaId"/>
/// Return <see cref="Manga"/> with <paramref name="MangaId"/>
/// </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="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
[HttpGet("{MangaId}")]
[ProducesResponseType<Manga>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<Manga>, NotFound<string>>> GetManga (string MangaId)
[ProducesResponseType(Status404NotFound)]
public IActionResult GetManga(string MangaId)
{
if (await context.MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.NotFound(nameof(MangaId));
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);
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
return Ok(manga);
}
/// <summary>
@@ -140,22 +64,22 @@ public class MangaController(MangaContext context) : Controller
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
/// <response code="404"><<see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpDelete("{MangaId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status404NotFound)]
[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)
return TypedResults.NotFound(nameof(MangaId));
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
context.Mangas.Remove(manga);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
return TypedResults.Ok();
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Ok();
}
@@ -167,71 +91,73 @@ public class MangaController(MangaContext context) : Controller
/// <response code="200"></response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaIdFrom"/> or <paramref name="MangaIdInto"/> not found</response>
[HttpPatch("{MangaIdFrom}/MergeInto/{MangaIdInto}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok, NotFound<string>>> MergeIntoManga (string MangaIdFrom, string MangaIdInto)
[ProducesResponseType<byte[]>(Status200OK,"image/jpeg")]
[ProducesResponseType(Status404NotFound)]
public IActionResult MergeIntoManga(string MangaIdFrom, string MangaIdInto)
{
if (await context.MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == MangaIdFrom, HttpContext.RequestAborted) is not { } from)
return TypedResults.NotFound(nameof(MangaIdFrom));
if (await context.MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == MangaIdInto, HttpContext.RequestAborted) is not { } into)
return TypedResults.NotFound(nameof(MangaIdInto));
if (context.Mangas.Find(MangaIdFrom) is not { } from)
return NotFound(nameof(MangaIdFrom));
if (context.Mangas.Find(MangaIdInto) is not { } into)
return NotFound(nameof(MangaIdInto));
BaseWorker[] newJobs = into.MergeFrom(from, context);
Tranga.AddWorkers(newJobs);
return TypedResults.Ok();
return Ok();
}
/// <summary>
/// Returns Cover of <see cref="Manga"/> with <paramref name="MangaId"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <param name="CoverSize">Size of the cover returned
/// <br /> - <see cref="CoverSize.Small"/> <see cref="Constants.ImageSmSize"/>
/// <br /> - <see cref="CoverSize.Medium"/> <see cref="Constants.ImageMdSize"/>
/// <br /> - <see cref="CoverSize.Large"/> <see cref="Constants.ImageLgSize"/>
/// </param>
/// <param name="width">If <paramref name="width"/> is provided, <paramref name="height"/> needs to also be provided</param>
/// <param name="height">If <paramref name="height"/> is provided, <paramref name="width"/> needs to also be provided</param>
/// <response code="200">JPEG Image</response>
/// <response code="204">Cover not loaded</response>
/// <response code="400">The formatting-request was invalid</response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
/// <response code="503">Retry later, downloading cover</response>
[HttpGet("{MangaId}/Cover/{CoverSize?}")]
[ProducesResponseType<FileContentResult>(Status200OK,"image/jpeg")]
[HttpGet("{MangaId}/Cover")]
[ProducesResponseType<byte[]>(Status200OK,"image/jpeg")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status503ServiceUnavailable)]
public async Task<Results<FileContentHttpResult, NoContent, BadRequest, NotFound<string>, StatusCodeHttpResult>> GetCover (string MangaId, CoverSize? CoverSize = null)
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
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)
return TypedResults.NotFound(nameof(MangaId));
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
string cache = CoverSize switch
{
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 (!System.IO.File.Exists(manga.CoverFileNameInCache))
{
if (Tranga.GetRunningWorkers().Any(worker => worker is DownloadCoverFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId))
{
Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}");
return TypedResults.StatusCode(Status503ServiceUnavailable);
return StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000);
}
return TypedResults.NoContent();
else
return NoContent();
}
DateTime lastModified = data.fileInfo.LastWriteTime;
EntityTagHeaderValue entityTagHeaderValue = EntityTagHeaderValue.Parse($"\"{lastModified.Ticks}\"");
if(HttpContext.Request.Headers.ETag.Equals(entityTagHeaderValue.Tag.Value))
return TypedResults.StatusCode(Status304NotModified);
HttpContext.Response.Headers.CacheControl = "public";
return TypedResults.Bytes(data.stream.ToArray(), "image/jpeg", lastModified: new DateTimeOffset(lastModified), entityTag: entityTagHeaderValue);
Image image = Image.Load(manga.CoverFileNameInCache);
if (width is { } w && height is { } h)
{
if (width < 10 || height < 10 || width > 65535 || height > 65535)
return BadRequest();
image.Mutate(i => i.ApplyProcessor(new ResizeProcessor(new ResizeOptions()
{
Mode = ResizeMode.Max,
Size = new Size(w, h)
}, image.Size)));
}
using MemoryStream ms = new();
image.Save(ms, new JpegEncoder(){Quality = 100});
DateTime lastModified = new FileInfo(manga.CoverFileNameInCache).LastWriteTime;
HttpContext.Response.Headers.CacheControl = "public";
return File(ms.GetBuffer(), "image/jpeg", new DateTimeOffset(lastModified), EntityTagHeaderValue.Parse($"\"{lastModified.Ticks}\""));
}
public enum CoverSize { Original, Large, Medium, Small }
/// <summary>
/// Returns all <see cref="Chapter"/> of <see cref="Manga"/> with <paramref name="MangaId"/>
@@ -240,20 +166,15 @@ public class MangaController(MangaContext context) : Controller
/// <response code="200"></response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
[HttpGet("{MangaId}/Chapters")]
[ProducesResponseType<List<Chapter>>(Status200OK, "application/json")]
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
[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)
return TypedResults.NotFound(nameof(MangaId));
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.Select(c =>
{
IEnumerable<MangaConnectorId> ids = c.MangaConnectorIds.Select(id =>
new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
return new Chapter(c.Key, c.ParentMangaId, c.VolumeNumber, c.ChapterNumber, c.Title, ids, c.Downloaded);
}).ToList();
return TypedResults.Ok(chapters);
Chapter[] chapters = manga.Chapters.ToArray();
return Ok(chapters);
}
/// <summary>
@@ -266,22 +187,17 @@ public class MangaController(MangaContext context) : Controller
[HttpGet("{MangaId}/Chapters/Downloaded")]
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<List<Chapter>>, NoContent, NotFound<string>>> GetChaptersDownloaded(string MangaId)
[ProducesResponseType(Status404NotFound)]
public IActionResult GetChaptersDownloaded(string MangaId)
{
if (await context.Mangas.Include(m => m.Chapters).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.NotFound(nameof(MangaId));
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.Where(c => c.Downloaded).Select(c =>
{
IEnumerable<MangaConnectorId> ids = c.MangaConnectorIds.Select(id =>
new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
return new Chapter(c.Key, c.ParentMangaId, c.VolumeNumber, c.ChapterNumber, c.Title, ids, c.Downloaded);
}).ToList();
List<Chapter> chapters = manga.Chapters.Where(c => c.Downloaded).ToList();
if (chapters.Count == 0)
return TypedResults.NoContent();
return NoContent();
return TypedResults.Ok(chapters);
return Ok(chapters);
}
/// <summary>
@@ -292,28 +208,23 @@ public class MangaController(MangaContext context) : Controller
/// <response code="204">No available chapters</response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found.</response>
[HttpGet("{MangaId}/Chapters/NotDownloaded")]
[ProducesResponseType<List<Chapter>>(Status200OK, "application/json")]
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<List<Chapter>>, NoContent, NotFound<string>>> GetChaptersNotDownloaded(string MangaId)
[ProducesResponseType(Status404NotFound)]
public IActionResult GetChaptersNotDownloaded(string MangaId)
{
if (await context.Mangas.Include(m => m.Chapters).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.NotFound(nameof(MangaId));
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.Where(c => c.Downloaded == false).Select(c =>
{
IEnumerable<MangaConnectorId> ids = c.MangaConnectorIds.Select(id =>
new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
return new Chapter(c.Key, c.ParentMangaId, c.VolumeNumber, c.ChapterNumber, c.Title, ids, c.Downloaded);
}).ToList();
List<Chapter> chapters = manga.Chapters.Where(c => c.Downloaded == false).ToList();
if (chapters.Count == 0)
return TypedResults.NoContent();
return NoContent();
return TypedResults.Ok(chapters);
return Ok(chapters);
}
/// <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>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <response code="200"></response>
@@ -322,41 +233,32 @@ public class MangaController(MangaContext context) : Controller
/// <response code="412">Could not retrieve the maximum chapter-number</response>
/// <response code="503">Retry after timeout, updating value</response>
[HttpGet("{MangaId}/Chapter/LatestAvailable")]
[ProducesResponseType<int>(Status200OK, "application/json")]
[ProducesResponseType<Chapter>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status500InternalServerError)]
[ProducesResponseType(Status503ServiceUnavailable)]
public async Task<Results<Ok<Chapter>, NoContent, InternalServerError, NotFound<string>, StatusCodeHttpResult>> GetLatestChapter(string MangaId)
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
[ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
public IActionResult GetLatestChapter(string MangaId)
{
if (await context.Mangas.Include(m => m.Chapters).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.NotFound(nameof(MangaId));
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
List<API.Schema.MangaContext.Chapter> chapters = manga.Chapters.ToList();
List<Chapter> chapters = manga.Chapters.ToList();
if (chapters.Count == 0)
{
if (Tranga.GetRunningWorkers().Any(worker =>
worker is RetrieveMangaChaptersFromMangaconnectorWorker w &&
context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId &&
w.State < WorkerExecutionState.Completed))
if (Tranga.GetRunningWorkers().Any(worker => worker is RetrieveMangaChaptersFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId && w.State < WorkerExecutionState.Completed))
{
Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}");
return TypedResults.StatusCode(Status503ServiceUnavailable);
}
else
return TypedResults.NoContent();
return StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2/ 1000);
}else
return Ok(0);
}
API.Schema.MangaContext.Chapter? max = chapters.Max();
Chapter? max = chapters.Max();
if (max is null)
return TypedResults.InternalServerError();
return StatusCode(Status500InternalServerError, "Max chapter could not be found");
foreach (CollectionEntry collectionEntry in context.Entry(max).Collections)
await collectionEntry.LoadAsync(HttpContext.RequestAborted);
IEnumerable<MangaConnectorId> ids = max.MangaConnectorIds.Select(id =>
new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
return TypedResults.Ok(new Chapter(max.Key, max.ParentMangaId, max.VolumeNumber, max.ChapterNumber, max.Title,ids, max.Downloaded));
return Ok(max);
}
/// <summary>
@@ -371,34 +273,30 @@ public class MangaController(MangaContext context) : Controller
[HttpGet("{MangaId}/Chapter/LatestDownloaded")]
[ProducesResponseType<Chapter>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status412PreconditionFailed)]
[ProducesResponseType(Status503ServiceUnavailable)]
public async Task<Results<Ok<Chapter>, NoContent, NotFound<string>, StatusCodeHttpResult>> GetLatestChapterDownloaded(string MangaId)
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
[ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
public IActionResult GetLatestChapterDownloaded(string MangaId)
{
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.NotFound(nameof(MangaId));
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
await context.Entry(manga).Collection(m => m.Chapters).LoadAsync(HttpContext.RequestAborted);
List<API.Schema.MangaContext.Chapter> chapters = manga.Chapters.ToList();
List<Chapter> chapters = manga.Chapters.ToList();
if (chapters.Count == 0)
{
if (Tranga.GetRunningWorkers().Any(worker => worker is RetrieveMangaChaptersFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId && w.State < WorkerExecutionState.Completed))
{
Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}");
return TypedResults.StatusCode(Status503ServiceUnavailable);
return StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2/ 1000);
}else
return TypedResults.NoContent();
return NoContent();
}
API.Schema.MangaContext.Chapter? max = chapters.Max();
Chapter? max = chapters.Max();
if (max is null)
return TypedResults.StatusCode(Status412PreconditionFailed);
return StatusCode(Status412PreconditionFailed, "Max chapter could not be found");
IEnumerable<MangaConnectorId> ids = max.MangaConnectorIds.Select(id =>
new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
return TypedResults.Ok(new Chapter(max.Key, max.ParentMangaId, max.VolumeNumber, max.ChapterNumber, max.Title,ids, max.Downloaded));
return Ok(max);
}
/// <summary>
@@ -410,19 +308,19 @@ public class MangaController(MangaContext context) : Controller
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found.</response>
/// <response code="500">Error during Database Operation</response>
[HttpPatch("{MangaId}/IgnoreChaptersBefore")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status202Accepted)]
[ProducesResponseType(Status404NotFound)]
[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)
return TypedResults.NotFound(nameof(MangaId));
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound();
manga.IgnoreChaptersBefore = chapterThreshold;
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return TypedResults.Ok();
return Accepted();
}
/// <summary>
@@ -433,35 +331,32 @@ public class MangaController(MangaContext context) : Controller
/// <response code="202">Folder is going to be moved</response>
/// <response code="404"><paramref name="MangaId"/> or <paramref name="LibraryId"/> not found</response>
[HttpPost("{MangaId}/ChangeLibrary/{LibraryId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok, NotFound<string>>> ChangeLibrary(string MangaId, string LibraryId)
[ProducesResponseType(Status202Accepted)]
[ProducesResponseType(Status404NotFound)]
public IActionResult MoveFolder(string MangaId, string LibraryId)
{
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.NotFound(nameof(MangaId));
if (await context.FileLibraries.FirstOrDefaultAsync(l => l.Key == LibraryId, HttpContext.RequestAborted) is not { } library)
return TypedResults.NotFound(nameof(LibraryId));
if(manga.LibraryId == library.Key)
return TypedResults.Ok();
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
if(context.FileLibraries.Find(LibraryId) is not { } library)
return NotFound(nameof(LibraryId));
MoveMangaLibraryWorker moveLibrary = new(manga, library);
Tranga.AddWorkers([moveLibrary]);
return TypedResults.Ok();
return Accepted();
}
/// <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>
/// <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>
/// <response code="200"></response>
/// <response code="404"><paramref name="MangaId"/> or <paramref name="MangaConnectorName"/> not found</response>
/// <response code="412"><see cref="Manga"/> was not linked to <see cref="API.MangaConnectors.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="412"><see cref="Manga"/> was not linked to <see cref="MangaConnector"/>, so nothing changed</response>
/// <response code="428"><see cref="Manga"/> is not linked to <see cref="MangaConnector"/> yet. Search for <see cref="Manga"/> on <see cref="MangaConnector"/> first (to create a <see cref="MangaConnectorId{T}"/>).</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("{MangaId}/SetAsDownloadFrom/{MangaConnectorName}/{IsRequested}")]
[ProducesResponseType(Status200OK)]
@@ -469,111 +364,27 @@ public class MangaController(MangaContext context) : Controller
[ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
[ProducesResponseType<string>(Status428PreconditionRequired, "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 { } _)
return TypedResults.NotFound(nameof(MangaId));
if(!Tranga.TryGetMangaConnector(MangaConnectorName, out API.MangaConnectors.MangaConnector? _))
return TypedResults.NotFound(nameof(MangaConnectorName));
if (context.Mangas.Find(MangaId) is null)
return NotFound(nameof(MangaId));
if(context.MangaConnectors.Find(MangaConnectorName) is null)
return NotFound(nameof(MangaConnectorName));
if (context.MangaConnectorToManga
.FirstOrDefault(id => id.MangaConnectorName == MangaConnectorName && id.ObjId == MangaId)
is not { } mcId)
{
if (context.MangaConnectorToManga.FirstOrDefault(id => id.MangaConnectorName == MangaConnectorName && id.ObjId == MangaId) is not { } mcId)
if(IsRequested)
return TypedResults.StatusCode(Status428PreconditionRequired);
return StatusCode(Status428PreconditionRequired, "Don't know how to download this Manga from MangaConnector");
else
return TypedResults.StatusCode(Status412PreconditionFailed);
}
return StatusCode(Status412PreconditionFailed, "Not linked anyways.");
mcId.UseForDownload = IsRequested;
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
DownloadCoverFromMangaconnectorWorker downloadCover = new(mcId);
RetrieveMangaChaptersFromMangaconnectorWorker retrieveChapters = new(mcId, Tranga.Settings.DownloadLanguage);
Tranga.AddWorkers([downloadCover, retrieveChapters]);
return TypedResults.Ok();
}
/// <summary>
/// Initiate a search for <see cref="API.Schema.MangaContext.Manga"/> on a different <see cref="API.MangaConnectors.MangaConnector"/>
/// </summary>
/// <param name="MangaId"><see cref="API.Schema.MangaContext.Manga"/> with <paramref name="MangaId"/></param>
/// <param name="MangaConnectorName"><see cref="API.MangaConnectors.MangaConnector"/>.Name</param>
/// <response code="200"><see cref="MinimalManga"/> exert of <see cref="Schema.MangaContext.Manga"/></response>
/// <response code="404"><see cref="API.MangaConnectors.MangaConnector"/> with Name not found</response>
/// <response code="412"><see cref="API.MangaConnectors.MangaConnector"/> with Name is disabled</response>
[HttpPost("{MangaId}/SearchOn/{MangaConnectorName}")]
[ProducesResponseType<List<MinimalManga>>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status406NotAcceptable)]
public async Task<Results<Ok<List<MinimalManga>>, NotFound<string>, StatusCodeHttpResult>> SearchOnDifferentConnector (string MangaId, string MangaConnectorName)
{
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.NotFound(nameof(MangaId));
return new SearchController(context).SearchManga(MangaConnectorName, manga.Name);
}
/// <summary>
/// Returns all <see cref="Manga"/> which where Authored by <see cref="Author"/> with <paramref name="AuthorId"/>
/// </summary>
/// <param name="AuthorId"><see cref="Author"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="Author"/> with <paramref name="AuthorId"/></response>
/// /// <response code="500">Error during Database Operation</response>
[HttpGet("WithAuthorId/{AuthorId}")]
[ProducesResponseType<List<Manga>>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
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 { } _)
return TypedResults.NotFound(nameof(AuthorId));
if (await context.MangaIncludeAll().Where(m => m.Authors.Any(a => a.Key == AuthorId)).ToListAsync(HttpContext.RequestAborted) is not { } result)
return TypedResults.InternalServerError();
return TypedResults.Ok(result.Select(m =>
{
IEnumerable<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>
/// Returns all <see cref="Manga"/> with <see cref="Tag"/>
/// </summary>
/// <param name="Tag"><see cref="Tag"/>.Tag</param>
/// <response code="200"></response>
/// <response code="404"><see cref="Tag"/> not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpGet("WithTag/{Tag}")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[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)
return TypedResults.NotFound(nameof(Tag));
if (await context.MangaIncludeAll().Where(m => m.MangaTags.Any(t => t.Tag.Equals(tag))) .ToListAsync(HttpContext.RequestAborted) is not { } result)
return TypedResults.InternalServerError();
return TypedResults.Ok(result.Select(m =>
{
IEnumerable<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());
return Ok();
}
}

View File

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

View File

@@ -1,11 +1,9 @@
using System.Text;
using API.Controllers.DTOs;
using API.Controllers.Requests;
using API.APIEndpointRecords;
using API.Schema.NotificationsContext;
using API.Schema.NotificationsContext.NotificationConnectors;
using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming
@@ -21,18 +19,12 @@ public class NotificationConnectorController(NotificationsContext context) : Con
/// Gets all configured <see cref="NotificationConnector"/>
/// </summary>
/// <response code="200"></response>
/// <response code="500">Error during Database Operation</response>
[HttpGet]
[ProducesResponseType<List<NotificationConnector>>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<List<NotificationConnector>>, InternalServerError>> GetAllConnectors ()
[ProducesResponseType<NotificationConnector[]>(Status200OK, "application/json")]
public IActionResult GetAllConnectors()
{
if(await context.NotificationConnectors.ToListAsync(HttpContext.RequestAborted) is not { } result)
return TypedResults.InternalServerError();
List<NotificationConnector> notificationConnectors = result.Select(n => new NotificationConnector(n.Name, n.Url, n.HttpMethod, n.Body, n.Headers)).ToList();
return TypedResults.Ok(notificationConnectors);
return Ok(context.NotificationConnectors.ToArray());
}
/// <summary>
@@ -43,101 +35,103 @@ public class NotificationConnectorController(NotificationsContext context) : Con
/// <response code="404"><see cref="NotificationConnector"/> with <paramref name="Name"/> not found</response>
[HttpGet("{Name}")]
[ProducesResponseType<NotificationConnector>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<NotificationConnector>, NotFound<string>>> GetConnector (string Name)
[ProducesResponseType(Status404NotFound)]
public IActionResult GetConnector(string Name)
{
if (await context.NotificationConnectors.FirstOrDefaultAsync(c => c.Name == Name, HttpContext.RequestAborted) is not { } connector)
return TypedResults.NotFound(nameof(Name));
if(context.NotificationConnectors.Find(Name) is not { } connector)
return NotFound();
NotificationConnector notificationConnector = new NotificationConnector(connector.Name, connector.Url, connector.HttpMethod, connector.Body, connector.Headers);
return TypedResults.Ok(notificationConnector);
return Ok(connector);
}
/// <summary>
/// Creates a new <see cref="NotificationConnector"/>
/// </summary>
/// <remarks>Formatting placeholders: "%title" and "%text" can be placed in url, header-values and body and will be replaced when notifications are sent</remarks>
/// <response code="200">ID of the new <see cref="NotificationConnector"/></response>
/// <response code="201"></response>
/// <response code="500">Error during Database Operation</response>
[HttpPut]
[ProducesResponseType<string>(Status201Created, "text/plain")]
[ProducesResponseType(Status201Created)]
[ProducesResponseType(Status409Conflict)]
[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
API.Schema.NotificationsContext.NotificationConnectors.NotificationConnector newConnector =
new(requestData.Name, requestData.Url, requestData.Headers, requestData.HttpMethod, requestData.Body);
context.NotificationConnectors.Add(newConnector);
context.Notifications.Add(new ("Added new Notification Connector!", newConnector.Name, NotificationUrgency.High));
context.NotificationConnectors.Add(notificationConnector);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
return TypedResults.Created(string.Empty, newConnector.Name);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Created();
}
/// <summary>
/// Creates a new Gotify-<see cref="NotificationConnector"/>
/// </summary>
/// <remarks>Priority needs to be between 0 and 10</remarks>
/// <response code="200">ID of the new <see cref="NotificationConnector"/></response>
/// <response code="201"></response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("Gotify")]
[ProducesResponseType<string>(Status201Created, "text/plain")]
[ProducesResponseType<string>(Status201Created, "application/json")]
[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
CreateNotificationConnectorRecord gotifyConnector = new (createGotifyConnectorData.Name,
createGotifyConnectorData.Url,
NotificationConnector gotifyConnector = new (gotifyData.Name,
gotifyData.Endpoint,
new Dictionary<string, string>() { { "X-Gotify-IDOnConnector", gotifyData.AppToken } },
"POST",
$"{{\"message\": \"%text\", \"title\": \"%title\", \"Priority\": {createGotifyConnectorData.Priority}}}",
new () { { "X-Gotify-Key", createGotifyConnectorData.AppToken } });
return await CreateConnector(gotifyConnector);
$"{{\"message\": \"%text\", \"title\": \"%title\", \"Priority\": {gotifyData.Priority}}}");
return CreateConnector(gotifyConnector);
}
/// <summary>
/// Creates a new Ntfy-<see cref="NotificationConnector"/>
/// </summary>
/// <remarks>Priority needs to be between 1 and 5</remarks>
/// <response code="200">ID of the new <see cref="NotificationConnector"/></response>
/// <response code="201"></response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("Ntfy")]
[ProducesResponseType<string>(Status201Created, "text/plain")]
[ProducesResponseType<string>(Status201Created, "application/json")]
[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
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("=","");
CreateNotificationConnectorRecord ntfyConnector = new (createNtfyConnectorRecord.Name,
$"{createNtfyConnectorRecord.Url}?auth={auth}",
NotificationConnector ntfyConnector = new (ntfyRecord.Name,
$"{ntfyRecord.Endpoint}/{ntfyRecord.Topic}?auth={auth}",
new Dictionary<string, string>()
{
{"Title", "%title"},
{"Priority", ntfyRecord.Priority.ToString()},
},
"POST",
$"{{\"message\": \"%text\", \"title\": \"%title\", \"Priority\": {createNtfyConnectorRecord.Priority} \"Topic\": \"{createNtfyConnectorRecord.Topic}\"}}",
new () {{"Authorization", auth}});
return await CreateConnector(ntfyConnector);
"%text");
return CreateConnector(ntfyConnector);
}
/// <summary>
/// Creates a new Pushover-<see cref="NotificationConnector"/>
/// </summary>
/// <remarks>https://pushover.net/api</remarks>
/// <response code="200">ID of the new <see cref="NotificationConnector"/></response>
/// <response code="201">ID of new connector</response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("Pushover")]
[ProducesResponseType<string>(Status201Created, "text/plain")]
[ProducesResponseType<string>(Status201Created, "application/json")]
[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
CreateNotificationConnectorRecord pushoverConnector = new (createPushoverConnectorRecord.Name,
NotificationConnector pushoverConnector = new (pushoverRecord.Name,
$"https://api.pushover.net/1/messages.json",
new Dictionary<string, string>(),
"POST",
$"{{\"token\": \"{createPushoverConnectorRecord.AppToken}\", \"user\": \"{createPushoverConnectorRecord.Username}\", \"message:\":\"%text\", \"%title\" }}",
new ());
return await CreateConnector(pushoverConnector);
$"{{\"token\": \"{pushoverRecord.AppToken}\", \"user\": \"{pushoverRecord.User}\", \"message:\":\"%text\", \"%title\" }}");
return CreateConnector(pushoverConnector);
}
/// <summary>
@@ -149,17 +143,17 @@ public class NotificationConnectorController(NotificationsContext context) : Con
/// <response code="500">Error during Database Operation</response>
[HttpDelete("{Name}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status404NotFound)]
[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)
return TypedResults.NotFound(nameof(Name));
if(context.NotificationConnectors.Find(Name) is not { } connector)
return NotFound();
context.NotificationConnectors.Remove(connector);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
return TypedResults.Ok();
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Created();
}
}

View File

@@ -1,14 +1,7 @@
using API.Controllers.DTOs;
using API.Schema.MangaContext;
using API.Schema.MangaContext;
using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Soenneker.Utils.String.NeedlemanWunsch;
using static Microsoft.AspNetCore.Http.StatusCodes;
using Author = API.Controllers.DTOs.Author;
using Chapter = API.Controllers.DTOs.Chapter;
// ReSharper disable InconsistentNaming
namespace API.Controllers;
@@ -26,13 +19,45 @@ public class QueryController(MangaContext context) : Controller
/// <response code="404"><see cref="Author"/> with <paramref name="AuthorId"/> not found</response>
[HttpGet("Author/{AuthorId}")]
[ProducesResponseType<Author>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<Author>, NotFound<string>>> GetAuthor (string AuthorId)
[ProducesResponseType(Status404NotFound)]
public IActionResult GetAuthor(string AuthorId)
{
if (await context.Authors.FirstOrDefaultAsync(a => a.Key == AuthorId, HttpContext.RequestAborted) is not { } author)
return TypedResults.NotFound(nameof(AuthorId));
if (context.Authors.Find(AuthorId) is not { } author)
return NotFound();
return TypedResults.Ok(new Author(author.Key, author.AuthorName));
return Ok(author);
}
/// <summary>
/// Returns all <see cref="Manga"/> which where Authored by <see cref="Author"/> with <paramref name="AuthorId"/>
/// </summary>
/// <param name="AuthorId"><see cref="Author"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="Author"/> with <paramref name="AuthorId"/></response>
[HttpGet("Mangas/WithAuthorId/{AuthorId}")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult GetMangaWithAuthorIds(string AuthorId)
{
if (context.Authors.Find(AuthorId) is not { } author)
return NotFound();
return Ok(context.Mangas.Where(m => m.Authors.Contains(author)));
}
/// <summary>
/// Returns all <see cref="Manga"/> with <see cref="Tag"/>
/// </summary>
/// <param name="Tag"><see cref="Tag"/>.Tag</param>
/// <response code="200"></response>
/// <response code="404"><see cref="Tag"/> not found</response>
[HttpGet("Mangas/WithTag/{Tag}")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult GetMangasWithTag(string Tag)
{
if (context.Tags.Find(Tag) is not { } tag)
return NotFound();
return Ok(context.Mangas.Where(m => m.MangaTags.Contains(tag)));
}
/// <summary>
@@ -43,82 +68,11 @@ public class QueryController(MangaContext context) : Controller
/// <response code="404"><see cref="Chapter"/> with <paramref name="ChapterId"/> not found</response>
[HttpGet("Chapter/{ChapterId}")]
[ProducesResponseType<Chapter>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
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)
return TypedResults.NotFound(nameof(ChapterId));
if (context.Chapters.Find(ChapterId) is not { } chapter)
return NotFound();
IEnumerable<MangaConnectorId> ids = chapter.MangaConnectorIds.Select(id =>
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>
/// Returns the <see cref="MangaConnectorId{Manga}"/> with <see cref="MangaConnectorId{Manga}"/>.Key
/// </summary>
/// <param name="MangaConnectorIdId">Key of <see cref="MangaConnectorId{Manga}"/></param>
/// <response code="200"></response>
/// <response code="404"><see cref="MangaConnectorId{Manga}"/> with <paramref name="MangaConnectorIdId"/> not found</response>
[HttpGet("Manga/MangaConnectorId/{MangaConnectorIdId}")]
[ProducesResponseType<MangaConnectorId>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<MangaConnectorId>, NotFound<string>>> GetMangaMangaConnectorId (string MangaConnectorIdId)
{
if (await context.MangaConnectorToManga.FirstOrDefaultAsync(c => c.Key == MangaConnectorIdId, HttpContext.RequestAborted) is not { } mcIdManga)
return TypedResults.NotFound(nameof(MangaConnectorIdId));
MangaConnectorId result = new (mcIdManga.Key, mcIdManga.MangaConnectorName, mcIdManga.ObjId, mcIdManga.WebsiteUrl, mcIdManga.UseForDownload);
return TypedResults.Ok(result);
}
/// <summary>
/// Returns <see cref="Schema.MangaContext.Manga"/> with names similar to <see cref="Schema.MangaContext.Manga"/> (identified by <paramref name="MangaId"/>)
/// </summary>
/// <param name="MangaId">Key of <see cref="Schema.MangaContext.Manga"/></param>
/// <response code="200"></response>
/// <response code="404"><see cref="Schema.MangaContext.Manga"/> with <paramref name="MangaId"/> not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpGet("Manga/{MangaId}/SimilarName")]
[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)
return TypedResults.NotFound(nameof(MangaId));
string name = manga.Name;
if (await context.Mangas.Where(m => m.Key != MangaId)
.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)
.Select(kv => kv.Key)
.ToList();
return TypedResults.Ok(similarIds);
}
/// <summary>
/// Returns the <see cref="MangaConnectorId{Chapter}"/> with <see cref="MangaConnectorId{Chapter}"/>.Key
/// </summary>
/// <param name="MangaConnectorIdId">Key of <see cref="MangaConnectorId{Manga}"/></param>
/// <response code="200"></response>
/// <response code="404"><see cref="MangaConnectorId{Chapter}"/> with <paramref name="MangaConnectorIdId"/> not found</response>
[HttpGet("Chapter/MangaConnectorId/{MangaConnectorIdId}")]
[ProducesResponseType<MangaConnectorId>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<MangaConnectorId>, NotFound<string>>> GetChapterMangaConnectorId (string MangaConnectorIdId)
{
if (await context.MangaConnectorToManga.FirstOrDefaultAsync(c => c.Key == MangaConnectorIdId, HttpContext.RequestAborted) is not { } mcIdChapter)
return TypedResults.NotFound(nameof(MangaConnectorIdId));
MangaConnectorId result = new(mcIdChapter.Key, mcIdChapter.MangaConnectorName, mcIdChapter.ObjId, mcIdChapter.WebsiteUrl, mcIdChapter.UseForDownload);
return TypedResults.Ok(result);
return Ok(chapter);
}
}

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

View File

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

View File

@@ -1,7 +1,7 @@
using API.Controllers.DTOs;
using API.APIEndpointRecords;
using API.Workers;
using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using log4net;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming
@@ -11,30 +11,29 @@ namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[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>
/// <response code="200"></response>
[HttpGet]
[ProducesResponseType<List<Worker>>(Status200OK, "application/json")]
public Ok<List<Worker>> GetWorkers()
[ProducesResponseType<BaseWorker[]>(Status200OK, "application/json")]
public IActionResult GetAllWorkers()
{
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());
return Ok(Tranga.AllWorkers.ToArray());
}
/// <summary>
/// Returns all <see cref="BaseWorker"/>.Keys
/// Returns <see cref="BaseWorker"/> with requested <paramref name="WorkerIds"/>
/// </summary>
/// <param name="WorkerIds">Array of <see cref="BaseWorker"/>.Key</param>
/// <response code="200"></response>
[HttpGet("Keys")]
[ProducesResponseType<string[]>(Status200OK, "application/json")]
public Ok<List<string>> GetWorkerIds()
[HttpPost("WithIDs")]
[ProducesResponseType<BaseWorker[]>(Status200OK, "application/json")]
public IActionResult GetJobs([FromBody]string[] WorkerIds)
{
return TypedResults.Ok(Tranga.GetRunningWorkers().Select(w => w.Key).ToList());
return Ok(Tranga.AllWorkers.Where(worker => WorkerIds.Contains(worker.Key)).ToArray());
}
/// <summary>
@@ -43,12 +42,10 @@ public class WorkerController : Controller
/// <param name="State">Requested <see cref="WorkerExecutionState"/></param>
/// <response code="200"></response>
[HttpGet("State/{State}")]
[ProducesResponseType<List<Worker>>(Status200OK, "application/json")]
public Ok<List<Worker>> GetWorkersInState(WorkerExecutionState State)
[ProducesResponseType<BaseWorker[]>(Status200OK, "application/json")]
public IActionResult GetJobsInState(WorkerExecutionState State)
{
IEnumerable<Worker> result = Tranga.GetRunningWorkers().Where(worker => worker.State == State).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());
return Ok(Tranga.AllWorkers.Where(worker => worker.State == State).ToArray());
}
/// <summary>
@@ -58,16 +55,13 @@ public class WorkerController : Controller
/// <response code="200"></response>
/// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response>
[HttpGet("{WorkerId}")]
[ProducesResponseType<Worker>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public Results<Ok<Worker>, NotFound<string>> GetWorker(string WorkerId)
[ProducesResponseType<BaseWorker>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetJob(string WorkerId)
{
if(Tranga.GetRunningWorkers().FirstOrDefault(w => w.Key == WorkerId) is not { } w)
return TypedResults.NotFound(nameof(WorkerId));
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);
if(Tranga.AllWorkers.FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return NotFound(nameof(WorkerId));
return Ok(worker);
}
/// <summary>
@@ -78,13 +72,40 @@ public class WorkerController : Controller
/// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response>
[HttpDelete("{WorkerId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public Results<Ok, NotFound<string>> DeleteWorker(string WorkerId)
[ProducesResponseType(Status404NotFound)]
public IActionResult DeleteJob(string WorkerId)
{
if(Tranga.GetRunningWorkers().FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return TypedResults.NotFound(nameof(WorkerId));
Tranga.StopWorker(worker);
return TypedResults.Ok();
if(Tranga.AllWorkers.FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return NotFound(nameof(WorkerId));
Tranga.RemoveWorker(worker);
return Ok();
}
/// <summary>
/// Modify <see cref="BaseWorker"/> with <paramref name="WorkerId"/>
/// </summary>
/// <param name="WorkerId"><see cref="BaseWorker"/>.Key</param>
/// <param name="modifyWorkerRecord">Fields to modify, set to null to keep previous value</param>
/// <response code="202"></response>
/// <response code="400"></response>
/// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response>
/// <response code="409"><see cref="BaseWorker"/> is not <see cref="IPeriodic"/>, can not modify <paramref name="modifyWorkerRecord.IntervalMs"/></response>
[HttpPatch("{WorkerId}")]
[ProducesResponseType<BaseWorker>(Status202Accepted, "application/json")]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status409Conflict, "text/plain")]
public IActionResult ModifyJob(string WorkerId, [FromBody]ModifyWorkerRecord modifyWorkerRecord)
{
if(Tranga.AllWorkers.FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return NotFound(nameof(WorkerId));
if(modifyWorkerRecord.IntervalMs is not null && worker is not IPeriodic)
return Conflict("Can not modify Interval of non-Periodic worker");
else if(modifyWorkerRecord.IntervalMs is not null && worker is IPeriodic periodic)
periodic.Interval = TimeSpan.FromMilliseconds((long)modifyWorkerRecord.IntervalMs);
return Accepted(worker);
}
/// <summary>
@@ -96,18 +117,18 @@ public class WorkerController : Controller
/// <response code="412"><see cref="BaseWorker"/> was already running</response>
[HttpPost("{WorkerId}/Start")]
[ProducesResponseType(Status202Accepted)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status412PreconditionFailed)]
public Results<Ok, NotFound<string>, StatusCodeHttpResult> StartWorker(string WorkerId)
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
public IActionResult StartJob(string WorkerId)
{
if(Tranga.GetRunningWorkers().FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return TypedResults.NotFound(nameof(WorkerId));
if(Tranga.AllWorkers.FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return NotFound(nameof(WorkerId));
if (worker.State >= WorkerExecutionState.Waiting)
return TypedResults.StatusCode(Status412PreconditionFailed);
return StatusCode(Status412PreconditionFailed, "Already running");
Tranga.StartWorker(worker);
return TypedResults.Ok();
Tranga.MarkWorkerForStart(worker);
return Ok();
}
/// <summary>
@@ -116,20 +137,18 @@ public class WorkerController : Controller
/// <param name="WorkerId"><see cref="BaseWorker"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="BaseWorker"/> with <paramref name="WorkerId"/> could not be found</response>
/// <response code="412"><see cref="BaseWorker"/> was already not running</response>
/// <response code="208"><see cref="BaseWorker"/> was not running</response>
[HttpPost("{WorkerId}/Stop")]
[ProducesResponseType(Status202Accepted)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status412PreconditionFailed)]
public Results<Ok, NotFound<string>, StatusCodeHttpResult> StopWorker(string WorkerId)
[ProducesResponseType(Status501NotImplemented)]
public IActionResult StopJob(string WorkerId)
{
if(Tranga.GetRunningWorkers().FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return TypedResults.NotFound(nameof(WorkerId));
if(Tranga.AllWorkers.FirstOrDefault(w => w.Key == WorkerId) is not { } worker)
return NotFound(nameof(WorkerId));
if(worker.State is < WorkerExecutionState.Running or >= WorkerExecutionState.Completed)
return TypedResults.StatusCode(Status412PreconditionFailed);
return StatusCode(Status208AlreadyReported, "Not running");
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

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

@@ -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

@@ -1,12 +1,11 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net;
using log4net;
namespace API.MangaDownloadClients;
public abstract class DownloadClient
{
private static readonly ConcurrentDictionary<RequestType, DateTime> LastExecutedRateLimit = new();
private static readonly Dictionary<RequestType, DateTime> LastExecutedRateLimit = new();
protected ILog Log { get; init; }
protected DownloadClient()
@@ -14,22 +13,18 @@ public abstract class DownloadClient
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)
{
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))
requestType = RequestType.Default;
{
return new RequestResult(HttpStatusCode.NotAcceptable, null, Stream.Null);
}
int rateLimit = Tranga.Settings.RequestLimits[requestType];
// TODO this probably needs a better check whether the useragent matches...
// If the UserAgent is the default one, do not exceed the default request-limits.
if (Tranga.Settings.UserAgent == TrangaSettings.DefaultUserAgent && rateLimit > TrangaSettings.DefaultRequestLimits[requestType])
rateLimit = TrangaSettings.DefaultRequestLimits[requestType];
int rateLimit = Tranga.Settings.UserAgent == TrangaSettings.DefaultUserAgent
? TrangaSettings.DefaultRequestLimits[requestType]
: Tranga.Settings.RequestLimits[requestType];
// Apply the delay
TimeSpan timeBetweenRequests = TimeSpan.FromMinutes(1).Divide(rateLimit);
DateTime now = DateTime.Now;
LastExecutedRateLimit.TryAdd(requestType, now.Subtract(timeBetweenRequests));
@@ -38,12 +33,11 @@ public abstract class DownloadClient
Log.Debug($"Request limit {requestType} {rateLimit}/Minute timeBetweenRequests: {timeBetweenRequests:ss'.'fffff} Timeout: {rateLimitTimeout:ss'.'fffff}");
if (rateLimitTimeout > TimeSpan.Zero)
{
Thread.Sleep(rateLimitTimeout);
}
// Make the request
RequestResult result = MakeRequestInternal(url, referrer, clickButton);
// Update the time the last request was made
LastExecutedRateLimit[requestType] = DateTime.UtcNow;
Log.Debug($"Result {url}: {result}");
return result;

View File

@@ -10,6 +10,8 @@ namespace API.MangaDownloadClients;
public class FlareSolverrDownloadClient : DownloadClient
{
internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null)
{
if (clickButton is not null)

View File

@@ -5,7 +5,6 @@ namespace API.MangaDownloadClients;
internal class HttpDownloadClient : DownloadClient
{
private static readonly FlareSolverrDownloadClient FlareSolverrDownloadClient = new();
internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null)
{
if (clickButton is not null)
@@ -37,7 +36,7 @@ internal class HttpDownloadClient : DownloadClient
(s.Product?.Name ?? "").Contains("cloudflare", StringComparison.InvariantCultureIgnoreCase)))
{
Log.Debug("Retrying with FlareSolverr!");
return FlareSolverrDownloadClient.MakeRequestInternal(url, referrer, clickButton);
return new FlareSolverrDownloadClient().MakeRequestInternal(url, referrer, clickButton);
}
else
{

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

View File

@@ -11,7 +11,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace API.Migrations.Manga
{
[DbContext(typeof(MangaContext))]
[Migration("20250722203315_Initial")]
[Migration("20250703192023_Initial")]
partial class Initial
{
/// <inheritdoc />
@@ -24,39 +24,6 @@ namespace API.Migrations.Manga
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")
@@ -210,6 +177,8 @@ namespace API.Migrations.Manga
b.HasKey("Key");
b.HasIndex("MangaConnectorName");
b.HasIndex("ObjId");
b.ToTable("MangaConnectorToChapter");
@@ -244,11 +213,46 @@ namespace API.Migrations.Manga
b.HasKey("Key");
b.HasIndex("MangaConnectorName");
b.HasIndex("ObjId");
b.ToTable("MangaConnectorToManga");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.MangaConnector", b =>
{
b.Property<string>("Name")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.PrimitiveCollection<string[]>("BaseUris")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("text[]");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("IconUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.PrimitiveCollection<string[]>("SupportedLanguages")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("text[]");
b.HasKey("Name");
b.ToTable("MangaConnectors");
b.HasDiscriminator<string>("Name").HasValue("MangaConnector");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.MangaContext.MangaTag", b =>
{
b.Property<string>("Tag")
@@ -328,23 +332,23 @@ namespace API.Migrations.Manga
b.ToTable("MangaTagToManga");
});
modelBuilder.Entity("API.MangaConnectors.ComickIo", b =>
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.ComickIo", b =>
{
b.HasBaseType("API.MangaConnectors.MangaConnector");
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("ComickIo");
});
modelBuilder.Entity("API.MangaConnectors.Global", b =>
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.Global", b =>
{
b.HasBaseType("API.MangaConnectors.MangaConnector");
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Global");
});
modelBuilder.Entity("API.MangaConnectors.MangaDex", b =>
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.MangaDex", b =>
{
b.HasBaseType("API.MangaConnectors.MangaConnector");
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("MangaDex");
});
@@ -441,23 +445,39 @@ namespace API.Migrations.Manga
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Chapter>", b =>
{
b.HasOne("API.Schema.MangaContext.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.Chapter", "Obj")
.WithMany("MangaConnectorIds")
.HasForeignKey("ObjId")
.OnDelete(DeleteBehavior.Cascade)
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.Navigation("MangaConnector");
b.Navigation("Obj");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Manga>", b =>
{
b.HasOne("API.Schema.MangaContext.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.Manga", "Obj")
.WithMany("MangaConnectorIds")
.HasForeignKey("ObjId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MangaConnector");
b.Navigation("Obj");
});

View File

@@ -36,7 +36,7 @@ namespace API.Migrations.Manga
});
migrationBuilder.CreateTable(
name: "MangaConnector",
name: "MangaConnectors",
columns: table => new
{
Name = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
@@ -47,7 +47,7 @@ namespace API.Migrations.Manga
},
constraints: table =>
{
table.PrimaryKey("PK_MangaConnector", x => x.Name);
table.PrimaryKey("PK_MangaConnectors", x => x.Name);
});
migrationBuilder.CreateTable(
@@ -201,6 +201,12 @@ namespace API.Migrations.Manga
constraints: table =>
{
table.PrimaryKey("PK_MangaConnectorToManga", x => x.Key);
table.ForeignKey(
name: "FK_MangaConnectorToManga_MangaConnectors_MangaConnectorName",
column: x => x.MangaConnectorName,
principalTable: "MangaConnectors",
principalColumn: "Name",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MangaConnectorToManga_Mangas_ObjId",
column: x => x.ObjId,
@@ -276,7 +282,12 @@ namespace API.Migrations.Manga
name: "FK_MangaConnectorToChapter_Chapters_ObjId",
column: x => x.ObjId,
principalTable: "Chapters",
principalColumn: "Key",
principalColumn: "Key");
table.ForeignKey(
name: "FK_MangaConnectorToChapter_MangaConnectors_MangaConnectorName",
column: x => x.MangaConnectorName,
principalTable: "MangaConnectors",
principalColumn: "Name",
onDelete: ReferentialAction.Cascade);
});
@@ -300,11 +311,21 @@ namespace API.Migrations.Manga
table: "Link",
column: "MangaKey");
migrationBuilder.CreateIndex(
name: "IX_MangaConnectorToChapter_MangaConnectorName",
table: "MangaConnectorToChapter",
column: "MangaConnectorName");
migrationBuilder.CreateIndex(
name: "IX_MangaConnectorToChapter_ObjId",
table: "MangaConnectorToChapter",
column: "ObjId");
migrationBuilder.CreateIndex(
name: "IX_MangaConnectorToManga_MangaConnectorName",
table: "MangaConnectorToManga",
column: "MangaConnectorName");
migrationBuilder.CreateIndex(
name: "IX_MangaConnectorToManga_ObjId",
table: "MangaConnectorToManga",
@@ -338,9 +359,6 @@ namespace API.Migrations.Manga
migrationBuilder.DropTable(
name: "Link");
migrationBuilder.DropTable(
name: "MangaConnector");
migrationBuilder.DropTable(
name: "MangaConnectorToChapter");
@@ -359,6 +377,9 @@ namespace API.Migrations.Manga
migrationBuilder.DropTable(
name: "Chapters");
migrationBuilder.DropTable(
name: "MangaConnectors");
migrationBuilder.DropTable(
name: "Tags");

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,49 +16,15 @@ namespace API.Migrations.Manga
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("ProductVersion", "9.0.5")
.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)");
.HasColumnType("text");
b.Property<string>("AuthorName")
.IsRequired()
@@ -73,8 +39,7 @@ namespace API.Migrations.Manga
modelBuilder.Entity("API.Schema.MangaContext.Chapter", b =>
{
b.Property<string>("Key")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
.HasColumnType("text");
b.Property<string>("ChapterNumber")
.IsRequired()
@@ -111,8 +76,7 @@ namespace API.Migrations.Manga
modelBuilder.Entity("API.Schema.MangaContext.FileLibrary", b =>
{
b.Property<string>("Key")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
.HasColumnType("text");
b.Property<string>("BasePath")
.IsRequired()
@@ -132,8 +96,7 @@ namespace API.Migrations.Manga
modelBuilder.Entity("API.Schema.MangaContext.Manga", b =>
{
b.Property<string>("Key")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
.HasColumnType("text");
b.Property<string>("CoverFileNameInCache")
.HasMaxLength(512)
@@ -185,8 +148,7 @@ namespace API.Migrations.Manga
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Chapter>", b =>
{
b.Property<string>("Key")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
.HasColumnType("text");
b.Property<string>("IdOnConnectorSite")
.IsRequired()
@@ -212,6 +174,8 @@ namespace API.Migrations.Manga
b.HasKey("Key");
b.HasIndex("MangaConnectorName");
b.HasIndex("ObjId");
b.ToTable("MangaConnectorToChapter");
@@ -220,8 +184,7 @@ namespace API.Migrations.Manga
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Manga>", b =>
{
b.Property<string>("Key")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
.HasColumnType("text");
b.Property<string>("IdOnConnectorSite")
.IsRequired()
@@ -247,11 +210,46 @@ namespace API.Migrations.Manga
b.HasKey("Key");
b.HasIndex("MangaConnectorName");
b.HasIndex("ObjId");
b.ToTable("MangaConnectorToManga");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.MangaConnector", b =>
{
b.Property<string>("Name")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.PrimitiveCollection<string[]>("BaseUris")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("text[]");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<string>("IconUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.PrimitiveCollection<string[]>("SupportedLanguages")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("text[]");
b.HasKey("Name");
b.ToTable("MangaConnectors");
b.HasDiscriminator<string>("Name").HasValue("MangaConnector");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.MangaContext.MangaTag", b =>
{
b.Property<string>("Tag")
@@ -273,7 +271,7 @@ namespace API.Migrations.Manga
b.Property<string>("MangaId")
.IsRequired()
.HasColumnType("character varying(64)");
.HasColumnType("text");
b.HasKey("MetadataFetcherName", "Identifier");
@@ -304,10 +302,10 @@ namespace API.Migrations.Manga
modelBuilder.Entity("AuthorToManga", b =>
{
b.Property<string>("AuthorIds")
.HasColumnType("character varying(64)");
.HasColumnType("text");
b.Property<string>("MangaIds")
.HasColumnType("character varying(64)");
.HasColumnType("text");
b.HasKey("AuthorIds", "MangaIds");
@@ -322,7 +320,7 @@ namespace API.Migrations.Manga
.HasColumnType("character varying(64)");
b.Property<string>("MangaIds")
.HasColumnType("character varying(64)");
.HasColumnType("text");
b.HasKey("MangaTagIds", "MangaIds");
@@ -331,23 +329,23 @@ namespace API.Migrations.Manga
b.ToTable("MangaTagToManga");
});
modelBuilder.Entity("API.MangaConnectors.ComickIo", b =>
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.ComickIo", b =>
{
b.HasBaseType("API.MangaConnectors.MangaConnector");
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("ComickIo");
});
modelBuilder.Entity("API.MangaConnectors.Global", b =>
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.Global", b =>
{
b.HasBaseType("API.MangaConnectors.MangaConnector");
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Global");
});
modelBuilder.Entity("API.MangaConnectors.MangaDex", b =>
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.MangaDex", b =>
{
b.HasBaseType("API.MangaConnectors.MangaConnector");
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("MangaDex");
});
@@ -380,8 +378,7 @@ namespace API.Migrations.Manga
b.OwnsMany("API.Schema.MangaContext.AltTitle", "AltTitles", b1 =>
{
b1.Property<string>("Key")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
.HasColumnType("text");
b1.Property<string>("Language")
.IsRequired()
@@ -390,7 +387,7 @@ namespace API.Migrations.Manga
b1.Property<string>("MangaKey")
.IsRequired()
.HasColumnType("character varying(64)");
.HasColumnType("text");
b1.Property<string>("Title")
.IsRequired()
@@ -410,8 +407,7 @@ namespace API.Migrations.Manga
b.OwnsMany("API.Schema.MangaContext.Link", "Links", b1 =>
{
b1.Property<string>("Key")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
.HasColumnType("text");
b1.Property<string>("LinkProvider")
.IsRequired()
@@ -425,7 +421,7 @@ namespace API.Migrations.Manga
b1.Property<string>("MangaKey")
.IsRequired()
.HasColumnType("character varying(64)");
.HasColumnType("text");
b1.HasKey("Key");
@@ -446,23 +442,39 @@ namespace API.Migrations.Manga
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Chapter>", b =>
{
b.HasOne("API.Schema.MangaContext.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.Chapter", "Obj")
.WithMany("MangaConnectorIds")
.HasForeignKey("ObjId")
.OnDelete(DeleteBehavior.Cascade)
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.Navigation("MangaConnector");
b.Navigation("Obj");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectorId<API.Schema.MangaContext.Manga>", b =>
{
b.HasOne("API.Schema.MangaContext.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaContext.Manga", "Obj")
.WithMany("MangaConnectorIds")
.HasForeignKey("ObjId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MangaConnector");
b.Navigation("Obj");
});

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

View File

@@ -34,7 +34,7 @@ public class NamedSwaggerGenOptions : IConfigureNamedOptions<SwaggerGenOptions>
{
var info = new OpenApiInfo()
{
Title = "Tranga " + description.GroupName,
Title = "Test API " + description.GroupName,
Version = description.ApiVersion.ToString()
};
if (description.IsDeprecated)

View File

@@ -2,6 +2,7 @@ using System.Reflection;
using API;
using API.Schema.LibraryContext;
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
using API.Schema.NotificationsContext;
using Asp.Versioning;
using Asp.Versioning.Builder;
@@ -75,7 +76,7 @@ builder.Services.AddControllers().AddNewtonsoftJson(opts =>
opts.SerializerSettings.Converters.Add(new StringEnumConverter());
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");
@@ -108,10 +109,18 @@ using (IServiceScope scope = app.Services.CreateScope())
MangaContext context = scope.ServiceProvider.GetRequiredService<MangaContext>();
context.Database.Migrate();
MangaConnector[] connectors =
[
new MangaDex(),
new ComickIo(),
new Global(scope.ServiceProvider.GetService<MangaContext>()!)
];
MangaConnector[] newConnectors = connectors.Where(c => !context.MangaConnectors.Contains(c)).ToArray();
context.MangaConnectors.AddRange(newConnectors);
if (!context.FileLibraries.Any())
context.FileLibraries.Add(new FileLibrary(Tranga.Settings.DownloadLocation, "Default FileLibrary"));
await context.Sync(CancellationToken.None);
context.Sync();
}
using (IServiceScope scope = app.Services.CreateScope())
@@ -123,7 +132,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=)"};
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())
@@ -131,12 +140,12 @@ using (IServiceScope scope = app.Services.CreateScope())
LibraryContext context = scope.ServiceProvider.GetRequiredService<LibraryContext>();
context.Database.Migrate();
await context.Sync(CancellationToken.None);
context.Sync();
}
Tranga.SetServiceProvider(app.Services);
Tranga.StartLogger(new FileInfo("Log4Net.config.xml"));
Tranga.AddDefaultWorkers();
Tranga.StartLogger();
Tranga.PeriodicWorkerStarterThread.Start(app.Services);
app.UseCors("AllowAll");

View File

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

View File

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

View File

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

View File

@@ -4,29 +4,52 @@ using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
namespace API.Schema.MangaContext;
[PrimaryKey("Key")]
public class Chapter : Identifiable, IComparable<Chapter>
{
[StringLength(64)] public string ParentMangaId { get; init; } = null!;
public Manga ParentManga = null!;
[StringLength(64)] [Required] public string ParentMangaId { get; init; } = null!;
private Manga? _parentManga;
[NotMapped] public Dictionary<string, string> IdsOnMangaConnectors =>
[JsonIgnore]
public Manga ParentManga
{
get => _lazyLoader.Load(this, ref _parentManga) ?? throw new InvalidOperationException();
init
{
ParentMangaId = value.Key;
_parentManga = value;
}
}
[NotMapped]
public Dictionary<string, string> IdsOnMangaConnectors =>
MangaConnectorIds.ToDictionary(id => id.MangaConnectorName, id => id.IdOnConnectorSite);
public ICollection<MangaConnectorId<Chapter>> MangaConnectorIds = null!;
private ICollection<MangaConnectorId<Chapter>>? _mangaConnectorIds;
[JsonIgnore]
public ICollection<MangaConnectorId<Chapter>> MangaConnectorIds
{
get => _lazyLoader.Load(this, ref _mangaConnectorIds) ?? throw new InvalidOperationException();
init => _mangaConnectorIds = value;
}
public int? VolumeNumber { get; private set; }
[StringLength(10)] 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 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);
private readonly ILazyLoader _lazyLoader = null!;
public Chapter(Manga parentManga, string chapterNumber,
int? volumeNumber, string? title = null)
: base(TokenGen.CreateToken(typeof(Chapter), parentManga.Key, chapterNumber))
@@ -38,15 +61,15 @@ public class Chapter : Identifiable, IComparable<Chapter>
this.Title = title;
this.FileName = GetArchiveFilePath();
this.Downloaded = false;
this.MangaConnectorIds = [];
}
/// <summary>
/// EF ONLY!!!
/// </summary>
internal Chapter(string key, int? volumeNumber, string chapterNumber, string? title, string fileName, bool downloaded)
internal Chapter(ILazyLoader lazyLoader, string key, int? volumeNumber, string chapterNumber, string? title, string fileName, bool downloaded)
: base(key)
{
this._lazyLoader = lazyLoader;
this.VolumeNumber = volumeNumber;
this.ChapterNumber = chapterNumber;
this.Title = title;
@@ -66,27 +89,11 @@ public class Chapter : Identifiable, IComparable<Chapter>
};
}
/// <summary>
/// Checks the filesystem if an archive at the ArchiveFilePath exists
/// </summary>
/// <param name="context"></param>
/// <param name="token"></param>
/// <returns>True if archive exists on disk</returns>
/// <exception cref="KeyNotFoundException">Unable to load Chapter, Parent or Library</exception>
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;
}
public bool CheckDownloaded() => File.Exists(FullArchiveFilePath);
/// Placeholders:
/// %M Obj Name
@@ -156,20 +163,6 @@ public class Chapter : Identifiable, IComparable<Chapter>
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)
{
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)
: 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}";
}

View File

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

View File

@@ -4,10 +4,8 @@ using System.Runtime.InteropServices;
using System.Text;
using API.Workers;
using Microsoft.EntityFrameworkCore;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Transforms;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
using static System.IO.UnixFileMode;
namespace API.Schema.MangaContext;
@@ -15,31 +13,57 @@ namespace API.Schema.MangaContext;
[PrimaryKey("Key")]
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; }
[Url] [StringLength(512)] public string CoverUrl { get; internal set; }
public MangaReleaseStatus ReleaseStatus { get; internal set; }
[JsonIgnore] [Url] [StringLength(512)] public string CoverUrl { get; internal set; }
[Required] public MangaReleaseStatus ReleaseStatus { get; internal set; }
[StringLength(64)] public string? LibraryId { get; private set; }
public FileLibrary? Library = null!;
private FileLibrary? _library;
[JsonIgnore]
public FileLibrary? Library
{
get => _lazyLoader.Load(this, ref _library);
set
{
LibraryId = value?.Key;
_library = value;
}
}
public ICollection<Author> Authors { get; internal set; } = null!;
public ICollection<MangaTag> MangaTags { get; internal set; } = null!;
public ICollection<Link> Links { get; internal set; } = null!;
public ICollection<Author> Authors { get; internal set; }= null!;
public ICollection<MangaTag> MangaTags { get; internal set; }= null!;
public ICollection<Link> Links { get; internal set; }= null!;
public ICollection<AltTitle> AltTitles { get; internal set; } = null!;
public float IgnoreChaptersBefore { get; internal set; }
[Required] public float IgnoreChaptersBefore { get; internal 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; }
[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();
public ICollection<Chapter> Chapters = null!;
private ICollection<Chapter>? _chapters;
[JsonIgnore]
public ICollection<Chapter> Chapters
{
get => _lazyLoader.Load(this, ref _chapters) ?? throw new InvalidOperationException();
init => _chapters = value;
}
[NotMapped] public Dictionary<string, string> IdsOnMangaConnectors => MangaConnectorIds.ToDictionary(id => id.MangaConnectorName, id => id.IdOnConnectorSite);
[NotMapped] public ICollection<string> MangaConnectorIdsIds => MangaConnectorIds.Select(id => id.Key).ToList();
public ICollection<MangaConnectorId<Manga>> MangaConnectorIds = null!;
[NotMapped] public Dictionary<string, string> IdsOnMangaConnectors =>
MangaConnectorIds.ToDictionary(id => id.MangaConnectorName, id => id.IdOnConnectorSite);
private ICollection<MangaConnectorId<Manga>>? _mangaConnectorIds;
[JsonIgnore]
public ICollection<MangaConnectorId<Manga>> MangaConnectorIds
{
get => _lazyLoader.Load(this, ref _mangaConnectorIds) ?? throw new InvalidOperationException();
private set => _mangaConnectorIds = value;
}
private readonly ILazyLoader _lazyLoader = null!;
public Manga(string name, string description, string coverUrl, MangaReleaseStatus releaseStatus,
ICollection<Author> authors, ICollection<MangaTag> mangaTags, ICollection<Link> links, ICollection<AltTitle> altTitles,
@@ -60,17 +84,17 @@ public class Manga : Identifiable
this.Year = year;
this.OriginalLanguage = originalLanguage;
this.Chapters = [];
this.MangaConnectorIds = [];
}
/// <summary>
/// EF ONLY!!!
/// </summary>
public Manga(string key, string name, string description, string coverUrl,
public Manga(ILazyLoader lazyLoader, string key, string name, string description, string coverUrl,
MangaReleaseStatus releaseStatus,
string directoryName, float ignoreChaptersBefore, string? libraryId, uint? year, string? originalLanguage)
: base(key)
{
this._lazyLoader = lazyLoader;
this.Name = name;
this.Description = description;
this.CoverUrl = coverUrl;
@@ -159,18 +183,6 @@ public class Manga : Identifiable
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}";
}

View File

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

View File

@@ -1,9 +1,8 @@
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using API.Schema.MangaContext;
using Newtonsoft.Json.Linq;
namespace API.MangaConnectors;
namespace API.Schema.MangaContext.MangaConnectors;
public class ComickIo : MangaConnector
{
@@ -177,19 +176,19 @@ public class ComickIo : MangaConnector
byte whatever = 0;
List<AltTitle> altTitles = altTitlesArray?
.Select(token => new AltTitle(token.Value<string>("lang")??whatever++.ToString(), token.Value<string>("title")!))
.ToList()??[];
.ToList()!;
JArray? authorsArray = json["authors"] as JArray;
JArray? artistsArray = json["artists"] as JArray;
List<Author> authors = authorsArray?.Concat(artistsArray!)
.Select(token => new Author(token.Value<string>("name")!))
.DistinctBy(a => a.Key)
.ToList()??[];
.ToList()!;
JArray? genreArray = json["comic"]?["md_comic_md_genres"] as JArray;
List<MangaTag> tags = genreArray?
.Select(token => new MangaTag(token["md_genres"]?.Value<string>("name")!))
.ToList()??[];
.ToList()!;
JArray? linksArray = json["comic"]?["links"] as JArray;
List<Link> links = linksArray?
@@ -221,7 +220,7 @@ public class ComickIo : MangaConnector
_ => kv.Key
};
return new Link(key, fullUrl);
}).ToList()??[];
}).ToList()!;
if(hid is null)
throw new Exception("hid is null");
@@ -232,9 +231,7 @@ public class ComickIo : MangaConnector
Manga manga = new (name, description??"", coverUrl, status, authors, tags, links, altTitles,
year: year, originalLanguage: originalLanguage);
MangaConnectorId<Manga> mcId = new (manga, this, hid, url);
manga.MangaConnectorIds.Add(mcId);
return (manga, mcId);
return (manga, new MangaConnectorId<Manga>(manga, this, hid, url));
}
private List<(Chapter, MangaConnectorId<Chapter>)> ParseChapters(MangaConnectorId<Manga> mcIdManga, JArray chaptersArray)
@@ -253,9 +250,8 @@ public class ComickIo : MangaConnector
continue;
Chapter ch = new (mcIdManga.Obj, chapterNum, volumeNum, title);
MangaConnectorId<Chapter> mcId = new(ch, this, hid, url);
ch.MangaConnectorIds.Add(mcId);
chapters.Add((ch, mcId));
chapters.Add((ch, new (ch, this, hid, url)));
}
return chapters;
}

View File

@@ -1,17 +1,17 @@
using API.Schema.MangaContext;
namespace API.MangaConnectors;
namespace API.Schema.MangaContext.MangaConnectors;
public class Global : MangaConnector
{
public Global() : base("Global", ["all"], [""], "https://avatars.githubusercontent.com/u/13404778")
private MangaContext context { get; init; }
public Global(MangaContext context) : base("Global", ["all"], [""], "")
{
this.context = context;
}
public override (Manga, MangaConnectorId<Manga>)[] SearchManga(string mangaSearchName)
{
//Get all enabled Connectors
MangaConnector[] enabledConnectors = Tranga.MangaConnectors.Where(c => c.Enabled && c.Name != "Global").ToArray();
MangaConnector[] enabledConnectors = context.MangaConnectors.Where(c => c.Enabled && c.Name != "Global").ToArray();
//Create Task for each MangaConnector to search simultaneously
Task<(Manga, MangaConnectorId<Manga>)[]>[] tasks =
@@ -32,7 +32,7 @@ public class Global : MangaConnector
public override (Manga, MangaConnectorId<Manga>)? GetMangaFromUrl(string url)
{
MangaConnector? mc = Tranga.MangaConnectors.FirstOrDefault(c => c.UrlMatchesConnector(url));
MangaConnector? mc = context.MangaConnectors.ToArray().FirstOrDefault(c => c.UrlMatchesConnector(url));
return mc?.GetMangaFromUrl(url) ?? null;
}
@@ -44,15 +44,11 @@ public class Global : MangaConnector
public override (Chapter, MangaConnectorId<Chapter>)[] GetChapters(MangaConnectorId<Manga> manga,
string? language = null)
{
if (!Tranga.TryGetMangaConnector(manga.MangaConnectorName, out MangaConnector? mangaConnector))
return [];
return mangaConnector.GetChapters(manga, language);
return manga.MangaConnector.GetChapters(manga, language);
}
internal override string[] GetChapterImageUrls(MangaConnectorId<Chapter> chapterId)
{
if (!Tranga.TryGetMangaConnector(chapterId.MangaConnectorName, out MangaConnector? mangaConnector))
return [];
return mangaConnector.GetChapterImageUrls(chapterId);
return chapterId.MangaConnector.GetChapterImageUrls(chapterId);
}
}

View File

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

View File

@@ -1,9 +1,8 @@
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using API.Schema.MangaContext;
using Newtonsoft.Json.Linq;
namespace API.MangaConnectors;
namespace API.Schema.MangaContext.MangaConnectors;
public class MangaDex : MangaConnector
{
@@ -277,9 +276,9 @@ public class MangaDex : MangaConnector
_ => kv.Key
};
return new Link(key, url);
}).ToList()??[];
}).ToList()!;
List<AltTitle> altTitles = altTitlesJArray?
List<AltTitle> altTitles = (altTitlesJArray??[])
.Select(t =>
{
JObject? j = t as JObject;
@@ -287,19 +286,19 @@ public class MangaDex : MangaConnector
if (p is null)
return null;
return new AltTitle(p.Name, p.Value.ToString());
}).Where(x => x is not null).Cast<AltTitle>().ToList()??[];
}).Where(x => x is not null).ToList()!;
List<MangaTag> tags = tagsJArray?
List<MangaTag> tags = (tagsJArray??[])
.Where(t => t.Value<string>("type") == "tag")
.Select(t => t["attributes"]?["name"]?.Value<string>("en")??t["attributes"]?["name"]?.First?.First?.Value<string>())
.Select(str => str is not null ? new MangaTag(str) : null)
.Where(x => x is not null).Cast<MangaTag>().ToList()??[];
.Where(x => x is not null).ToList()!;
List<Author> authors = relationships
.Where(r => r["type"]?.Value<string>() == "author")
.Select(t => t["attributes"]?.Value<string>("name"))
.Select(str => str is not null ? new Author(str) : null)
.Where(x => x is not null).Cast<Author>().ToList();
.Where(x => x is not null).ToList()!;
MangaReleaseStatus releaseStatus = status switch
@@ -313,18 +312,16 @@ public class MangaDex : MangaConnector
string websiteUrl = $"https://mangadex.org/title/{id}";
string coverUrl = $"https://uploads.mangadex.org/covers/{id}/{coverFileName}";
Manga manga = new (name, description, coverUrl, releaseStatus, authors, tags, links,altTitles,
Manga manga = new Manga(name, description, coverUrl, releaseStatus, authors, tags, links,altTitles,
null, 0f, year, originalLanguage);
MangaConnectorId<Manga> mcId = new (manga, this, id, websiteUrl);
manga.MangaConnectorIds.Add(mcId);
return (manga, mcId);
return (manga, new MangaConnectorId<Manga>(manga, this, id, websiteUrl));
}
private (Chapter chapter, MangaConnectorId<Chapter> id) ParseChapterFromJToken(MangaConnectorId<Manga> mcIdManga, JToken jToken)
{
string? id = jToken.Value<string>("id");
JToken? attributes = jToken["attributes"];
string? chapterStr = attributes?.Value<string>("chapter") ?? "0";
string? chapterStr = attributes?.Value<string>("chapter");
string? volumeStr = attributes?.Value<string>("volume");
int? volumeNumber = null;
string? title = attributes?.Value<string>("title");
@@ -336,8 +333,6 @@ public class MangaDex : MangaConnector
string websiteUrl = $"https://mangadex.org/chapter/{id}";
Chapter chapter = new (mcIdManga.Obj, chapterStr, volumeNumber, title);
MangaConnectorId<Chapter> mcId = new(chapter, this, id, websiteUrl);
chapter.MangaConnectorIds.Add(mcId);
return (chapter, mcId);
return (chapter, new MangaConnectorId<Chapter>(chapter, this, id, websiteUrl));
}
}

View File

@@ -1,12 +1,12 @@
using API.MangaConnectors;
using API.Schema.MangaContext.MangaConnectors;
using API.Schema.MangaContext.MetadataFetchers;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
namespace API.Schema.MangaContext;
public class MangaContext(DbContextOptions<MangaContext> options) : TrangaBaseContext<MangaContext>(options)
{
public DbSet<MangaConnector> MangaConnectors { get; set; }
public DbSet<Manga> Mangas { get; set; }
public DbSet<FileLibrary> FileLibraries { get; set; }
public DbSet<Chapter> Chapters { get; set; }
@@ -31,12 +31,26 @@ public class MangaContext(DbContextOptions<MangaContext> options) : TrangaBaseCo
.WithOne(c => c.ParentManga)
.HasForeignKey(c => c.ParentMangaId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Manga>()
.Navigation(m => m.Chapters)
.EnableLazyLoading();
modelBuilder.Entity<Chapter>()
.Navigation(c => c.ParentManga)
.EnableLazyLoading();
//Chapter has MangaConnectorIds
modelBuilder.Entity<Chapter>()
.HasMany<MangaConnectorId<Chapter>>(c => c.MangaConnectorIds)
.WithOne(id => id.Obj)
.HasForeignKey(id => id.ObjId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity<MangaConnectorId<Chapter>>()
.HasOne<MangaConnector>(id => id.MangaConnector)
.WithMany()
.HasForeignKey(id => id.MangaConnectorName)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MangaConnectorId<Chapter>>()
.Navigation(entry => entry.MangaConnector)
.EnableLazyLoading();
//Manga owns MangaAltTitles
modelBuilder.Entity<Manga>()
.OwnsMany<AltTitle>(m => m.AltTitles)
@@ -81,6 +95,17 @@ public class MangaContext(DbContextOptions<MangaContext> options) : TrangaBaseCo
.WithOne(id => id.Obj)
.HasForeignKey(id => id.ObjId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Manga>()
.Navigation(m => m.MangaConnectorIds)
.EnableLazyLoading();
modelBuilder.Entity<MangaConnectorId<Manga>>()
.HasOne<MangaConnector>(id => id.MangaConnector)
.WithMany()
.HasForeignKey(id => id.MangaConnectorName)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MangaConnectorId<Manga>>()
.Navigation(entry => entry.MangaConnector)
.EnableLazyLoading();
//FileLibrary has many Mangas
@@ -89,6 +114,9 @@ public class MangaContext(DbContextOptions<MangaContext> options) : TrangaBaseCo
.WithOne(m => m.Library)
.HasForeignKey(m => m.LibraryId)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<Manga>()
.Navigation(m => m.Library)
.EnableLazyLoading();
modelBuilder.Entity<MetadataFetcher>()
.HasDiscriminator<string>(nameof(MetadataEntry))
@@ -103,25 +131,4 @@ public class MangaContext(DbContextOptions<MangaContext> options) : TrangaBaseCo
.WithMany()
.OnDelete(DeleteBehavior.Cascade);
}
public async Task<Manga?> FindMangaLike(Manga other, CancellationToken token)
{
if (await MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == other.Key, token) is { } f)
return f;
return await MangaIncludeAll()
.FirstOrDefaultAsync(m =>
m.Links.Any(l => l.Key == other.Key) ||
m.AltTitles.Any(t => other.AltTitles.Select(ot => ot.Title).Any(s => s.Equals(t.Title))), token);
}
public IIncludableQueryable<Manga, ICollection<MangaConnectorId<Manga>>> MangaIncludeAll() =>
Mangas
.Include(m => m.Library)
.Include(m => m.Authors)
.Include(m => m.MangaTags)
.Include(m => m.Links)
.Include(m => m.AltTitles)
.Include(m => m.Chapters)
.Include(m => m.MangaConnectorIds);
}

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
using System.Text.RegularExpressions;
using JikanDotNet;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace API.Schema.MangaContext.MetadataFetchers;
@@ -43,41 +42,24 @@ public class MyAnimeList : MetadataFetcher
/// </summary>
/// <param name="metadataEntry"></param>
/// <param name="dbContext"></param>
/// <param name="token"></param>
/// <exception cref="FormatException"></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!
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)
{
if(!collectionEntry.IsLoaded)
await collectionEntry.LoadAsync(token);
}
await dbContext.Entry(dbManga).Reference(m => m.Library).LoadAsync(token);
Manga dbManga = dbContext.Mangas.Find(metadataEntry.MangaId)!;
MangaFull resultData;
try
{
long id = long.Parse(metadataEntry.Identifier);
if(await Jikan.GetMangaFullDataAsync(id, token) is not { } response)
throw new DbUpdateException("Manga Data not found");
resultData = response.Data;
resultData = Jikan.GetMangaFullDataAsync(id).Result.Data;
}
catch (Exception)
{
throw new FormatException("ID was not in correct format");
}
try
{
dbManga.Name = resultData.Titles.First().Title;
dbManga.Description = resultData.Synopsis;
dbManga.AltTitles.Clear();
@@ -85,7 +67,12 @@ public class MyAnimeList : MetadataFetcher
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,50 +10,75 @@ namespace API.Schema.NotificationsContext.NotificationConnectors;
[PrimaryKey("Name")]
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 } }
};
[JsonIgnore] protected ILog Log = LogManager.GetLogger(name);
[JsonIgnore]
[NotMapped]
protected ILog Log = LogManager.GetLogger(name);
public void SendNotification(string title, string notificationText)
{
Log.Info($"Sending notification: {title} - {notificationText}");
string formattedUrl = FormatStr(Url, title, notificationText);
string formattedBody = FormatStr(Body, title, notificationText);
CustomWebhookFormatProvider formatProvider = new (title, notificationText);
string formattedUrl = string.Format(formatProvider, Url);
string formattedBody = string.Format(formatProvider, Body, title, notificationText);
Dictionary<string, string> formattedHeaders = Headers.ToDictionary(h => h.Key,
h => FormatStr(h.Value, title, notificationText));
h => string.Format(formatProvider, h.Value, title, notificationText));
HttpRequestMessage request = new(System.Net.Http.HttpMethod.Parse(HttpMethod), formattedUrl);
foreach (var (key, value) in formattedHeaders)
request.Headers.Add(key, value);
request.Content = new StringContent(formattedBody);
request.Content.Headers.ContentType = new ("application/json");
Log.Debug($"Request: {request}");
HttpResponseMessage response = Client.Send(request);
Log.Debug($"Response status code: {response.StatusCode} {response.Content.ReadAsStringAsync().Result}");
Log.Debug($"Response status code: {response.StatusCode}");
}
private string FormatStr(string str, string title, string text)
private class CustomWebhookFormatProvider(string title, string text) : IFormatProvider
{
StringBuilder sb = new (str);
public object? GetFormat(Type? formatType)
{
return this;
}
public string Format(string fmt, object arg, IFormatProvider provider)
{
if(arg.GetType() != typeof(string))
return arg.ToString() ?? string.Empty;
StringBuilder sb = new StringBuilder(fmt);
sb.Replace("%title", title);
sb.Replace("%text", text);
return sb.ToString();
}
}
public override string ToString() => $"{GetType().Name} {Name}";
}

View File

@@ -22,11 +22,11 @@ public abstract class TrangaBaseContext<T> : DbContext where T : DbContext
}, 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
{
await this.SaveChangesAsync(token);
this.SaveChanges();
return (true, null);
}
catch (Exception e)

View File

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

View File

@@ -1,6 +1,4 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using API.MangaConnectors;
using System.Diagnostics.CodeAnalysis;
using API.Schema.LibraryContext;
using API.Schema.MangaContext;
using API.Schema.MangaContext.MetadataFetchers;
@@ -9,17 +7,24 @@ using API.Workers;
using API.Workers.MaintenanceWorkers;
using log4net;
using log4net.Config;
using Microsoft.EntityFrameworkCore;
namespace API;
public static class Tranga
{
private static IServiceProvider? ServiceProvider;
// ReSharper disable once InconsistentNaming
private const string TRANGA =
"\n\n" +
" _______ v2\n" +
"|_ _|.----..---.-..-----..-----..---.-.\n" +
" | | | _|| _ || || _ || _ |\n" +
" |___| |__| |___._||__|__||___ ||___._|\n" +
" |_____| \n\n";
public static Thread PeriodicWorkerStarterThread { get; } = new (WorkerStarter);
private static readonly ILog Log = LogManager.GetLogger(typeof(Tranga));
internal static readonly MetadataFetcher[] MetadataFetchers = [new MyAnimeList()];
internal static readonly MangaConnector[] MangaConnectors = [new Global(), new MangaDex(), new ComickIo()];
internal static TrangaSettings Settings = TrangaSettings.Load();
internal static readonly UpdateMetadataWorker UpdateMetadataWorker = new ();
@@ -29,17 +34,13 @@ public static class Tranga
internal static readonly CleanupMangaCoversWorker CleanupMangaCoversWorker = new();
internal static readonly StartNewChapterDownloadsWorker StartNewChapterDownloadsWorker = new();
internal static readonly RemoveOldNotificationsWorker RemoveOldNotificationsWorker = 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(Constants.TRANGA);
}
Log.Info(TRANGA);
internal static void AddDefaultWorkers()
{
AddWorker(UpdateMetadataWorker);
AddWorker(SendNotificationsWorker);
AddWorker(UpdateChaptersDownloadedWorker);
@@ -47,158 +48,119 @@ public static class Tranga
AddWorker(CleanupMangaCoversWorker);
AddWorker(StartNewChapterDownloadsWorker);
AddWorker(RemoveOldNotificationsWorker);
AddWorker(UpdateCoversWorker);
}
internal static void SetServiceProvider(IServiceProvider serviceProvider)
{
ServiceProvider = serviceProvider;
}
internal static bool TryGetMangaConnector(string name, [NotNullWhen(true)]out MangaConnector? mangaConnector)
{
mangaConnector =
MangaConnectors.FirstOrDefault(c => c.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase));
return mangaConnector != null;
}
internal static readonly ConcurrentDictionary<IPeriodic, Task> PeriodicWorkers = new ();
public static void AddWorker(BaseWorker worker)
{
Log.Debug($"Adding Worker {worker}");
StartWorker(worker);
if(worker is IPeriodic periodic)
AddPeriodicWorker(worker, periodic);
}
private static void AddPeriodicWorker(BaseWorker worker, IPeriodic periodic)
{
Log.Debug($"Adding Periodic {worker}");
Task periodicTask = PeriodicTask(worker, periodic);
PeriodicWorkers.TryAdd((worker as IPeriodic)!, periodicTask);
periodicTask.Start();
}
private static Task PeriodicTask(BaseWorker worker, IPeriodic periodic) => new (() =>
{
Log.Debug($"Waiting {periodic.Interval} for next run of {worker}");
Thread.Sleep(periodic.Interval);
StartWorker(worker, RefreshTask(worker, periodic));
});
private static Action RefreshTask(BaseWorker worker, IPeriodic periodic) => () =>
{
if (worker.State < WorkerExecutionState.Created) //Failed
return;
Log.Debug($"Refreshing {worker}");
Task periodicTask = PeriodicTask(worker, periodic);
PeriodicWorkers.AddOrUpdate((worker as IPeriodic)!, periodicTask, (_, _) => periodicTask);
periodicTask.Start();
};
internal static HashSet<BaseWorker> AllWorkers { get; private set; } = new ();
public static void AddWorker(BaseWorker worker) => AllWorkers.Add(worker);
public static void AddWorkers(IEnumerable<BaseWorker> workers)
{
foreach (BaseWorker baseWorker in workers)
{
AddWorker(baseWorker);
}
}
private static readonly ConcurrentDictionary<BaseWorker, Task<BaseWorker[]>> RunningWorkers = new();
public static void RemoveWorker(BaseWorker removeWorker)
{
IEnumerable<BaseWorker> baseWorkers = AllWorkers.Where(w => w.DependenciesAndSelf.Any(worker => worker == removeWorker));
foreach (BaseWorker worker in baseWorkers)
{
StopWorker(worker);
AllWorkers.Remove(worker);
}
}
private static readonly Dictionary<BaseWorker, Task<BaseWorker[]>> RunningWorkers = new();
public static BaseWorker[] GetRunningWorkers() => RunningWorkers.Keys.ToArray();
internal static void StartWorker(BaseWorker worker, Action? callback = null)
private static readonly HashSet<BaseWorker> StartWorkers = new();
private static void WorkerStarter(object? serviceProviderObj)
{
Log.Debug($"Starting {worker}");
if (ServiceProvider is null)
Log.Info("WorkerStarter Thread running.");
if (serviceProviderObj is null)
{
Log.Fatal("ServiceProvider is null");
Log.Error("serviceProviderObj is null");
return;
}
Action afterWorkCallback = AfterWork(worker, callback);
IServiceProvider serviceProvider = (IServiceProvider)serviceProviderObj;
while (RunningWorkers.Count > Settings.MaxConcurrentWorkers)
while (true)
{
Log.Warn($"{worker}: Max worker concurrency reached ({Settings.MaxConcurrentWorkers})! Waiting {Settings.WorkCycleTimeoutMs}ms...");
Thread.Sleep(Settings.WorkCycleTimeoutMs);
}
CheckRunningWorkers();
foreach (BaseWorker baseWorker in AllWorkers.DueWorkers())
StartWorkers.Add(baseWorker);
foreach (BaseWorker worker in StartWorkers.ToArray())
{
if(RunningWorkers.ContainsKey(worker))
continue;
if (worker is BaseWorkerWithContext<MangaContext> mangaContextWorker)
{
mangaContextWorker.SetScope(ServiceProvider.CreateScope());
RunningWorkers.TryAdd(mangaContextWorker, mangaContextWorker.DoWork(afterWorkCallback));
mangaContextWorker.SetScope(serviceProvider.CreateScope());
RunningWorkers.Add(mangaContextWorker, mangaContextWorker.DoWork());
}else if (worker is BaseWorkerWithContext<NotificationsContext> notificationContextWorker)
{
notificationContextWorker.SetScope(ServiceProvider.CreateScope());
RunningWorkers.TryAdd(notificationContextWorker, notificationContextWorker.DoWork(afterWorkCallback));
notificationContextWorker.SetScope(serviceProvider.CreateScope());
RunningWorkers.Add(notificationContextWorker, notificationContextWorker.DoWork());
}else if (worker is BaseWorkerWithContext<LibraryContext> libraryContextWorker)
{
libraryContextWorker.SetScope(ServiceProvider.CreateScope());
RunningWorkers.TryAdd(libraryContextWorker, libraryContextWorker.DoWork(afterWorkCallback));
libraryContextWorker.SetScope(serviceProvider.CreateScope());
RunningWorkers.Add(libraryContextWorker, libraryContextWorker.DoWork());
}else
RunningWorkers.TryAdd(worker, worker.DoWork(afterWorkCallback));
RunningWorkers.Add(worker, worker.DoWork());
StartWorkers.Remove(worker);
}
Thread.Sleep(Settings.WorkCycleTimeoutMs);
}
}
private static Action AfterWork(BaseWorker worker, Action? callback = null) => () =>
private static void CheckRunningWorkers()
{
Log.Debug($"AfterWork {worker}");
if (RunningWorkers.TryGetValue(worker, out Task<BaseWorker[]>? task))
KeyValuePair<BaseWorker, Task<BaseWorker[]>>[] done = RunningWorkers.Where(kv => kv.Value.IsCompleted).ToArray();
if (done.Length < 1)
return;
Log.Info($"Done: {done.Length}");
Log.Debug(string.Join("\n", done.Select(d => d.Key.ToString())));
foreach ((BaseWorker worker, Task<BaseWorker[]> task) in done)
{
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);
RunningWorkers.Remove(worker);
foreach (BaseWorker newWorker in task.Result)
AllWorkers.Add(newWorker);
task.Dispose();
}
catch (Exception e)
}
private static IEnumerable<BaseWorker> DueWorkers(this IEnumerable<BaseWorker> workers)
{
Log.Error(e);
return workers.Where(w =>
{
if (w.State is >= WorkerExecutionState.Running and < WorkerExecutionState.Completed)
return false;
if (w is IPeriodic periodicWorker)
return periodicWorker.IsDue;
return true;
});
}
}else Log.Warn($"Children failed: {worker}");
}
RunningWorkers.Remove(worker, out _);
callback?.Invoke();
};
internal static void MarkWorkerForStart(BaseWorker worker) => StartWorkers.Add(worker);
internal static void StopWorker(BaseWorker worker)
{
Log.Debug($"Stopping {worker}");
if(worker is IPeriodic periodicWorker)
PeriodicWorkers.Remove(periodicWorker, out _);
StartWorkers.Remove(worker);
worker.Cancel();
RunningWorkers.Remove(worker, out _);
RunningWorkers.Remove(worker);
}
internal static async Task<bool> AddMangaToContext(this MangaContext context, (Manga, MangaConnectorId<Manga>) addManga, CancellationToken token) =>
await AddMangaToContext(context, addManga.Item1, addManga.Item2, token);
internal static bool AddMangaToContext((Manga, MangaConnectorId<Manga>) addManga, MangaContext context, [NotNullWhen(true)]out Manga? manga) => AddMangaToContext(addManga.Item1, addManga.Item2, context, out manga);
internal static async Task<bool> AddMangaToContext(this MangaContext context, Manga addManga, MangaConnectorId<Manga> addMcId,
CancellationToken token)
{
context.ChangeTracker.Clear();
Manga? manga = await context.FindMangaLike(addManga, token);
if (manga is not null)
{
foreach (MangaConnectorId<Manga> mcId in addManga.MangaConnectorIds)
internal static bool AddMangaToContext(Manga addManga, MangaConnectorId<Manga> addMcId, MangaContext context, [NotNullWhen(true)]out Manga? manga)
{
manga = context.Mangas.Find(addManga.Key) ?? addManga;
MangaConnectorId<Manga> mcId = context.MangaConnectorToManga.Find(addMcId.Key) ?? addMcId;
mcId.Obj = manga;
mcId.ObjId = manga.Key;
}
manga.MangaTags = manga.MangaTags.UnionBy(addManga.MangaTags, tag => tag.Tag).ToList();
manga.Authors = manga.Authors.UnionBy(addManga.Authors, author => author.Key).ToList();
manga.Links = manga.Links.UnionBy(addManga.Links, link => link.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.MangaConnectorIds = manga.MangaConnectorIds.UnionBy(addManga.MangaConnectorIds, id => id.MangaConnectorName).ToList();
addManga = manga;
}
else
{
manga = addManga;
IEnumerable<MangaTag> mergedTags = manga.MangaTags.Select(mt =>
{
MangaTag? inDb = context.Tags.Find(mt.Tag);
@@ -213,15 +175,28 @@ public static class Tranga
});
manga.Authors = mergedAuthors.ToList();
context.Mangas.Add(manga);
if(context.MangaConnectorToManga.Find(addMcId.Key) is null)
context.MangaConnectorToManga.Add(mcId);
if (context.Sync() is { success: false })
return false;
return true;
}
if (await context.Sync(token) is { success: false })
internal static bool AddChapterToContext((Chapter, MangaConnectorId<Chapter>) addChapter, MangaContext context,
[NotNullWhen(true)] out Chapter? chapter) => AddChapterToContext(addChapter.Item1, addChapter.Item2, context, out chapter);
internal static bool AddChapterToContext(Chapter addChapter, MangaConnectorId<Chapter> addChId, MangaContext context, [NotNullWhen(true)] out Chapter? chapter)
{
chapter = context.Chapters.Find(addChapter.Key) ?? addChapter;
MangaConnectorId<Chapter> chId = context.MangaConnectorToChapter.Find(addChId.Key) ?? addChId;
chId.Obj = chapter;
if(context.MangaConnectorToChapter.Find(chId.Key) is null)
context.MangaConnectorToChapter.Add(chId);
if (context.Sync() is { success: false })
return false;
DownloadCoverFromMangaconnectorWorker downloadCoverWorker = new (addMcId);
AddWorker(downloadCoverWorker);
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");
[JsonIgnore]
public static string settingsFilePath => Path.Join(workingDirectory, "settings.json");
[JsonIgnore] public static string coverImageCache => Path.Join(workingDirectory, "imageCache");
[JsonIgnore] public static string coverImageCacheOriginal => Path.Join(coverImageCache, "original");
[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");
[JsonIgnore]
public static string coverImageCache => Path.Join(workingDirectory, "imageCache");
public string DownloadLocation => RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/Manga" : Path.Join(Directory.GetCurrentDirectory(), "Manga");
[JsonIgnore]
internal static readonly string DefaultUserAgent = $"Tranga/2.0 ({Enum.GetName(Environment.OSVersion.Platform)}; {(Environment.Is64BitOperatingSystem ? "x64" : "")})";
@@ -42,21 +39,17 @@ public struct TrangaSettings()
[JsonIgnore]
internal static readonly Dictionary<RequestType, int> DefaultRequestLimits = new ()
{
{RequestType.MangaInfo, 360},
{RequestType.MangaDexFeed, 360},
{RequestType.MangaInfo, 60},
{RequestType.MangaDexFeed, 60},
{RequestType.MangaDexImage, 60},
{RequestType.MangaImage, 240},
{RequestType.MangaCover, 60},
{RequestType.Default, 360}
{RequestType.Default, 60}
};
public Dictionary<RequestType, int> RequestLimits { get; set; } = DefaultRequestLimits;
public string DownloadLanguage { get; set; } = "en";
public int MaxConcurrentDownloads { get; set; } = 5;
public int MaxConcurrentWorkers { get; set; } = 10;
public static TrangaSettings Load()
{
if (!File.Exists(settingsFilePath))
@@ -66,7 +59,7 @@ public struct TrangaSettings()
public void Save()
{
File.WriteAllText(settingsFilePath, JsonConvert.SerializeObject(this, Formatting.Indented));
File.WriteAllText(settingsFilePath, JsonConvert.SerializeObject(this));
}
public void SetUserAgent(string value)
@@ -116,10 +109,4 @@ public struct TrangaSettings()
this.DownloadLanguage = language;
Save();
}
public void SetMaxConcurrentDownloads(int value)
{
this.MaxConcurrentDownloads = value;
Save();
}
}

View File

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

View File

@@ -1,10 +1,8 @@
using System.IO.Compression;
using System.Runtime.InteropServices;
using API.MangaConnectors;
using API.MangaDownloadClients;
using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using API.Schema.MangaContext.MangaConnectors;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
@@ -13,62 +11,26 @@ using static System.IO.UnixFileMode;
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)
: BaseWorkerWithContext<MangaContext>(dependsOn)
{
internal readonly string MangaConnectorIdId = chId.Key;
protected override async Task<BaseWorker[]> DoWorkInternal()
protected override BaseWorker[] DoWorkInternal()
{
Log.Debug($"Downloading chapter for MangaConnectorId {MangaConnectorIdId}...");
// 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.");
if (DbContext.MangaConnectorToChapter.Find(MangaConnectorIdId) is not { } MangaConnectorId)
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))
{
Log.Error("Could not get MangaConnector.");
return []; //TODO Exception?
}
Log.Debug($"Downloading chapter for MangaConnectorId {mangaConnectorId}...");
Chapter chapter = mangaConnectorId.Obj;
MangaConnector mangaConnector = MangaConnectorId.MangaConnector;
Chapter chapter = MangaConnectorId.Obj;
if (chapter.Downloaded)
{
Log.Info("Chapter was already downloaded.");
return [];
}
if (chapter.ParentManga.LibraryId is null)
{
Log.Info($"Library is not set for {chapter.ParentManga} {chapter}");
return [];
}
string[] imageUrls = mangaConnector.GetChapterImageUrls(mangaConnectorId);
string[] imageUrls = mangaConnector.GetChapterImageUrls(MangaConnectorId);
if (imageUrls.Length < 1)
{
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 [];
}
string saveArchiveFilePath = chapter.FullArchiveFilePath;
@@ -117,12 +79,10 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
}
}
await CopyCoverFromCacheToDownloadLocation(chapter.ParentManga);
CopyCoverFromCacheToDownloadLocation(chapter.ParentManga);
Log.Debug($"Creating ComicInfo.xml {chapter}");
foreach (CollectionEntry collectionEntry in DbContext.Entry(chapter.ParentManga).Collections)
await collectionEntry.LoadAsync(CancellationToken);
await File.WriteAllTextAsync(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString(), CancellationToken);
File.WriteAllText(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString());
Log.Debug($"Packaging images to archive {chapter}");
//ZIP-it and ship-it
@@ -131,11 +91,8 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
File.SetUnixFileMode(saveArchiveFilePath, UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute | OtherRead | OtherExecute);
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 [];
}
@@ -178,7 +135,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
string publicationFolder = manga.CreatePublicationFolder();
@@ -190,29 +147,21 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
}
//TODO MangaConnector Selection
await DbContext.Entry(manga).Collection(m => m.MangaConnectorIds).LoadAsync(CancellationToken);
MangaConnectorId<Manga> mangaConnectorId = manga.MangaConnectorIds.First();
if (!Tranga.TryGetMangaConnector(mangaConnectorId.MangaConnectorName, out MangaConnector? mangaConnector))
{
Log.Error($"MangaConnector with name {mangaConnectorId.MangaConnectorName} could not be found");
return;
}
MangaConnectorId<Manga> mcId = manga.MangaConnectorIds.First();
Log.Info($"Copying cover to {publicationFolder}");
await DbContext.Entry(mangaConnectorId).Navigation(nameof(MangaConnectorId<Manga>.Obj)).LoadAsync(CancellationToken);
string? coverFileNameInCache = manga.CoverFileNameInCache ?? mangaConnector.SaveCoverImageToCache(mangaConnectorId);
if (coverFileNameInCache is null)
string? fileInCache = manga.CoverFileNameInCache ?? mcId.MangaConnector.SaveCoverImageToCache(mcId);
if (fileInCache is null)
{
Log.Error($"File {coverFileNameInCache} does not exist");
Log.Error($"File {fileInCache} does not exist");
return;
}
string fullCoverPath = Path.Join(TrangaSettings.coverImageCacheOriginal, coverFileNameInCache);
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(coverFileNameInCache).Split('.')[^1]}" );
File.Copy(fullCoverPath, newFilePath, true);
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" );
File.Copy(fileInCache, newFilePath, true);
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | UserRead | UserWrite | OtherRead | OtherWrite);
Log.Debug($"Copied cover from {fullCoverPath} to {newFilePath}");
Log.Debug($"Copied cover from {fileInCache} to {newFilePath}");
}
private bool DownloadImage(string imageUrl, string savePath)

View File

@@ -1,45 +1,22 @@
using API.MangaConnectors;
using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
using API.Schema.MangaContext.MangaConnectors;
namespace API.Workers;
/// <summary>
/// Downloads the cover for Manga from Mangaconnector
/// </summary>
public class DownloadCoverFromMangaconnectorWorker(MangaConnectorId<Manga> mcId, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn)
{
internal readonly string MangaConnectorIdId = mcId.Key;
protected override async Task<BaseWorker[]> DoWorkInternal()
protected override BaseWorker[] DoWorkInternal()
{
Log.Debug($"Getting Cover for MangaConnectorId {MangaConnectorIdId}...");
// 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.");
if (DbContext.MangaConnectorToManga.Find(MangaConnectorIdId) is not { } MangaConnectorId)
return []; //TODO Exception?
}
if (!Tranga.TryGetMangaConnector(mangaConnectorId.MangaConnectorName, out MangaConnector? mangaConnector))
{
Log.Error("Could not get MangaConnector.");
return []; //TODO Exception?
}
Log.Debug($"Getting Cover for MangaConnectorId {mangaConnectorId}...");
MangaConnector mangaConnector = MangaConnectorId.MangaConnector;
Manga manga = MangaConnectorId.Obj;
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}");
manga.CoverFileNameInCache = mangaConnector.SaveCoverImageToCache(MangaConnectorId);
DbContext.Sync();
return [];
}

View File

@@ -1,80 +1,32 @@
using API.MangaConnectors;
using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
using API.Schema.MangaContext.MangaConnectors;
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)
: BaseWorkerWithContext<MangaContext>(dependsOn)
{
internal readonly string MangaConnectorIdId = mcId.Key;
protected override async Task<BaseWorker[]> DoWorkInternal()
protected override BaseWorker[] DoWorkInternal()
{
Log.Debug($"Getting Chapters for MangaConnectorId {MangaConnectorIdId}...");
// 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.");
if (DbContext.MangaConnectorToManga.Find(MangaConnectorIdId) is not { } MangaConnectorId)
return []; //TODO Exception?
}
if (!Tranga.TryGetMangaConnector(mangaConnectorId.MangaConnectorName, out MangaConnector? mangaConnector))
MangaConnector mangaConnector = MangaConnectorId.MangaConnector;
Manga manga = MangaConnectorId.Obj;
// This gets all chapters that are not downloaded
(Chapter, MangaConnectorId<Chapter>)[] allChapters =
mangaConnector.GetChapters(MangaConnectorId, language).DistinctBy(c => c.Item1.Key).ToArray();
int addedChapters = 0;
foreach ((Chapter chapter, MangaConnectorId<Chapter> mcId) newChapter in allChapters)
{
Log.Error("Could not get MangaConnector.");
return []; //TODO Exception?
if (Tranga.AddChapterToContext(newChapter, DbContext, out Chapter? addedChapter) == false)
continue;
manga.Chapters.Add(addedChapter);
}
Log.Debug($"Getting Chapters for MangaConnectorId {mangaConnectorId}...");
Log.Info($"{manga.Chapters.Count} existing + {addedChapters} new chapters.");
Manga manga = mangaConnectorId.Obj;
// Retrieve available Chapters from Connector
(Chapter chapter, MangaConnectorId<Chapter> chapterId)[] allChapters =
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
manga.Chapters = manga.Chapters.Union(newChapters.Select(ch => ch.chapter)).ToList();
// 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)
{
chapterId.UseForDownload = mangaConnectorId.UseForDownload;
}
}
if(await DbContext.Sync(CancellationToken) is { success: false } e)
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
DbContext.Sync();
return [];
}

View File

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

View File

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

View File

@@ -1,28 +1,20 @@
using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
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)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
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...");
List<MangaConnectorId<Manga>> connectorIdsManga = await DbContext.MangaConnectorToManga
.Include(id => id.Obj)
.Where(id => id.UseForDownload)
.ToListAsync(CancellationToken);
Log.Debug($"Creating {connectorIdsManga.Count} update jobs...");
IQueryable<MangaConnectorId<Manga>> connectorIdsManga = DbContext.MangaConnectorToManga.Where(id => id.UseForDownload);
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();
}

View File

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

View File

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

View File

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

View File

@@ -1,38 +1,20 @@
using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
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)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
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.Where(id => id.Obj.Downloaded == false && id.UseForDownload);
// Get missing chapters
List<MangaConnectorId<Chapter>> missingChapters = await DbContext.MangaConnectorToChapter
.Include(id => id.Obj)
.Where(id => id.Obj.Downloaded == false && id.UseForDownload)
.ToListAsync(CancellationToken);
Log.Debug($"Found {missingChapters.Count} missing downloads.");
// Maximum Concurrent workers
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>();
List<BaseWorker> newWorkers = new();
foreach (MangaConnectorId<Chapter> mangaConnectorId in mangaConnectorIds)
newWorkers.Add(new DownloadChapterFromMangaconnectorWorker(mangaConnectorId));
return newWorkers.ToArray();
}

View File

@@ -1,36 +1,17 @@
using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
namespace API.Workers;
/// <summary>
/// Updates the database to reflect changes made on disk
/// </summary>
public class UpdateChaptersDownloadedWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval??TimeSpan.FromMinutes(60);
protected override async Task<BaseWorker[]> DoWorkInternal()
protected override BaseWorker[] DoWorkInternal()
{
Log.Debug("Checking chapter files...");
List<Chapter> chapters = await DbContext.Chapters.ToListAsync(CancellationToken);
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)
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
foreach (Chapter dbContextChapter in DbContext.Chapters)
dbContextChapter.Downloaded = dbContextChapter.CheckDownloaded();
DbContext.Sync();
return [];
}
}

View File

@@ -1,24 +0,0 @@
using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
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)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(6);
protected override async Task<BaseWorker[]> DoWorkInternal()
{
List<MangaConnectorId<Manga>> manga = await DbContext.MangaConnectorToManga.Where(mcId => mcId.UseForDownload).ToListAsync(CancellationToken);
List<BaseWorker> newWorkers = manga.Select(m => new DownloadCoverFromMangaconnectorWorker(m)).ToList<BaseWorker>();
return newWorkers.ToArray();
}
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

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

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