140 Commits

Author SHA1 Message Date
57cb48cbd0 New Connector PR template
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-09-08 19:33:30 +02:00
611e8a04df Update issue templates 2025-09-08 19:26:36 +02:00
6231f9a842 Improve Contributing Guidelines 2025-09-08 19:16:15 +02:00
7f9bea00a4 Fix BaseWorker unnecessary nesting of Tasks
Some checks failed
Docker Image CI / build (push) Has been cancelled
Fix MangaDex Oneshots have no Chapternumber
2025-09-08 18:52:41 +02:00
1360b7afc5 Update Readme and License
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-09-06 17:27:07 +02:00
3a8a2d2c86 Remove obsolete files 2025-09-06 17:03:28 +02:00
af01e4a6a4 NotificationConnector DTO and RequestRecords 2025-09-06 17:02:15 +02:00
d4ea40a875 Descriptions for Records and DTOs 2025-09-06 16:40:19 +02:00
09e6b07186 LibrarConnector DTO and CreateLibraryConnectorRecord 2025-09-06 16:31:16 +02:00
26987c983f Add FileLibrary DTO and CreateLibraryRecord 2025-09-06 16:26:17 +02:00
b3df3bf144 Server v2 is cuttingedge 2025-09-06 16:10:10 +02:00
757d7a8ab0 Fix crashes if task is not finished yet. 2025-09-05 20:52:06 +02:00
ddcab0c6ed Fix caching etag comparison 2025-09-05 20:42:02 +02:00
f1a61a7b90 Fix wrong value being returned 2025-09-05 20:36:24 +02:00
78e7e8fc06 Resize Covers on download 2025-09-05 20:28:50 +02:00
d1b2f0ab19 Include="Log4Net.config.xml" CopyToOutputDirectory="Always" CopyToPublishDirectory="Always" 2025-09-05 17:31:49 +02:00
8439b8b2ab Configure log4net loggers 2025-09-05 15:31:06 +02:00
62090d1677 Use Tracked entity 2025-09-05 03:00:17 +02:00
e0735eea0f Mark ConnectorId to not use again if there is no image-urls 2025-09-05 02:36:26 +02:00
65418c0495 Max Worker Concurrency 2025-09-05 01:13:31 +02:00
35e36a557c Fix asynchronous chapter checking 2025-09-05 00:08:48 +02:00
ee3789fdfd Fix Chapter already exists check 2025-09-05 00:05:14 +02:00
aae5c88ec9 Order the chapters 2025-09-04 23:46:31 +02:00
0cb239a4e6 Maximum concurrent downloads (too many crashes the application) 2025-09-04 23:33:42 +02:00
b24308021b Fix request limits for UserAgents not being set correctly 2025-09-04 23:14:32 +02:00
2d662c269b Fix new Workers not getting added. 2025-09-04 23:02:24 +02:00
c8be88d645 Add new MangaConnectorId<Chapter> for existing Chapters to database 2025-09-04 22:50:17 +02:00
48914985d2 If Manga is marked for Download from Connector, mark the new Chapters as UseForDownload 2025-09-04 22:20:42 +02:00
f94513a4ae Fix Manga ChangeLibrary Endpoint to be faster if library is the same.
Fix MoveMangaLibraryWorker.cs setting off new Library
2025-09-04 22:15:29 +02:00
b76f460778 Include Library in Manga-Response 2025-09-04 21:52:19 +02:00
f9e017ec11 Fix RetrieveMangaChaptersFromMangaconnector Chapters not getting added to Manga 2025-09-04 21:46:23 +02:00
ec54cad58c Add icon to global connector 2025-09-04 19:17:41 +02:00
a5c82b2505 Fix search not adding manga to database 2025-09-04 19:11:41 +02:00
35bbbc521e Change DefaultRequestLimits 2025-09-04 18:02:02 +02:00
2cb898d8eb fix healthcheck for local testing 2025-09-04 18:00:25 +02:00
c23d29436f MangaConnector DTO 2025-09-03 17:55:26 +02:00
cb14a7c31f Use DTOs to return API requests instead of Database Schema types.
Make use of IHttpStatusCodeResults
2025-09-02 22:26:50 +02:00
55f04710a7 Improved CancellationToken usage, Added more logging to Workers, Added Code-comments (please future me, be thankful) 2025-09-02 19:36:06 +02:00
6fa166363a Fix task finished before Finish-Action could be set. 2025-09-02 01:30:41 +02:00
1b0aa7d543 Remove armv6 failed to build: failed to solve: no match for platform in manifest: not found 2025-09-02 00:57:09 +02:00
0c94e6e5fe workflow more platforms 2025-09-02 00:50:01 +02:00
ffe1f4fd2c Search use MinimalManga 2025-09-02 00:42:07 +02:00
e96b2585e0 Return new DTO "MinimalManga" for endpoints that return a lot of Manga-data 2025-09-01 23:43:59 +02:00
3b8570cf57 Allow requests to be cancelled.
Make workers have a CancellationTokenSource
2025-09-01 23:26:49 +02:00
6c61869e66 remove --platform=$TARGETPLATFORM 2025-09-01 21:51:30 +02:00
1c6398414d Add key length annotation to Identifiable 2025-09-01 21:50:00 +02:00
29d21f06e5 Move endpoint to get downloading manga 2025-09-01 21:26:34 +02:00
b4a0ce68c3 Return all Manga by default, add endpoint to return Mangakeys 2025-09-01 21:22:29 +02:00
94a8dfc90a Add endpoint to return worker keys 2025-09-01 21:21:13 +02:00
81463de409 Return Workers directly instead of keys 2025-09-01 21:18:56 +02:00
e31ecbd66b specify user for postgres healthcheck 2025-09-01 20:35:00 +02:00
c81a3633de Update packages, remove puppeteer 2025-09-01 20:35:00 +02:00
5883ed6426 Fix rebase 2025-09-01 20:35:00 +02:00
7e70288662 Fix MangaConnectorId Chapters Cascade 2025-09-01 20:35:00 +02:00
24299a955a Add Maintenance Controller
- CleanupNoDownloadManga
2025-09-01 20:35:00 +02:00
49856657ca Add Query to get similar Manga by name 2025-09-01 20:35:00 +02:00
16ef2fc9f1 Fix MangaDex null 2025-09-01 20:35:00 +02:00
32f7a6642a Fix merging of Manga
Fix ComickIo empty lists
2025-09-01 20:35:00 +02:00
4d5c95b119 Additional Query to get only downloading Manga 2025-09-01 20:35:00 +02:00
6da116acb3 Fix Merge of Chapters 2025-09-01 20:35:00 +02:00
34b7d0c2a3 Fix Merge of Manga 2025-09-01 20:35:00 +02:00
cb06cbbb61 Add Queries for MangaConnectorIds 2025-09-01 20:35:00 +02:00
a0eb2be4bd Fix DownloadChapterFromMangaconnectorWorker trying to download even if Library is not set 2025-09-01 20:35:00 +02:00
ce9195272d Fix return of GET MangaConnector 2025-09-01 20:35:00 +02:00
d6bb9fca21 Fix CancellationToken Source crashing all Workers after 10 Minutes of runtime 2025-09-01 20:35:00 +02:00
4f6053172d Fix NotificationConnectors 2025-09-01 20:35:00 +02:00
bbaf3c46b3 NamedSwaggerGenOptions.cs 2025-09-01 20:35:00 +02:00
ccaed156bb More Logging 2025-09-01 20:35:00 +02:00
dcf8ada486 Do not use a Thread to Periodically check for Due workers.
Each Periodic Worker has it's own Thread that waits for execution.
2025-09-01 20:35:00 +02:00
9d560692dc Fix Concurrency of DownloadClient LastExecutedRateLimit 2025-09-01 20:35:00 +02:00
9d2dd2eabb Add UpdateCoversWorker 2025-09-01 20:35:00 +02:00
16c0cde526 Download Cover when adding Manga 2025-09-01 20:35:00 +02:00
7189dccd89 Remove non-periodic workers after they finish 2025-09-01 20:35:00 +02:00
044553abcb Search for Manga on different MangaConnector 2025-09-01 20:35:00 +02:00
f2bc48bc52 Context Load Navigations and Collections 2025-09-01 20:35:00 +02:00
93d3878b07 GET Workers return IDs 2025-09-01 20:35:00 +02:00
cae8cde53f Disable LazyLoading
Remove MangaConnectors from Database
2025-09-01 20:35:00 +02:00
394944e11a Indent TrangaSettings 2025-09-01 20:35:00 +02:00
fb004657eb Move Configuration of Workers to separate method 2025-09-01 20:35:00 +02:00
9d84716278 Rename methods for workers from old Job 2025-09-01 20:35:00 +02:00
461272d02a StartNewChapterDownloadsWorker interval 1 minute 2025-09-01 20:35:00 +02:00
7a8ae9e175 Fix request path 2025-09-01 20:35:00 +02:00
31a22416ee Fix Scope/Context for Workers 2025-09-01 20:35:00 +02:00
f08e77102f Fix Worker-Cycle:
Periodic set last execution,
Print Running Worker-Names when done
2025-09-01 20:35:00 +02:00
6604c1b412 Fix TrangaBaseContext.Sync 2025-09-01 20:35:00 +02:00
2ab1ae04c1 Fix RemoveOldNotificationsWorker.cs: RemoveRange 2025-09-01 20:35:00 +02:00
902ebc48ff Add Migrations
Add RemoveOldNotificationsWorker.cs
2025-09-01 20:35:00 +02:00
54efc5fd5b Add default Tranga-Workers 2025-09-01 20:35:00 +02:00
6f3ccda0ed Enable Manga Downloading 2025-09-01 20:35:00 +02:00
04da402847 SettingsController set download language 2025-09-01 20:35:00 +02:00
d8c8310a48 Tranga WorkerCycle 2025-09-01 20:35:00 +02:00
fd508b7e6c UpdateMetadataWorker.cs 2025-09-01 20:35:00 +02:00
3c4f1c16db ToString overrides 2025-09-01 20:35:00 +02:00
fa29c21dfb StartNewChapterDownloadsWorker.cs 2025-09-01 20:35:00 +02:00
15f38f009f SendNotificationsWorker, CleanupMangaCoversWorker, UpdateChaptersDownloadedWorker add optional interval parameter 2025-09-01 20:35:00 +02:00
b34aa5e73a CheckForNewChaptersWorker 2025-09-01 20:35:00 +02:00
d63733bb5a Move AddMangaToContext to Tranga.cs 2025-09-01 20:35:00 +02:00
d9144e3708 SendNotificationsWorker.cs 2025-09-01 20:35:00 +02:00
072a5c3210 TrangaSettings as static field in Tranga instead of Static class 2025-09-01 20:35:00 +02:00
ed2dc72c75 Tranga CheckRunning Workers 2025-09-01 20:35:00 +02:00
56397dd0d2 BaseWorker Logging 2025-09-01 20:35:00 +02:00
9deac27f11 DbContext never null 2025-09-01 20:35:00 +02:00
637ad2e215 BaseWorker, BaseWorkerWithContext DoWork, call: Scope setting
TrangaBaseContext Sync return with success state and exception message
2025-09-01 20:35:00 +02:00
650cbffc8a IPeriodic non-generic 2025-09-01 20:35:00 +02:00
c8fcde656e Refactor Controllers
SettingsController.cs

SearchController.cs

QueryController.cs

NotificationConnectorController.cs

MetadataFetcherController.cs

MangaConnectorController.cs

FileLibraryController

LibraryConnectors

WorkerController
2025-09-01 20:35:00 +02:00
10d0a65637 WIP 2025-09-01 20:35:00 +02:00
35053bbc85 Create TrangaBaseContext for common OnConfiguring implementation of Contexts 2025-09-01 20:35:00 +02:00
d7cbafba50 Notifications-Identifiable 2025-09-01 20:35:00 +02:00
e9052de0e6 Add TODO to remove migrations after some time 2025-09-01 20:35:00 +02:00
e9231691fd Metadata-Site Search (Interactive linking) 2025-09-01 20:35:00 +02:00
191d805afa Change PrimaryKey of MetadataEntry to Fetcher + Identifier 2025-09-01 20:35:00 +02:00
826b0e4b98 MetadataFetching:
- Jikan (MAL) linking, fetching/updating
2025-09-01 20:35:00 +02:00
1abee5149c Manga and Chapters are shared across Connectors 2025-09-01 20:35:00 +02:00
ed148ce3d6 WIP: Manga can be linked to multiple Connectors
- PgsqlContext Adjustment
2025-09-01 20:35:00 +02:00
ddf009293a WIP: Manga can be linked to multiple Connectors 2025-09-01 20:35:00 +02:00
c6bb949224 Job is IComparable<Job> 2025-09-01 20:35:00 +02:00
2ff163067a Merge branch 'master' into postgres-Server-V2
# Conflicts:
#	README.md
#	Tranga/MangaConnectors/Webtoons.cs
#	Tranga/MangaConnectors/WeebCentral.cs
2025-09-01 20:27:10 +02:00
88ecf8d20b Create testing branch
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-09-01 19:47:48 +02:00
96b2e8d97a Merge pull request #422 from C9Glax/dependabot/github_actions/actions/checkout-5
Bump actions/checkout from 4 to 5
2025-08-22 20:59:39 +02:00
29cdaa382e Merge pull request #424 from PBXg33k/bugfix/jsonexception
[V2] BUGFIX: JsonException not caught if response is not JSON
2025-08-22 20:59:14 +02:00
PBX_g33k
1ff3673adb BUGFIX: JsonException not caught if response is not JSON 2025-08-22 17:41:32 +02:00
dependabot[bot]
f54d12b729 Bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-12 14:53:02 +00:00
ba1ebcd6ba Merge pull request #407 from C9Glax/dependabot/github_actions/docker/setup-buildx-action-3.11.1
Some checks failed
Docker Image CI / build (push) Has been cancelled
Bump docker/setup-buildx-action from 3.11.0 to 3.11.1
2025-06-20 14:21:48 +02:00
dependabot[bot]
cc655b0acd Bump docker/setup-buildx-action from 3.11.0 to 3.11.1
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.11.0 to 3.11.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.11.0...v3.11.1)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-19 05:58:29 +00:00
3f5c9d0ca1 Merge pull request #403 from C9Glax/dependabot/github_actions/docker/setup-buildx-action-3.11.0
Some checks failed
Docker Image CI / build (push) Has been cancelled
Bump docker/setup-buildx-action from 3.10.0 to 3.11.0
2025-06-17 11:41:35 +02:00
dependabot[bot]
538825f0ef Bump docker/setup-buildx-action from 3.10.0 to 3.11.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.10.0 to 3.11.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.10.0...v3.11.0)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-17 06:03:00 +00:00
f0de0a29da Merge pull request #400 from C9Glax/dependabot/github_actions/docker/build-push-action-6.18.0
Some checks failed
Docker Image CI / build (push) Has been cancelled
Bump docker/build-push-action from 6.17.0 to 6.18.0
2025-05-28 15:50:47 +02:00
dependabot[bot]
d4227f2b8f Bump docker/build-push-action from 6.17.0 to 6.18.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.17.0 to 6.18.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.17.0...v6.18.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 6.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-28 05:59:24 +00:00
cd00d35f22 Merge pull request #395 from C9Glax/dependabot/github_actions/docker/build-push-action-6.17.0
Some checks failed
Docker Image CI / build (push) Has been cancelled
Bump docker/build-push-action from 6.16.0 to 6.17.0
2025-05-16 16:15:40 +02:00
dependabot[bot]
4ef3e877ce Bump docker/build-push-action from 6.16.0 to 6.17.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.16.0 to 6.17.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.16.0...v6.17.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 6.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-16 05:31:47 +00:00
7dba2518f9 Merge pull request #388 from C9Glax/dependabot/github_actions/docker/build-push-action-6.16.0
Some checks failed
Docker Image CI / build (push) Has been cancelled
Bump docker/build-push-action from 6.15.0 to 6.16.0
2025-04-25 09:01:00 +02:00
dependabot[bot]
7506a0201e Bump docker/build-push-action from 6.15.0 to 6.16.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.15.0 to 6.16.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.15.0...v6.16.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 6.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-25 05:51:29 +00:00
91fb815153 Update README.md
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-03-29 21:17:22 +01:00
6faf8bc733 Merge pull request #376 from C9Glax/cuttingedge
Some checks failed
Docker Image CI / build (push) Has been cancelled
Weebcentral fixxes
2025-03-18 18:13:47 +01:00
bdff5b7aec Merge pull request #375 from TheyCallMeTravis/webtoons-search_regex_fix
Some checks failed
Docker Image CI / build (push) Has been cancelled
webtoons - fix search regex
2025-03-18 18:12:35 +01:00
5af8060d7b Merge pull request #374 from TheyCallMeTravis/weebcentral-fixsearch
Weebcentral - Fix Search Results Parse
2025-03-18 18:12:23 +01:00
TheyCallMeTravis
6ed8ff1d52 webtoons - fix search regex parsing 2025-03-18 10:12:42 -05:00
TheyCallMeTravis
3324ed6e4a Weebcentral - Fix Search Results Parse 2025-03-17 14:29:09 -05:00
67fd9d284b Merge pull request #369 from TheyCallMeTravis/WeebCentral-add_referrer
Some checks failed
Docker Image CI / build (push) Has been cancelled
WeebCentral - add referer to DownloadChapterImages
2025-03-15 10:24:28 +01:00
TheyCallMeTravis
08f26dd21d add referer to DownloadChapterImages 2025-03-14 21:18:51 -05:00
105 changed files with 3937 additions and 2384 deletions

View File

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

View File

@@ -1,6 +1,6 @@
name: New Connector Request
description: Request a new site to be added
title: "[New Connector]: "
title: "[New Connector]: <title>"
labels: ["New Connector"]
body:
- type: input
@@ -9,15 +9,12 @@ 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

@@ -0,0 +1,31 @@
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@v4
uses: actions/checkout@v5
# 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.10.0
uses: docker/setup-buildx-action@v3.11.1
# 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.15.0
uses: docker/build-push-action@v6.18.0
with:
context: ./
file: ./Dockerfile
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
platforms: linux/amd64,linux/arm64
#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
pull: true
push: true
tags: |

View File

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

View File

@@ -2,7 +2,7 @@ name: Docker Image CI
on:
push:
branches: [ "postgres-Server-V2" ]
branches: [ "testing" ]
workflow_dispatch:
jobs:
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
# 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.10.0
uses: docker/setup-buildx-action@v3.11.1
# 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.15.0
uses: docker/build-push-action@v6.18.0
with:
context: ./
file: ./Dockerfile
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
platforms: linux/amd64,linux/arm64
#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
pull: true
push: true
tags: |
glax/tranga-api:Server-V2
glax/tranga-api:testing

View File

@@ -11,26 +11,32 @@
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.2" />
<PackageReference Include="JikanDotNet" Version="2.9.1" />
<PackageReference Include="log4net" Version="3.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<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">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="9.0.5" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql" Version="9.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="PuppeteerSharp" Version="20.1.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="Soenneker.Utils.String.NeedlemanWunsch" Version="3.0.929" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="8.1.0" />
<PackageReference Include="System.Drawing.Common" Version="9.0.3" />
<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" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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

View File

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

View File

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

18
API/Constants.cs Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,8 +1,13 @@
using API.Schema.LibraryContext;
using API.Controllers.Requests;
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;
@@ -13,16 +18,20 @@ namespace API.Controllers;
public class LibraryConnectorController(LibraryContext context) : Controller
{
/// <summary>
/// Gets all configured <see cref="LibraryConnector"/>
/// Gets all configured <see cref="DTOs.LibraryConnector"/>
/// </summary>
/// <response code="200"></response>
/// <response code="500">Error during Database Operation</response>
[HttpGet]
[ProducesResponseType<LibraryConnector[]>(Status200OK, "application/json")]
public IActionResult GetAllConnectors()
[ProducesResponseType<List<LibraryConnector>>(Status200OK, "application/json")]
public async Task<Results<Ok<List<LibraryConnector>>, InternalServerError>> GetAllConnectors ()
{
LibraryConnector[] connectors = context.LibraryConnectors.ToArray();
return Ok(connectors);
if (await context.LibraryConnectors.ToListAsync(HttpContext.RequestAborted) is not { } connectors)
return TypedResults.InternalServerError();
List<LibraryConnector> libraryConnectors = connectors.Select(c => new LibraryConnector(c.Key, c.BaseUrl, c.LibraryType)).ToList();
return TypedResults.Ok(libraryConnectors);
}
/// <summary>
@@ -33,32 +42,39 @@ 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(Status404NotFound)]
public IActionResult GetConnector(string LibraryConnectorId)
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<LibraryConnector>, NotFound<string>>> GetConnector (string LibraryConnectorId)
{
if (context.LibraryConnectors.Find(LibraryConnectorId) is not { } connector)
return NotFound();
if (await context.LibraryConnectors.FirstOrDefaultAsync(l => l.Key == LibraryConnectorId) is not { } connector)
return TypedResults.NotFound(nameof(LibraryConnectorId));
return Ok(connector);
return TypedResults.Ok(new LibraryConnector(connector.Key, connector.BaseUrl, connector.LibraryType));
}
/// <summary>
/// Creates a new <see cref="LibraryConnector"/>
/// </summary>
/// <param name="libraryConnector"></param>
/// <param name="requestData"></param>
/// <response code="201"></response>
/// <response code="500">Error during Database Operation</response>
[HttpPut]
[ProducesResponseType(Status201Created)]
[ProducesResponseType<string>(Status201Created, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateConnector([FromBody]LibraryConnector libraryConnector)
public async Task<Results<Created<string>, InternalServerError<string>>> CreateConnector ([FromBody]CreateLibraryConnectorRecord requestData)
{
//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(libraryConnector);
context.LibraryConnectors.Add(connector);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Created();
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
return TypedResults.Created(string.Empty, connector.Key);
}
/// <summary>
@@ -66,21 +82,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(Status404NotFound)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult DeleteConnector(string LibraryConnectorId)
public async Task<Results<Ok, NotFound<string>, InternalServerError<string>>> DeleteConnector (string LibraryConnectorId)
{
if (context.LibraryConnectors.Find(LibraryConnectorId) is not { } connector)
return NotFound();
if (await context.LibraryConnectors.FirstOrDefaultAsync(l => l.Key == LibraryConnectorId) is not { } connector)
return TypedResults.NotFound(nameof(LibraryConnectorId));
context.LibraryConnectors.Remove(connector);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Ok();
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
return TypedResults.Ok();
}
}

View File

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

View File

@@ -1,14 +1,19 @@
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
using API.Controllers.DTOs;
using API.Schema.MangaContext;
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;
@@ -18,45 +23,116 @@ namespace API.Controllers;
[Route("v{v:apiVersion}/[controller]")]
public class MangaController(MangaContext context) : Controller
{
/// <summary>
/// Returns all cached <see cref="Manga"/>
/// Returns all cached <see cref="DTOs.Manga"/>
/// </summary>
/// <response code="200"></response>
/// <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]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult GetAllManga()
[ProducesResponseType<List<MinimalManga>>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<List<MinimalManga>>, InternalServerError>> GetAllManga ()
{
Manga[] ret = context.Mangas.ToArray();
return Ok(ret);
if (await context.Mangas.Include(m => m.MangaConnectorIds).ToArrayAsync(HttpContext.RequestAborted) is not
{ } result)
return TypedResults.InternalServerError();
return TypedResults.Ok(result.Select(m =>
{
IEnumerable<MangaConnectorId> ids = m.MangaConnectorIds.Select(id => new MangaConnectorId(id.Key, id.MangaConnectorName, id.ObjId, id.WebsiteUrl, id.UseForDownload));
return new MinimalManga(m.Key, m.Name, m.Description, m.ReleaseStatus, ids);
}).ToList());
}
/// <summary>
/// Returns all cached <see cref="Manga"/> with <paramref name="MangaIds"/>
/// Returns all cached <see cref="Schema.MangaContext.Manga"/>.Keys
/// </summary>
/// <param name="MangaIds">Array of <<see cref="Manga"/>.Key</param>
/// <response code="200"></response>
[HttpPost("WithIDs")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult GetManga([FromBody]string[] MangaIds)
/// <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 ()
{
Manga[] ret = context.Mangas.Where(m => MangaIds.Contains(m.Key)).ToArray();
return Ok(ret);
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>
[HttpPost("WithIDs")]
[ProducesResponseType<List<Manga>>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<List<Manga>>, InternalServerError>> GetMangaWithIds ([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());
}
/// <summary>
/// Return <see cref="Manga"/> with <paramref name="MangaId"/>
/// Return <see cref="Schema.MangaContext.Manga"/> with <paramref name="MangaId"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <param name="MangaId"><see cref="Schema.MangaContext.Manga"/>.Key</param>
/// <response code="200"></response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
[HttpGet("{MangaId}")]
[ProducesResponseType<Manga>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetManga(string MangaId)
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<Manga>, NotFound<string>>> GetManga (string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
return Ok(manga);
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);
}
/// <summary>
@@ -64,22 +140,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(Status404NotFound)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult DeleteManga(string MangaId)
public async Task<Results<Ok, NotFound<string>, InternalServerError<string>>> DeleteManga (string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.NotFound(nameof(MangaId));
context.Mangas.Remove(manga);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Ok();
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
return TypedResults.Ok();
}
@@ -91,73 +167,71 @@ 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<byte[]>(Status200OK,"image/jpeg")]
[ProducesResponseType(Status404NotFound)]
public IActionResult MergeIntoManga(string MangaIdFrom, string MangaIdInto)
[ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok, NotFound<string>>> MergeIntoManga (string MangaIdFrom, string MangaIdInto)
{
if (context.Mangas.Find(MangaIdFrom) is not { } from)
return NotFound(nameof(MangaIdFrom));
if (context.Mangas.Find(MangaIdInto) is not { } into)
return NotFound(nameof(MangaIdInto));
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));
BaseWorker[] newJobs = into.MergeFrom(from, context);
Tranga.AddWorkers(newJobs);
return Ok();
return TypedResults.Ok();
}
/// <summary>
/// Returns Cover of <see cref="Manga"/> with <paramref name="MangaId"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <param name="width">If <paramref name="width"/> is provided, <paramref name="height"/> needs to also be provided</param>
/// <param name="height">If <paramref name="height"/> is provided, <paramref name="width"/> needs to also be provided</param>
/// <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>
/// <response code="200">JPEG Image</response>
/// <response code="204">Cover not loaded</response>
/// <response code="400">The formatting-request was invalid</response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
/// <response code="503">Retry later, downloading cover</response>
[HttpGet("{MangaId}/Cover")]
[ProducesResponseType<byte[]>(Status200OK,"image/jpeg")]
[HttpGet("{MangaId}/Cover/{CoverSize?}")]
[ProducesResponseType<FileContentResult>(Status200OK,"image/jpeg")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
public IActionResult GetCover(string MangaId, [FromQuery]int? width, [FromQuery]int? height)
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status503ServiceUnavailable)]
public async Task<Results<FileContentHttpResult, NoContent, BadRequest, NotFound<string>, StatusCodeHttpResult>> GetCover (string MangaId, CoverSize? CoverSize = null)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
if (!System.IO.File.Exists(manga.CoverFileNameInCache))
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.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 (Tranga.GetRunningWorkers().Any(worker => worker is DownloadCoverFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId))
{
Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}");
return StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000);
return TypedResults.StatusCode(Status503ServiceUnavailable);
}
else
return NoContent();
}
Image image = Image.Load(manga.CoverFileNameInCache);
if (width is { } w && height is { } h)
{
if (width < 10 || height < 10 || width > 65535 || height > 65535)
return BadRequest();
image.Mutate(i => i.ApplyProcessor(new ResizeProcessor(new ResizeOptions()
{
Mode = ResizeMode.Max,
Size = new Size(w, h)
}, image.Size)));
return TypedResults.NoContent();
}
using MemoryStream ms = new();
image.Save(ms, new JpegEncoder(){Quality = 100});
DateTime lastModified = new FileInfo(manga.CoverFileNameInCache).LastWriteTime;
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 File(ms.GetBuffer(), "image/jpeg", new DateTimeOffset(lastModified), EntityTagHeaderValue.Parse($"\"{lastModified.Ticks}\""));
return TypedResults.Bytes(data.stream.ToArray(), "image/jpeg", lastModified: new DateTimeOffset(lastModified), entityTag: entityTagHeaderValue);
}
public enum CoverSize { Original, Large, Medium, Small }
/// <summary>
/// Returns all <see cref="Chapter"/> of <see cref="Manga"/> with <paramref name="MangaId"/>
@@ -166,15 +240,20 @@ 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<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType<List<Chapter>>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetChapters(string MangaId)
public async Task<Results<Ok<List<Chapter>>, NotFound<string>>> GetChapters(string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(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));
Chapter[] chapters = manga.Chapters.ToArray();
return Ok(chapters);
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);
}
/// <summary>
@@ -187,17 +266,22 @@ public class MangaController(MangaContext context) : Controller
[HttpGet("{MangaId}/Chapters/Downloaded")]
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetChaptersDownloaded(string MangaId)
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<List<Chapter>>, NoContent, NotFound<string>>> GetChaptersDownloaded(string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.Where(c => c.Downloaded).ToList();
if (chapters.Count == 0)
return NoContent();
if (await context.Mangas.Include(m => m.Chapters).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.NotFound(nameof(MangaId));
return Ok(chapters);
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();
if (chapters.Count == 0)
return TypedResults.NoContent();
return TypedResults.Ok(chapters);
}
/// <summary>
@@ -208,23 +292,28 @@ 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<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType<List<Chapter>>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetChaptersNotDownloaded(string MangaId)
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<List<Chapter>>, NoContent, NotFound<string>>> GetChaptersNotDownloaded(string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
if (await context.Mangas.Include(m => m.Chapters).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.Where(c => c.Downloaded == false).ToList();
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();
if (chapters.Count == 0)
return NoContent();
return TypedResults.NoContent();
return Ok(chapters);
return TypedResults.Ok(chapters);
}
/// <summary>
/// Returns the latest <see cref="Chapter"/> of requested <see cref="Manga"/> available on <see cref="MangaConnector"/>
/// Returns the latest <see cref="Chapter"/> of requested <see cref="Manga"/> available on <see cref="API.MangaConnectors.MangaConnector"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/>.Key</param>
/// <response code="200"></response>
@@ -233,32 +322,41 @@ 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<Chapter>(Status200OK, "application/json")]
[ProducesResponseType<int>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
[ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
public IActionResult GetLatestChapter(string MangaId)
[ProducesResponseType(Status500InternalServerError)]
[ProducesResponseType(Status503ServiceUnavailable)]
public async Task<Results<Ok<Chapter>, NoContent, InternalServerError, NotFound<string>, StatusCodeHttpResult>> GetLatestChapter(string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
if (await context.Mangas.Include(m => m.Chapters).FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.ToList();
List<API.Schema.MangaContext.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 StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2/ 1000);
}else
return Ok(0);
return TypedResults.StatusCode(Status503ServiceUnavailable);
}
else
return TypedResults.NoContent();
}
Chapter? max = chapters.Max();
API.Schema.MangaContext.Chapter? max = chapters.Max();
if (max is null)
return StatusCode(Status500InternalServerError, "Max chapter could not be found");
return TypedResults.InternalServerError();
return Ok(max);
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));
}
/// <summary>
@@ -273,30 +371,34 @@ public class MangaController(MangaContext context) : Controller
[HttpGet("{MangaId}/Chapter/LatestDownloaded")]
[ProducesResponseType<Chapter>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
[ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
public IActionResult GetLatestChapterDownloaded(string MangaId)
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status412PreconditionFailed)]
[ProducesResponseType(Status503ServiceUnavailable)]
public async Task<Results<Ok<Chapter>, NoContent, NotFound<string>, StatusCodeHttpResult>> GetLatestChapterDownloaded(string MangaId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.NotFound(nameof(MangaId));
List<Chapter> chapters = manga.Chapters.ToList();
await context.Entry(manga).Collection(m => m.Chapters).LoadAsync(HttpContext.RequestAborted);
List<API.Schema.MangaContext.Chapter> chapters = manga.Chapters.ToList();
if (chapters.Count == 0)
{
if (Tranga.GetRunningWorkers().Any(worker => worker is RetrieveMangaChaptersFromMangaconnectorWorker w && context.MangaConnectorToManga.Find(w.MangaConnectorIdId)?.ObjId == MangaId && w.State < WorkerExecutionState.Completed))
{
Response.Headers.Append("Retry-After", $"{Tranga.Settings.WorkCycleTimeoutMs * 2 / 1000:D}");
return StatusCode(Status503ServiceUnavailable, Tranga.Settings.WorkCycleTimeoutMs * 2/ 1000);
return TypedResults.StatusCode(Status503ServiceUnavailable);
}else
return NoContent();
return TypedResults.NoContent();
}
Chapter? max = chapters.Max();
API.Schema.MangaContext.Chapter? max = chapters.Max();
if (max is null)
return StatusCode(Status412PreconditionFailed, "Max chapter could not be found");
return TypedResults.StatusCode(Status412PreconditionFailed);
return Ok(max);
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));
}
/// <summary>
@@ -308,19 +410,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(Status202Accepted)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult IgnoreChaptersBefore(string MangaId, [FromBody]float chapterThreshold)
public async Task<Results<Ok, NotFound<string>, InternalServerError<string>>> IgnoreChaptersBefore(string MangaId, [FromBody]float chapterThreshold)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound();
if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return TypedResults.NotFound(nameof(MangaId));
manga.IgnoreChaptersBefore = chapterThreshold;
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
return Accepted();
return TypedResults.Ok();
}
/// <summary>
@@ -331,32 +433,35 @@ 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(Status202Accepted)]
[ProducesResponseType(Status404NotFound)]
public IActionResult MoveFolder(string MangaId, string LibraryId)
[ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok, NotFound<string>>> ChangeLibrary(string MangaId, string LibraryId)
{
if (context.Mangas.Find(MangaId) is not { } manga)
return NotFound(nameof(MangaId));
if(context.FileLibraries.Find(LibraryId) is not { } library)
return NotFound(nameof(LibraryId));
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();
MoveMangaLibraryWorker moveLibrary = new(manga, library);
Tranga.AddWorkers([moveLibrary]);
return Accepted();
return TypedResults.Ok();
}
/// <summary>
/// (Un-)Marks <see cref="Manga"/> as requested for Download from <see cref="MangaConnector"/>
/// (Un-)Marks <see cref="Manga"/> as requested for Download from <see cref="API.MangaConnectors.MangaConnector"/>
/// </summary>
/// <param name="MangaId"><see cref="Manga"/> with <paramref name="MangaId"/></param>
/// <param name="MangaConnectorName"><see cref="MangaConnector"/> with <paramref name="MangaConnectorName"/></param>
/// <param name="MangaConnectorName"><see cref="API.MangaConnectors.MangaConnector"/> with <paramref name="MangaConnectorName"/></param>
/// <param name="IsRequested">true to mark as requested, false to mark as not-requested</param>
/// <response code="200"></response>
/// <response code="404"><paramref name="MangaId"/> or <paramref name="MangaConnectorName"/> not found</response>
/// <response code="412"><see cref="Manga"/> was not linked to <see cref="MangaConnector"/>, so nothing changed</response>
/// <response code="428"><see cref="Manga"/> is not linked to <see cref="MangaConnector"/> yet. Search for <see cref="Manga"/> on <see cref="MangaConnector"/> first (to create a <see cref="MangaConnectorId{T}"/>).</response>
/// <response code="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="500">Error during Database Operation</response>
[HttpPost("{MangaId}/SetAsDownloadFrom/{MangaConnectorName}/{IsRequested}")]
[ProducesResponseType(Status200OK)]
@@ -364,27 +469,111 @@ public class MangaController(MangaContext context) : Controller
[ProducesResponseType<string>(Status412PreconditionFailed, "text/plain")]
[ProducesResponseType<string>(Status428PreconditionRequired, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult MarkAsRequested(string MangaId, string MangaConnectorName, bool IsRequested)
public async Task<Results<Ok, NotFound<string>, StatusCodeHttpResult, InternalServerError<string>>> MarkAsRequested(string MangaId, string MangaConnectorName, bool IsRequested)
{
if (context.Mangas.Find(MangaId) is null)
return NotFound(nameof(MangaId));
if(context.MangaConnectors.Find(MangaConnectorName) is null)
return NotFound(nameof(MangaConnectorName));
if (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.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 StatusCode(Status428PreconditionRequired, "Don't know how to download this Manga from MangaConnector");
return TypedResults.StatusCode(Status428PreconditionRequired);
else
return StatusCode(Status412PreconditionFailed, "Not linked anyways.");
return TypedResults.StatusCode(Status412PreconditionFailed);
}
mcId.UseForDownload = IsRequested;
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
DownloadCoverFromMangaconnectorWorker downloadCover = new(mcId);
RetrieveMangaChaptersFromMangaconnectorWorker retrieveChapters = new(mcId, Tranga.Settings.DownloadLanguage);
Tranga.AddWorkers([downloadCover, retrieveChapters]);
return Ok();
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());
}
}

View File

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

View File

@@ -1,9 +1,11 @@
using System.Text;
using API.APIEndpointRecords;
using API.Controllers.DTOs;
using API.Controllers.Requests;
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
@@ -19,12 +21,18 @@ 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<NotificationConnector[]>(Status200OK, "application/json")]
public IActionResult GetAllConnectors()
[ProducesResponseType<List<NotificationConnector>>(Status200OK, "application/json")]
[ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<List<NotificationConnector>>, InternalServerError>> GetAllConnectors ()
{
return Ok(context.NotificationConnectors.ToArray());
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);
}
/// <summary>
@@ -35,103 +43,101 @@ 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(Status404NotFound)]
public IActionResult GetConnector(string Name)
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<NotificationConnector>, NotFound<string>>> GetConnector (string Name)
{
if(context.NotificationConnectors.Find(Name) is not { } connector)
return NotFound();
return Ok(connector);
if (await context.NotificationConnectors.FirstOrDefaultAsync(c => c.Name == Name, HttpContext.RequestAborted) is not { } connector)
return TypedResults.NotFound(nameof(Name));
NotificationConnector notificationConnector = new NotificationConnector(connector.Name, connector.Url, connector.HttpMethod, connector.Body, connector.Headers);
return TypedResults.Ok(notificationConnector);
}
/// <summary>
/// Creates a new <see cref="NotificationConnector"/>
/// </summary>
/// <remarks>Formatting placeholders: "%title" and "%text" can be placed in url, header-values and body and will be replaced when notifications are sent</remarks>
/// <response code="201"></response>
/// <response code="200">ID of the new <see cref="NotificationConnector"/></response>
/// <response code="500">Error during Database Operation</response>
[HttpPut]
[ProducesResponseType(Status201Created)]
[ProducesResponseType(Status409Conflict)]
[ProducesResponseType<string>(Status201Created, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateConnector([FromBody]NotificationConnector notificationConnector)
public async Task<Results<Created<string>, InternalServerError<string>>> CreateConnector ([FromBody]CreateNotificationConnectorRecord requestData)
{
// TODO validate data
API.Schema.NotificationsContext.NotificationConnectors.NotificationConnector newConnector =
new(requestData.Name, requestData.Url, requestData.Headers, requestData.HttpMethod, requestData.Body);
context.NotificationConnectors.Add(notificationConnector);
context.NotificationConnectors.Add(newConnector);
context.Notifications.Add(new ("Added new Notification Connector!", newConnector.Name, NotificationUrgency.High));
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Created();
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
return TypedResults.Created(string.Empty, newConnector.Name);
}
/// <summary>
/// Creates a new Gotify-<see cref="NotificationConnector"/>
/// </summary>
/// <remarks>Priority needs to be between 0 and 10</remarks>
/// <response code="201"></response>
/// <response code="200">ID of the new <see cref="NotificationConnector"/></response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("Gotify")]
[ProducesResponseType<string>(Status201Created, "application/json")]
[ProducesResponseType<string>(Status201Created, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateGotifyConnector([FromBody]GotifyRecord gotifyData)
public async Task<Results<Created<string>, InternalServerError<string>>> CreateGotifyConnector ([FromBody]CreateGotifyConnectorRecord createGotifyConnectorData)
{
//TODO Validate Data
NotificationConnector gotifyConnector = new (gotifyData.Name,
gotifyData.Endpoint,
new Dictionary<string, string>() { { "X-Gotify-IDOnConnector", gotifyData.AppToken } },
CreateNotificationConnectorRecord gotifyConnector = new (createGotifyConnectorData.Name,
createGotifyConnectorData.Url,
"POST",
$"{{\"message\": \"%text\", \"title\": \"%title\", \"Priority\": {gotifyData.Priority}}}");
return CreateConnector(gotifyConnector);
$"{{\"message\": \"%text\", \"title\": \"%title\", \"Priority\": {createGotifyConnectorData.Priority}}}",
new () { { "X-Gotify-Key", createGotifyConnectorData.AppToken } });
return await CreateConnector(gotifyConnector);
}
/// <summary>
/// Creates a new Ntfy-<see cref="NotificationConnector"/>
/// </summary>
/// <remarks>Priority needs to be between 1 and 5</remarks>
/// <response code="201"></response>
/// <response code="200">ID of the new <see cref="NotificationConnector"/></response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("Ntfy")]
[ProducesResponseType<string>(Status201Created, "application/json")]
[ProducesResponseType<string>(Status201Created, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateNtfyConnector([FromBody]NtfyRecord ntfyRecord)
public async Task<Results<Created<string>, InternalServerError<string>>> CreateNtfyConnector ([FromBody]CreateNtfyConnectorRecord createNtfyConnectorRecord)
{
//TODO Validate Data
string authHeader = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ntfyRecord.Username}:{ntfyRecord.Password}"));
string authHeader = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{createNtfyConnectorRecord.Username}:{createNtfyConnectorRecord.Password}"));
string auth = Convert.ToBase64String(Encoding.UTF8.GetBytes(authHeader)).Replace("=","");
NotificationConnector ntfyConnector = new (ntfyRecord.Name,
$"{ntfyRecord.Endpoint}/{ntfyRecord.Topic}?auth={auth}",
new Dictionary<string, string>()
{
{"Title", "%title"},
{"Priority", ntfyRecord.Priority.ToString()},
},
CreateNotificationConnectorRecord ntfyConnector = new (createNtfyConnectorRecord.Name,
$"{createNtfyConnectorRecord.Url}?auth={auth}",
"POST",
"%text");
return CreateConnector(ntfyConnector);
$"{{\"message\": \"%text\", \"title\": \"%title\", \"Priority\": {createNtfyConnectorRecord.Priority} \"Topic\": \"{createNtfyConnectorRecord.Topic}\"}}",
new () {{"Authorization", auth}});
return await CreateConnector(ntfyConnector);
}
/// <summary>
/// Creates a new Pushover-<see cref="NotificationConnector"/>
/// </summary>
/// <remarks>https://pushover.net/api</remarks>
/// <response code="201">ID of new connector</response>
/// <response code="200">ID of the new <see cref="NotificationConnector"/></response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("Pushover")]
[ProducesResponseType<string>(Status201Created, "application/json")]
[ProducesResponseType<string>(Status201Created, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreatePushoverConnector([FromBody]PushoverRecord pushoverRecord)
public async Task<Results<Created<string>, InternalServerError<string>>> CreatePushoverConnector ([FromBody]CreatePushoverConnectorRecord createPushoverConnectorRecord)
{
//TODO Validate Data
NotificationConnector pushoverConnector = new (pushoverRecord.Name,
CreateNotificationConnectorRecord pushoverConnector = new (createPushoverConnectorRecord.Name,
$"https://api.pushover.net/1/messages.json",
new Dictionary<string, string>(),
"POST",
$"{{\"token\": \"{pushoverRecord.AppToken}\", \"user\": \"{pushoverRecord.User}\", \"message:\":\"%text\", \"%title\" }}");
return CreateConnector(pushoverConnector);
$"{{\"token\": \"{createPushoverConnectorRecord.AppToken}\", \"user\": \"{createPushoverConnectorRecord.Username}\", \"message:\":\"%text\", \"%title\" }}",
new ());
return await CreateConnector(pushoverConnector);
}
/// <summary>
@@ -143,17 +149,17 @@ public class NotificationConnectorController(NotificationsContext context) : Con
/// <response code="500">Error during Database Operation</response>
[HttpDelete("{Name}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult DeleteConnector(string Name)
public async Task<Results<Ok, NotFound<string>, InternalServerError<string>>> DeleteConnector (string Name)
{
if(context.NotificationConnectors.Find(Name) is not { } connector)
return NotFound();
if (await context.NotificationConnectors.FirstOrDefaultAsync(c => c.Name == Name, HttpContext.RequestAborted) is not { } connector)
return TypedResults.NotFound(nameof(Name));
context.NotificationConnectors.Remove(connector);
if(context.Sync() is { success: false } result)
return StatusCode(Status500InternalServerError, result.exceptionMessage);
return Created();
if(await context.Sync(HttpContext.RequestAborted) is { success: false } result)
return TypedResults.InternalServerError(result.exceptionMessage);
return TypedResults.Ok();
}
}

View File

@@ -1,7 +1,14 @@
using API.Schema.MangaContext;
using API.Controllers.DTOs;
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;
@@ -19,45 +26,13 @@ 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(Status404NotFound)]
public IActionResult GetAuthor(string AuthorId)
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<Author>, NotFound<string>>> GetAuthor (string AuthorId)
{
if (context.Authors.Find(AuthorId) is not { } author)
return NotFound();
if (await context.Authors.FirstOrDefaultAsync(a => a.Key == AuthorId, HttpContext.RequestAborted) is not { } author)
return TypedResults.NotFound(nameof(AuthorId));
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)));
return TypedResults.Ok(new Author(author.Key, author.AuthorName));
}
/// <summary>
@@ -68,11 +43,82 @@ 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")]
public IActionResult GetChapter(string ChapterId)
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
public async Task<Results<Ok<Chapter>, NotFound<string>>> GetChapter (string ChapterId)
{
if (context.Chapters.Find(ChapterId) is not { } chapter)
return NotFound();
if (await context.Chapters.FirstOrDefaultAsync(c => c.Key == ChapterId, HttpContext.RequestAborted) is not { } chapter)
return TypedResults.NotFound(nameof(ChapterId));
return Ok(chapter);
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);
}
}

View File

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

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

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

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

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

@@ -0,0 +1,28 @@
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,8 +1,11 @@
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;
@@ -13,58 +16,64 @@ namespace API.Controllers;
public class SearchController(MangaContext context) : Controller
{
/// <summary>
/// Initiate a search for a <see cref="Manga"/> on <see cref="MangaConnector"/> with searchTerm
/// Initiate a search for a <see cref="Schema.MangaContext.Manga"/> on <see cref="MangaConnector"/> with searchTerm
/// </summary>
/// <param name="MangaConnectorName"><see cref="MangaConnector"/>.Name</param>
/// <param name="Query">searchTerm</param>
/// <response code="200"></response>
/// <response code="200"><see cref="MinimalManga"/> exert of <see cref="Schema.MangaContext.Manga"/></response>
/// <response code="404"><see cref="MangaConnector"/> with Name not found</response>
/// <response code="412"><see cref="MangaConnector"/> with Name is disabled</response>
[HttpGet("{MangaConnectorName}/{Query}")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<List<MinimalManga>>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status406NotAcceptable)]
public IActionResult SearchManga(string MangaConnectorName, string Query)
public Results<Ok<List<MinimalManga>>, NotFound<string>, StatusCodeHttpResult> SearchManga (string MangaConnectorName, string Query)
{
if(context.MangaConnectors.Find(MangaConnectorName) is not { } connector)
return NotFound();
if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals(MangaConnectorName, StringComparison.InvariantCultureIgnoreCase)) is not { } connector)
return TypedResults.NotFound(nameof(MangaConnectorName));
if (connector.Enabled is false)
return StatusCode(Status412PreconditionFailed);
return TypedResults.StatusCode(Status412PreconditionFailed);
(Manga, MangaConnectorId<Manga>)[] mangas = connector.SearchManga(Query);
List<Manga> retMangas = new();
foreach ((Manga manga, MangaConnectorId<Manga> mcId) manga in mangas)
{
if(Tranga.AddMangaToContext(manga, context, out Manga? add))
retMangas.Add(add);
}
(Manga manga, MangaConnectorId<Manga> id)[] mangas = connector.SearchManga(Query);
return Ok(retMangas.ToArray());
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 =>
{
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);
});
return TypedResults.Ok(result.ToList());
}
/// <summary>
/// Returns <see cref="Manga"/> from the <see cref="MangaConnector"/> associated with <paramref name="url"/>
/// Returns <see cref="Schema.MangaContext.Manga"/> from the <see cref="MangaConnector"/> associated with <paramref name="url"/>
/// </summary>
/// <param name="url"></param>
/// <response code="200"></response>
/// <response code="300">Multiple <see cref="MangaConnector"/> found for URL</response>
/// <response code="200"><see cref="MinimalManga"/> exert of <see cref="Schema.MangaContext.Manga"/>.</response>
/// <response code="404"><see cref="Manga"/> not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("Url")]
[ProducesResponseType<Manga>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status500InternalServerError)]
public IActionResult GetMangaFromUrl([FromBody]string url)
[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)
{
if (context.MangaConnectors.Find("Global") is not { } connector)
return StatusCode(Status500InternalServerError, "Could not find Global Connector.");
if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals("Global", StringComparison.InvariantCultureIgnoreCase)) is not { } connector)
return TypedResults.InternalServerError("Could not find Global Connector.");
if(connector.GetMangaFromUrl(url) is not { } manga)
return NotFound();
if(connector.GetMangaFromUrl(url) is not ({ } m, not null) manga)
return TypedResults.NotFound("Could not retrieve Manga");
if(Tranga.AddMangaToContext(manga, context, out Manga? add) == false)
return StatusCode(Status500InternalServerError);
if(await context.AddMangaToContext(manga, HttpContext.RequestAborted) == false)
return TypedResults.InternalServerError("Could not add Manga to context");
return Ok(add);
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);
}
}

View File

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

View File

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

23
API/Log4Net.config.xml Normal file
View File

@@ -0,0 +1,23 @@
<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,8 +1,9 @@
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using API.Schema.MangaContext;
using Newtonsoft.Json.Linq;
namespace API.Schema.MangaContext.MangaConnectors;
namespace API.MangaConnectors;
public class ComickIo : MangaConnector
{
@@ -176,19 +177,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?
@@ -220,7 +221,7 @@ public class ComickIo : MangaConnector
_ => kv.Key
};
return new Link(key, fullUrl);
}).ToList()!;
}).ToList()??[];
if(hid is null)
throw new Exception("hid is null");
@@ -231,7 +232,9 @@ public class ComickIo : MangaConnector
Manga manga = new (name, description??"", coverUrl, status, authors, tags, links, altTitles,
year: year, originalLanguage: originalLanguage);
return (manga, new MangaConnectorId<Manga>(manga, this, hid, url));
MangaConnectorId<Manga> mcId = new (manga, this, hid, url);
manga.MangaConnectorIds.Add(mcId);
return (manga, mcId);
}
private List<(Chapter, MangaConnectorId<Chapter>)> ParseChapters(MangaConnectorId<Manga> mcIdManga, JArray chaptersArray)
@@ -250,8 +253,9 @@ public class ComickIo : MangaConnector
continue;
Chapter ch = new (mcIdManga.Obj, chapterNum, volumeNum, title);
chapters.Add((ch, new (ch, this, hid, url)));
MangaConnectorId<Chapter> mcId = new(ch, this, hid, url);
ch.MangaConnectorIds.Add(mcId);
chapters.Add((ch, mcId));
}
return chapters;
}

View File

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

View File

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

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

View File

@@ -1,151 +0,0 @@
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,11 +1,12 @@
using System.Net;
using System.Collections.Concurrent;
using System.Net;
using log4net;
namespace API.MangaDownloadClients;
public abstract class DownloadClient
{
private static readonly Dictionary<RequestType, DateTime> LastExecutedRateLimit = new();
private static readonly ConcurrentDictionary<RequestType, DateTime> LastExecutedRateLimit = new();
protected ILog Log { get; init; }
protected DownloadClient()
@@ -13,18 +14,22 @@ 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 (!Tranga.Settings.RequestLimits.ContainsKey(requestType))
{
return new RequestResult(HttpStatusCode.NotAcceptable, null, Stream.Null);
}
int rateLimit = Tranga.Settings.UserAgent == TrangaSettings.DefaultUserAgent
? TrangaSettings.DefaultRequestLimits[requestType]
: Tranga.Settings.RequestLimits[requestType];
// If we don't have a RequestLimit set for a Type, use the default one
if (!Tranga.Settings.RequestLimits.ContainsKey(requestType))
requestType = RequestType.Default;
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];
// Apply the delay
TimeSpan timeBetweenRequests = TimeSpan.FromMinutes(1).Divide(rateLimit);
DateTime now = DateTime.Now;
LastExecutedRateLimit.TryAdd(requestType, now.Subtract(timeBetweenRequests));
@@ -33,11 +38,12 @@ 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,8 +10,6 @@ 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,6 +5,7 @@ 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)
@@ -36,7 +37,7 @@ internal class HttpDownloadClient : DownloadClient
(s.Product?.Name ?? "").Contains("cloudflare", StringComparison.InvariantCultureIgnoreCase)))
{
Log.Debug("Retrying with FlareSolverr!");
return new FlareSolverrDownloadClient().MakeRequestInternal(url, referrer, clickButton);
return FlareSolverrDownloadClient.MakeRequestInternal(url, referrer, clickButton);
}
else
{

View File

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

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

View File

@@ -11,7 +11,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace API.Migrations.Manga
{
[DbContext(typeof(MangaContext))]
[Migration("20250703192023_Initial")]
[Migration("20250722203315_Initial")]
partial class Initial
{
/// <inheritdoc />
@@ -24,6 +24,39 @@ 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")
@@ -177,8 +210,6 @@ namespace API.Migrations.Manga
b.HasKey("Key");
b.HasIndex("MangaConnectorName");
b.HasIndex("ObjId");
b.ToTable("MangaConnectorToChapter");
@@ -213,46 +244,11 @@ 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")
@@ -332,23 +328,23 @@ namespace API.Migrations.Manga
b.ToTable("MangaTagToManga");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.ComickIo", b =>
modelBuilder.Entity("API.MangaConnectors.ComickIo", b =>
{
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasBaseType("API.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("ComickIo");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.Global", b =>
modelBuilder.Entity("API.MangaConnectors.Global", b =>
{
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasBaseType("API.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Global");
});
modelBuilder.Entity("API.Schema.MangaContext.MangaConnectors.MangaDex", b =>
modelBuilder.Entity("API.MangaConnectors.MangaDex", b =>
{
b.HasBaseType("API.Schema.MangaContext.MangaConnectors.MangaConnector");
b.HasBaseType("API.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("MangaDex");
});
@@ -445,39 +441,23 @@ 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.NoAction)
.OnDelete(DeleteBehavior.Cascade)
.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: "MangaConnectors",
name: "MangaConnector",
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_MangaConnectors", x => x.Name);
table.PrimaryKey("PK_MangaConnector", x => x.Name);
});
migrationBuilder.CreateTable(
@@ -201,12 +201,6 @@ 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,
@@ -282,12 +276,7 @@ namespace API.Migrations.Manga
name: "FK_MangaConnectorToChapter_Chapters_ObjId",
column: x => x.ObjId,
principalTable: "Chapters",
principalColumn: "Key");
table.ForeignKey(
name: "FK_MangaConnectorToChapter_MangaConnectors_MangaConnectorName",
column: x => x.MangaConnectorName,
principalTable: "MangaConnectors",
principalColumn: "Name",
principalColumn: "Key",
onDelete: ReferentialAction.Cascade);
});
@@ -311,21 +300,11 @@ 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",
@@ -359,6 +338,9 @@ namespace API.Migrations.Manga
migrationBuilder.DropTable(
name: "Link");
migrationBuilder.DropTable(
name: "MangaConnector");
migrationBuilder.DropTable(
name: "MangaConnectorToChapter");
@@ -377,9 +359,6 @@ namespace API.Migrations.Manga
migrationBuilder.DropTable(
name: "Chapters");
migrationBuilder.DropTable(
name: "MangaConnectors");
migrationBuilder.DropTable(
name: "Tags");

View File

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

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

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

@@ -0,0 +1,36 @@
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.5")
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
@@ -27,7 +27,8 @@ namespace API.Migrations.Notifications
modelBuilder.Entity("API.Schema.NotificationsContext.Notification", b =>
{
b.Property<string>("Key")
.HasColumnType("text");
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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 = "Test API " + description.GroupName,
Title = "Tranga " + description.GroupName,
Version = description.ApiVersion.ToString()
};
if (description.IsDeprecated)

View File

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

View File

@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace API.Schema;
@@ -5,16 +6,18 @@ namespace API.Schema;
[PrimaryKey("Key")]
public abstract class Identifiable
{
public Identifiable()
protected Identifiable()
{
this.Key = TokenGen.CreateToken(this.GetType());
}
public Identifiable(string key)
protected 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,19 +9,10 @@ namespace API.Schema.LibraryContext.LibraryConnectors;
[PrimaryKey("Key")]
public abstract class LibraryConnector : Identifiable
{
[Required]
public LibraryType LibraryType { get; init; }
[StringLength(256)]
[Required]
[Url]
public string BaseUrl { get; init; }
[StringLength(256)]
[Required]
public string Auth { get; init; }
[JsonIgnore]
[NotMapped]
protected ILog Log { get; init; }
[StringLength(256)] [Url] public string BaseUrl { get; init; }
[StringLength(256)] public string Auth { get; init; }
[NotMapped] protected ILog Log { get; init; }
protected LibraryConnector(LibraryType libraryType, string baseUrl, string auth)
: base()

View File

@@ -7,7 +7,6 @@ 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,52 +4,29 @@ using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
namespace API.Schema.MangaContext;
[PrimaryKey("Key")]
public class Chapter : Identifiable, IComparable<Chapter>
{
[StringLength(64)] [Required] public string ParentMangaId { get; init; } = null!;
private Manga? _parentManga;
[StringLength(64)] public string ParentMangaId { get; init; } = null!;
public Manga ParentManga = null!;
[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 =>
[NotMapped] public Dictionary<string, string> IdsOnMangaConnectors =>
MangaConnectorIds.ToDictionary(id => id.MangaConnectorName, id => id.IdOnConnectorSite);
private ICollection<MangaConnectorId<Chapter>>? _mangaConnectorIds;
[JsonIgnore]
public ICollection<MangaConnectorId<Chapter>> MangaConnectorIds
{
get => _lazyLoader.Load(this, ref _mangaConnectorIds) ?? throw new InvalidOperationException();
init => _mangaConnectorIds = value;
}
public ICollection<MangaConnectorId<Chapter>> MangaConnectorIds = null!;
public int? VolumeNumber { get; private set; }
[StringLength(10)] [Required] public string ChapterNumber { get; private set; }
[StringLength(10)] public string ChapterNumber { get; private set; }
[StringLength(256)] public string? Title { get; private set; }
[StringLength(256)] [Required] public string FileName { get; private set; }
[StringLength(256)] public string FileName { get; private set; }
[Required] public bool Downloaded { get; internal set; }
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))
@@ -61,15 +38,15 @@ public class Chapter : Identifiable, IComparable<Chapter>
this.Title = title;
this.FileName = GetArchiveFilePath();
this.Downloaded = false;
this.MangaConnectorIds = [];
}
/// <summary>
/// EF ONLY!!!
/// </summary>
internal Chapter(ILazyLoader lazyLoader, string key, int? volumeNumber, string chapterNumber, string? title, string fileName, bool downloaded)
internal Chapter(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;
@@ -89,11 +66,27 @@ 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>
public bool CheckDownloaded() => File.Exists(FullArchiveFilePath);
/// <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;
}
/// Placeholders:
/// %M Obj Name
@@ -163,6 +156,20 @@ 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,13 +7,9 @@ namespace API.Schema.MangaContext;
public class FileLibrary(string basePath, string libraryName)
: Identifiable(TokenGen.CreateToken(typeof(FileLibrary), basePath))
{
[StringLength(256)]
[Required]
public string BasePath { get; internal set; } = basePath;
[StringLength(256)] public string BasePath { get; internal set; } = basePath;
[StringLength(512)]
[Required]
public string LibraryName { get; internal set; } = libraryName;
[StringLength(512)] public string LibraryName { get; internal set; } = libraryName;
public override string ToString() => $"{base.ToString()} {LibraryName} - {BasePath}";
}

View File

@@ -7,10 +7,8 @@ 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,8 +4,10 @@ using System.Runtime.InteropServices;
using System.Text;
using API.Workers;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Transforms;
using static System.IO.UnixFileMode;
namespace API.Schema.MangaContext;
@@ -13,57 +15,31 @@ namespace API.Schema.MangaContext;
[PrimaryKey("Key")]
public class Manga : Identifiable
{
[StringLength(512)] [Required] public string Name { get; internal set; }
[StringLength(512)] public string Name { get; internal set; }
[Required] public string Description { get; internal set; }
[JsonIgnore] [Url] [StringLength(512)] public string CoverUrl { get; internal set; }
[Required] public MangaReleaseStatus ReleaseStatus { get; internal set; }
[Url] [StringLength(512)] public string CoverUrl { get; internal set; }
public MangaReleaseStatus ReleaseStatus { get; internal set; }
[StringLength(64)] public string? LibraryId { get; private set; }
private FileLibrary? _library;
[JsonIgnore]
public FileLibrary? Library
{
get => _lazyLoader.Load(this, ref _library);
set
{
LibraryId = value?.Key;
_library = value;
}
}
public FileLibrary? Library = 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<Author> Authors { get; internal set; } = null!;
public ICollection<MangaTag> MangaTags { get; internal set; } = null!;
public ICollection<Link> Links { get; internal set; } = null!;
public ICollection<AltTitle> AltTitles { get; internal set; } = null!;
[Required] public float IgnoreChaptersBefore { get; internal set; }
public float IgnoreChaptersBefore { get; internal set; }
[StringLength(1024)] [Required] public string DirectoryName { get; private set; }
[JsonIgnore] [StringLength(512)] public string? CoverFileNameInCache { get; internal set; }
[StringLength(512)] public string? CoverFileNameInCache { get; internal set; }
public uint? Year { get; internal init; }
[StringLength(8)] public string? OriginalLanguage { get; internal init; }
[JsonIgnore]
[NotMapped]
public string? FullDirectoryPath => Library is not null ? Path.Join(Library.BasePath, DirectoryName) : null;
[NotMapped] public string? FullDirectoryPath => Library is not null ? Path.Join(Library.BasePath, DirectoryName) : null;
[NotMapped] public ICollection<string> ChapterIds => Chapters.Select(c => c.Key).ToList();
private ICollection<Chapter>? _chapters;
[JsonIgnore]
public ICollection<Chapter> Chapters
{
get => _lazyLoader.Load(this, ref _chapters) ?? throw new InvalidOperationException();
init => _chapters = value;
}
public ICollection<Chapter> Chapters = 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!;
[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!;
public Manga(string name, string description, string coverUrl, MangaReleaseStatus releaseStatus,
ICollection<Author> authors, ICollection<MangaTag> mangaTags, ICollection<Link> links, ICollection<AltTitle> altTitles,
@@ -84,17 +60,17 @@ public class Manga : Identifiable
this.Year = year;
this.OriginalLanguage = originalLanguage;
this.Chapters = [];
this.MangaConnectorIds = [];
}
/// <summary>
/// EF ONLY!!!
/// </summary>
public Manga(ILazyLoader lazyLoader, string key, string name, string description, string coverUrl,
public Manga(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;
@@ -183,6 +159,18 @@ 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,52 +1,27 @@
using System.ComponentModel.DataAnnotations;
using API.Schema.MangaContext.MangaConnectors;
using API.MangaConnectors;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
namespace API.Schema.MangaContext;
[PrimaryKey("Key")]
public class MangaConnectorId<T> : Identifiable where T : Identifiable
{
[StringLength(64)] [Required] public string ObjId { get; private set; } = null!;
[JsonIgnore] private T? _obj;
public T Obj = null!;
[StringLength(64)] public string ObjId { get; internal set; }
[JsonIgnore]
public T Obj
{
get => _lazyLoader.Load(this, ref _obj) ?? throw new InvalidOperationException();
internal set
{
ObjId = value.Key;
_obj = value;
}
}
[StringLength(32)] public string MangaConnectorName { get; private set; }
[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; }
[StringLength(256)] public string IdOnConnectorSite { get; init; }
[Url] [StringLength(512)] public string? WebsiteUrl { get; internal init; }
public bool UseForDownload { get; internal set; }
private readonly ILazyLoader _lazyLoader = null!;
public MangaConnectorId(T obj, MangaConnector mangaConnector, string idOnConnectorSite, string? websiteUrl, bool useForDownload = false)
: base(TokenGen.CreateToken(typeof(MangaConnectorId<T>), mangaConnector.Name, idOnConnectorSite))
{
this.Obj = obj;
this.MangaConnector = mangaConnector;
this.ObjId = obj.Key;
this.MangaConnectorName = mangaConnector.Name;
this.IdOnConnectorSite = idOnConnectorSite;
this.WebsiteUrl = websiteUrl;
this.UseForDownload = useForDownload;
@@ -55,10 +30,9 @@ public class MangaConnectorId<T> : Identifiable where T : Identifiable
/// <summary>
/// EF CORE ONLY!!!
/// </summary>
public MangaConnectorId(ILazyLoader lazyLoader, string key, string objId, string mangaConnectorName, string idOnConnectorSite, bool useForDownload, string? websiteUrl)
public MangaConnectorId(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;
@@ -66,5 +40,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,75 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using log4net;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.MangaContext.MangaConnectors;
[PrimaryKey("Name")]
public abstract class MangaConnector(string name, string[] supportedLanguages, string[] baseUris, string iconUrl)
{
[JsonIgnore]
[NotMapped]
internal DownloadClient downloadClient { get; init; } = null!;
[JsonIgnore]
[NotMapped]
protected ILog Log { get; init; } = LogManager.GetLogger(name);
[StringLength(32)]
[Required]
public string Name { get; init; } = name;
[StringLength(8)]
[Required]
public string[] SupportedLanguages { get; init; } = supportedLanguages;
[StringLength(2048)]
[Required]
public string IconUrl { get; init; } = iconUrl;
[StringLength(256)]
[Required]
public string[] BaseUris { get; init; } = baseUris;
[Required]
public bool Enabled { get; internal set; } = true;
public abstract (Manga, MangaConnectorId<Manga>)[] SearchManga(string mangaSearchName);
public abstract (Manga, MangaConnectorId<Manga>)? GetMangaFromUrl(string url);
public abstract (Manga, MangaConnectorId<Manga>)? GetMangaFromId(string mangaIdOnSite);
public abstract (Chapter, MangaConnectorId<Chapter>)[] GetChapters(MangaConnectorId<Manga> mangaId,
string? language = null);
internal abstract string[] GetChapterImageUrls(MangaConnectorId<Chapter> chapterId);
public bool UrlMatchesConnector(string url) => BaseUris.Any(baseUri => Regex.IsMatch(url, "https?://" + baseUri + "/.*"));
internal string? SaveCoverImageToCache(MangaConnectorId<Manga> mangaId, int retries = 3)
{
if(retries < 0)
return null;
Regex urlRex = new (@"https?:\/\/((?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]+)\/(?:.+\/)*(.+\.([a-zA-Z]+))");
//https?:\/\/[a-zA-Z0-9-]+\.([a-zA-Z0-9-]+\.[a-zA-Z0-9]+)\/(?:.+\/)*(.+\.([a-zA-Z]+)) for only second level domains
Match match = urlRex.Match(mangaId.Obj.CoverUrl);
string filename = $"{match.Groups[1].Value}-{mangaId.Key}.{match.Groups[3].Value}";
string saveImagePath = Path.Join(TrangaSettings.coverImageCache, filename);
if (File.Exists(saveImagePath))
return saveImagePath;
RequestResult coverResult = downloadClient.MakeRequest(mangaId.Obj.CoverUrl, RequestType.MangaCover, $"https://{match.Groups[1].Value}");
if ((int)coverResult.statusCode < 200 || (int)coverResult.statusCode >= 300)
return SaveCoverImageToCache(mangaId, --retries);
using MemoryStream ms = new();
coverResult.result.CopyTo(ms);
Directory.CreateDirectory(TrangaSettings.coverImageCache);
File.WriteAllBytes(saveImagePath, ms.ToArray());
return saveImagePath;
}
}

View File

@@ -1,12 +1,12 @@
using API.Schema.MangaContext.MangaConnectors;
using API.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,26 +31,12 @@ 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)
@@ -95,17 +81,6 @@ 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
@@ -114,9 +89,6 @@ 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))
@@ -131,4 +103,25 @@ 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,4 +32,6 @@ 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 void UpdateMetadata(MetadataEntry metadataEntry, MangaContext dbContext);
public abstract Task UpdateMetadata(MetadataEntry metadataEntry, MangaContext dbContext, CancellationToken token);
}

View File

@@ -1,6 +1,7 @@
using System.Text.RegularExpressions;
using JikanDotNet;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace API.Schema.MangaContext.MetadataFetchers;
@@ -42,37 +43,49 @@ 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 void UpdateMetadata(MetadataEntry metadataEntry, MangaContext dbContext)
public override async Task UpdateMetadata(MetadataEntry metadataEntry, MangaContext dbContext, CancellationToken token)
{
Manga dbManga = dbContext.Mangas.Find(metadataEntry.MangaId)!;
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);
MangaFull resultData;
try
{
long id = long.Parse(metadataEntry.Identifier);
resultData = Jikan.GetMangaFullDataAsync(id).Result.Data;
if(await Jikan.GetMangaFullDataAsync(id, token) is not { } response)
throw new DbUpdateException("Manga Data not found");
resultData = response.Data;
}
catch (Exception)
{
throw new FormatException("ID was not in correct format");
}
try
{
dbManga.Name = resultData.Titles.First().Title;
dbManga.Description = resultData.Synopsis;
dbManga.AltTitles.Clear();
dbManga.AltTitles = resultData.Titles.Select(t => new AltTitle(t.Type, t.Title)).ToList();
dbManga.Authors.Clear();
dbManga.Authors = resultData.Authors.Select(a => new Author(a.Name)).ToList();
dbManga.Name = resultData.Titles.First().Title;
dbManga.Description = resultData.Synopsis;
dbManga.AltTitles.Clear();
dbManga.AltTitles = resultData.Titles.Select(t => new AltTitle(t.Type, t.Title)).ToList();
dbManga.Authors.Clear();
dbManga.Authors = resultData.Authors.Select(a => new Author(a.Name)).ToList();
dbContext.Sync();
}
catch (DbUpdateException e)
{
throw;
}
await dbContext.Sync(token);
}
}

View File

@@ -10,74 +10,49 @@ namespace API.Schema.NotificationsContext.NotificationConnectors;
[PrimaryKey("Name")]
public class NotificationConnector(string name, string url, Dictionary<string, string> headers, string httpMethod, string body)
{
[StringLength(64)]
[Required]
public string Name { get; init; } = name;
[StringLength(64)] public string Name { get; init; } = name;
[StringLength(2048)]
[Required]
[Url]
public string Url { get; internal set; } = url;
[StringLength(2048)] [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)]
[Required]
public string HttpMethod { get; internal set; } = httpMethod;
[StringLength(8)] public string HttpMethod { get; internal set; } = httpMethod;
[StringLength(4096)]
[Required]
public string Body { get; internal set; } = body;
[StringLength(4096)] public string Body { get; internal set; } = body;
[JsonIgnore]
[NotMapped]
private readonly HttpClient Client = new()
[NotMapped] private readonly HttpClient Client = new()
{
DefaultRequestHeaders = { { "User-Agent", Tranga.Settings.UserAgent } }
};
[JsonIgnore]
[NotMapped]
protected ILog Log = LogManager.GetLogger(name);
[JsonIgnore] protected ILog Log = LogManager.GetLogger(name);
public void SendNotification(string title, string notificationText)
{
Log.Info($"Sending notification: {title} - {notificationText}");
CustomWebhookFormatProvider formatProvider = new (title, notificationText);
string formattedUrl = string.Format(formatProvider, Url);
string formattedBody = string.Format(formatProvider, Body, title, notificationText);
string formattedUrl = FormatStr(Url, title, notificationText);
string formattedBody = FormatStr(Body, title, notificationText);
Dictionary<string, string> formattedHeaders = Headers.ToDictionary(h => h.Key,
h => string.Format(formatProvider, h.Value, title, notificationText));
h => FormatStr(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}");
Log.Debug($"Response status code: {response.StatusCode} {response.Content.ReadAsStringAsync().Result}");
}
private class CustomWebhookFormatProvider(string title, string text) : IFormatProvider
private string FormatStr(string str, string title, string text)
{
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);
StringBuilder sb = new (str);
sb.Replace("%title", title);
sb.Replace("%text", text);
return sb.ToString();
}
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 (bool success, string? exceptionMessage) Sync()
internal async Task<(bool success, string? exceptionMessage)> Sync(CancellationToken token)
{
try
{
this.SaveChanges();
await this.SaveChangesAsync(token);
return (true, null);
}
catch (Exception e)

View File

@@ -5,8 +5,8 @@ namespace API;
public static class TokenGen
{
private const int MinimumLength = 16;
private const int MaximumLength = 64;
public const int MinimumLength = 16;
public 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,4 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using API.MangaConnectors;
using API.Schema.LibraryContext;
using API.Schema.MangaContext;
using API.Schema.MangaContext.MetadataFetchers;
@@ -7,24 +9,17 @@ using API.Workers;
using API.Workers.MaintenanceWorkers;
using log4net;
using log4net.Config;
using Microsoft.EntityFrameworkCore;
namespace API;
public static class Tranga
{
// ReSharper disable once InconsistentNaming
private const string TRANGA =
"\n\n" +
" _______ v2\n" +
"|_ _|.----..---.-..-----..-----..---.-.\n" +
" | | | _|| _ || || _ || _ |\n" +
" |___| |__| |___._||__|__||___ ||___._|\n" +
" |_____| \n\n";
private static IServiceProvider? ServiceProvider;
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 ();
@@ -34,13 +29,17 @@ 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()
internal static void StartLogger(FileInfo loggerConfigFile)
{
BasicConfigurator.Configure();
XmlConfigurator.ConfigureAndWatch(loggerConfigFile);
Log.Info("Logger Configured.");
Log.Info(TRANGA);
Log.Info(Constants.TRANGA);
}
internal static void AddDefaultWorkers()
{
AddWorker(UpdateMetadataWorker);
AddWorker(SendNotificationsWorker);
AddWorker(UpdateChaptersDownloadedWorker);
@@ -48,155 +47,181 @@ 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 HashSet<BaseWorker> AllWorkers { get; private set; } = new ();
public static void AddWorker(BaseWorker worker) => AllWorkers.Add(worker);
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();
};
public static void AddWorkers(IEnumerable<BaseWorker> workers)
{
foreach (BaseWorker baseWorker in workers)
{
AddWorker(baseWorker);
}
}
public static void RemoveWorker(BaseWorker removeWorker)
{
IEnumerable<BaseWorker> baseWorkers = AllWorkers.Where(w => w.DependenciesAndSelf.Any(worker => worker == removeWorker));
foreach (BaseWorker worker in baseWorkers)
{
StopWorker(worker);
AllWorkers.Remove(worker);
}
}
private static readonly Dictionary<BaseWorker, Task<BaseWorker[]>> RunningWorkers = new();
private static readonly ConcurrentDictionary<BaseWorker, Task<BaseWorker[]>> RunningWorkers = new();
public static BaseWorker[] GetRunningWorkers() => RunningWorkers.Keys.ToArray();
private static readonly HashSet<BaseWorker> StartWorkers = new();
private static void WorkerStarter(object? serviceProviderObj)
internal static void StartWorker(BaseWorker worker, Action? callback = null)
{
Log.Info("WorkerStarter Thread running.");
if (serviceProviderObj is null)
Log.Debug($"Starting {worker}");
if (ServiceProvider is null)
{
Log.Error("serviceProviderObj is null");
Log.Fatal("ServiceProvider is null");
return;
}
IServiceProvider serviceProvider = (IServiceProvider)serviceProviderObj;
while (true)
Action afterWorkCallback = AfterWork(worker, callback);
while (RunningWorkers.Count > Settings.MaxConcurrentWorkers)
{
CheckRunningWorkers();
foreach (BaseWorker baseWorker in AllWorkers.DueWorkers())
StartWorkers.Add(baseWorker);
foreach (BaseWorker worker in StartWorkers.ToArray())
{
if(RunningWorkers.ContainsKey(worker))
continue;
if (worker is BaseWorkerWithContext<MangaContext> mangaContextWorker)
{
mangaContextWorker.SetScope(serviceProvider.CreateScope());
RunningWorkers.Add(mangaContextWorker, mangaContextWorker.DoWork());
}else if (worker is BaseWorkerWithContext<NotificationsContext> notificationContextWorker)
{
notificationContextWorker.SetScope(serviceProvider.CreateScope());
RunningWorkers.Add(notificationContextWorker, notificationContextWorker.DoWork());
}else if (worker is BaseWorkerWithContext<LibraryContext> libraryContextWorker)
{
libraryContextWorker.SetScope(serviceProvider.CreateScope());
RunningWorkers.Add(libraryContextWorker, libraryContextWorker.DoWork());
}else
RunningWorkers.Add(worker, worker.DoWork());
StartWorkers.Remove(worker);
}
Log.Warn($"{worker}: Max worker concurrency reached ({Settings.MaxConcurrentWorkers})! Waiting {Settings.WorkCycleTimeoutMs}ms...");
Thread.Sleep(Settings.WorkCycleTimeoutMs);
}
if (worker is BaseWorkerWithContext<MangaContext> mangaContextWorker)
{
mangaContextWorker.SetScope(ServiceProvider.CreateScope());
RunningWorkers.TryAdd(mangaContextWorker, mangaContextWorker.DoWork(afterWorkCallback));
}else if (worker is BaseWorkerWithContext<NotificationsContext> notificationContextWorker)
{
notificationContextWorker.SetScope(ServiceProvider.CreateScope());
RunningWorkers.TryAdd(notificationContextWorker, notificationContextWorker.DoWork(afterWorkCallback));
}else if (worker is BaseWorkerWithContext<LibraryContext> libraryContextWorker)
{
libraryContextWorker.SetScope(ServiceProvider.CreateScope());
RunningWorkers.TryAdd(libraryContextWorker, libraryContextWorker.DoWork(afterWorkCallback));
}else
RunningWorkers.TryAdd(worker, worker.DoWork(afterWorkCallback));
}
private static void CheckRunningWorkers()
private static Action AfterWork(BaseWorker worker, Action? callback = null) => () =>
{
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($"AfterWork {worker}");
if (RunningWorkers.TryGetValue(worker, out Task<BaseWorker[]>? task))
{
RunningWorkers.Remove(worker);
foreach (BaseWorker newWorker in task.Result)
AllWorkers.Add(newWorker);
task.Dispose();
Log.Debug($"Waiting for Children to exit {worker}");
task.Wait();
if (task.IsCompleted)
{
try
{
BaseWorker[] newWorkers = task.Result;
Log.Debug($"{worker} created {newWorkers.Length} new Workers.");
AddWorkers(newWorkers);
}
catch (Exception e)
{
Log.Error(e);
}
}else Log.Warn($"Children failed: {worker}");
}
}
private static IEnumerable<BaseWorker> DueWorkers(this IEnumerable<BaseWorker> workers)
{
return workers.Where(w =>
{
if (w.State is >= WorkerExecutionState.Running and < WorkerExecutionState.Completed)
return false;
if (w is IPeriodic periodicWorker)
return periodicWorker.IsDue;
return true;
});
}
internal static void MarkWorkerForStart(BaseWorker worker) => StartWorkers.Add(worker);
RunningWorkers.Remove(worker, out _);
callback?.Invoke();
};
internal static void StopWorker(BaseWorker worker)
{
StartWorkers.Remove(worker);
Log.Debug($"Stopping {worker}");
if(worker is IPeriodic periodicWorker)
PeriodicWorkers.Remove(periodicWorker, out _);
worker.Cancel();
RunningWorkers.Remove(worker);
RunningWorkers.Remove(worker, out _);
}
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, MangaConnectorId<Manga>) addManga, CancellationToken token) =>
await AddMangaToContext(context, addManga.Item1, addManga.Item2, token);
internal static bool AddMangaToContext(Manga addManga, MangaConnectorId<Manga> addMcId, MangaContext context, [NotNullWhen(true)]out Manga? manga)
internal static async Task<bool> AddMangaToContext(this MangaContext context, Manga addManga, MangaConnectorId<Manga> addMcId,
CancellationToken token)
{
manga = context.Mangas.Find(addManga.Key) ?? addManga;
MangaConnectorId<Manga> mcId = context.MangaConnectorToManga.Find(addMcId.Key) ?? addMcId;
mcId.Obj = manga;
IEnumerable<MangaTag> mergedTags = manga.MangaTags.Select(mt =>
context.ChangeTracker.Clear();
Manga? manga = await context.FindMangaLike(addManga, token);
if (manga is not null)
{
MangaTag? inDb = context.Tags.Find(mt.Tag);
return inDb ?? mt;
});
manga.MangaTags = mergedTags.ToList();
foreach (MangaConnectorId<Manga> mcId in addManga.MangaConnectorIds)
{
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();
IEnumerable<Author> mergedAuthors = manga.Authors.Select(ma =>
addManga = manga;
}
else
{
Author? inDb = context.Authors.Find(ma.Key);
return inDb ?? ma;
});
manga.Authors = mergedAuthors.ToList();
if(context.MangaConnectorToManga.Find(addMcId.Key) is null)
context.MangaConnectorToManga.Add(mcId);
manga = addManga;
IEnumerable<MangaTag> mergedTags = manga.MangaTags.Select(mt =>
{
MangaTag? inDb = context.Tags.Find(mt.Tag);
return inDb ?? mt;
});
manga.MangaTags = mergedTags.ToList();
if (context.Sync() is { success: false })
IEnumerable<Author> mergedAuthors = manga.Authors.Select(ma =>
{
Author? inDb = context.Authors.Find(ma.Key);
return inDb ?? ma;
});
manga.Authors = mergedAuthors.ToList();
context.Mangas.Add(manga);
}
if (await context.Sync(token) is { success: false })
return false;
return true;
}
internal static bool AddChapterToContext((Chapter, MangaConnectorId<Chapter>) addChapter, MangaContext context,
[NotNullWhen(true)] out Chapter? chapter) => AddChapterToContext(addChapter.Item1, addChapter.Item2, context, out chapter);
internal static bool AddChapterToContext(Chapter addChapter, MangaConnectorId<Chapter> addChId, MangaContext context, [NotNullWhen(true)] out Chapter? chapter)
{
chapter = context.Chapters.Find(addChapter.Key) ?? addChapter;
MangaConnectorId<Chapter> chId = context.MangaConnectorToChapter.Find(addChId.Key) ?? addChId;
chId.Obj = chapter;
DownloadCoverFromMangaconnectorWorker downloadCoverWorker = new (addMcId);
AddWorker(downloadCoverWorker);
if(context.MangaConnectorToChapter.Find(chId.Key) is null)
context.MangaConnectorToChapter.Add(chId);
if (context.Sync() is { success: false })
return false;
return true;
}
}

View File

@@ -11,8 +11,11 @@ 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 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");
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" : "")})";
@@ -39,16 +42,20 @@ public struct TrangaSettings()
[JsonIgnore]
internal static readonly Dictionary<RequestType, int> DefaultRequestLimits = new ()
{
{RequestType.MangaInfo, 60},
{RequestType.MangaDexFeed, 60},
{RequestType.MangaInfo, 360},
{RequestType.MangaDexFeed, 360},
{RequestType.MangaDexImage, 60},
{RequestType.MangaImage, 240},
{RequestType.MangaCover, 60},
{RequestType.Default, 60}
{RequestType.Default, 360}
};
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()
{
@@ -59,7 +66,7 @@ public struct TrangaSettings()
public void Save()
{
File.WriteAllText(settingsFilePath, JsonConvert.SerializeObject(this));
File.WriteAllText(settingsFilePath, JsonConvert.SerializeObject(this, Formatting.Indented));
}
public void SetUserAgent(string value)
@@ -109,4 +116,10 @@ public struct TrangaSettings()
this.DownloadLanguage = language;
Save();
}
public void SetMaxConcurrentDownloads(int value)
{
this.MaxConcurrentDownloads = value;
Save();
}
}

View File

@@ -8,22 +8,23 @@ public abstract class BaseWorker : Identifiable
/// <summary>
/// Workers this Worker depends on being completed before running.
/// </summary>
public BaseWorker[] DependsOn { get; init; }
private BaseWorker[] DependsOn { get; init; }
/// <summary>
/// Dependencies and dependencies of dependencies. See also <see cref="DependsOn"/>.
/// </summary>
public IEnumerable<BaseWorker> AllDependencies => DependsOn.Select(d => d.AllDependencies).SelectMany(x => x);
internal IEnumerable<BaseWorker> AllDependencies => DependsOn.Select(d => d.AllDependencies).SelectMany(x => x);
/// <summary>
/// <see cref="AllDependencies"/> and Self.
/// </summary>
public IEnumerable<BaseWorker> DependenciesAndSelf => AllDependencies.Append(this);
internal IEnumerable<BaseWorker> DependenciesAndSelf => AllDependencies.Append(this);
/// <summary>
/// <see cref="DependsOn"/> where <see cref="WorkerExecutionState"/> is less than Completed.
/// </summary>
public IEnumerable<BaseWorker> MissingDependencies => DependsOn.Where(d => d.State < WorkerExecutionState.Completed);
internal IEnumerable<BaseWorker> MissingDependencies => DependsOn.Where(d => d.State < WorkerExecutionState.Completed);
public bool AllDependenciesFulfilled => DependsOn.All(d => d.State >= WorkerExecutionState.Completed);
internal WorkerExecutionState State { get; private set; }
private static readonly CancellationTokenSource CancellationTokenSource = new(TimeSpan.FromMinutes(10));
private CancellationTokenSource _cancellationTokenSource = new ();
protected CancellationToken CancellationToken => _cancellationTokenSource.Token;
protected ILog Log { get; init; }
/// <summary>
@@ -33,7 +34,7 @@ public abstract class BaseWorker : Identifiable
{
Log.Debug($"Cancelled {this}");
this.State = WorkerExecutionState.Cancelled;
CancellationTokenSource.Cancel();
_cancellationTokenSource.Cancel();
}
/// <summary>
@@ -43,10 +44,10 @@ public abstract class BaseWorker : Identifiable
{
Log.Debug($"Failed {this}");
this.State = WorkerExecutionState.Failed;
CancellationTokenSource.Cancel();
_cancellationTokenSource.Cancel();
}
public BaseWorker(IEnumerable<BaseWorker>? dependsOn = null)
protected BaseWorker(IEnumerable<BaseWorker>? dependsOn = null)
{
this.DependsOn = dependsOn?.ToArray() ?? [];
this.Log = LogManager.GetLogger(GetType());
@@ -68,40 +69,46 @@ public abstract class BaseWorker : Identifiable
/// <item>If <see cref="BaseWorker"/> has run, additional <see cref="BaseWorker"/>.</item>
/// </list>
/// </returns>
public Task<BaseWorker[]> DoWork()
public Task<BaseWorker[]> DoWork(Action? callback = null)
{
// Start the worker
Log.Debug($"Checking {this}");
this.State = WorkerExecutionState.Waiting;
_cancellationTokenSource = new(TimeSpan.FromMinutes(10));
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 Task<BaseWorker[]>(() => missingDependenciesThatNeedStarting);
return new (() => missingDependenciesThatNeedStarting);
if (MissingDependencies.Any())
return new Task<BaseWorker[]>(WaitForDependencies);
return new (WaitForDependencies);
// Run the actual work
Log.Info($"Running {this}");
DateTime startTime = DateTime.UtcNow;
Task<BaseWorker[]> task = new (DoWorkInternal, CancellationTokenSource.Token);
task.GetAwaiter().OnCompleted(() =>
{
DateTime endTime = DateTime.UtcNow;
Log.Info($"Completed {this}\n\t{endTime.Subtract(startTime).TotalMilliseconds} ms");
this.State = WorkerExecutionState.Completed;
if(this is IPeriodic periodic)
periodic.LastExecution = DateTime.UtcNow;
});
task.Start();
this.State = WorkerExecutionState.Running;
State = WorkerExecutionState.Running;
Task<BaseWorker[]> task = DoWorkInternal();
task.GetAwaiter().OnCompleted(Finish(startTime, callback));
return task;
}
private Action Finish(DateTime startTime, Action? callback = null) => () =>
{
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();
};
protected abstract BaseWorker[] DoWorkInternal();
protected abstract Task<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,8 +1,10 @@
using System.IO.Compression;
using System.Runtime.InteropServices;
using API.MangaConnectors;
using API.MangaDownloadClients;
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
@@ -11,26 +13,62 @@ 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 BaseWorker[] DoWorkInternal()
protected override async Task<BaseWorker[]> DoWorkInternal()
{
if (DbContext.MangaConnectorToChapter.Find(MangaConnectorIdId) is not { } MangaConnectorId)
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.");
return []; //TODO Exception?
MangaConnector mangaConnector = MangaConnectorId.MangaConnector;
Chapter chapter = MangaConnectorId.Obj;
}
// 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;
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;
@@ -79,10 +117,12 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
}
}
CopyCoverFromCacheToDownloadLocation(chapter.ParentManga);
await CopyCoverFromCacheToDownloadLocation(chapter.ParentManga);
Log.Debug($"Creating ComicInfo.xml {chapter}");
File.WriteAllText(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString());
foreach (CollectionEntry collectionEntry in DbContext.Entry(chapter.ParentManga).Collections)
await collectionEntry.LoadAsync(CancellationToken);
await File.WriteAllTextAsync(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString(), CancellationToken);
Log.Debug($"Packaging images to archive {chapter}");
//ZIP-it and ship-it
@@ -90,9 +130,12 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
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}");
chapter.Downloaded = true;
DbContext.Sync();
Log.Debug($"Downloaded chapter {chapter}.");
return [];
}
@@ -135,7 +178,7 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
}
}
private void CopyCoverFromCacheToDownloadLocation(Manga manga)
private async Task CopyCoverFromCacheToDownloadLocation(Manga manga)
{
//Check if Publication already has a Folder and cover
string publicationFolder = manga.CreatePublicationFolder();
@@ -147,21 +190,29 @@ public class DownloadChapterFromMangaconnectorWorker(MangaConnectorId<Chapter> c
}
//TODO MangaConnector Selection
MangaConnectorId<Manga> mcId = manga.MangaConnectorIds.First();
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;
}
Log.Info($"Copying cover to {publicationFolder}");
string? fileInCache = manga.CoverFileNameInCache ?? mcId.MangaConnector.SaveCoverImageToCache(mcId);
if (fileInCache is null)
await DbContext.Entry(mangaConnectorId).Navigation(nameof(MangaConnectorId<Manga>.Obj)).LoadAsync(CancellationToken);
string? coverFileNameInCache = manga.CoverFileNameInCache ?? mangaConnector.SaveCoverImageToCache(mangaConnectorId);
if (coverFileNameInCache is null)
{
Log.Error($"File {fileInCache} does not exist");
Log.Error($"File {coverFileNameInCache} does not exist");
return;
}
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" );
File.Copy(fileInCache, newFilePath, true);
string fullCoverPath = Path.Join(TrangaSettings.coverImageCacheOriginal, coverFileNameInCache);
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(coverFileNameInCache).Split('.')[^1]}" );
File.Copy(fullCoverPath, newFilePath, true);
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | UserRead | UserWrite | OtherRead | OtherWrite);
Log.Debug($"Copied cover from {fileInCache} to {newFilePath}");
Log.Debug($"Copied cover from {fullCoverPath} to {newFilePath}");
}
private bool DownloadImage(string imageUrl, string savePath)

View File

@@ -1,22 +1,45 @@
using API.MangaConnectors;
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
using Microsoft.EntityFrameworkCore;
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 BaseWorker[] DoWorkInternal()
protected override async Task<BaseWorker[]> DoWorkInternal()
{
if (DbContext.MangaConnectorToManga.Find(MangaConnectorIdId) is not { } MangaConnectorId)
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.");
return []; //TODO Exception?
MangaConnector mangaConnector = MangaConnectorId.MangaConnector;
Manga manga = MangaConnectorId.Obj;
manga.CoverFileNameInCache = mangaConnector.SaveCoverImageToCache(MangaConnectorId);
}
if (!Tranga.TryGetMangaConnector(mangaConnectorId.MangaConnectorName, out MangaConnector? mangaConnector))
{
Log.Error("Could not get MangaConnector.");
return []; //TODO Exception?
}
Log.Debug($"Getting Cover for MangaConnectorId {mangaConnectorId}...");
DbContext.Sync();
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}");
return [];
}

View File

@@ -1,32 +1,80 @@
using API.MangaConnectors;
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
using Microsoft.EntityFrameworkCore;
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 BaseWorker[] DoWorkInternal()
protected override async Task<BaseWorker[]> DoWorkInternal()
{
if (DbContext.MangaConnectorToManga.Find(MangaConnectorIdId) is not { } MangaConnectorId)
return []; //TODO Exception?
MangaConnector mangaConnector = MangaConnectorId.MangaConnector;
Manga manga = MangaConnectorId.Obj;
// This gets all chapters that are not downloaded
(Chapter, MangaConnectorId<Chapter>)[] allChapters =
mangaConnector.GetChapters(MangaConnectorId, language).DistinctBy(c => c.Item1.Key).ToArray();
int addedChapters = 0;
foreach ((Chapter chapter, MangaConnectorId<Chapter> mcId) newChapter in allChapters)
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)
{
if (Tranga.AddChapterToContext(newChapter, DbContext, out Chapter? addedChapter) == false)
continue;
manga.Chapters.Add(addedChapter);
Log.Error("Could not get MangaConnectorId.");
return []; //TODO Exception?
}
Log.Info($"{manga.Chapters.Count} existing + {addedChapters} new chapters.");
if (!Tranga.TryGetMangaConnector(mangaConnectorId.MangaConnectorName, out MangaConnector? mangaConnector))
{
Log.Error("Could not get MangaConnector.");
return []; //TODO Exception?
}
Log.Debug($"Getting Chapters for MangaConnectorId {mangaConnectorId}...");
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.");
DbContext.Sync();
// 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}");
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 BaseWorker[] DoWorkInternal()
protected override Task<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 [];
return new Task<BaseWorker[]>(() => []);
}
if (File.Exists(ToLocation))//Do not override existing
{
Log.Error($"File already exists at {ToLocation}");
return [];
return new Task<BaseWorker[]>(() => []);
}
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 [];
return new Task<BaseWorker[]>(() => []);
}
private void MoveDirectory(FileInfo from, string toLocation)

View File

@@ -1,24 +1,44 @@
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 BaseWorker[] DoWorkInternal()
protected override async Task<BaseWorker[]> DoWorkInternal()
{
if (DbContext.Mangas.Find(MangaId) is not { } manga)
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.");
return []; //TODO Exception?
if (DbContext.FileLibraries.Find(LibraryId) is not { } toLibrary)
}
// Get new Library
if (await DbContext.FileLibraries.FirstOrDefaultAsync(l => l.Key == LibraryId, CancellationToken) is not { } toLibrary)
{
Log.Error("Could not find Library.");
return []; //TODO Exception?
}
// 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 (DbContext.Sync() is { success: false })
if (await DbContext.Sync(CancellationToken) 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,21 +1,29 @@
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 BaseWorker[] DoWorkInternal()
protected override async Task<BaseWorker[]> DoWorkInternal()
{
IQueryable<MangaConnectorId<Manga>> connectorIdsManga = DbContext.MangaConnectorToManga.Where(id => id.UseForDownload);
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...");
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,14 +8,25 @@ 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 BaseWorker[] DoWorkInternal()
protected override Task<BaseWorker[]> DoWorkInternal()
{
Log.Info("Removing stale files...");
if (!Directory.Exists(TrangaSettings.coverImageCache))
return [];
string[] usedFiles = DbContext.Mangas.Select(m => m.CoverFileNameInCache).Where(s => s != null).ToArray()!;
string[] extraneousFiles = new DirectoryInfo(TrangaSettings.coverImageCache).GetFiles()
.Where(f => usedFiles.Contains(f.FullName) == false)
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)
.Select(f => f.FullName)
.ToArray();
foreach (string path in extraneousFiles)
@@ -23,7 +34,5 @@ public class CleanupMangaCoversWorker(TimeSpan? interval = null, IEnumerable<Bas
Log.Info($"Deleting {path}");
File.Delete(path);
}
return [];
}
}

View File

@@ -1,18 +1,27 @@
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 BaseWorker[] DoWorkInternal()
protected override async Task<BaseWorker[]> DoWorkInternal()
{
IQueryable<Notification> toRemove = DbContext.Notifications.Where(n => n.IsSent || DateTime.UtcNow - n.Date > Interval);
Log.Debug("Removing old notifications...");
List<Notification> toRemove = await DbContext.Notifications.Where(n => n.IsSent).ToListAsync(CancellationToken);
Log.Debug($"Removing {toRemove.Count} old notifications...");
DbContext.RemoveRange(toRemove);
DbContext.Sync();
if(await DbContext.Sync(CancellationToken) is { success: false } e)
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
return [];
}

View File

@@ -1,28 +1,41 @@
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 BaseWorker[] DoWorkInternal()
protected override async Task<BaseWorker[]> DoWorkInternal()
{
NotificationConnector[] connectors = DbContext.NotificationConnectors.ToArray();
Notification[] notifications = DbContext.Notifications.Where(n => n.IsSent == false).ToArray();
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);
foreach (Notification notification in notifications)
Log.Debug($"Sending {unsentNotifications.Count} notifications to {connectors.Count} connectors...");
unsentNotifications.ForEach(notification =>
{
foreach (NotificationConnector connector in connectors)
connectors.ForEach(connector =>
{
connector.SendNotification(notification.Title, notification.Message);
notification.IsSent = true;
}
}
DbContext.Entry(notification).Property(n => n.IsSent).CurrentValue = true;
});
});
DbContext.Sync();
Log.Debug("Notifications sent.");
if(await DbContext.Sync(CancellationToken) is { success: false } e)
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
return [];
}

View File

@@ -1,20 +1,38 @@
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 BaseWorker[] DoWorkInternal()
protected override async Task<BaseWorker[]> DoWorkInternal()
{
IQueryable<MangaConnectorId<Chapter>> mangaConnectorIds = DbContext.MangaConnectorToChapter.Where(id => id.Obj.Downloaded == false && id.UseForDownload);
Log.Debug("Checking for missing chapters...");
List<BaseWorker> newWorkers = new();
foreach (MangaConnectorId<Chapter> mangaConnectorId in mangaConnectorIds)
newWorkers.Add(new DownloadChapterFromMangaconnectorWorker(mangaConnectorId));
// 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>();
return newWorkers.ToArray();
}

View File

@@ -1,17 +1,36 @@
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 BaseWorker[] DoWorkInternal()
protected override async Task<BaseWorker[]> DoWorkInternal()
{
foreach (Chapter dbContextChapter in DbContext.Chapters)
dbContextChapter.Downloaded = dbContextChapter.CheckDownloaded();
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);
}
}
DbContext.Sync();
if(await DbContext.Sync(CancellationToken) is { success: false } e)
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
return [];
}
}

View File

@@ -0,0 +1,24 @@
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,6 +4,11 @@ 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
{
@@ -11,20 +16,29 @@ 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 BaseWorker[] DoWorkInternal()
protected override async Task<BaseWorker[]> DoWorkInternal()
{
IQueryable<string> mangaIds = DbContext.MangaConnectorToManga
.Where(m => m.UseForDownload)
.Select(m => m.ObjId);
IQueryable<MetadataEntry> metadataEntriesToUpdate = DbContext.MetadataEntries
.Include(e => e.MetadataFetcher)
.Where(e =>
mangaIds.Any(id => id == e.MangaId));
foreach (MetadataEntry metadataEntry in metadataEntriesToUpdate)
metadataEntry.MetadataFetcher.UpdateMetadata(metadataEntry, DbContext);
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...");
DbContext.Sync();
foreach (MetadataEntry metadataEntry in metadataEntriesToUpdate)
{
Log.Debug($"Updating metadata of {metadataEntry}...");
await metadataEntry.MetadataFetcher.UpdateMetadata(metadataEntry, DbContext, CancellationToken);
}
Log.Debug("Updated metadata.");
if(await DbContext.Sync(CancellationToken) is { success: false } e)
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
return [];
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

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

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