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
98 changed files with 3424 additions and 1972 deletions

View File

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

View File

@@ -1,6 +1,6 @@
name: New Connector Request name: New Connector Request
description: Request a new site to be added description: Request a new site to be added
title: "[New Connector]: " title: "[New Connector]: <title>"
labels: ["New Connector"] labels: ["New Connector"]
body: body:
- type: input - type: input
@@ -9,15 +9,12 @@ body:
placeholder: https:// placeholder: https://
validations: validations:
required: true required: true
- type: checkboxes
attributes:
label: Is the Website free to access?
description: We can't support pay-to-use sites, or captcha-proxied sites as Cloudflare.
options:
- label: The Website is freely accessible.
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Anything else? label: Anything else?
validations: validations:
required: false required: false
- type: markdown
attributes:
value: |
If you want implement this read [Contributing](https://github.com/C9Glax/tranga#contributing). Thank you!

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,14 @@
using API.MangaConnectors; using API.Controllers.DTOs;
using API.Schema.MangaContext; using API.Schema.MangaContext;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Soenneker.Utils.String.NeedlemanWunsch; using Soenneker.Utils.String.NeedlemanWunsch;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
using Author = API.Controllers.DTOs.Author;
using Chapter = API.Controllers.DTOs.Chapter;
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
namespace API.Controllers; namespace API.Controllers;
@@ -21,13 +26,13 @@ public class QueryController(MangaContext context) : Controller
/// <response code="404"><see cref="Author"/> with <paramref name="AuthorId"/> not found</response> /// <response code="404"><see cref="Author"/> with <paramref name="AuthorId"/> not found</response>
[HttpGet("Author/{AuthorId}")] [HttpGet("Author/{AuthorId}")]
[ProducesResponseType<Author>(Status200OK, "application/json")] [ProducesResponseType<Author>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)] [ProducesResponseType<string>(Status404NotFound, "text/plain")]
public IActionResult GetAuthor(string AuthorId) public async Task<Results<Ok<Author>, NotFound<string>>> GetAuthor (string AuthorId)
{ {
if (context.Authors.Find(AuthorId) is not { } author) if (await context.Authors.FirstOrDefaultAsync(a => a.Key == AuthorId, HttpContext.RequestAborted) is not { } author)
return NotFound(); return TypedResults.NotFound(nameof(AuthorId));
return Ok(author); return TypedResults.Ok(new Author(author.Key, author.AuthorName));
} }
/// <summary> /// <summary>
@@ -38,13 +43,15 @@ public class QueryController(MangaContext context) : Controller
/// <response code="404"><see cref="Chapter"/> with <paramref name="ChapterId"/> not found</response> /// <response code="404"><see cref="Chapter"/> with <paramref name="ChapterId"/> not found</response>
[HttpGet("Chapter/{ChapterId}")] [HttpGet("Chapter/{ChapterId}")]
[ProducesResponseType<Chapter>(Status200OK, "application/json")] [ProducesResponseType<Chapter>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)] [ProducesResponseType<string>(Status404NotFound, "text/plain")]
public IActionResult GetChapter(string ChapterId) public async Task<Results<Ok<Chapter>, NotFound<string>>> GetChapter (string ChapterId)
{ {
if (context.Chapters.Find(ChapterId) is not { } chapter) if (await context.Chapters.FirstOrDefaultAsync(c => c.Key == ChapterId, HttpContext.RequestAborted) is not { } chapter)
return NotFound(); 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> /// <summary>
@@ -54,49 +61,46 @@ public class QueryController(MangaContext context) : Controller
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="MangaConnectorId{Manga}"/> with <paramref name="MangaConnectorIdId"/> not found</response> /// <response code="404"><see cref="MangaConnectorId{Manga}"/> with <paramref name="MangaConnectorIdId"/> not found</response>
[HttpGet("Manga/MangaConnectorId/{MangaConnectorIdId}")] [HttpGet("Manga/MangaConnectorId/{MangaConnectorIdId}")]
[ProducesResponseType<MangaConnectorId<Manga>>(Status200OK, "application/json")] [ProducesResponseType<MangaConnectorId>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)] [ProducesResponseType<string>(Status404NotFound, "text/plain")]
public IActionResult GetMangaMangaConnectorId(string MangaConnectorIdId) public async Task<Results<Ok<MangaConnectorId>, NotFound<string>>> GetMangaMangaConnectorId (string MangaConnectorIdId)
{ {
if(context.MangaConnectorToManga.Find(MangaConnectorIdId) is not { } mcIdManga) if (await context.MangaConnectorToManga.FirstOrDefaultAsync(c => c.Key == MangaConnectorIdId, HttpContext.RequestAborted) is not { } mcIdManga)
return NotFound(); return TypedResults.NotFound(nameof(MangaConnectorIdId));
return Ok(mcIdManga); MangaConnectorId result = new (mcIdManga.Key, mcIdManga.MangaConnectorName, mcIdManga.ObjId, mcIdManga.WebsiteUrl, mcIdManga.UseForDownload);
return TypedResults.Ok(result);
} }
/// <summary> /// <summary>
/// Returns all <see cref="Manga"/> that are being downloaded from at least one <see cref="MangaConnector"/> /// Returns <see cref="Schema.MangaContext.Manga"/> with names similar to <see cref="Schema.MangaContext.Manga"/> (identified by <paramref name="MangaId"/>)
/// </summary> /// </summary>
/// <param name="MangaId">Key of <see cref="Schema.MangaContext.Manga"/></param>
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet("Manga/Downloading")] /// <response code="404"><see cref="Schema.MangaContext.Manga"/> with <paramref name="MangaId"/> not found</response>
[ProducesResponseType<Manga[]>(Status200OK, "application/json")] /// <response code="500">Error during Database Operation</response>
public IActionResult GetMangaDownloading()
{
Manga[] ret = context.MangaIncludeAll()
.Where(m => m.MangaConnectorIds.Any(id => id.UseForDownload))
.ToArray();
return Ok(ret);
}
/// <summary>
/// Returns <see cref="Manga"/> with names similar to <see cref="Manga"/> (identified by <paramref name="MangaId"/>
/// </summary>
/// <param name="MangaId">Key of <see cref="Manga"/></param>
/// <response code="200"></response>
/// <response code="404"><see cref="Manga"/> with <paramref name="MangaId"/> not found</response>
[HttpGet("Manga/{MangaId}/SimilarName")] [HttpGet("Manga/{MangaId}/SimilarName")]
[ProducesResponseType<string[]>(Status200OK, "application/json")] [ProducesResponseType<List<string>>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)] [ProducesResponseType<string>(Status404NotFound, "text/plain")]
public IActionResult GetSimilarManga(string MangaId) [ProducesResponseType(Status500InternalServerError)]
public async Task<Results<Ok<List<string>>, NotFound<string>, InternalServerError>> GetSimilarManga (string MangaId)
{ {
if (context.Mangas.Find(MangaId) is not { } manga) if (await context.Mangas.FirstOrDefaultAsync(m => m.Key == MangaId, HttpContext.RequestAborted) is not { } manga)
return NotFound(); return TypedResults.NotFound(nameof(MangaId));
string name = manga.Name; string name = manga.Name;
Dictionary<string, string> mangaNames = context.Mangas.Where(m => m.Key != MangaId).ToDictionary(m => m.Key, m => m.Name);
string[] similarIds = mangaNames 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) .Where(kv => NeedlemanWunschStringUtil.CalculateSimilarityPercentage(name, kv.Value) > 0.8)
.Select(kv => kv.Key).ToArray(); .Select(kv => kv.Key)
return Ok(similarIds); .ToList();
return TypedResults.Ok(similarIds);
} }
/// <summary> /// <summary>
@@ -106,13 +110,15 @@ public class QueryController(MangaContext context) : Controller
/// <response code="200"></response> /// <response code="200"></response>
/// <response code="404"><see cref="MangaConnectorId{Chapter}"/> with <paramref name="MangaConnectorIdId"/> not found</response> /// <response code="404"><see cref="MangaConnectorId{Chapter}"/> with <paramref name="MangaConnectorIdId"/> not found</response>
[HttpGet("Chapter/MangaConnectorId/{MangaConnectorIdId}")] [HttpGet("Chapter/MangaConnectorId/{MangaConnectorIdId}")]
[ProducesResponseType<MangaConnectorId<Chapter>>(Status200OK, "application/json")] [ProducesResponseType<MangaConnectorId>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)] [ProducesResponseType<string>(Status404NotFound, "text/plain")]
public IActionResult GetChapterMangaConnectorId(string MangaConnectorIdId) public async Task<Results<Ok<MangaConnectorId>, NotFound<string>>> GetChapterMangaConnectorId (string MangaConnectorIdId)
{ {
if(context.MangaConnectorToChapter.Find(MangaConnectorIdId) is not { } mcIdChapter) if (await context.MangaConnectorToManga.FirstOrDefaultAsync(c => c.Key == MangaConnectorIdId, HttpContext.RequestAborted) is not { } mcIdChapter)
return NotFound(); return TypedResults.NotFound(nameof(MangaConnectorIdId));
return Ok(mcIdChapter); 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.MangaConnectors; using API.Controllers.DTOs;
using API.Schema.MangaContext; using API.Schema.MangaContext;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
using Manga = API.Schema.MangaContext.Manga;
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
namespace API.Controllers; namespace API.Controllers;
@@ -13,58 +16,64 @@ namespace API.Controllers;
public class SearchController(MangaContext context) : Controller public class SearchController(MangaContext context) : Controller
{ {
/// <summary> /// <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> /// </summary>
/// <param name="MangaConnectorName"><see cref="MangaConnector"/>.Name</param> /// <param name="MangaConnectorName"><see cref="MangaConnector"/>.Name</param>
/// <param name="Query">searchTerm</param> /// <param name="Query">searchTerm</param>
/// <response code="200"></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="404"><see cref="MangaConnector"/> with Name not found</response>
/// <response code="412"><see cref="MangaConnector"/> with Name is disabled</response> /// <response code="412"><see cref="MangaConnector"/> with Name is disabled</response>
[HttpGet("{MangaConnectorName}/{Query}")] [HttpGet("{MangaConnectorName}/{Query}")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")] [ProducesResponseType<List<MinimalManga>>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)] [ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status406NotAcceptable)] [ProducesResponseType(Status406NotAcceptable)]
public IActionResult SearchManga(string MangaConnectorName, string Query) public Results<Ok<List<MinimalManga>>, NotFound<string>, StatusCodeHttpResult> SearchManga (string MangaConnectorName, string Query)
{ {
if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals(MangaConnectorName, StringComparison.InvariantCultureIgnoreCase)) is not { } connector) if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals(MangaConnectorName, StringComparison.InvariantCultureIgnoreCase)) is not { } connector)
return NotFound(); return TypedResults.NotFound(nameof(MangaConnectorName));
if (connector.Enabled is false) if (connector.Enabled is false)
return StatusCode(Status412PreconditionFailed); return TypedResults.StatusCode(Status412PreconditionFailed);
(Manga, MangaConnectorId<Manga>)[] mangas = connector.SearchManga(Query); (Manga manga, MangaConnectorId<Manga> id)[] mangas = connector.SearchManga(Query);
List<Manga> retMangas = new();
foreach ((Manga manga, MangaConnectorId<Manga> mcId) manga in mangas) 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 =>
{ {
if(Tranga.AddMangaToContext(manga, context, out Manga? add)) IEnumerable<MangaConnectorId> ids = m.MangaConnectorIds.Select(id =>
retMangas.Add(add); 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 Ok(retMangas.ToArray()); return TypedResults.Ok(result.ToList());
} }
/// <summary> /// <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> /// </summary>
/// <param name="url"></param> /// <param name="url"></param>
/// <response code="200"></response> /// <response code="200"><see cref="MinimalManga"/> exert of <see cref="Schema.MangaContext.Manga"/>.</response>
/// <response code="300">Multiple <see cref="MangaConnector"/> found for URL</response>
/// <response code="404"><see cref="Manga"/> not found</response> /// <response code="404"><see cref="Manga"/> not found</response>
/// <response code="500">Error during Database Operation</response> /// <response code="500">Error during Database Operation</response>
[HttpPost("Url")] [HttpPost("Url")]
[ProducesResponseType<Manga>(Status200OK, "application/json")] [ProducesResponseType<MinimalManga>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)] [ProducesResponseType<string>(Status404NotFound, "text/plain")]
[ProducesResponseType(Status500InternalServerError)] [ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult GetMangaFromUrl([FromBody]string url) public async Task<Results<Ok<MinimalManga>, NotFound<string>, InternalServerError<string>>> GetMangaFromUrl ([FromBody]string url)
{ {
if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals("Global", StringComparison.InvariantCultureIgnoreCase)) is not { } connector) if(Tranga.MangaConnectors.FirstOrDefault(c => c.Name.Equals("Global", StringComparison.InvariantCultureIgnoreCase)) is not { } connector)
return StatusCode(Status500InternalServerError, "Could not find Global Connector."); return TypedResults.InternalServerError("Could not find Global Connector.");
if(connector.GetMangaFromUrl(url) is not { } manga) if(connector.GetMangaFromUrl(url) is not ({ } m, not null) manga)
return NotFound(); return TypedResults.NotFound("Could not retrieve Manga");
if(Tranga.AddMangaToContext(manga, context, out Manga? add) == false) if(await context.AddMangaToContext(manga, HttpContext.RequestAborted) == false)
return StatusCode(Status500InternalServerError); 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 API.MangaDownloadClients;
using Asp.Versioning; using Asp.Versioning;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes; using static Microsoft.AspNetCore.Http.StatusCodes;
// ReSharper disable InconsistentNaming // ReSharper disable InconsistentNaming
@@ -17,9 +18,9 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet] [HttpGet]
[ProducesResponseType<TrangaSettings>(Status200OK, "application/json")] [ProducesResponseType<TrangaSettings>(Status200OK, "application/json")]
public IActionResult GetSettings() public Ok<TrangaSettings> GetSettings()
{ {
return Ok(Tranga.Settings); return TypedResults.Ok(Tranga.Settings);
} }
/// <summary> /// <summary>
@@ -28,9 +29,9 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet("UserAgent")] [HttpGet("UserAgent")]
[ProducesResponseType<string>(Status200OK, "text/plain")] [ProducesResponseType<string>(Status200OK, "text/plain")]
public IActionResult GetUserAgent() public Ok<string> GetUserAgent()
{ {
return Ok(Tranga.Settings.UserAgent); return TypedResults.Ok(Tranga.Settings.UserAgent);
} }
/// <summary> /// <summary>
@@ -39,11 +40,11 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpPatch("UserAgent")] [HttpPatch("UserAgent")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public IActionResult SetUserAgent([FromBody]string userAgent) public Ok SetUserAgent([FromBody]string userAgent)
{ {
//TODO Validate //TODO Validate
Tranga.Settings.SetUserAgent(userAgent); Tranga.Settings.SetUserAgent(userAgent);
return Ok(); return TypedResults.Ok();
} }
/// <summary> /// <summary>
@@ -52,10 +53,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpDelete("UserAgent")] [HttpDelete("UserAgent")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public IActionResult ResetUserAgent() public Ok ResetUserAgent()
{ {
Tranga.Settings.SetUserAgent(TrangaSettings.DefaultUserAgent); Tranga.Settings.SetUserAgent(TrangaSettings.DefaultUserAgent);
return Ok(); return TypedResults.Ok();
} }
/// <summary> /// <summary>
@@ -64,9 +65,9 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet("RequestLimits")] [HttpGet("RequestLimits")]
[ProducesResponseType<Dictionary<RequestType,int>>(Status200OK, "application/json")] [ProducesResponseType<Dictionary<RequestType,int>>(Status200OK, "application/json")]
public IActionResult GetRequestLimits() public Ok<Dictionary<RequestType,int>> GetRequestLimits()
{ {
return Ok(Tranga.Settings.RequestLimits); return TypedResults.Ok(Tranga.Settings.RequestLimits);
} }
/// <summary> /// <summary>
@@ -75,9 +76,9 @@ public class SettingsController() : Controller
/// <remarks><h1>NOT IMPLEMENTED</h1></remarks> /// <remarks><h1>NOT IMPLEMENTED</h1></remarks>
[HttpPatch("RequestLimits")] [HttpPatch("RequestLimits")]
[ProducesResponseType(Status501NotImplemented)] [ProducesResponseType(Status501NotImplemented)]
public IActionResult SetRequestLimits() public StatusCodeHttpResult SetRequestLimits()
{ {
return StatusCode(501); return TypedResults.StatusCode(Status501NotImplemented);
} }
/// <summary> /// <summary>
@@ -90,12 +91,12 @@ public class SettingsController() : Controller
[HttpPatch("RequestLimits/{RequestType}")] [HttpPatch("RequestLimits/{RequestType}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status400BadRequest)]
public IActionResult SetRequestLimit(RequestType RequestType, [FromBody]int requestLimit) public Results<Ok, BadRequest> SetRequestLimit(RequestType RequestType, [FromBody]int requestLimit)
{ {
if (requestLimit <= 0) if (requestLimit <= 0)
return BadRequest(); return TypedResults.BadRequest();
Tranga.Settings.SetRequestLimit(RequestType, requestLimit); Tranga.Settings.SetRequestLimit(RequestType, requestLimit);
return Ok(); return TypedResults.Ok();
} }
/// <summary> /// <summary>
@@ -104,10 +105,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpDelete("RequestLimits/{RequestType}")] [HttpDelete("RequestLimits/{RequestType}")]
[ProducesResponseType<string>(Status200OK)] [ProducesResponseType<string>(Status200OK)]
public IActionResult ResetRequestLimits(RequestType RequestType) public Ok ResetRequestLimits(RequestType RequestType)
{ {
Tranga.Settings.SetRequestLimit(RequestType, TrangaSettings.DefaultRequestLimits[RequestType]); Tranga.Settings.SetRequestLimit(RequestType, TrangaSettings.DefaultRequestLimits[RequestType]);
return Ok(); return TypedResults.Ok();
} }
/// <summary> /// <summary>
@@ -116,10 +117,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpDelete("RequestLimits")] [HttpDelete("RequestLimits")]
[ProducesResponseType<string>(Status200OK)] [ProducesResponseType<string>(Status200OK)]
public IActionResult ResetRequestLimits() public Ok ResetRequestLimits()
{ {
Tranga.Settings.ResetRequestLimits(); Tranga.Settings.ResetRequestLimits();
return Ok(); return TypedResults.Ok();
} }
/// <summary> /// <summary>
@@ -128,9 +129,9 @@ public class SettingsController() : Controller
/// <response code="200">JPEG ImageCompression-level as Integer</response> /// <response code="200">JPEG ImageCompression-level as Integer</response>
[HttpGet("ImageCompressionLevel")] [HttpGet("ImageCompressionLevel")]
[ProducesResponseType<int>(Status200OK, "text/plain")] [ProducesResponseType<int>(Status200OK, "text/plain")]
public IActionResult GetImageCompression() public Ok<int> GetImageCompression()
{ {
return Ok(Tranga.Settings.ImageCompression); return TypedResults.Ok(Tranga.Settings.ImageCompression);
} }
/// <summary> /// <summary>
@@ -142,12 +143,12 @@ public class SettingsController() : Controller
[HttpPatch("ImageCompressionLevel/{level}")] [HttpPatch("ImageCompressionLevel/{level}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType(Status400BadRequest)] [ProducesResponseType(Status400BadRequest)]
public IActionResult SetImageCompression(int level) public Results<Ok, BadRequest> SetImageCompression(int level)
{ {
if (level < 1 || level > 100) if (level < 1 || level > 100)
return BadRequest(); return TypedResults.BadRequest();
Tranga.Settings.UpdateImageCompression(level); Tranga.Settings.UpdateImageCompression(level);
return Ok(); return TypedResults.Ok();
} }
/// <summary> /// <summary>
@@ -156,9 +157,9 @@ public class SettingsController() : Controller
/// <response code="200">True if enabled</response> /// <response code="200">True if enabled</response>
[HttpGet("BWImages")] [HttpGet("BWImages")]
[ProducesResponseType<bool>(Status200OK, "text/plain")] [ProducesResponseType<bool>(Status200OK, "text/plain")]
public IActionResult GetBwImagesToggle() public Ok<bool> GetBwImagesToggle()
{ {
return Ok(Tranga.Settings.BlackWhiteImages); return TypedResults.Ok(Tranga.Settings.BlackWhiteImages);
} }
/// <summary> /// <summary>
@@ -168,10 +169,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpPatch("BWImages/{enabled}")] [HttpPatch("BWImages/{enabled}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public IActionResult SetBwImagesToggle(bool enabled) public Ok SetBwImagesToggle(bool enabled)
{ {
Tranga.Settings.SetBlackWhiteImageEnabled(enabled); Tranga.Settings.SetBlackWhiteImageEnabled(enabled);
return Ok(); return TypedResults.Ok();
} }
/// <summary> /// <summary>
@@ -194,9 +195,9 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet("ChapterNamingScheme")] [HttpGet("ChapterNamingScheme")]
[ProducesResponseType<string>(Status200OK, "text/plain")] [ProducesResponseType<string>(Status200OK, "text/plain")]
public IActionResult GetCustomNamingScheme() public Ok<string> GetCustomNamingScheme()
{ {
return Ok(Tranga.Settings.ChapterNamingScheme); return TypedResults.Ok(Tranga.Settings.ChapterNamingScheme);
} }
/// <summary> /// <summary>
@@ -217,12 +218,12 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpPatch("ChapterNamingScheme")] [HttpPatch("ChapterNamingScheme")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public IActionResult SetCustomNamingScheme([FromBody]string namingScheme) public Ok SetCustomNamingScheme([FromBody]string namingScheme)
{ {
//TODO Move old Chapters //TODO Move old Chapters
Tranga.Settings.SetChapterNamingScheme(namingScheme); Tranga.Settings.SetChapterNamingScheme(namingScheme);
return Ok(); return TypedResults.Ok();
} }
/// <summary> /// <summary>
@@ -232,10 +233,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpPost("FlareSolverr/Url")] [HttpPost("FlareSolverr/Url")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public IActionResult SetFlareSolverrUrl([FromBody]string flareSolverrUrl) public Ok SetFlareSolverrUrl([FromBody]string flareSolverrUrl)
{ {
Tranga.Settings.SetFlareSolverrUrl(flareSolverrUrl); Tranga.Settings.SetFlareSolverrUrl(flareSolverrUrl);
return Ok(); return TypedResults.Ok();
} }
/// <summary> /// <summary>
@@ -244,10 +245,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpDelete("FlareSolverr/Url")] [HttpDelete("FlareSolverr/Url")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public IActionResult ClearFlareSolverrUrl() public Ok ClearFlareSolverrUrl()
{ {
Tranga.Settings.SetFlareSolverrUrl(string.Empty); Tranga.Settings.SetFlareSolverrUrl(string.Empty);
return Ok(); return TypedResults.Ok();
} }
/// <summary> /// <summary>
@@ -258,12 +259,12 @@ public class SettingsController() : Controller
[HttpPost("FlareSolverr/Test")] [HttpPost("FlareSolverr/Test")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
[ProducesResponseType(Status500InternalServerError)] [ProducesResponseType(Status500InternalServerError)]
public IActionResult TestFlareSolverrReachable() public Results<Ok, InternalServerError> TestFlareSolverrReachable()
{ {
const string knownProtectedUrl = "https://prowlarr.servarr.com/v1/ping"; const string knownProtectedUrl = "https://prowlarr.servarr.com/v1/ping";
FlareSolverrDownloadClient client = new(); FlareSolverrDownloadClient client = new();
RequestResult result = client.MakeRequestInternal(knownProtectedUrl); RequestResult result = client.MakeRequestInternal(knownProtectedUrl);
return (int)result.statusCode >= 200 && (int)result.statusCode < 300 ? Ok() : StatusCode(500, result.statusCode); return (int)result.statusCode >= 200 && (int)result.statusCode < 300 ? TypedResults.Ok() : TypedResults.InternalServerError();
} }
/// <summary> /// <summary>
@@ -272,9 +273,9 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpGet("DownloadLanguage")] [HttpGet("DownloadLanguage")]
[ProducesResponseType<string>(Status200OK, "text/plain")] [ProducesResponseType<string>(Status200OK, "text/plain")]
public IActionResult GetDownloadLanguage() public Ok<string> GetDownloadLanguage()
{ {
return Ok(Tranga.Settings.DownloadLanguage); return TypedResults.Ok(Tranga.Settings.DownloadLanguage);
} }
/// <summary> /// <summary>
@@ -283,10 +284,10 @@ public class SettingsController() : Controller
/// <response code="200"></response> /// <response code="200"></response>
[HttpPatch("DownloadLanguage/{Language}")] [HttpPatch("DownloadLanguage/{Language}")]
[ProducesResponseType(Status200OK)] [ProducesResponseType(Status200OK)]
public IActionResult SetDownloadLanguage(string Language) public Ok SetDownloadLanguage(string Language)
{ {
//TODO Validation //TODO Validation
Tranga.Settings.SetDownloadLanguage(Language); Tranga.Settings.SetDownloadLanguage(Language);
return Ok(); return TypedResults.Ok();
} }
} }

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

@@ -1,6 +1,5 @@
using System.Reflection; using System.Reflection;
using API; using API;
using API.MangaConnectors;
using API.Schema.LibraryContext; using API.Schema.LibraryContext;
using API.Schema.MangaContext; using API.Schema.MangaContext;
using API.Schema.NotificationsContext; using API.Schema.NotificationsContext;
@@ -76,7 +75,7 @@ builder.Services.AddControllers().AddNewtonsoftJson(opts =>
opts.SerializerSettings.Converters.Add(new StringEnumConverter()); opts.SerializerSettings.Converters.Add(new StringEnumConverter());
opts.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; opts.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
}); });
builder.Services.AddScoped<ILog>(opts => LogManager.GetLogger("API")); builder.Services.AddScoped<ILog>(_ => LogManager.GetLogger("API"));
builder.WebHost.UseUrls("http://*:6531"); builder.WebHost.UseUrls("http://*:6531");
@@ -112,7 +111,7 @@ using (IServiceScope scope = app.Services.CreateScope())
if (!context.FileLibraries.Any()) if (!context.FileLibraries.Any())
context.FileLibraries.Add(new FileLibrary(Tranga.Settings.DownloadLocation, "Default FileLibrary")); context.FileLibraries.Add(new FileLibrary(Tranga.Settings.DownloadLocation, "Default FileLibrary"));
context.Sync(); await context.Sync(CancellationToken.None);
} }
using (IServiceScope scope = app.Services.CreateScope()) using (IServiceScope scope = app.Services.CreateScope())
@@ -124,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=)"}; string[] emojis = { "(•‿•)", "(づ \u25d5‿\u25d5 )づ", "( \u02d8\u25bd\u02d8)っ\u2668", "=\uff3e\u25cf \u22cf \u25cf\uff3e=", "(ΦωΦ)", "(\u272a\u3268\u272a)", "( ノ・o・ )ノ", "(〜^\u2207^ )〜", "~(\u2267ω\u2266)~","૮ \u00b4• ﻌ \u00b4• ა", "(\u02c3ᆺ\u02c2)", "(=\ud83d\udf66 \u0f1d \ud83d\udf66=)"};
context.Notifications.Add(new Notification("Tranga Started", emojis[Random.Shared.Next(0, emojis.Length - 1)], NotificationUrgency.High)); context.Notifications.Add(new Notification("Tranga Started", emojis[Random.Shared.Next(0, emojis.Length - 1)], NotificationUrgency.High));
context.Sync(); await context.Sync(CancellationToken.None);
} }
using (IServiceScope scope = app.Services.CreateScope()) using (IServiceScope scope = app.Services.CreateScope())
@@ -132,11 +131,11 @@ using (IServiceScope scope = app.Services.CreateScope())
LibraryContext context = scope.ServiceProvider.GetRequiredService<LibraryContext>(); LibraryContext context = scope.ServiceProvider.GetRequiredService<LibraryContext>();
context.Database.Migrate(); context.Database.Migrate();
context.Sync(); await context.Sync(CancellationToken.None);
} }
Tranga.SetServiceProvider(app.Services); Tranga.SetServiceProvider(app.Services);
Tranga.StartLogger(); Tranga.StartLogger(new FileInfo("Log4Net.config.xml"));
Tranga.AddDefaultWorkers(); Tranga.AddDefaultWorkers();
app.UseCors("AllowAll"); app.UseCors("AllowAll");

View File

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

View File

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

View File

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

View File

@@ -4,28 +4,27 @@ using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Xml.Linq; using System.Xml.Linq;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.MangaContext; namespace API.Schema.MangaContext;
[PrimaryKey("Key")] [PrimaryKey("Key")]
public class Chapter : Identifiable, IComparable<Chapter> public class Chapter : Identifiable, IComparable<Chapter>
{ {
[StringLength(64)] [Required] public string ParentMangaId { get; init; } = null!; [StringLength(64)] public string ParentMangaId { get; init; } = null!;
[JsonIgnore] public Manga ParentManga = null!; public Manga ParentManga = null!;
[NotMapped] public Dictionary<string, string> IdsOnMangaConnectors => [NotMapped] public Dictionary<string, string> IdsOnMangaConnectors =>
MangaConnectorIds.ToDictionary(id => id.MangaConnectorName, id => id.IdOnConnectorSite); MangaConnectorIds.ToDictionary(id => id.MangaConnectorName, id => id.IdOnConnectorSite);
[JsonIgnore] public ICollection<MangaConnectorId<Chapter>> MangaConnectorIds = null!; public ICollection<MangaConnectorId<Chapter>> MangaConnectorIds = null!;
public int? VolumeNumber { get; private set; } 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)] 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); [NotMapped] public string FullArchiveFilePath => Path.Join(ParentManga.FullDirectoryPath, FileName);
public Chapter(Manga parentManga, string chapterNumber, public Chapter(Manga parentManga, string chapterNumber,
@@ -67,11 +66,27 @@ public class Chapter : Identifiable, IComparable<Chapter>
}; };
} }
/// <summary> /// <summary>
/// Checks the filesystem if an archive at the ArchiveFilePath exists /// Checks the filesystem if an archive at the ArchiveFilePath exists
/// </summary> /// </summary>
/// <param name="context"></param>
/// <param name="token"></param>
/// <returns>True if archive exists on disk</returns> /// <returns>True if archive exists on disk</returns>
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: /// Placeholders:
/// %M Obj Name /// %M Obj Name
@@ -141,6 +156,20 @@ public class Chapter : Identifiable, IComparable<Chapter>
return stringBuilder.ToString(); return stringBuilder.ToString();
} }
public class ChapterComparer : IComparer<Chapter>
{
public int Compare(Chapter? x, Chapter? y)
{
if (x is null && y is null)
return 0;
if(x is null)
return -1;
if (y is null)
return 1;
return CompareChapterNumbers(x.ChapterNumber, y.ChapterNumber);
}
}
private static int CompareChapterNumbers(string ch1, string ch2) private static int CompareChapterNumbers(string ch1, string ch2)
{ {
int[] ch1Arr = ch1.Split('.').Select(c => int.TryParse(c, out int result) ? result : -1).ToArray(); int[] ch1Arr = ch1.Split('.').Select(c => int.TryParse(c, out int result) ? result : -1).ToArray();

View File

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

View File

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

View File

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

View File

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

View File

@@ -104,22 +104,24 @@ public class MangaContext(DbContextOptions<MangaContext> options) : TrangaBaseCo
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
} }
public Manga? FindMangaLike(Manga other) public async Task<Manga?> FindMangaLike(Manga other, CancellationToken token)
{ {
if (MangaIncludeAll().FirstOrDefault(m => m.Key == other.Key) is { } f) if (await MangaIncludeAll().FirstOrDefaultAsync(m => m.Key == other.Key, token) is { } f)
return f; return f;
return MangaIncludeAll() return await MangaIncludeAll()
.FirstOrDefault(m => m.Links.Any(l => l.Key == other.Key) || .FirstOrDefaultAsync(m =>
m.AltTitles.Any(t => other.AltTitles.Select(ot => ot.Title) m.Links.Any(l => l.Key == other.Key) ||
.Any(s => s.Equals(t.Title)))); 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) public IIncludableQueryable<Manga, ICollection<MangaConnectorId<Manga>>> MangaIncludeAll() =>
.Include(m => m.Authors) Mangas
.Include(m => m.MangaTags) .Include(m => m.Library)
.Include(m => m.Links) .Include(m => m.Authors)
.Include(m => m.AltTitles) .Include(m => m.MangaTags)
.Include(m => m.Chapters) .Include(m => m.Links)
.Include(m => m.MangaConnectorIds); .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.Identifier = identifier;
this.MetadataFetcherName = metadataFetcherName; this.MetadataFetcherName = metadataFetcherName;
} }
public override string ToString() => $"{GetType().FullName} {MangaId} {MetadataFetcherName}";
} }

View File

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

View File

@@ -43,42 +43,49 @@ public class MyAnimeList : MetadataFetcher
/// </summary> /// </summary>
/// <param name="metadataEntry"></param> /// <param name="metadataEntry"></param>
/// <param name="dbContext"></param> /// <param name="dbContext"></param>
/// <param name="token"></param>
/// <exception cref="FormatException"></exception> /// <exception cref="FormatException"></exception>
/// <exception cref="DbUpdateException"></exception> /// <exception cref="DbUpdateException"></exception>
public override 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) foreach (CollectionEntry collectionEntry in dbContext.Entry(dbManga).Collections)
collectionEntry.Load(); {
dbContext.Entry(dbManga).Navigation(nameof(Manga.Library)).Load(); if(!collectionEntry.IsLoaded)
await collectionEntry.LoadAsync(token);
}
await dbContext.Entry(dbManga).Reference(m => m.Library).LoadAsync(token);
MangaFull resultData; MangaFull resultData;
try try
{ {
long id = long.Parse(metadataEntry.Identifier); long id = long.Parse(metadataEntry.Identifier);
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) catch (Exception)
{ {
throw new FormatException("ID was not in correct format"); throw new FormatException("ID was not in correct format");
} }
try dbManga.Name = resultData.Titles.First().Title;
{ dbManga.Description = resultData.Synopsis;
dbManga.Name = resultData.Titles.First().Title; dbManga.AltTitles.Clear();
dbManga.Description = resultData.Synopsis; dbManga.AltTitles = resultData.Titles.Select(t => new AltTitle(t.Type, t.Title)).ToList();
dbManga.AltTitles.Clear(); dbManga.Authors.Clear();
dbManga.AltTitles = resultData.Titles.Select(t => new AltTitle(t.Type, t.Title)).ToList(); dbManga.Authors = resultData.Authors.Select(a => new Author(a.Name)).ToList();
dbManga.Authors.Clear();
dbManga.Authors = resultData.Authors.Select(a => new Author(a.Name)).ToList();
dbContext.Sync(); await dbContext.Sync(token);
}
catch (DbUpdateException e)
{
throw;
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,21 +3,26 @@ using Microsoft.EntityFrameworkCore;
namespace API.Workers; namespace API.Workers;
/// <summary>
/// Creates Jobs to update available Chapters for all Manga that are marked for Download
/// </summary>
public class CheckForNewChaptersWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null) public class CheckForNewChaptersWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic : BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{ {
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch; public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval??TimeSpan.FromMinutes(60); public TimeSpan Interval { get; set; } = interval??TimeSpan.FromMinutes(60);
protected override BaseWorker[] DoWorkInternal() protected override async Task<BaseWorker[]> DoWorkInternal()
{ {
IQueryable<MangaConnectorId<Manga>> connectorIdsManga = DbContext.MangaConnectorToManga Log.Debug("Checking for new chapters...");
List<MangaConnectorId<Manga>> connectorIdsManga = await DbContext.MangaConnectorToManga
.Include(id => id.Obj) .Include(id => id.Obj)
.Where(id => id.UseForDownload); .Where(id => id.UseForDownload)
.ToListAsync(CancellationToken);
Log.Debug($"Creating {connectorIdsManga.Count} update jobs...");
List<BaseWorker> newWorkers = new(); List<BaseWorker> newWorkers = connectorIdsManga.Select(id => new RetrieveMangaChaptersFromMangaconnectorWorker(id, Tranga.Settings.DownloadLanguage))
foreach (MangaConnectorId<Manga> mangaConnectorId in connectorIdsManga) .ToList<BaseWorker>();
newWorkers.Add(new RetrieveMangaChaptersFromMangaconnectorWorker(mangaConnectorId, Tranga.Settings.DownloadLanguage));
return newWorkers.ToArray(); 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 DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(24); public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(24);
protected override BaseWorker[] DoWorkInternal() protected override Task<BaseWorker[]> DoWorkInternal()
{ {
Log.Info("Removing stale files..."); Log.Info("Removing stale files...");
if (!Directory.Exists(TrangaSettings.coverImageCache))
return [];
string[] usedFiles = DbContext.Mangas.Select(m => m.CoverFileNameInCache).Where(s => s != null).ToArray()!; string[] usedFiles = DbContext.Mangas.Select(m => m.CoverFileNameInCache).Where(s => s != null).ToArray()!;
string[] extraneousFiles = new DirectoryInfo(TrangaSettings.coverImageCache).GetFiles() CleanupImageCache(usedFiles, TrangaSettings.coverImageCacheOriginal);
.Where(f => usedFiles.Contains(f.FullName) == false) 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) .Select(f => f.FullName)
.ToArray(); .ToArray();
foreach (string path in extraneousFiles) foreach (string path in extraneousFiles)
@@ -23,7 +34,5 @@ public class CleanupMangaCoversWorker(TimeSpan? interval = null, IEnumerable<Bas
Log.Info($"Deleting {path}"); Log.Info($"Deleting {path}");
File.Delete(path); File.Delete(path);
} }
return [];
} }
} }

View File

@@ -1,18 +1,27 @@
using API.Schema.NotificationsContext; using API.Schema.NotificationsContext;
using Microsoft.EntityFrameworkCore;
namespace API.Workers.MaintenanceWorkers; namespace API.Workers.MaintenanceWorkers;
/// <summary>
/// Removes sent notifications from database
/// </summary>
public class RemoveOldNotificationsWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null) public class RemoveOldNotificationsWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<NotificationsContext>(dependsOn), IPeriodic : BaseWorkerWithContext<NotificationsContext>(dependsOn), IPeriodic
{ {
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch; public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(1); public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromHours(1);
protected override 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.RemoveRange(toRemove);
DbContext.Sync();
if(await DbContext.Sync(CancellationToken) is { success: false } e)
Log.Error($"Failed to save database changes: {e.exceptionMessage}");
return []; return [];
} }

View File

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

View File

@@ -3,21 +3,36 @@ using Microsoft.EntityFrameworkCore;
namespace API.Workers; namespace API.Workers;
/// <summary>
/// Create new Workers for Chapters on Manga marked for Download, that havent been downloaded yet.
/// </summary>
public class StartNewChapterDownloadsWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null) public class StartNewChapterDownloadsWorker(TimeSpan? interval = null, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic : BaseWorkerWithContext<MangaContext>(dependsOn), IPeriodic
{ {
public DateTime LastExecution { get; set; } = DateTime.UnixEpoch; public DateTime LastExecution { get; set; } = DateTime.UnixEpoch;
public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromMinutes(1); public TimeSpan Interval { get; set; } = interval ?? TimeSpan.FromMinutes(1);
protected override BaseWorker[] DoWorkInternal() protected override async Task<BaseWorker[]> DoWorkInternal()
{ {
IQueryable<MangaConnectorId<Chapter>> mangaConnectorIds = DbContext.MangaConnectorToChapter Log.Debug("Checking for missing chapters...");
.Include(id => id.Obj)
.Where(id => id.Obj.Downloaded == false && id.UseForDownload);
List<BaseWorker> newWorkers = new(); // Get missing chapters
foreach (MangaConnectorId<Chapter> mangaConnectorId in mangaConnectorIds) List<MangaConnectorId<Chapter>> missingChapters = await DbContext.MangaConnectorToChapter
newWorkers.Add(new DownloadChapterFromMangaconnectorWorker(mangaConnectorId)); .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(); return newWorkers.ToArray();
} }

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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