359 Commits

Author SHA1 Message Date
6b4317834d StartNewChapterDownloadsWorker interval 1 minute 2025-07-03 23:06:35 +02:00
88fef8417c Fix request path 2025-07-03 23:04:32 +02:00
eb9fc08b2d Fix Scope/Context for Workers 2025-07-03 23:02:37 +02:00
9743bb6e8e Fix Worker-Cycle:
Periodic set last execution,
Print Running Worker-Names when done
2025-07-03 22:48:06 +02:00
e8d612557f Fix TrangaBaseContext.Sync 2025-07-03 22:39:06 +02:00
cf2dbeaf6a Fix RemoveOldNotificationsWorker.cs: RemoveRange 2025-07-03 21:57:07 +02:00
84940c414c Add Migrations
Add RemoveOldNotificationsWorker.cs
2025-07-03 21:55:38 +02:00
ea627081b8 Add default Tranga-Workers 2025-07-03 21:17:08 +02:00
a90a6fb200 Enable Manga Downloading 2025-07-03 21:11:33 +02:00
c3a0bb03e9 SettingsController set download language 2025-07-03 20:53:20 +02:00
f8ccd2d69e Tranga WorkerCycle 2025-07-03 20:51:06 +02:00
ad224190a2 UpdateMetadataWorker.cs 2025-07-03 20:38:29 +02:00
f05f2cc8e0 ToString overrides 2025-07-03 20:38:18 +02:00
d6f0630a99 StartNewChapterDownloadsWorker.cs 2025-07-03 20:21:48 +02:00
0ac4c23ac9 SendNotificationsWorker, CleanupMangaCoversWorker, UpdateChaptersDownloadedWorker add optional interval parameter 2025-07-03 20:14:18 +02:00
d6847d769e CheckForNewChaptersWorker 2025-07-03 20:11:18 +02:00
f6f5e21151 Move AddMangaToContext to Tranga.cs 2025-07-03 19:44:11 +02:00
da3b5078af SendNotificationsWorker.cs 2025-07-03 19:43:23 +02:00
681d56710a TrangaSettings as static field in Tranga instead of Static class 2025-07-03 17:30:58 +02:00
6f5823596a Tranga CheckRunning Workers 2025-07-02 22:34:12 +02:00
8a06ed648c BaseWorker Logging 2025-07-02 22:34:00 +02:00
4dcd6ee035 DbContext never null 2025-07-02 22:17:51 +02:00
e327e93163 BaseWorker, BaseWorkerWithContext DoWork, call: Scope setting
TrangaBaseContext Sync return with success state and exception message
2025-07-02 22:15:34 +02:00
6cd836540a IPeriodic non-generic 2025-07-02 21:12:47 +02:00
91c91e4989 Refactor Controllers
SettingsController.cs

SearchController.cs

QueryController.cs

NotificationConnectorController.cs

MetadataFetcherController.cs

MangaConnectorController.cs

FileLibraryController

LibraryConnectors

WorkerController
2025-07-02 21:12:47 +02:00
57bb87120a WIP 2025-07-02 02:26:02 +02:00
07880fedb5 Create TrangaBaseContext for common OnConfiguring implementation of Contexts 2025-07-01 23:00:47 +02:00
f1d3203ae1 Notifications-Identifiable 2025-07-01 22:35:44 +02:00
314700aa8e Add TODO to remove migrations after some time 2025-06-30 22:05:49 +02:00
b299cc9109 Merge branch 'Jikan' into JobQueue-Sortable
# Conflicts:
#	API/Schema/Contexts/PgsqlContext.cs
2025-06-30 22:03:22 +02:00
7e9ba7090a Manga and Chapters are shared across Connectors 2025-06-30 22:01:10 +02:00
ea73d03b8f WIP: Manga can be linked to multiple Connectors
- PgsqlContext Adjustment
2025-06-30 14:42:24 +02:00
e9d9bebcd7 WIP: Manga can be linked to multiple Connectors 2025-06-30 14:24:17 +02:00
e5937d2654 Job is IComparable<Job> 2025-06-30 12:58:00 +02:00
7c9e0eddf9 Metadata-Site Search (Interactive linking) 2025-06-29 21:13:05 +02:00
ae0c6c8240 Change PrimaryKey of MetadataEntry to Fetcher + Identifier 2025-06-29 20:43:21 +02:00
e063cf1fd9 Debug: MatchJobsRunningAndWaiting
Some checks failed
Docker Image CI / build (push) Has been cancelled
UpdateCoverJobs not starting.
2025-06-28 23:15:51 +02:00
2eb0d941b2 MetadataFetching:
- Jikan (MAL) linking, fetching/updating
2025-06-28 22:59:35 +02:00
8170e1d762 JobCycle Info-Debug list jobs started/running
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-06-28 20:35:10 +02:00
254383b006 Include Description in ComicInfo.xml 2025-06-28 20:28:28 +02:00
df431e533a Add POST Jobs/Cleaup Endpoint:
Removes failed and completed Jobs (that are not recurring)
2025-06-28 20:18:28 +02:00
9a4cc0cbaf Only log Error on image-processing if we dont know what Exception was thrown 2025-06-28 20:13:09 +02:00
861cf7e166 Fix Image-Processing:
Format is not supported by Imagesharp, throwing exception causing Job to fail.
2025-06-28 20:00:01 +02:00
7e34b3b91e Update readme to contain information on how to test locally 2025-06-28 19:48:47 +02:00
29d36484f9 include logging driver in docker-compose
Remove parameters from start-CMD in Dockerfile
2025-06-28 19:39:19 +02:00
2c6e8e4d16 Default startNewJobTimeoutMs set to 20s
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-06-18 02:11:03 +02:00
fab2886684 ComickIo Stop double work for retrieving chapters:
We can build the canonical url from the hids
2025-06-18 01:55:19 +02:00
d9ccf71b21 DownloadSingleChapterJob add check if chapter is already downloaded before re-downloading 2025-06-18 01:18:06 +02:00
f36f34f212 We dont need to actually load the MangaConnector to know if two names match.
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-06-18 00:23:33 +02:00
ff10432c79 Fix FilterJobsWithoutDownloading: Dont check if a job has a connector, that takes forever 2025-06-18 00:11:05 +02:00
776e1e4890 ...use what we coded... 2025-06-17 20:18:10 +02:00
db0643fa19 More Debug 2025-06-17 20:09:49 +02:00
3eeb563ca1 Add Debug Statement to find slow operations in Job-Cycle 2025-06-17 19:55:54 +02:00
7a88b1f7ee Increase default request Limits 2025-06-17 19:55:31 +02:00
b5411e9c6c Better Debugging for HttpDownloadClient 2025-06-17 18:52:27 +02:00
07b260dea6 GC Cleanup 2025-06-17 18:52:14 +02:00
71ad32de31 Fix FlareSolverr IsJson-Check 2025-06-17 18:51:29 +02:00
ecd2c2722f Fix FlareSolverr, Flaresolverrsharp is broken 2025-06-17 18:28:18 +02:00
ff1e467ada Add caching header to Covers
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-06-17 16:23:58 +02:00
24f68b4a8e SearchController GetFromUrl StatusCode 404 instead of 400 if URL does not yield a Manga
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-06-17 00:25:09 +02:00
e51e90aabc FlareSolverr by FlareSolverrSharp
#372
2025-06-17 00:25:08 +02:00
dc2c27f4bd Merge pull request #402 from catumin/docker-compose
Some checks failed
Docker Image CI / build (push) Has been cancelled
Wait for Postgres healthcheck before attempting to continue
2025-06-16 09:52:11 +02:00
406d8eef51 Wait for Postgres healthcheck before attempting to continue
Signed-off-by: Cat Aulucya <cat@aulucya.gay>
2025-06-15 21:17:24 -07:00
1fba599c79 Fix UserAgent formatting
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-06-16 01:31:58 +02:00
a668a16035 Use TrangaSettings.userAgent 2025-06-16 01:14:05 +02:00
f89b8e1977 Fix UserAgent RequestHeader:
UserAgent should not be added after it already existed
2025-06-16 01:11:38 +02:00
11290062c0 Fix setting of version policy 2025-06-16 00:58:54 +02:00
f46910fac6 Formatting 2025-06-16 00:52:10 +02:00
f974c5ddd1 header formatting (debug) HttpDownloadClient.cs 2025-06-16 00:49:27 +02:00
a01963a125 HttpVersionPolicy.RequestVersionOrHigher 2025-06-16 00:47:26 +02:00
8a877ee465 Extend debug for requests 2025-06-16 00:34:03 +02:00
c370e656f1 HttpDownloadClient add a Debug statement if the request fails with status code and content 2025-06-16 00:10:59 +02:00
58ed976737 HttpDownloadClient Check if original uri is equal to final uri 2025-06-16 00:10:28 +02:00
1b6af73a0c MangaDex nullvalue checks and allow null-fields in response
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-06-15 23:55:23 +02:00
70fe23857b Update UserAgent-String to Version 2.0 2025-06-15 23:26:30 +02:00
0027af2d36 Fix: First startup coverImageCache does not exist (on stale check) 2025-06-15 23:07:34 +02:00
1a8f70f501 Cleanup code for HttpDownloadClient and error-log 2025-06-15 23:00:01 +02:00
aa67c11050 Start-Job endpoint: Add option to start Jobs that our job is dependent on
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-05-19 19:57:51 +02:00
7b38d0aa2b Add Debug-output for when next job is due if not job was started 2025-05-19 19:57:27 +02:00
64e31fad54 Job-Cycle match JobTypes and MangaConnectors on running and waiting Jobs 2025-05-19 17:36:32 +02:00
49a70e2341 startNewJobTimeoutMs set to 5000 2025-05-19 17:36:07 +02:00
9659f2a68a MangaDex.cs year may be null
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-05-18 22:44:32 +02:00
d474868116 Fix missing Permissions for covers 2025-05-18 22:14:51 +02:00
b1312c4164 Remove UpdateSingleChapterDownloadedJob.cs 2025-05-18 20:39:24 +02:00
33856f9927 Fix infinity joby (because we did not create new Scope on every cycle) 2025-05-18 20:31:46 +02:00
02ab3d8cae UpdatecoverJob Migrations
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-05-18 18:17:41 +02:00
7974c58fd5 Fix PgsqlContext Discriminator UpdateCoverJob 2025-05-18 18:16:17 +02:00
503d9dfb5f Fix Name UpdateCoverJob 2025-05-18 17:55:37 +02:00
62b035f6c5 GET Mangaconnector endpoint 2025-05-18 17:20:53 +02:00
40c70fbf19 Update readme 2025-05-18 17:15:53 +02:00
49bd66ccab Add UpdateCoverJob.cs
Covers get updated on every pull
If a Manga has no DownloadAvailableChaptersJob, Cover is removed
Add endpoint POST Settings/CleanupCovers that removed covers not associated to any Manga
2025-05-18 17:05:01 +02:00
9b251169a5 Remove old covers from ImageCache 2025-05-18 16:54:53 +02:00
aa29c45094 Do not regenerate JobIds in EF Constructor
(and pass down recurrenceTime regardless of usage)
2025-05-18 16:53:42 +02:00
bd60fda05a Chapters now have IdOnConnector-Site 2025-05-18 16:30:03 +02:00
8ecbdb91b2 Let Job update itself in its own context 2025-05-18 16:06:52 +02:00
cb1c68f295 Remove Job.DependenciesFulfilled 2025-05-18 16:06:39 +02:00
421a25ec31 Delete duplicate IsRequired Statements 2025-05-18 16:06:16 +02:00
2d122a918f Create a Context per cycle
Load each Job in a separate context per Job.
2025-05-18 16:06:00 +02:00
100cb06ba0 SearchController.cs Local-Search endpoint 2025-05-18 15:32:58 +02:00
6125b036bf SearchController.cs Local-Search endpoint 2025-05-18 15:31:11 +02:00
3fe3fc09b0 JobContext per Job 2025-05-18 15:21:59 +02:00
96d5b09391 Ony load necessary References and Collections 2025-05-18 15:16:55 +02:00
84aecda916 NoTrackingWithIdentityResolution 2025-05-18 15:00:33 +02:00
0803a92a66 UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) 2025-05-18 14:52:06 +02:00
7f55aaf85d test 2025-05-18 14:41:43 +02:00
3853e2daa2 Chapter.cs remove comparison again and instead check chapterids in RetrieveChaptersJob.cs 2025-05-18 14:28:07 +02:00
852fbf5ae8 Chapter.cs Compare Ids for Collection-Comparisons 2025-05-18 14:05:03 +02:00
4e7a725fee Load entry references and collections 2025-05-18 13:53:23 +02:00
698d138642 Load ParentManga.Library for Chapter
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-05-17 23:40:15 +02:00
8efb60652b RetrieveChaptersJob.cs distinct Chapters 2025-05-17 23:25:00 +02:00
fe60b98cb8 MangaDex fix crash if "en" tag was missing 2025-05-17 23:05:48 +02:00
63442e9af6 MangaDex fix crash if "en" tag was missing 2025-05-17 22:51:57 +02:00
703e32a30e Check if directorypath is null 2025-05-17 22:38:12 +02:00
4ddfe4a54c ComicInfoXML filter null values 2025-05-17 22:33:22 +02:00
fb2b4d6920 Merge pull request #396
Some checks failed
Docker Image CI / build (push) Has been cancelled
Backend-Logic-Update
2025-05-17 19:26:59 +02:00
496b49ccb3 ParentJob can be updated, DownloadAvailableJobs Endpoint automatically sets ParentJob to the DownloadAvailableChaptersJob (ondeleteCascade) 2025-05-17 19:24:24 +02:00
b3efcf19d9 Manga GetCover, GetLatestDownloaded, GetLatestAvailable: Check if Jobs are running to fulfill request 2025-05-17 19:07:01 +02:00
0903ec606b MangaController.cs use Manga.Chapters for Navigation instead of new context-Request 2025-05-17 18:54:24 +02:00
6cfa29e3dd Append Headers instead of Adding MangaController.cs 2025-05-17 18:17:51 +02:00
0519ed26de Remove Lunasea 2025-05-17 18:00:46 +02:00
aacdb72d6a Remove LunaseaRecord 2025-05-17 17:42:07 +02:00
3283dd7290 DownloadAvailableChaptersJob.cs only create DownloadSingleChapterJobs for Chapters that have not been downloaded 2025-05-16 22:00:17 +02:00
937c5cb7a7 Create a UpdateChaptersDownloadedJob with creation of DownloadAvailableChaptersJob 2025-05-16 21:59:53 +02:00
225b7f02ad Lazy Load Jobs.DependsOnJobs, Manga.Chapters 2025-05-16 21:53:59 +02:00
6258e07f20 Remove unnecessary Attachments 2025-05-16 21:36:24 +02:00
622198a09e Changes to Job.cs:
- Nest try-catch for DBUpdateException and other Exceptions
- More Logging for Jobs
2025-05-16 21:32:42 +02:00
49b382fe1f Logging for Job-Cycle 2025-05-16 21:27:02 +02:00
5a6dc5a5b2 Logging for Jobs-Filtering 2025-05-16 21:25:08 +02:00
4bc70eca68 Distinct Jobs 2025-05-16 21:23:47 +02:00
63fee081e6 Catch all Exceptions in Job 2025-05-16 21:18:46 +02:00
e45b72dcf9 Include Spaces in Directory-Path 2025-05-16 21:18:37 +02:00
021ad5e804 Include FullArchivePath in Chapter-Response 2025-05-16 21:18:19 +02:00
8e0c964883 Update Jobs on each cycle (since it is super fast now) 2025-05-16 21:09:43 +02:00
d6e945741a Fix Manga-Chapter loading 2025-05-16 21:09:32 +02:00
3a3306240f Use LazyLoading 2025-05-16 21:05:55 +02:00
110dd36166 Do not update context.Jobs on every cycle 2025-05-16 20:16:11 +02:00
065cac62af Move TRANGA message 2025-05-16 20:16:02 +02:00
563afa1e6f Split UpdateFilesDownloadedJob.cs to UpdateChaptersDownloadedJob.cs and split into UpdateSingleChapterDownloadedJob.cs 2025-05-16 20:13:39 +02:00
be2adff57d DownloadSingleChapterJob.cs load Jobs 2025-05-16 20:01:50 +02:00
adc7ee606e RetrieveChaptersJob.cs do not use context to access Chapters 2025-05-16 20:00:53 +02:00
a764f381c9 Logging for DBContexts 2025-05-16 19:46:14 +02:00
590ccdd09a Use GlobalConnector for Url Search requests 2025-05-16 19:27:34 +02:00
0f0a49f74f Change Search with name to GET Request 2025-05-16 19:27:22 +02:00
a1a5028858 Ordering of DownloadChapterJobs (start at first chapter and work up) 2025-05-16 19:18:07 +02:00
1792952039 Fix RequestTypes for ComickIo 2025-05-16 19:17:45 +02:00
9e62eb53cb Log-Output for DownloadClient improved 2025-05-16 19:17:29 +02:00
f3c4b012b0 ToString Override for RequestResult 2025-05-16 19:17:13 +02:00
7e1c65b470 Optimize requests for JobStarter 2025-05-16 15:15:34 +02:00
4247ae7740 Do not autoinclude chapters for Manga 2025-05-16 15:15:11 +02:00
a5954ed5c8 DownloadSingleChapterJob only spawn one Job per Queue for Manga 2025-05-16 14:46:36 +02:00
d08544b892 Sending notifications for -> Debug instead of Info 2025-05-16 14:38:47 +02:00
f6f86deb7f Add Debug output 2025-05-16 14:36:48 +02:00
16f5817a31 Fix ComickIo Chapter-Loading 2025-05-16 14:36:29 +02:00
d5d9f44a5f Add Comick.Io
https://github.com/C9Glax/tranga/issues/253
2025-05-16 14:24:18 +02:00
83bc3b418b Manga Year is not required (nullable) 2025-05-16 14:23:33 +02:00
205f0a1629 MangaAltTitle change Id to random 2025-05-16 14:23:20 +02:00
a1c2942208 SearchAddMangaToContext fix 2025-05-16 14:21:14 +02:00
4b4e24c6a0 Migrations update 2025-05-15 15:14:09 +02:00
475a29b10d Attach Entities to Jobs 2025-05-15 15:13:53 +02:00
694b88d200 Reload Jobs loaded in context 2025-05-09 12:31:40 +02:00
0f6c060026 Remove unnecessary default value for EF Constructors 2025-05-09 12:30:30 +02:00
b49b11828c Add ToString Overriddes 2025-05-09 12:22:32 +02:00
2d69b30e83 Fix missing Entity-Relation for UpdateFilesDownloadedJob 2025-05-09 12:03:18 +02:00
53d9be5656 Add ToString Overrides for Chapter and Manga 2025-05-09 12:03:01 +02:00
7d4a6be569 MangaConnectors do not have to return an Object with 6 Parameters.
Job-Start Logic readable and optimized
More robust Database design
2025-05-09 06:28:44 +02:00
7477f4d04d Do no replace spaces with %20
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-05-08 05:28:31 +02:00
30a8162777 Shorten RequestUrl for MangaDex MangaInfo
Lower MaxRequestLimits
2025-05-08 04:39:22 +02:00
57baad3d2c Make LastRequest (LastExecutedRateLimit) static 2025-05-08 03:57:45 +02:00
3c5f51e495 fix time formatting 2025-05-08 03:48:57 +02:00
397d3c93df More logging 2025-05-08 03:36:32 +02:00
1b49b171f4 Remove Manganato (for now) 2025-05-08 03:22:31 +02:00
ec5d048df5 Add more Logging 2025-05-08 03:03:44 +02:00
a490e233d7 Jobs remove redundant fields (context tracking)
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-04-02 02:16:55 +02:00
f085c5cf8e Fix RetrieveChaptersJob 2025-04-02 02:09:05 +02:00
31beeeffae Fix Job has ParentJob -> ParentJob has Job 2025-04-02 01:58:17 +02:00
99a3f2614d Catch Stream closed
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-04-01 18:24:25 +02:00
15ced9aed8 Longer Var-Chars in Manga 2025-04-01 18:21:24 +02:00
64b17aea7a weebcentral no commas in tags 2025-04-01 03:54:58 +02:00
c696c38983 Fix Weebcentral
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-04-01 02:14:46 +02:00
dbbac1ad59 Fix Weebcentral 2025-04-01 02:14:13 +02:00
b955d41530 Fix Weebcentral 2025-04-01 02:09:28 +02:00
91e033a2ec Logging 2025-03-31 19:08:35 +02:00
4dd31dfe18 Dependency Updates 2025-03-31 18:14:34 +02:00
66fcdca7e7 Pushover Notifications https://github.com/C9Glax/tranga/issues/246
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-03-29 20:55:24 +01:00
9350de0ae9 Custom Chapter-Naming scheme
https://github.com/C9Glax/tranga/issues/92
2025-03-29 20:33:58 +01:00
c94c55300c https://github.com/C9Glax/tranga/issues/361 Chromium Close Pages that errored.
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-03-27 20:30:51 +01:00
721f932fac Fix MangaConnector enablestate endpoint 2025-03-27 19:49:44 +01:00
f691529591 "Global"-Connector https://github.com/C9Glax/tranga-website/issues/50 2025-03-27 19:35:49 +01:00
d75262a8f3 DBUpdate Exception on Jobs update 2025-03-27 19:03:06 +01:00
9521f66bac Fix compression level check on endpoint
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-03-19 23:55:39 +01:00
3981a41303 Fix Settings Endpoints (FromBody tags) 2025-03-19 23:49:24 +01:00
f2961711cf Return TrangaSettings as proper Json instead of string 2025-03-19 23:13:56 +01:00
93ad691971 Endpoint to change all information of locallibrary 2025-03-19 22:44:31 +01:00
cef3b24efd Do not delay LatestChapterAvailable/Downloaded Requests if we have a number (which will still be updated)
Some checks are pending
Docker Image CI / build (push) Waiting to run
2025-03-19 00:46:43 +01:00
f10c478cab Add 503 response for covers and requests that take longer 2025-03-19 00:16:37 +01:00
01ba927491 Fix Chapter FileName 2025-03-19 00:16:11 +01:00
90ce1395b8 Add Middleware to add Startofrequest time to all requests 2025-03-19 00:16:00 +01:00
892ef6c9d6 Fix: Add MoveMangaLibrary job to queue 2025-03-18 20:19:21 +01:00
31c039d71e Change Endpoint to move Manga
Some checks are pending
Docker Image CI / build (push) Waiting to run
2025-03-17 20:16:39 +01:00
5b03befbf1 #168 Multiple Base-Paths (Libraries) Support
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-03-16 16:48:19 +01:00
5012bbb2eb Only update jobs that still exist
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-03-14 00:43:46 +01:00
45a8f7a038 Logging 2025-03-14 00:39:52 +01:00
9b4baa1334 Do not try and add duplicate chapters. 2025-03-13 23:39:31 +01:00
657ab571f9 TrangaSettings add timeout to start new jobs field 2025-03-13 23:39:03 +01:00
1480aa0a03 Fix MaxURL length in db (2048) 2025-03-13 23:26:58 +01:00
232fe6406a Fix weebcentral baseuri-usage 2025-03-13 23:15:37 +01:00
a2bc14d54a Query Endpoints documentation 2025-03-13 22:47:50 +01:00
d278a25f16 Cover Endpoint wait for DownloadMangaCoverJob to finish if it exists 2025-03-13 22:29:59 +01:00
5f42a2d5ae OnDelete cascade behaviour for all manga-related items 2025-03-13 22:28:33 +01:00
60d84d1186 weebcentral remove http from baseuri 2025-03-13 22:28:11 +01:00
7ee4d32c07 Add BadRequest Check for width and height on cover endpoint 2025-03-13 21:30:05 +01:00
0e68d64f75 Annotation for jobs 2025-03-13 20:51:05 +01:00
434e30dc47 DownloadSingleChapterJob creates a scan for downloaded files 2025-03-13 20:06:59 +01:00
19ff3f578a Move adding jobs to context to Job.Run (from Tranga.cs) 2025-03-13 20:06:46 +01:00
cc03b6fa9c Fix referrer and statuscode when fetching covers 2025-03-13 19:58:29 +01:00
1f59ef66cd Fix Request-Method for cover (set to GET) 2025-03-13 19:16:06 +01:00
d7f21550cd Fix annotations 2025-03-13 19:15:55 +01:00
d93c8fdb94 Fix pgsqlcontext for Manganato 2025-03-13 18:47:15 +01:00
80320fd44d Fix pgsqlcontext for Manganato 2025-03-13 18:46:33 +01:00
6449132dbf Move name on Endpoint Search by controller body 2025-03-13 17:47:04 +01:00
0df3381c8c Fix Base-Endpoint-Path for MangaConnectorController.cs 2025-03-13 17:29:17 +01:00
2ca43e6f5d Fix Endpoint ignorechaptersbefore 2025-03-13 17:25:34 +01:00
3ba1261f31 Add Endpoints to retrieve all (not-)Downloaded Chapters 2025-03-13 17:23:52 +01:00
be72d4ba97 Add Endpoint to return Jobs with State and Type combined 2025-03-13 16:52:40 +01:00
f237d82cac Add Endpoints to retrieve Database-Entries for Author, Link, and MangaAltTitle from Id 2025-03-13 16:44:34 +01:00
a43901564b Manga Cover request retrieve sizing from query instead of body 2025-03-13 16:35:29 +01:00
34ec185125 Rename Manga.ConnectorId to IdOnConnectorSite
Do not include Manga.CoverUrl and CoverFileNameInCache in API-request-responses
2025-03-13 16:07:07 +01:00
cd08d9d78b Update TrangaSettings request limits to private set 2025-03-13 15:40:13 +01:00
339f40b61b More readme 2025-03-13 15:34:29 +01:00
8ee8f33f33 Update docker-compose.local.yaml to not spin up a website-instance
Some checks are pending
Docker Image CI / build (push) Waiting to run
2025-03-13 15:25:09 +01:00
6721bd863f Update Readme 2025-03-13 15:20:22 +01:00
ee820cfa27 Merge branch 'master' into postgres-Server-V2 2025-03-13 14:00:42 +01:00
24361d5a43 Update packages 2025-03-13 14:00:38 +01:00
6687ab4b3b Port Manganato 2025-03-08 19:22:23 +01:00
290324f9d9 Merge branch 'cuttingedge-merge-ServerV2' into postgres-Server-V2
# Conflicts:
#	Tranga/Chapter.cs
#	Tranga/MangaConnectors/Manganato.cs
#	Tranga/MangaConnectors/WeebCentral.cs
2025-03-08 19:04:36 +01:00
89ed500751 Update actions for Server-V2
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-03-08 19:02:14 +01:00
b00b0ee030 Merge branch 'master' into cuttingedge-merge-ServerV2
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-03-08 19:00:42 +01:00
e47c52ad48 Merge pull request #367 from C9Glax/cuttingedge
Cuttingedge merge
2025-03-08 18:58:40 +01:00
ef87e02d0b Add Endpoints to change single requestlimit 2025-03-08 18:46:35 +01:00
0af83f2fd0 Update Swagger Documenatation for 501 NotImplemented 2025-03-08 18:36:57 +01:00
f3854ab594 Implement MangaController/MoveFolder 2025-03-08 18:36:30 +01:00
207604a437 Return JobIds and NotificationConnector-IDs on creation 2025-03-08 18:36:10 +01:00
832ddf1442 Implement MoveFileOrFolderJob.cs 2025-03-08 18:35:41 +01:00
313225a1a1 Migrations 2025-03-08 18:09:54 +01:00
ffc0e7555a Add NewtonsoftJson to Swagger
Add RetrieveChaptersJob.cs
Add UpdateFilesDownloadedJob.cs
Remove DownloadNewChaptersJob.cs and instead use DownloadAvailableChaptersJob.cs
2025-03-08 18:09:41 +01:00
ecfc8f349b Back to working 2025-03-08 16:53:27 +01:00
94678e744f Replace occurences of "ID" with more meaningful names in documentation 2025-03-08 13:26:07 +01:00
73eb02e7cb Fix Route for new Endpoint
Adjust Routes for Search
2025-03-08 13:20:28 +01:00
c3ebd6acac Database Migrations 2025-03-08 13:10:39 +01:00
a694b9f5ab Add endpoint to add Manga by URL 2025-03-08 13:09:19 +01:00
b24d2e12fc Provide Method to validate URL for GetMangaFromUrl 2025-03-08 12:54:29 +01:00
6909c367e5 Types return only Ids of related Entities 2025-03-08 12:54:07 +01:00
293f0af8e3 Merge pull request #366 from merlinmarijn/manganato-domain-switch
Some checks are pending
Docker Image CI / build (push) Waiting to run
Manganato connector search fix
2025-03-08 07:40:11 +01:00
ebfa34e386 Update Manganato.cs 2025-03-07 22:33:24 +01:00
14524407f9 Update Manganato.cs 2025-03-07 22:29:40 +01:00
d56f0b383a Merge pull request #365 from merlinmarijn/manganato-domain-switch
Some checks are pending
Docker Image CI / build (push) Waiting to run
Manganato fix chapter naming format in CBZ files (i am sorry)
2025-03-07 21:54:06 +01:00
70391c83c1 Update Manganato.cs
i found out, i am stupid
2025-03-07 21:39:17 +01:00
dc7696ee26 Merge pull request #364 from merlinmarijn/manganato-domain-switch
Some checks are pending
Docker Image CI / build (push) Waiting to run
Enforce correct referrer check for access to Manganato
2025-03-07 20:19:52 +01:00
49dab9a670 Referrer policy changed
- Updated: image hosting platform seem to have changed a policy requiring now to send the referrer from the actual site instead of just allowing any connecting regardless of the referrer address
2025-03-07 19:57:27 +01:00
4cb48dd1b4 Remove TrangaSettings.apiportnumber 2025-03-07 18:56:01 +01:00
d43ae881b5 #362 add links to MangaConnectors for Icons 2025-03-07 18:53:34 +01:00
3583e45071 Only use enabled connectors for global search 2025-03-07 18:28:43 +01:00
c4adba6357 Update Metadata of all Manga on startup 2025-03-07 18:25:28 +01:00
ed74975312 Filter and order jobs by type and chapter 2025-03-07 18:13:39 +01:00
043f0b9593 #74 Endpoints for latest chapter downloaded and latest chapter available 2025-03-07 16:37:32 +01:00
022ebe2bcc Arbitrary Webhook for NotificationConnectors
Backend only deals in REST Webhooks
The API has custom Endpoints for Ntfy, Gotify, Lunasea that create pre-formatted Webhooks
#297 #259
2025-03-07 16:30:32 +01:00
3a8b400851 Update XML-Documentation with Mediatype 2025-03-07 14:46:15 +01:00
6c5bc3685e #229 Resize cover-images on request before transfer 2025-03-07 14:29:25 +01:00
c679d7c677 Fix DownloadMangaCover, Implement Manga/Cover endpoint 2025-03-07 14:07:59 +01:00
4075adfe6b Searching adds DownloadMangaCoverJob 2025-03-07 14:07:22 +01:00
72abc90af3 Implement Manga/Cover 2025-03-07 13:38:46 +01:00
894f105786 DownloadSingleChapter narrow down FileStream options 2025-03-07 13:38:32 +01:00
d314559361 UpdateMetadataJob add check if archives for chapters exist on disk 2025-03-07 13:26:49 +01:00
5ab0bd0b78 Chapter short Documentation update 2025-03-07 13:26:25 +01:00
5c0ace291b DownloadSingleChapterJob update coverfilename in cache in database 2025-03-07 13:26:07 +01:00
0d931bc835 MangaConnector shorten Endpoint paths 2025-03-07 13:11:41 +01:00
e1e5a45960 Implement SettingsController 2025-03-07 13:06:01 +01:00
3ecbc1a805 XML-Documentation 2025-03-07 13:05:51 +01:00
3305519307 JobController XML-Documentation 2025-03-07 12:31:10 +01:00
9fca2d81ab DeleteJob also deletes children 2025-03-07 12:25:02 +01:00
949c0cc16d #180 Option to disable/enable MangaConnectors 2025-03-07 11:45:45 +01:00
5921e524a9 Option to disable/enable jobs 2025-03-07 11:32:59 +01:00
bf332717a5 #114 Modify Job Intervals 2025-03-07 11:27:40 +01:00
bb31a94eea Dependency Updates 2025-03-07 10:36:57 +01:00
c9bc79fbd5 Update new_connector.yml
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-03-07 10:19:08 +01:00
83ce315f87 Merge pull request #357 from merlinmarijn/manganato-domain-switch
Some checks are pending
Docker Image CI / build (push) Waiting to run
Update Connector for Manganato connector: Migrate from .com to .gg & Adjust HTML Parsing
#358 @merlinmarijn
2025-03-07 10:06:44 +01:00
59511056d0 added try around getting urls 2025-03-03 23:43:35 +01:00
ed3ca5dba8 removed leftover comment 2025-03-03 23:04:43 +01:00
8df05d7e8a fixed image referrer 2025-03-03 22:59:25 +01:00
95d1e37b47 Update Manganato.cs 2025-03-03 22:27:37 +01:00
1d2ca4d76a Dependency Updates and Migrations 2025-03-03 21:13:18 +01:00
e2ff2c76ed Jobs:
Fix multiple jobs running on the same connector
Fix state-updates
Add more Documentation to JobController
Only allow non-running jobs to be started
2025-03-03 21:13:03 +01:00
8a0829ef69 JobState set enum to bytes with fixed values 2025-03-03 21:11:39 +01:00
d257095885 Fix MangaDex Downloads: Get Chapter-Id from URL 2025-03-03 21:11:21 +01:00
68cc23e158 Mangaconnector-Update:
Remove MangaNato
Update Weebcentral
2025-03-03 15:03:16 +01:00
b6494ab7f9 Merge pull request #354 from C9Glax/dependabot/github_actions/docker/setup-qemu-action-3.6.0
Some checks failed
Docker Image CI / build (push) Has been cancelled
Bump docker/setup-qemu-action from 3.5.0 to 3.6.0
2025-03-03 13:54:59 +01:00
1d1d01b6e5 Bump docker/setup-qemu-action from 3.5.0 to 3.6.0
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.5.0 to 3.6.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3.5.0...v3.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-03 05:09:43 +00:00
5bb4977876 Merge pull request #353 from ale-ben/cuttingedge
Some checks failed
Docker Image CI / build (push) Has been cancelled
Weebcentral: File name also depends on original chapter name
2025-03-02 16:20:07 +01:00
c6bb1c9180 [cuttingedge] fix(Chapter): Minor logic change to account for all chapterName cases 2025-03-02 16:06:21 +01:00
9a066e7ac7 [cuttingedge] fix(Weebcentral): Updated CheckChapterIsDownloaded logic to also consider chapter name if present 2025-03-02 15:57:09 +01:00
4bafffded4 [cuttingedge] feat(Weebcentra): When ordering chapters, order name desc to put special chapters first 2025-03-02 15:56:12 +01:00
235183cd7f Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into postgres-Server-V2
# Conflicts:
#	API/Schema/MangaConnectors/Webtoons.cs
#	Tranga/Server.cs
2025-03-02 10:07:33 +01:00
942b43da67 Merge branch 'refs/heads/cuttingedge' into cuttingedge-merge-ServerV2 2025-03-02 10:06:48 +01:00
ce5538b352 Merge pull request #341 from Makhuta/cuttingedge
Some checks are pending
Docker Image CI / build (push) Waiting to run
Added to HandleGet support for connector languages
2025-03-02 09:51:11 +01:00
0cfdf17bd4 Merge pull request #350 from C9Glax/dependabot/github_actions/docker/setup-buildx-action-3.10.0
Some checks failed
Docker Image CI / build (push) Has been cancelled
Bump docker/setup-buildx-action from 3.9.0 to 3.10.0
2025-03-02 09:50:21 +01:00
0c48c1e020 Merge pull request #351 from C9Glax/dependabot/github_actions/docker/build-push-action-6.15.0
Bump docker/build-push-action from 6.14.0 to 6.15.0
2025-03-02 09:50:17 +01:00
0638e75ed6 Merge pull request #349 from C9Glax/dependabot/github_actions/docker/setup-qemu-action-3.5.0
Bump docker/setup-qemu-action from 3.4.0 to 3.5.0
2025-03-02 09:49:16 +01:00
5a4bc1c6de [cuttingedge] fix(Weebcentral): Handle case of chapter name with multiple number parts 2025-03-01 12:06:51 +01:00
71f663ca2f [cuttingedge] fix(Weebcentral): File name also depends on original chapter name 2025-03-01 11:39:12 +01:00
1b61a16061 Bump docker/build-push-action from 6.14.0 to 6.15.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.14.0 to 6.15.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.14.0...v6.15.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-27 05:25:23 +00:00
db81fdce39 Bump docker/setup-buildx-action from 3.9.0 to 3.10.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.9.0 to 3.10.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.9.0...v3.10.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-27 05:25:21 +00:00
fdb5451162 Bump docker/setup-qemu-action from 3.4.0 to 3.5.0
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.4.0 to 3.5.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3.4.0...v3.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-27 05:25:19 +00:00
6b7632b071 Merge pull request #344 from C9Glax/dependabot/github_actions/docker/build-push-action-6.14.0
Some checks failed
Docker Image CI / build (push) Has been cancelled
Bump docker/build-push-action from 6.13.0 to 6.14.0
2025-02-20 16:49:19 +01:00
06c080dfce Bump docker/build-push-action from 6.13.0 to 6.14.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.13.0 to 6.14.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.13.0...v6.14.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-20 05:50:25 +00:00
8130e11a9c Merge pull request #342 from C9Glax/cuttingedge
Some checks failed
Docker Image CI / build (push) Has been cancelled
Cuttingedge master merge
2025-02-14 15:51:42 +01:00
659a42d370 Add
- ability to get supported languages of connector for use in monitoring languages selector
2025-02-12 14:07:13 +01:00
9cef068785 Merge pull request #340 from Makhuta/cuttingedge
Some checks failed
Docker Image CI / build (push) Has been cancelled
Fix the Webtoons connector getting few chapters multiple times
2025-02-11 21:24:14 +01:00
4ad3149523 Fix
- fixed when parsing chapters the pages was incorrectly parsed resulting into adding the chapters from the last page multiple times (was still downloading OK but it would try to download the chapters from last page multiple times)
2025-02-11 20:16:30 +01:00
dccc9fdbef Merge branch 'cuttingedge-merge-ServerV2' into postgres-Server-V2
# Conflicts:
#	API/MangaDownloadClients/ChromiumDownloadClient.cs
#	Tranga/Jobs/JobBoss.cs
#	Tranga/Tranga.csproj
2025-02-09 18:38:46 +01:00
e6d40a7b36 Remove unused code from Weebcentral
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-02-09 18:37:55 +01:00
a95cb90561 Update Nuget packages 2025-02-09 18:37:41 +01:00
603e1b41d9 Add Chromium referer header 2025-02-09 18:37:33 +01:00
bb8a514830 Do not create .duplicate files anymore.
Just warn in log and delete (or attempt to delete)
2025-02-09 17:57:08 +01:00
9928abb674 Webtoons 2025-02-09 17:53:22 +01:00
ebb034e0c7 Merge branch 'cuttingedge-merge-ServerV2' into postgres-Server-V2
# Conflicts:
#	API/Schema/MangaConnectors/Webtoons.cs
#	Tranga/MangaConnectors/MangaConnector.cs
#	Tranga/MangaConnectors/MangaConnectorJsonConverter.cs
#	Tranga/MangaConnectors/WeebCentral.cs
#	Tranga/Tranga.cs
2025-02-09 17:41:29 +01:00
edacaaba8a Update Readme 2025-02-09 17:38:54 +01:00
d97da26994 spelling error 2025-02-09 17:37:53 +01:00
8b923d73c4 Merge pull request #337 from Makhuta/cuttingedge
Add Manga Connector
2025-02-09 17:34:08 +01:00
1dca7ec569 Remove Manga4Life 2025-02-09 17:30:30 +01:00
7229fad6c5 Merge branch 'cuttingedge-merge-ServerV2' into postgres-Server-V2
# Conflicts:
#	API/Schema/MangaConnectors/MangaLife.cs
#	Tranga/MangaConnectors/MangaConnectorJsonConverter.cs
#	Tranga/Tranga.cs
2025-02-09 17:28:47 +01:00
814efd3528 Merge remote-tracking branch 'origin/cuttingedge' into cuttingedge 2025-02-09 17:28:03 +01:00
2cd5d8bc4f Merge branch 'cuttingedge-merge-ServerV2' into cuttingedge 2025-02-09 17:27:42 +01:00
5a864ab9b7 Remove Manga4Life 2025-02-09 17:27:35 +01:00
663e2e2ca0 Merge pull request #329 from ale-ben/postgres-Server-V2
[Server V2] Changed chapterNumber to string and fixed job spawning error
2025-02-09 17:20:19 +01:00
c700974693 Merge pull request #339 from C9Glax/dependabot/github_actions/docker/setup-qemu-action-3.4.0
Some checks failed
Docker Image CI / build (push) Has been cancelled
Bump docker/setup-qemu-action from 3.3.0 to 3.4.0
2025-02-09 17:18:42 +01:00
553b5558d3 Merge pull request #338 from C9Glax/dependabot/github_actions/docker/setup-buildx-action-3.9.0
Bump docker/setup-buildx-action from 3.8.0 to 3.9.0
2025-02-09 17:18:25 +01:00
c9bbfee26b Merge pull request #331 from C9Glax/dependabot/github_actions/docker/build-push-action-6.13.0
Bump docker/build-push-action from 6.12.0 to 6.13.0
2025-02-09 17:18:10 +01:00
6e869eeb0d Bump docker/setup-qemu-action from 3.3.0 to 3.4.0
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.3.0 to 3.4.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3.3.0...v3.4.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-07 05:13:18 +00:00
be7da69dbd Bump docker/setup-buildx-action from 3.8.0 to 3.9.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.8.0 to 3.9.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.8.0...v3.9.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-07 05:13:16 +00:00
7f13d9b1e6 Fix
- forgotten comma
2025-02-06 15:39:06 +01:00
0c9e3205c2 Add Manga Connector
- added [Webtoon](https://www.webtoons.com) manga connector
- modified/added support for saving covers with refferer
2025-02-06 15:37:30 +01:00
8c3b70b32e Bump docker/build-push-action from 6.12.0 to 6.13.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.12.0 to 6.13.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.12.0...v6.13.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-27 06:02:55 +00:00
4f7031ecfc Merge pull request #328 from ale-ben/cuttingedge
Some checks failed
Docker Image CI / build (push) Has been cancelled
fix: Add escape to Weebcentral regex
2025-01-25 23:26:48 +01:00
f7a285aabd [cuttingedge] fix: Add escape to Weebcentral regex 2025-01-25 11:40:00 +01:00
786482398c Merge pull request #327 from ale-ben/cuttingedge
Some checks are pending
Docker Image CI / build (push) Waiting to run
Fix bug that prevented download of chapters 0
2025-01-24 22:09:53 +01:00
7921dcb1cb [cuttingedge] fix: Change condition for newChapters. Should solve #323 2025-01-24 21:52:52 +01:00
d0c9313279 Merge pull request #322 from C9Glax/dependabot/github_actions/docker/build-push-action-6.12.0
Some checks failed
Docker Image CI / build (push) Has been cancelled
Bump docker/build-push-action from 6.11.0 to 6.12.0
2025-01-16 17:00:36 +01:00
58cf4cf4e0 Bump docker/build-push-action from 6.11.0 to 6.12.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.11.0 to 6.12.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.11.0...v6.12.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-16 05:52:33 +00:00
280d715a7c Merge branch 'cuttingedge-merge-ServerV2' into cuttingedge
Some checks failed
Docker Image CI / build (push) Has been cancelled
2025-01-15 23:14:20 +01:00
b4edcccafe Merge branch 'cuttingedge-merge-ServerV2' into cuttingedge 2025-01-15 23:02:55 +01:00
1701881f4b Merge branch 'cuttingedge-merge-ServerV2' into cuttingedge 2025-01-15 22:53:47 +01:00
e5be5703f8 Merge branch 'cuttingedge-merge-ServerV2' into cuttingedge 2025-01-15 22:25:16 +01:00
ce217aae4f Merge remote-tracking branch 'origin/cuttingedge' into cuttingedge 2025-01-15 22:18:11 +01:00
3abf7224d0 Merge pull request #316 from ale-ben/cuttingedge
Some checks failed
Docker Image CI / build (push) Has been cancelled
Fixed regex to capture chapters with decimal (1.5, ..)
2025-01-11 00:41:20 +01:00
b39dbd5671 [cuttingedge] fix(weebcentral): Fixed regex to capture chapters with decimal (1.5, ..) 2025-01-10 22:10:34 +01:00
375fad0c21 Merge pull request #314 from C9Glax/dependabot/github_actions/docker/setup-qemu-action-3.3.0
Some checks failed
Docker Image CI / build (push) Has been cancelled
Bump docker/setup-qemu-action from 3.2.0 to 3.3.0
2025-01-10 11:02:46 +01:00
ee0d17c24f Merge pull request #315 from C9Glax/dependabot/github_actions/docker/build-push-action-6.11.0
Bump docker/build-push-action from 6.9.0 to 6.11.0
2025-01-10 11:02:26 +01:00
36ab3c3fdb Bump docker/build-push-action from 6.9.0 to 6.11.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.9.0 to 6.11.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.9.0...v6.11.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-10 05:46:13 +00:00
c3d60c6586 Bump docker/setup-qemu-action from 3.2.0 to 3.3.0
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.2.0 to 3.3.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3.2.0...v3.3.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-10 05:46:10 +00:00
137 changed files with 7169 additions and 7233 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,27 +11,26 @@
<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.11.71" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
<PackageReference Include="log4net" Version="3.0.3" /> <PackageReference Include="JikanDotNet" Version="2.9.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.0" /> <PackageReference Include="log4net" Version="3.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0"> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="9.0.5" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql" Version="9.0.2" /> <PackageReference Include="Npgsql" Version="9.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.2" /> <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.0.5" /> <PackageReference Include="PuppeteerSharp" Version="20.1.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="Soenneker.Utils.String.NeedlemanWunsch" Version="3.0.697" /> <PackageReference Include="Soenneker.Utils.String.NeedlemanWunsch" Version="3.0.929" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" />
<PackageReference Include="System.Drawing.Common" Version="9.0.0" /> <PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="8.1.0" />
</ItemGroup> <PackageReference Include="System.Drawing.Common" Version="9.0.3" />
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,223 +0,0 @@
using API.Schema;
using API.Schema.Jobs;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Produces("application/json")]
[Route("v{version:apiVersion}/[controller]")]
public class JobController(PgsqlContext context) : Controller
{
/// <summary>
/// Returns all Jobs
/// </summary>
/// <returns>Array of Jobs</returns>
[HttpGet]
[ProducesResponseType<Job[]>(Status200OK)]
public IActionResult GetAllJobs()
{
Job[] ret = context.Jobs.ToArray();
return Ok(ret);
}
/// <summary>
/// Returns Jobs with requested Job-IDs
/// </summary>
/// <param name="ids">Array of Job-IDs</param>
/// <returns>Array of Jobs</returns>
[HttpPost("WithIDs")]
[ProducesResponseType<Job[]>(Status200OK)]
public IActionResult GetJobs([FromBody]string[] ids)
{
Job[] ret = context.Jobs.Where(job => ids.Contains(job.JobId)).ToArray();
return Ok(ret);
}
/// <summary>
/// Get all Jobs in requested State
/// </summary>
/// <param name="state">Requested Job-State</param>
/// <returns>Array of Jobs</returns>
[HttpGet("State/{state}")]
[ProducesResponseType<Job[]>(Status200OK)]
public IActionResult GetJobsInState(JobState state)
{
Job[] jobsInState = context.Jobs.Where(job => job.state == state).ToArray();
return Ok(jobsInState);
}
/// <summary>
/// Returns all Jobs of requested Type
/// </summary>
/// <param name="type">Requested Job-Type</param>
/// <returns>Array of Jobs</returns>
[HttpGet("Type/{type}")]
[ProducesResponseType<Job[]>(Status200OK)]
public IActionResult GetJobsOfType(JobType type)
{
Job[] jobsOfType = context.Jobs.Where(job => job.JobType == type).ToArray();
return Ok(jobsOfType);
}
/// <summary>
/// Return Job with ID
/// </summary>
/// <param name="id">Job-ID</param>
/// <returns>Job</returns>
[HttpGet("{id}")]
[ProducesResponseType<Job>(Status200OK)]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetJob(string id)
{
Job? ret = context.Jobs.Find(id);
return (ret is not null) switch
{
true => Ok(ret),
false => NotFound()
};
}
/// <summary>
/// Create a new CreateNewDownloadChapterJob
/// </summary>
/// <param name="request">ID of the Manga, and how often we check again</param>
/// <returns>Nothing</returns>
[HttpPut("NewDownloadChapterJob/{mangaId}")]
[ProducesResponseType(Status201Created)]
[ProducesResponseType<string>(Status500InternalServerError)]
public IActionResult CreateNewDownloadChapterJob(string mangaId, [FromBody]ulong recurrenceTime)
{
Job job = new DownloadNewChaptersJob(recurrenceTime, mangaId);
return AddJob(job);
}
/// <summary>
/// Create a new DownloadSingleChapterJob
/// </summary>
/// <param name="chapterId">ID of the Chapter</param>
/// <returns>Nothing</returns>
[HttpPut("DownloadSingleChapterJob/{chapterId}")]
[ProducesResponseType(Status201Created)]
[ProducesResponseType<string>(Status500InternalServerError)]
public IActionResult CreateNewDownloadChapterJob(string chapterId)
{
Job job = new DownloadSingleChapterJob(chapterId);
return AddJob(job);
}
/// <summary>
/// Create a new UpdateMetadataJob
/// </summary>
/// <param name="mangaId">ID of the Manga</param>
/// <returns>Nothing</returns>
[HttpPut("UpdateMetadataJob/{mangaId}")]
[ProducesResponseType(Status201Created)]
[ProducesResponseType<string>(Status500InternalServerError)]
public IActionResult CreateUpdateMetadataJob(string mangaId)
{
Job job = new UpdateMetadataJob(0, mangaId);
return AddJob(job);
}
/// <summary>
/// Create a new UpdateMetadataJob for all Manga
/// </summary>
/// <returns>Nothing</returns>
[HttpPut("UpdateMetadataJob")]
[ProducesResponseType(Status201Created)]
[ProducesResponseType<string>(Status500InternalServerError)]
public IActionResult CreateUpdateAllMetadataJob()
{
List<string> ids = context.Manga.Select(m => m.MangaId).ToList();
List<UpdateMetadataJob> jobs = ids.Select(id => new UpdateMetadataJob(0, id)).ToList();
try
{
context.Jobs.AddRange(jobs);
context.SaveChanges();
return Created();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
private IActionResult AddJob(Job job)
{
try
{
context.Jobs.Add(job);
context.SaveChanges();
return Created();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
/// <summary>
/// Delete Job with ID
/// </summary>
/// <param name="id">Job-ID</param>
/// <returns>Nothing</returns>
[HttpDelete("{id}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status500InternalServerError)]
public IActionResult DeleteJob(string id)
{
try
{
Job? ret = context.Jobs.Find(id);
switch (ret is not null)
{
case true:
context.Remove(ret);
context.SaveChanges();
return Ok();
case false: return NotFound();
}
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
/// <summary>
/// Starts the Job with the requested ID
/// </summary>
/// <param name="id">Job-ID</param>
/// <returns>Nothing</returns>
[HttpPost("{id}/Start")]
[ProducesResponseType(Status202Accepted)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status500InternalServerError)]
public IActionResult StartJob(string id)
{
Job? ret = context.Jobs.Find(id);
if (ret is null)
return NotFound();
try
{
context.Update(ret);
context.SaveChanges();
return Accepted();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
[HttpPost("{id}/Stop")]
public IActionResult StopJob(string id)
{
return NotFound(new ProblemResponse("Not implemented")); //TODO
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,26 +0,0 @@
using API.Schema;
using API.Schema.MangaConnectors;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Produces("application/json")]
[Route("v{v:apiVersion}")]
public class MiscController(PgsqlContext context) : Controller
{
/// <summary>
/// Get all available Connectors (Scanlation-Sites)
/// </summary>
/// <returns>Array of MangaConnector</returns>
[HttpGet("GetConnectors")]
[ProducesResponseType<MangaConnector[]>(Status200OK)]
public IActionResult GetConnectors()
{
MangaConnector[] connectors = context.MangaConnectors.ToArray();
return Ok(connectors);
}
}

View File

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

View File

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

View File

@ -1,151 +1,70 @@
using API.Schema; using API.Schema.MangaContext;
using API.Schema.MangaConnectors; using API.Schema.MangaContext.MangaConnectors;
using Asp.Versioning; using Asp.Versioning;
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
namespace API.Controllers; namespace API.Controllers;
[ApiVersion(2)] [ApiVersion(2)]
[ApiController] [ApiController]
[Produces("application/json")]
[Route("v{v:apiVersion}/[controller]")] [Route("v{v:apiVersion}/[controller]")]
public class SearchController(PgsqlContext context) : Controller public class SearchController(MangaContext context) : Controller
{ {
/// <summary> /// <summary>
/// Initiate a search for a Manga on all Connectors /// Initiate a search for a <see cref="Manga"/> on <see cref="MangaConnector"/> with searchTerm
/// </summary> /// </summary>
/// <param name="name">Name/Title of the Manga</param> /// <param name="MangaConnectorName"><see cref="MangaConnector"/>.Name</param>
/// <returns>Array of Manga</returns> /// <param name="Query">searchTerm</param>
[HttpPost("{name}")] /// <response code="200"></response>
[ProducesResponseType<Manga[]>(Status500InternalServerError)] /// <response code="404"><see cref="MangaConnector"/> with Name not found</response>
public IActionResult SearchMangaGlobal(string name) /// <response code="412"><see cref="MangaConnector"/> with Name is disabled</response>
[HttpGet("{MangaConnectorName}/{Query}")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status406NotAcceptable)]
public IActionResult SearchManga(string MangaConnectorName, string Query)
{ {
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> allManga = new(); if(context.MangaConnectors.Find(MangaConnectorName) is not { } connector)
foreach (MangaConnector contextMangaConnector in context.MangaConnectors) return NotFound();
allManga.AddRange(contextMangaConnector.GetManga(name)); if (connector.Enabled is false)
List<Manga> retMangas = new(); return StatusCode(Status412PreconditionFailed);
foreach ((Manga? manga, List<Author>? authors, List<MangaTag>? tags, List<Link>? links, List<MangaAltTitle>? altTitles) in allManga)
{
try
{
Manga? add = AddMangaToContext(manga, authors, tags, links, altTitles);
if(add is not null)
retMangas.Add(add);
}
catch (DbUpdateException)
{
return StatusCode(500, new ProblemResponse("An error occurred while processing your request."));
}
}
return Ok(retMangas.ToArray());
}
/// <summary> (Manga, MangaConnectorId<Manga>)[] mangas = connector.SearchManga(Query);
/// Initiate a search for a Manga on a specific Connector
/// </summary>
/// <param name="id">Manga-Connector-ID</param>
/// <param name="name">Name/Title of the Manga</param>
/// <returns>Manga</returns>
[HttpPost("{id}/{name}")]
[ProducesResponseType<Manga[]>(Status200OK)]
[ProducesResponseType<ProblemResponse>(Status404NotFound)]
[ProducesResponseType<ProblemResponse>(Status500InternalServerError)]
public IActionResult SearchManga(string id, string name)
{
MangaConnector? connector = context.MangaConnectors.Find(id);
if (connector is null)
return NotFound(new ProblemResponse("Connector not found."));
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] mangas = connector.GetManga(name);
List<Manga> retMangas = new(); List<Manga> retMangas = new();
foreach ((Manga? manga, List<Author>? authors, List<MangaTag>? tags, List<Link>? links, List<MangaAltTitle>? altTitles) in mangas) foreach ((Manga manga, MangaConnectorId<Manga> mcId) manga in mangas)
{ {
try if(Tranga.AddMangaToContext(manga, context, out Manga? add))
{
Manga? add = AddMangaToContext(manga, authors, tags, links, altTitles);
if(add is not null)
retMangas.Add(add); retMangas.Add(add);
} }
catch (DbUpdateException e)
{
return StatusCode(500, new ProblemResponse("An error occurred while processing your request.", e.Message));
}
}
return Ok(retMangas.ToArray()); return Ok(retMangas.ToArray());
} }
private Manga? AddMangaToContext(Manga? manga, List<Author>? authors, List<MangaTag>? tags, List<Link>? links, /// <summary>
List<MangaAltTitle>? altTitles) /// Returns <see cref="Manga"/> from the <see cref="MangaConnector"/> associated with <paramref name="url"/>
/// </summary>
/// <param name="url"></param>
/// <response code="200"></response>
/// <response code="300">Multiple <see cref="MangaConnector"/> found for URL</response>
/// <response code="404"><see cref="Manga"/> not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("Url")]
[ProducesResponseType<Manga>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status500InternalServerError)]
public IActionResult GetMangaFromUrl([FromBody]string url)
{ {
if (manga is null) if (context.MangaConnectors.Find("Global") is not { } connector)
return null; return StatusCode(Status500InternalServerError, "Could not find Global Connector.");
Manga? existing = context.Manga.FirstOrDefault(m => if(connector.GetMangaFromUrl(url) is not { } manga)
m.MangaId == manga.MangaId); return NotFound();
if (tags is not null) if(Tranga.AddMangaToContext(manga, context, out Manga? add) == false)
{ return StatusCode(Status500InternalServerError);
IEnumerable<MangaTag> mergedTags = tags.Select(mt =>
{
MangaTag? inDb = context.Tags.FirstOrDefault(t => t.Equals(mt));
return inDb ?? mt;
});
manga.Tags = mergedTags.ToList();
IEnumerable<MangaTag> newTags = manga.Tags.Where(mt => !context.Tags.Any(t => t.Tag.Equals(mt.Tag)));
context.Tags.AddRange(newTags);
}
if (authors is not null) return Ok(add);
{
IEnumerable<Author> mergedAuthors = authors.Select(ma =>
{
Author? inDb = context.Authors.FirstOrDefault(a => a.AuthorName == ma.AuthorName);
return inDb ?? ma;
});
manga.Authors = mergedAuthors.ToList();
IEnumerable<Author> newAuthors = manga.Authors.Where(ma => !context.Authors.Any(a =>
a.AuthorName == ma.AuthorName));
context.Authors.AddRange(newAuthors);
}
if (links is not null)
{
IEnumerable<Link> mergedLinks = links.Select(ml =>
{
Link? inDb = context.Link.FirstOrDefault(l =>
l.LinkProvider == ml.LinkProvider && l.LinkUrl == ml.LinkUrl);
return inDb ?? ml;
});
manga.Links = mergedLinks.ToList();
IEnumerable<Link> newLinks = manga.Links.Where(ml => !context.Link.Any(l =>
l.LinkProvider == ml.LinkProvider && l.LinkUrl == ml.LinkUrl));
context.Link.AddRange(newLinks);
}
if (altTitles is not null)
{
IEnumerable<MangaAltTitle> mergedAltTitles = altTitles.Select(mat =>
{
MangaAltTitle? inDb = context.AltTitles.FirstOrDefault(at =>
at.Language == mat.Language && at.Title == mat.Title);
return inDb ?? mat;
});
manga.AltTitles = mergedAltTitles.ToList();
IEnumerable<MangaAltTitle> newAltTitles = manga.AltTitles.Where(mat =>
!context.AltTitles.Any(at => at.Language == mat.Language && at.Title == mat.Title));
context.AltTitles.AddRange(newAltTitles);
}
existing?.UpdateWithInfo(manga);
if(existing is not null)
context.Manga.Update(existing);
else
context.Manga.Add(manga);
context.SaveChanges();
return existing ?? manga;
} }
} }

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using HtmlAgilityPack; using HtmlAgilityPack;
using log4net;
using PuppeteerSharp; using PuppeteerSharp;
namespace API.MangaDownloadClients; namespace API.MangaDownloadClients;
@ -10,8 +11,10 @@ internal class ChromiumDownloadClient : DownloadClient
{ {
private static IBrowser? _browser; private static IBrowser? _browser;
private readonly HttpDownloadClient _httpDownloadClient; private readonly HttpDownloadClient _httpDownloadClient;
private readonly Thread _closeStalePagesThread;
private readonly List<KeyValuePair<IPage, DateTime>> _openPages = new ();
private static async Task<IBrowser> StartBrowser() private static async Task<IBrowser> StartBrowser(ILog log)
{ {
return await Puppeteer.LaunchAsync(new LaunchOptions return await Puppeteer.LaunchAsync(new LaunchOptions
{ {
@ -22,43 +25,36 @@ internal class ChromiumDownloadClient : DownloadClient
"--disable-setuid-sandbox", "--disable-setuid-sandbox",
"--no-sandbox"}, "--no-sandbox"},
Timeout = 30000 Timeout = 30000
}, new LoggerFactory([new LogProvider()])); //TODO }, new LoggerFactory([new Provider(log)]));
}
private class LogProvider : ILoggerProvider
{
//TODO
public void Dispose() { }
public ILogger CreateLogger(string categoryName) => new Logger();
}
private class Logger : ILogger
{
public Logger() : base() { }
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (logLevel <= LogLevel.Information)
return;
//TODO
}
public bool IsEnabled(LogLevel logLevel) => true;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
} }
public ChromiumDownloadClient() public ChromiumDownloadClient()
{ {
_httpDownloadClient = new(); _httpDownloadClient = new();
if(_browser is null) if(_browser is null)
_browser = StartBrowser().Result; _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)(\?.*)?"); 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) internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null)
{ {
Log.Debug($"Requesting {url}");
return _imageUrlRex.IsMatch(url) return _imageUrlRex.IsMatch(url)
? _httpDownloadClient.MakeRequestInternal(url, referrer) ? _httpDownloadClient.MakeRequestInternal(url, referrer)
: MakeRequestBrowser(url, referrer, clickButton); : MakeRequestBrowser(url, referrer, clickButton);
@ -69,17 +65,20 @@ internal class ChromiumDownloadClient : DownloadClient
if (_browser is null) if (_browser is null)
return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null); return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null);
IPage page = _browser.NewPageAsync().Result; IPage page = _browser.NewPageAsync().Result;
page.DefaultTimeout = 10000; _openPages.Add(new(page, DateTime.Now));
page.SetExtraHttpHeadersAsync(new() { { "Referer", referrer } });
page.DefaultTimeout = 30000;
IResponse response; IResponse response;
try try
{ {
response = page.GoToAsync(url, WaitUntilNavigation.Networkidle0).Result; response = page.GoToAsync(url, WaitUntilNavigation.Networkidle0).Result;
//Log($"Page loaded. {url}"); Log.Debug($"Page loaded. {url}");
} }
catch (Exception e) catch (Exception e)
{ {
//Log($"Could not load Page {url}\n{e.Message}"); Log.Info($"Could not load Page {url}\n{e.Message}");
page.CloseAsync(); page.CloseAsync();
_openPages.Remove(_openPages.Find(i => i.Key == page));
return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null); return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null);
} }
@ -103,11 +102,50 @@ internal class ChromiumDownloadClient : DownloadClient
} }
else else
{ {
page.CloseAsync(); page.CloseAsync().Wait();
_openPages.Remove(_openPages.Find(i => i.Key == page));
return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null); return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null);
} }
page.CloseAsync(); page.CloseAsync().Wait();
_openPages.Remove(_openPages.Find(i => i.Key == page));
return new RequestResult(response.Status, document, stream, false, ""); return new RequestResult(response.Status, document, stream, false, "");
} }
private class Provider(ILog log) : ILoggerProvider
{
public void Dispose()
{
}
public ILogger CreateLogger(string categoryName)
{
return new ChromiumLogger(log);
}
}
private class ChromiumLogger(ILog log) : ILogger
{
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
string message = formatter.Invoke(state, exception);
switch(logLevel)
{
case LogLevel.Critical: log.Fatal(message); break;
case LogLevel.Error: log.Error(message); break;
case LogLevel.Warning: log.Warn(message); break;
case LogLevel.Information: log.Info(message); break;
case LogLevel.Debug: log.Debug(message); break;
default: log.Info(message); break;
}
}
public bool IsEnabled(LogLevel logLevel) => true;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
return null;
}
}
} }

View File

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

View File

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

View File

@ -1,55 +1,70 @@
using System.Net; using System.Net;
using API.Schema;
using HtmlAgilityPack; using HtmlAgilityPack;
namespace API.MangaDownloadClients; namespace API.MangaDownloadClients;
internal class HttpDownloadClient : DownloadClient internal class HttpDownloadClient : DownloadClient
{ {
private static readonly HttpClient Client = new()
{
Timeout = TimeSpan.FromSeconds(10)
};
public HttpDownloadClient()
{
Client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", TrangaSettings.userAgent);
}
internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null) internal override RequestResult MakeRequestInternal(string url, string? referrer = null, string? clickButton = null)
{ {
//TODO if (clickButton is not null)
//if (clickButton is not null) Log.Warn("Client can not click button");
//Log("Can not click button on static site."); HttpClient client = new();
HttpResponseMessage? response = null; client.Timeout = TimeSpan.FromSeconds(10);
while (response is null) client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher;
{ client.DefaultRequestHeaders.Add("User-Agent", Tranga.Settings.UserAgent);
HttpRequestMessage requestMessage = new(HttpMethod.Get, url); HttpResponseMessage? response;
Uri uri = new(url);
HttpRequestMessage requestMessage = new(HttpMethod.Get, uri);
if (referrer is not null) if (referrer is not null)
requestMessage.Headers.Referrer = new Uri(referrer); requestMessage.Headers.Referrer = new (referrer);
//Log($"Requesting {requestType} {url}"); Log.Debug($"Requesting {url}");
try try
{ {
response = Client.Send(requestMessage); response = client.Send(requestMessage);
} }
catch (Exception e) catch (HttpRequestException e)
{ {
switch (e) Log.Error(e);
{ return new (HttpStatusCode.Unused, null, Stream.Null);
case TaskCanceledException:
return new RequestResult(HttpStatusCode.RequestTimeout, null, Stream.Null);
case HttpRequestException:
return new RequestResult(HttpStatusCode.BadRequest, null, Stream.Null);
}
}
} }
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
return new RequestResult(response.StatusCode, null, Stream.Null); Log.Debug($"Request returned status code {(int)response.StatusCode} {response.StatusCode}");
if (response.Headers.Server.Any(s =>
(s.Product?.Name ?? "").Contains("cloudflare", StringComparison.InvariantCultureIgnoreCase)))
{
Log.Debug("Retrying with FlareSolverr!");
return new FlareSolverrDownloadClient().MakeRequestInternal(url, referrer, clickButton);
}
else
{
Log.Debug($"Request returned status code {(int)response.StatusCode} {response.StatusCode}:\n" +
$"=====\n" +
$"Request:\n" +
$"{requestMessage.Method} {requestMessage.RequestUri}\n" +
$"{requestMessage.Version} {requestMessage.VersionPolicy}\n" +
$"Headers:\n\t{string.Join("\n\t", requestMessage.Headers.Select(h => $"{h.Key}: <{string.Join(">, <", h.Value)}"))}>\n" +
$"{requestMessage.Content?.ReadAsStringAsync().Result}" +
$"=====\n" +
$"Response:\n" +
$"{response.Version}\n" +
$"Headers:\n\t{string.Join("\n\t", response.Headers.Select(h => $"{h.Key}: <{string.Join(">, <", h.Value)}"))}>\n" +
$"{response.Content.ReadAsStringAsync().Result}");
}
} }
Stream stream = response.Content.ReadAsStream(); Stream stream;
try
{
stream = response.Content.ReadAsStream();
}
catch (Exception e)
{
Log.Error(e);
return new (HttpStatusCode.Unused, null, Stream.Null);
}
HtmlDocument? document = null; HtmlDocument? document = null;
@ -62,12 +77,11 @@ internal class HttpDownloadClient : DownloadClient
} }
// Request has been redirected to another page. For example, it redirects directly to the results when there is only 1 result // Request has been redirected to another page. For example, it redirects directly to the results when there is only 1 result
if (response.RequestMessage is not null && response.RequestMessage.RequestUri is not null) if (response.RequestMessage is not null && response.RequestMessage.RequestUri is not null && response.RequestMessage.RequestUri != uri)
{ {
return new RequestResult(response.StatusCode, document, stream, true, return new (response.StatusCode, document, stream, true, response.RequestMessage.RequestUri.AbsoluteUri);
response.RequestMessage.RequestUri.AbsoluteUri);
} }
return new RequestResult(response.StatusCode, document, stream); return new (response.StatusCode, document, stream);
} }
} }

View File

@ -24,4 +24,10 @@ public struct RequestResult
this.hasBeenRedirected = hasBeenRedirected; this.hasBeenRedirected = hasBeenRedirected;
redirectedToUrl = redirectedTo; redirectedToUrl = redirectedTo;
} }
public override string ToString()
{
return
$"{(int)statusCode} {statusCode.ToString()} {(hasBeenRedirected ? "Redirected: " : "")} {redirectedToUrl}";
}
} }

View File

@ -1,781 +0,0 @@
// <auto-generated />
using System;
using API.Schema;
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
{
[DbContext(typeof(PgsqlContext))]
[Migration("20241201235443_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.Author", b =>
{
b.Property<string>("AuthorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("AuthorName")
.IsRequired()
.HasColumnType("text");
b.HasKey("AuthorId");
b.ToTable("Authors");
});
modelBuilder.Entity("API.Schema.Chapter", b =>
{
b.Property<string>("ChapterId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ArchiveFileName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ChapterIds")
.IsRequired()
.HasColumnType("text");
b.Property<float>("ChapterNumber")
.HasColumnType("real");
b.Property<bool>("Downloaded")
.HasColumnType("boolean");
b.Property<string>("ParentMangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.Property<float?>("VolumeNumber")
.HasColumnType("real");
b.HasKey("ChapterId");
b.HasIndex("ParentMangaId");
b.ToTable("Chapters");
});
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
{
b.Property<string>("JobId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.PrimitiveCollection<string[]>("DependsOnJobIds")
.HasMaxLength(64)
.HasColumnType("text[]");
b.Property<string>("JobId1")
.HasColumnType("character varying(64)");
b.Property<byte>("JobType")
.HasColumnType("smallint");
b.Property<DateTime>("LastExecution")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("NextExecution")
.HasColumnType("timestamp with time zone");
b.Property<string>("ParentJobId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<decimal>("RecurrenceMs")
.HasColumnType("numeric(20,0)");
b.Property<int>("state")
.HasColumnType("integer");
b.HasKey("JobId");
b.HasIndex("JobId1");
b.ToTable("Jobs");
b.HasDiscriminator<byte>("JobType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.LibraryConnectors.LibraryConnector", b =>
{
b.Property<string>("LibraryConnectorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Auth")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BaseUrl")
.IsRequired()
.HasColumnType("text");
b.Property<byte>("LibraryType")
.HasColumnType("smallint");
b.HasKey("LibraryConnectorId");
b.ToTable("LibraryConnectors");
b.HasDiscriminator<byte>("LibraryType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.Link", b =>
{
b.Property<string>("LinkId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LinkIds")
.HasColumnType("text");
b.Property<string>("LinkProvider")
.IsRequired()
.HasColumnType("text");
b.Property<string>("LinkUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MangaId")
.IsRequired()
.HasColumnType("character varying(64)");
b.HasKey("LinkId");
b.HasIndex("MangaId");
b.ToTable("Link");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Property<string>("MangaId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.PrimitiveCollection<string[]>("AltTitleIds")
.IsRequired()
.HasColumnType("text[]");
b.PrimitiveCollection<string[]>("AuthorIds")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("ConnectorId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("CoverFileNameInCache")
.HasColumnType("text");
b.Property<string>("CoverUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("FolderName")
.IsRequired()
.HasColumnType("text");
b.Property<float>("IgnoreChapterBefore")
.HasColumnType("real");
b.Property<string>("LatestChapterAvailableId")
.HasColumnType("character varying(64)");
b.Property<string>("LatestChapterDownloadedId")
.HasColumnType("character varying(64)");
b.PrimitiveCollection<string[]>("LinkIds")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("MangaConnectorName")
.IsRequired()
.HasColumnType("character varying(32)");
b.Property<string>("MangaIds")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("OriginalLanguage")
.HasColumnType("text");
b.Property<byte>("ReleaseStatus")
.HasColumnType("smallint");
b.PrimitiveCollection<string[]>("TagIds")
.IsRequired()
.HasColumnType("text[]");
b.Property<long>("year")
.HasColumnType("bigint");
b.HasKey("MangaId");
b.HasIndex("LatestChapterAvailableId")
.IsUnique();
b.HasIndex("LatestChapterDownloadedId")
.IsUnique();
b.HasIndex("MangaConnectorName");
b.ToTable("Manga");
});
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
{
b.Property<string>("AltTitleId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("AltTitleIds")
.HasColumnType("text");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("MangaId")
.IsRequired()
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("AltTitleId");
b.HasIndex("MangaId");
b.ToTable("AltTitles");
});
modelBuilder.Entity("API.Schema.MangaConnector", b =>
{
b.Property<string>("Name")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.PrimitiveCollection<string[]>("BaseUris")
.IsRequired()
.HasColumnType("text[]");
b.PrimitiveCollection<string[]>("SupportedLanguages")
.IsRequired()
.HasColumnType("text[]");
b.HasKey("Name");
b.ToTable("MangaConnectors");
});
modelBuilder.Entity("API.Schema.MangaTag", b =>
{
b.Property<string>("Tag")
.HasColumnType("text");
b.HasKey("Tag");
b.ToTable("Tags");
});
modelBuilder.Entity("API.Schema.NotificationConnectors.NotificationConnector", b =>
{
b.Property<string>("NotificationConnectorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<byte>("NotificationConnectorType")
.HasColumnType("smallint");
b.HasKey("NotificationConnectorId");
b.ToTable("NotificationConnectors");
b.HasDiscriminator<byte>("NotificationConnectorType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("MangaAuthor", b =>
{
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.Property<string>("AuthorId")
.HasColumnType("character varying(64)");
b.Property<string>("AuthorIds")
.HasColumnType("text");
b.Property<string>("MangaIds")
.HasColumnType("text");
b.HasKey("MangaId", "AuthorId");
b.HasIndex("AuthorId");
b.ToTable("MangaAuthor");
});
modelBuilder.Entity("MangaTag", b =>
{
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.Property<string>("Tag")
.HasColumnType("text");
b.Property<string>("MangaIds")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TagIds")
.HasColumnType("text");
b.HasKey("MangaId", "Tag");
b.HasIndex("MangaIds");
b.HasIndex("Tag");
b.ToTable("MangaTag");
});
modelBuilder.Entity("API.Schema.Jobs.CreateArchiveJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("ChapterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ComicInfoLocation")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ImagesLocation")
.IsRequired()
.HasColumnType("text");
b.HasIndex("ChapterId");
b.ToTable("Jobs", t =>
{
t.Property("ChapterId")
.HasColumnName("CreateArchiveJob_ChapterId");
});
b.HasDiscriminator().HasValue((byte)4);
});
modelBuilder.Entity("API.Schema.Jobs.CreateComicInfoXmlJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("ChapterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.HasIndex("ChapterId");
b.ToTable("Jobs", t =>
{
t.Property("ChapterId")
.HasColumnName("CreateComicInfoXmlJob_ChapterId");
});
b.HasDiscriminator().HasValue((byte)6);
});
modelBuilder.Entity("API.Schema.Jobs.DownloadNewChaptersJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("ChapterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("ChapterId");
b.HasDiscriminator().HasValue((byte)0);
});
modelBuilder.Entity("API.Schema.Jobs.MoveFileOrFolderJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("FromLocation")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ToLocation")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue((byte)3);
});
modelBuilder.Entity("API.Schema.Jobs.ProcessImagesJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<bool>("Bw")
.HasColumnType("boolean");
b.Property<int>("Compression")
.HasColumnType("integer");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("text");
b.ToTable("Jobs", t =>
{
t.Property("Path")
.HasColumnName("ProcessImagesJob_Path");
});
b.HasDiscriminator().HasValue((byte)5);
});
modelBuilder.Entity("API.Schema.Jobs.SearchMangaJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaConnectorName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SearchString")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue((byte)7);
});
modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.ToTable("Jobs", t =>
{
t.Property("MangaId")
.HasColumnName("UpdateMetadataJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)2);
});
modelBuilder.Entity("API.Schema.LibraryConnectors.Kavita", b =>
{
b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector");
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.LibraryConnectors.Komga", b =>
{
b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector");
b.HasDiscriminator().HasValue((byte)0);
});
modelBuilder.Entity("API.Schema.NotificationConnectors.Gotify", b =>
{
b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector");
b.Property<string>("AppToken")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Endpoint")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue((byte)0);
});
modelBuilder.Entity("API.Schema.NotificationConnectors.Lunasea", b =>
{
b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector");
b.Property<string>("Id")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.NotificationConnectors.Ntfy", b =>
{
b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector");
b.Property<string>("Auth")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Endpoint")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("text");
b.ToTable("NotificationConnectors", t =>
{
t.Property("Endpoint")
.HasColumnName("Ntfy_Endpoint");
});
b.HasDiscriminator().HasValue((byte)2);
});
modelBuilder.Entity("API.Schema.Chapter", b =>
{
b.HasOne("API.Schema.Manga", "ParentManga")
.WithMany("Chapters")
.HasForeignKey("ParentMangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentManga");
});
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
{
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany("DependsOnJobs")
.HasForeignKey("JobId1");
});
modelBuilder.Entity("API.Schema.Link", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany("Links")
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.HasOne("API.Schema.Chapter", "LatestChapterAvailable")
.WithOne()
.HasForeignKey("API.Schema.Manga", "LatestChapterAvailableId");
b.HasOne("API.Schema.Chapter", "LatestChapterDownloaded")
.WithOne()
.HasForeignKey("API.Schema.Manga", "LatestChapterDownloadedId");
b.HasOne("API.Schema.MangaConnector", "MangaConnector")
.WithMany("Mangas")
.HasForeignKey("MangaConnectorName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LatestChapterAvailable");
b.Navigation("LatestChapterDownloaded");
b.Navigation("MangaConnector");
});
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany("AltTitles")
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("MangaAuthor", b =>
{
b.HasOne("API.Schema.Author", null)
.WithMany()
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.Manga", null)
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MangaTag", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaTag", null)
.WithMany()
.HasForeignKey("MangaIds")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaTag", null)
.WithMany()
.HasForeignKey("Tag")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Schema.Jobs.CreateArchiveJob", b =>
{
b.HasOne("API.Schema.Chapter", "Chapter")
.WithMany()
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Schema.Jobs.CreateComicInfoXmlJob", b =>
{
b.HasOne("API.Schema.Chapter", "Chapter")
.WithMany()
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadNewChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
{
b.HasOne("API.Schema.Chapter", "Chapter")
.WithMany()
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
{
b.Navigation("DependsOnJobs");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Navigation("AltTitles");
b.Navigation("Chapters");
b.Navigation("Links");
});
modelBuilder.Entity("API.Schema.MangaConnector", b =>
{
b.Navigation("Mangas");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,447 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Authors",
columns: table => new
{
AuthorId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
AuthorName = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Authors", x => x.AuthorId);
});
migrationBuilder.CreateTable(
name: "LibraryConnectors",
columns: table => new
{
LibraryConnectorId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
LibraryType = table.Column<byte>(type: "smallint", nullable: false),
BaseUrl = table.Column<string>(type: "text", nullable: false),
Auth = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LibraryConnectors", x => x.LibraryConnectorId);
});
migrationBuilder.CreateTable(
name: "MangaConnectors",
columns: table => new
{
Name = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
SupportedLanguages = table.Column<string[]>(type: "text[]", nullable: false),
BaseUris = table.Column<string[]>(type: "text[]", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MangaConnectors", x => x.Name);
});
migrationBuilder.CreateTable(
name: "NotificationConnectors",
columns: table => new
{
NotificationConnectorId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
NotificationConnectorType = table.Column<byte>(type: "smallint", nullable: false),
Endpoint = table.Column<string>(type: "text", nullable: true),
AppToken = table.Column<string>(type: "text", nullable: true),
Id = table.Column<string>(type: "text", nullable: true),
Ntfy_Endpoint = table.Column<string>(type: "text", nullable: true),
Auth = table.Column<string>(type: "text", nullable: true),
Topic = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NotificationConnectors", x => x.NotificationConnectorId);
});
migrationBuilder.CreateTable(
name: "Tags",
columns: table => new
{
Tag = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tags", x => x.Tag);
});
migrationBuilder.CreateTable(
name: "AltTitles",
columns: table => new
{
AltTitleId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Language = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: false),
Title = table.Column<string>(type: "text", nullable: false),
MangaId = table.Column<string>(type: "character varying(64)", nullable: false),
AltTitleIds = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AltTitles", x => x.AltTitleId);
});
migrationBuilder.CreateTable(
name: "Chapters",
columns: table => new
{
ChapterId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
VolumeNumber = table.Column<float>(type: "real", nullable: true),
ChapterNumber = table.Column<float>(type: "real", nullable: false),
Url = table.Column<string>(type: "text", nullable: false),
Title = table.Column<string>(type: "text", nullable: true),
ArchiveFileName = table.Column<string>(type: "text", nullable: false),
Downloaded = table.Column<bool>(type: "boolean", nullable: false),
ParentMangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ChapterIds = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Chapters", x => x.ChapterId);
});
migrationBuilder.CreateTable(
name: "Manga",
columns: table => new
{
MangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ConnectorId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "text", nullable: false),
CoverUrl = table.Column<string>(type: "text", nullable: false),
CoverFileNameInCache = table.Column<string>(type: "text", nullable: true),
year = table.Column<long>(type: "bigint", nullable: false),
OriginalLanguage = table.Column<string>(type: "text", nullable: true),
ReleaseStatus = table.Column<byte>(type: "smallint", nullable: false),
FolderName = table.Column<string>(type: "text", nullable: false),
IgnoreChapterBefore = table.Column<float>(type: "real", nullable: false),
LatestChapterDownloadedId = table.Column<string>(type: "character varying(64)", nullable: true),
LatestChapterAvailableId = table.Column<string>(type: "character varying(64)", nullable: true),
MangaConnectorName = table.Column<string>(type: "character varying(32)", nullable: false),
AuthorIds = table.Column<string[]>(type: "text[]", nullable: false),
TagIds = table.Column<string[]>(type: "text[]", nullable: false),
LinkIds = table.Column<string[]>(type: "text[]", nullable: false),
AltTitleIds = table.Column<string[]>(type: "text[]", nullable: false),
MangaIds = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Manga", x => x.MangaId);
table.ForeignKey(
name: "FK_Manga_Chapters_LatestChapterAvailableId",
column: x => x.LatestChapterAvailableId,
principalTable: "Chapters",
principalColumn: "ChapterId");
table.ForeignKey(
name: "FK_Manga_Chapters_LatestChapterDownloadedId",
column: x => x.LatestChapterDownloadedId,
principalTable: "Chapters",
principalColumn: "ChapterId");
table.ForeignKey(
name: "FK_Manga_MangaConnectors_MangaConnectorName",
column: x => x.MangaConnectorName,
principalTable: "MangaConnectors",
principalColumn: "Name",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Jobs",
columns: table => new
{
JobId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ParentJobId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
DependsOnJobIds = table.Column<string[]>(type: "text[]", maxLength: 64, nullable: true),
JobType = table.Column<byte>(type: "smallint", nullable: false),
RecurrenceMs = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
LastExecution = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
NextExecution = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
state = table.Column<int>(type: "integer", nullable: false),
JobId1 = table.Column<string>(type: "character varying(64)", nullable: true),
ImagesLocation = table.Column<string>(type: "text", nullable: true),
ComicInfoLocation = table.Column<string>(type: "text", nullable: true),
CreateArchiveJob_ChapterId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
Path = table.Column<string>(type: "text", nullable: true),
CreateComicInfoXmlJob_ChapterId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
MangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
ChapterId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
FromLocation = table.Column<string>(type: "text", nullable: true),
ToLocation = table.Column<string>(type: "text", nullable: true),
ProcessImagesJob_Path = table.Column<string>(type: "text", nullable: true),
Bw = table.Column<bool>(type: "boolean", nullable: true),
Compression = table.Column<int>(type: "integer", nullable: true),
SearchString = table.Column<string>(type: "text", nullable: true),
MangaConnectorName = table.Column<string>(type: "text", nullable: true),
UpdateMetadataJob_MangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Jobs", x => x.JobId);
table.ForeignKey(
name: "FK_Jobs_Chapters_ChapterId",
column: x => x.ChapterId,
principalTable: "Chapters",
principalColumn: "ChapterId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Jobs_Chapters_CreateArchiveJob_ChapterId",
column: x => x.CreateArchiveJob_ChapterId,
principalTable: "Chapters",
principalColumn: "ChapterId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Jobs_Chapters_CreateComicInfoXmlJob_ChapterId",
column: x => x.CreateComicInfoXmlJob_ChapterId,
principalTable: "Chapters",
principalColumn: "ChapterId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Jobs_Jobs_JobId1",
column: x => x.JobId1,
principalTable: "Jobs",
principalColumn: "JobId");
table.ForeignKey(
name: "FK_Jobs_Manga_MangaId",
column: x => x.MangaId,
principalTable: "Manga",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Jobs_Manga_UpdateMetadataJob_MangaId",
column: x => x.UpdateMetadataJob_MangaId,
principalTable: "Manga",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Link",
columns: table => new
{
LinkId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
LinkProvider = table.Column<string>(type: "text", nullable: false),
LinkUrl = table.Column<string>(type: "text", nullable: false),
MangaId = table.Column<string>(type: "character varying(64)", nullable: false),
LinkIds = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Link", x => x.LinkId);
table.ForeignKey(
name: "FK_Link_Manga_MangaId",
column: x => x.MangaId,
principalTable: "Manga",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MangaAuthor",
columns: table => new
{
MangaId = table.Column<string>(type: "character varying(64)", nullable: false),
AuthorId = table.Column<string>(type: "character varying(64)", nullable: false),
AuthorIds = table.Column<string>(type: "text", nullable: true),
MangaIds = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MangaAuthor", x => new { x.MangaId, x.AuthorId });
table.ForeignKey(
name: "FK_MangaAuthor_Authors_AuthorId",
column: x => x.AuthorId,
principalTable: "Authors",
principalColumn: "AuthorId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MangaAuthor_Manga_MangaId",
column: x => x.MangaId,
principalTable: "Manga",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MangaTag",
columns: table => new
{
MangaId = table.Column<string>(type: "character varying(64)", nullable: false),
Tag = table.Column<string>(type: "text", nullable: false),
MangaIds = table.Column<string>(type: "text", nullable: false),
TagIds = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MangaTag", x => new { x.MangaId, x.Tag });
table.ForeignKey(
name: "FK_MangaTag_Manga_MangaId",
column: x => x.MangaId,
principalTable: "Manga",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MangaTag_Tags_MangaIds",
column: x => x.MangaIds,
principalTable: "Tags",
principalColumn: "Tag",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MangaTag_Tags_Tag",
column: x => x.Tag,
principalTable: "Tags",
principalColumn: "Tag",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AltTitles_MangaId",
table: "AltTitles",
column: "MangaId");
migrationBuilder.CreateIndex(
name: "IX_Chapters_ParentMangaId",
table: "Chapters",
column: "ParentMangaId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_ChapterId",
table: "Jobs",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_CreateArchiveJob_ChapterId",
table: "Jobs",
column: "CreateArchiveJob_ChapterId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_CreateComicInfoXmlJob_ChapterId",
table: "Jobs",
column: "CreateComicInfoXmlJob_ChapterId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_JobId1",
table: "Jobs",
column: "JobId1");
migrationBuilder.CreateIndex(
name: "IX_Jobs_MangaId",
table: "Jobs",
column: "MangaId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_UpdateMetadataJob_MangaId",
table: "Jobs",
column: "UpdateMetadataJob_MangaId");
migrationBuilder.CreateIndex(
name: "IX_Link_MangaId",
table: "Link",
column: "MangaId");
migrationBuilder.CreateIndex(
name: "IX_Manga_LatestChapterAvailableId",
table: "Manga",
column: "LatestChapterAvailableId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Manga_LatestChapterDownloadedId",
table: "Manga",
column: "LatestChapterDownloadedId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Manga_MangaConnectorName",
table: "Manga",
column: "MangaConnectorName");
migrationBuilder.CreateIndex(
name: "IX_MangaAuthor_AuthorId",
table: "MangaAuthor",
column: "AuthorId");
migrationBuilder.CreateIndex(
name: "IX_MangaTag_MangaIds",
table: "MangaTag",
column: "MangaIds");
migrationBuilder.CreateIndex(
name: "IX_MangaTag_Tag",
table: "MangaTag",
column: "Tag");
migrationBuilder.AddForeignKey(
name: "FK_AltTitles_Manga_MangaId",
table: "AltTitles",
column: "MangaId",
principalTable: "Manga",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Chapters_Manga_ParentMangaId",
table: "Chapters",
column: "ParentMangaId",
principalTable: "Manga",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Chapters_Manga_ParentMangaId",
table: "Chapters");
migrationBuilder.DropTable(
name: "AltTitles");
migrationBuilder.DropTable(
name: "Jobs");
migrationBuilder.DropTable(
name: "LibraryConnectors");
migrationBuilder.DropTable(
name: "Link");
migrationBuilder.DropTable(
name: "MangaAuthor");
migrationBuilder.DropTable(
name: "MangaTag");
migrationBuilder.DropTable(
name: "NotificationConnectors");
migrationBuilder.DropTable(
name: "Authors");
migrationBuilder.DropTable(
name: "Tags");
migrationBuilder.DropTable(
name: "Manga");
migrationBuilder.DropTable(
name: "Chapters");
migrationBuilder.DropTable(
name: "MangaConnectors");
}
}
}

View File

@ -1,693 +0,0 @@
// <auto-generated />
using System;
using API.Schema;
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
{
[DbContext(typeof(PgsqlContext))]
[Migration("20250111180034_ChapterNumber")]
partial class ChapterNumber
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.Author", b =>
{
b.Property<string>("AuthorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("AuthorName")
.IsRequired()
.HasColumnType("text");
b.HasKey("AuthorId");
b.ToTable("Authors");
});
modelBuilder.Entity("API.Schema.Chapter", b =>
{
b.Property<string>("ChapterId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ArchiveFileName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ChapterNumber")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<bool>("Downloaded")
.HasColumnType("boolean");
b.Property<string>("ParentMangaId")
.IsRequired()
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("VolumeNumber")
.HasColumnType("integer");
b.HasKey("ChapterId");
b.HasIndex("ParentMangaId");
b.ToTable("Chapters");
});
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
{
b.Property<string>("JobId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.PrimitiveCollection<string[]>("DependsOnJobsIds")
.HasMaxLength(64)
.HasColumnType("text[]");
b.Property<string>("JobId1")
.HasColumnType("character varying(64)");
b.Property<byte>("JobType")
.HasColumnType("smallint");
b.Property<DateTime>("LastExecution")
.HasColumnType("timestamp with time zone");
b.Property<string>("ParentJobId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<decimal>("RecurrenceMs")
.HasColumnType("numeric(20,0)");
b.Property<int>("state")
.HasColumnType("integer");
b.HasKey("JobId");
b.HasIndex("JobId1");
b.HasIndex("ParentJobId");
b.ToTable("Jobs");
b.HasDiscriminator<byte>("JobType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.LibraryConnectors.LibraryConnector", b =>
{
b.Property<string>("LibraryConnectorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Auth")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BaseUrl")
.IsRequired()
.HasColumnType("text");
b.Property<byte>("LibraryType")
.HasColumnType("smallint");
b.HasKey("LibraryConnectorId");
b.ToTable("LibraryConnectors");
b.HasDiscriminator<byte>("LibraryType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.Link", b =>
{
b.Property<string>("LinkId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LinkProvider")
.IsRequired()
.HasColumnType("text");
b.Property<string>("LinkUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.HasKey("LinkId");
b.HasIndex("MangaId");
b.ToTable("Link");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Property<string>("MangaId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ConnectorId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("CoverFileNameInCache")
.HasColumnType("text");
b.Property<string>("CoverUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("FolderName")
.IsRequired()
.HasColumnType("text");
b.Property<float>("IgnoreChapterBefore")
.HasColumnType("real");
b.Property<string>("MangaConnectorId")
.IsRequired()
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("OriginalLanguage")
.HasColumnType("text");
b.Property<byte>("ReleaseStatus")
.HasColumnType("smallint");
b.Property<string>("WebsiteUrl")
.IsRequired()
.HasColumnType("text");
b.Property<long>("Year")
.HasColumnType("bigint");
b.HasKey("MangaId");
b.HasIndex("MangaConnectorId");
b.ToTable("Manga");
});
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
{
b.Property<string>("AltTitleId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("AltTitleId");
b.HasIndex("MangaId");
b.ToTable("AltTitles");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaConnector", b =>
{
b.Property<string>("Name")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.PrimitiveCollection<string[]>("BaseUris")
.IsRequired()
.HasColumnType("text[]");
b.PrimitiveCollection<string[]>("SupportedLanguages")
.IsRequired()
.HasColumnType("text[]");
b.HasKey("Name");
b.ToTable("MangaConnectors");
b.HasDiscriminator<string>("Name").HasValue("MangaConnector");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.MangaTag", b =>
{
b.Property<string>("Tag")
.HasColumnType("text");
b.HasKey("Tag");
b.ToTable("Tags");
});
modelBuilder.Entity("API.Schema.Notification", b =>
{
b.Property<string>("NotificationId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<byte>("Urgency")
.HasColumnType("smallint");
b.HasKey("NotificationId");
b.ToTable("Notifications");
});
modelBuilder.Entity("API.Schema.NotificationConnectors.NotificationConnector", b =>
{
b.Property<string>("NotificationConnectorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<byte>("NotificationConnectorType")
.HasColumnType("smallint");
b.HasKey("NotificationConnectorId");
b.ToTable("NotificationConnectors");
b.HasDiscriminator<byte>("NotificationConnectorType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("AuthorManga", b =>
{
b.Property<string>("AuthorsAuthorId")
.HasColumnType("character varying(64)");
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.HasKey("AuthorsAuthorId", "MangaId");
b.HasIndex("MangaId");
b.ToTable("AuthorManga");
});
modelBuilder.Entity("MangaMangaTag", b =>
{
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.Property<string>("TagsTag")
.HasColumnType("text");
b.HasKey("MangaId", "TagsTag");
b.HasIndex("TagsTag");
b.ToTable("MangaMangaTag");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadNewChaptersJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("ChapterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("ChapterId");
b.HasDiscriminator().HasValue((byte)0);
});
modelBuilder.Entity("API.Schema.Jobs.MoveFileOrFolderJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("FromLocation")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ToLocation")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue((byte)3);
});
modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.ToTable("Jobs", t =>
{
t.Property("MangaId")
.HasColumnName("UpdateMetadataJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)2);
});
modelBuilder.Entity("API.Schema.LibraryConnectors.Kavita", b =>
{
b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector");
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.LibraryConnectors.Komga", b =>
{
b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector");
b.HasDiscriminator().HasValue((byte)0);
});
modelBuilder.Entity("API.Schema.MangaConnectors.AsuraToon", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("AsuraToon");
});
modelBuilder.Entity("API.Schema.MangaConnectors.Bato", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Bato");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaDex", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("MangaDex");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaHere", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("MangaHere");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaKatana", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("MangaKatana");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaLife", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Manga4Life");
});
modelBuilder.Entity("API.Schema.MangaConnectors.Manganato", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Manganato");
});
modelBuilder.Entity("API.Schema.MangaConnectors.Mangasee", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Mangasee");
});
modelBuilder.Entity("API.Schema.MangaConnectors.Mangaworld", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Mangaworld");
});
modelBuilder.Entity("API.Schema.MangaConnectors.ManhuaPlus", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("ManhuaPlus");
});
modelBuilder.Entity("API.Schema.MangaConnectors.Weebcentral", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Weebcentral");
});
modelBuilder.Entity("API.Schema.NotificationConnectors.Gotify", b =>
{
b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector");
b.Property<string>("AppToken")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Endpoint")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue((byte)0);
});
modelBuilder.Entity("API.Schema.NotificationConnectors.Lunasea", b =>
{
b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector");
b.Property<string>("Id")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.NotificationConnectors.Ntfy", b =>
{
b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector");
b.Property<string>("Auth")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Endpoint")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("text");
b.ToTable("NotificationConnectors", t =>
{
t.Property("Endpoint")
.HasColumnName("Ntfy_Endpoint");
});
b.HasDiscriminator().HasValue((byte)2);
});
modelBuilder.Entity("API.Schema.Chapter", b =>
{
b.HasOne("API.Schema.Manga", "ParentManga")
.WithMany()
.HasForeignKey("ParentMangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentManga");
});
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
{
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany("DependsOnJobs")
.HasForeignKey("JobId1");
b.HasOne("API.Schema.Jobs.Job", "ParentJob")
.WithMany()
.HasForeignKey("ParentJobId");
b.Navigation("ParentJob");
});
modelBuilder.Entity("API.Schema.Link", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("Links")
.HasForeignKey("MangaId");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.HasOne("API.Schema.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MangaConnector");
});
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("AltTitles")
.HasForeignKey("MangaId");
});
modelBuilder.Entity("AuthorManga", b =>
{
b.HasOne("API.Schema.Author", null)
.WithMany()
.HasForeignKey("AuthorsAuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.Manga", null)
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MangaMangaTag", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaTag", null)
.WithMany()
.HasForeignKey("TagsTag")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Schema.Jobs.DownloadNewChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
{
b.HasOne("API.Schema.Chapter", "Chapter")
.WithMany()
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
{
b.Navigation("DependsOnJobs");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Navigation("AltTitles");
b.Navigation("Links");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,54 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations
{
/// <inheritdoc />
public partial class ChapterNumber : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "VolumeNumber",
table: "Chapters",
type: "integer",
nullable: true,
oldClrType: typeof(float),
oldType: "real",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ChapterNumber",
table: "Chapters",
type: "character varying(10)",
maxLength: 10,
nullable: false,
oldClrType: typeof(float),
oldType: "real");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<float>(
name: "VolumeNumber",
table: "Chapters",
type: "real",
nullable: true,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
migrationBuilder.AlterColumn<float>(
name: "ChapterNumber",
table: "Chapters",
type: "real",
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(10)",
oldMaxLength: 10);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,690 +0,0 @@
// <auto-generated />
using System;
using API.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace API.Migrations
{
[DbContext(typeof(PgsqlContext))]
partial class PgsqlContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.Author", b =>
{
b.Property<string>("AuthorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("AuthorName")
.IsRequired()
.HasColumnType("text");
b.HasKey("AuthorId");
b.ToTable("Authors");
});
modelBuilder.Entity("API.Schema.Chapter", b =>
{
b.Property<string>("ChapterId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ArchiveFileName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ChapterNumber")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<bool>("Downloaded")
.HasColumnType("boolean");
b.Property<string>("ParentMangaId")
.IsRequired()
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("VolumeNumber")
.HasColumnType("integer");
b.HasKey("ChapterId");
b.HasIndex("ParentMangaId");
b.ToTable("Chapters");
});
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
{
b.Property<string>("JobId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.PrimitiveCollection<string[]>("DependsOnJobsIds")
.HasMaxLength(64)
.HasColumnType("text[]");
b.Property<string>("JobId1")
.HasColumnType("character varying(64)");
b.Property<byte>("JobType")
.HasColumnType("smallint");
b.Property<DateTime>("LastExecution")
.HasColumnType("timestamp with time zone");
b.Property<string>("ParentJobId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<decimal>("RecurrenceMs")
.HasColumnType("numeric(20,0)");
b.Property<int>("state")
.HasColumnType("integer");
b.HasKey("JobId");
b.HasIndex("JobId1");
b.HasIndex("ParentJobId");
b.ToTable("Jobs");
b.HasDiscriminator<byte>("JobType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.LibraryConnectors.LibraryConnector", b =>
{
b.Property<string>("LibraryConnectorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Auth")
.IsRequired()
.HasColumnType("text");
b.Property<string>("BaseUrl")
.IsRequired()
.HasColumnType("text");
b.Property<byte>("LibraryType")
.HasColumnType("smallint");
b.HasKey("LibraryConnectorId");
b.ToTable("LibraryConnectors");
b.HasDiscriminator<byte>("LibraryType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.Link", b =>
{
b.Property<string>("LinkId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LinkProvider")
.IsRequired()
.HasColumnType("text");
b.Property<string>("LinkUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.HasKey("LinkId");
b.HasIndex("MangaId");
b.ToTable("Link");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Property<string>("MangaId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ConnectorId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("CoverFileNameInCache")
.HasColumnType("text");
b.Property<string>("CoverUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("FolderName")
.IsRequired()
.HasColumnType("text");
b.Property<float>("IgnoreChapterBefore")
.HasColumnType("real");
b.Property<string>("MangaConnectorId")
.IsRequired()
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("OriginalLanguage")
.HasColumnType("text");
b.Property<byte>("ReleaseStatus")
.HasColumnType("smallint");
b.Property<string>("WebsiteUrl")
.IsRequired()
.HasColumnType("text");
b.Property<long>("Year")
.HasColumnType("bigint");
b.HasKey("MangaId");
b.HasIndex("MangaConnectorId");
b.ToTable("Manga");
});
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
{
b.Property<string>("AltTitleId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("AltTitleId");
b.HasIndex("MangaId");
b.ToTable("AltTitles");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaConnector", b =>
{
b.Property<string>("Name")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.PrimitiveCollection<string[]>("BaseUris")
.IsRequired()
.HasColumnType("text[]");
b.PrimitiveCollection<string[]>("SupportedLanguages")
.IsRequired()
.HasColumnType("text[]");
b.HasKey("Name");
b.ToTable("MangaConnectors");
b.HasDiscriminator<string>("Name").HasValue("MangaConnector");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("API.Schema.MangaTag", b =>
{
b.Property<string>("Tag")
.HasColumnType("text");
b.HasKey("Tag");
b.ToTable("Tags");
});
modelBuilder.Entity("API.Schema.Notification", b =>
{
b.Property<string>("NotificationId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<byte>("Urgency")
.HasColumnType("smallint");
b.HasKey("NotificationId");
b.ToTable("Notifications");
});
modelBuilder.Entity("API.Schema.NotificationConnectors.NotificationConnector", b =>
{
b.Property<string>("NotificationConnectorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<byte>("NotificationConnectorType")
.HasColumnType("smallint");
b.HasKey("NotificationConnectorId");
b.ToTable("NotificationConnectors");
b.HasDiscriminator<byte>("NotificationConnectorType");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("AuthorManga", b =>
{
b.Property<string>("AuthorsAuthorId")
.HasColumnType("character varying(64)");
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.HasKey("AuthorsAuthorId", "MangaId");
b.HasIndex("MangaId");
b.ToTable("AuthorManga");
});
modelBuilder.Entity("MangaMangaTag", b =>
{
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.Property<string>("TagsTag")
.HasColumnType("text");
b.HasKey("MangaId", "TagsTag");
b.HasIndex("TagsTag");
b.ToTable("MangaMangaTag");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadNewChaptersJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("ChapterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("ChapterId");
b.HasDiscriminator().HasValue((byte)0);
});
modelBuilder.Entity("API.Schema.Jobs.MoveFileOrFolderJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("FromLocation")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ToLocation")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue((byte)3);
});
modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b =>
{
b.HasBaseType("API.Schema.Jobs.Job");
b.Property<string>("MangaId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasIndex("MangaId");
b.ToTable("Jobs", t =>
{
t.Property("MangaId")
.HasColumnName("UpdateMetadataJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)2);
});
modelBuilder.Entity("API.Schema.LibraryConnectors.Kavita", b =>
{
b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector");
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.LibraryConnectors.Komga", b =>
{
b.HasBaseType("API.Schema.LibraryConnectors.LibraryConnector");
b.HasDiscriminator().HasValue((byte)0);
});
modelBuilder.Entity("API.Schema.MangaConnectors.AsuraToon", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("AsuraToon");
});
modelBuilder.Entity("API.Schema.MangaConnectors.Bato", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Bato");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaDex", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("MangaDex");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaHere", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("MangaHere");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaKatana", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("MangaKatana");
});
modelBuilder.Entity("API.Schema.MangaConnectors.MangaLife", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Manga4Life");
});
modelBuilder.Entity("API.Schema.MangaConnectors.Manganato", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Manganato");
});
modelBuilder.Entity("API.Schema.MangaConnectors.Mangasee", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Mangasee");
});
modelBuilder.Entity("API.Schema.MangaConnectors.Mangaworld", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Mangaworld");
});
modelBuilder.Entity("API.Schema.MangaConnectors.ManhuaPlus", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("ManhuaPlus");
});
modelBuilder.Entity("API.Schema.MangaConnectors.Weebcentral", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Weebcentral");
});
modelBuilder.Entity("API.Schema.NotificationConnectors.Gotify", b =>
{
b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector");
b.Property<string>("AppToken")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Endpoint")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue((byte)0);
});
modelBuilder.Entity("API.Schema.NotificationConnectors.Lunasea", b =>
{
b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector");
b.Property<string>("Id")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.NotificationConnectors.Ntfy", b =>
{
b.HasBaseType("API.Schema.NotificationConnectors.NotificationConnector");
b.Property<string>("Auth")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Endpoint")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("text");
b.ToTable("NotificationConnectors", t =>
{
t.Property("Endpoint")
.HasColumnName("Ntfy_Endpoint");
});
b.HasDiscriminator().HasValue((byte)2);
});
modelBuilder.Entity("API.Schema.Chapter", b =>
{
b.HasOne("API.Schema.Manga", "ParentManga")
.WithMany()
.HasForeignKey("ParentMangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentManga");
});
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
{
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany("DependsOnJobs")
.HasForeignKey("JobId1");
b.HasOne("API.Schema.Jobs.Job", "ParentJob")
.WithMany()
.HasForeignKey("ParentJobId");
b.Navigation("ParentJob");
});
modelBuilder.Entity("API.Schema.Link", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("Links")
.HasForeignKey("MangaId");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.HasOne("API.Schema.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MangaConnector");
});
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("AltTitles")
.HasForeignKey("MangaId");
});
modelBuilder.Entity("AuthorManga", b =>
{
b.HasOne("API.Schema.Author", null)
.WithMany()
.HasForeignKey("AuthorsAuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.Manga", null)
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MangaMangaTag", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.MangaTag", null)
.WithMany()
.HasForeignKey("TagsTag")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Schema.Jobs.DownloadNewChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadSingleChapterJob", b =>
{
b.HasOne("API.Schema.Chapter", "Chapter")
.WithMany()
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Schema.Jobs.UpdateMetadataJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.Job", b =>
{
b.Navigation("DependsOnJobs");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Navigation("AltTitles");
b.Navigation("Links");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,3 +0,0 @@
namespace API;
public record ProblemResponse(string title, string? message = null);

View File

@ -1,13 +1,15 @@
using System.Reflection; using System.Reflection;
using System.Text.Json.Serialization;
using API; using API;
using API.Schema; using API.Schema.LibraryContext;
using API.Schema.Jobs; using API.Schema.MangaContext;
using API.Schema.MangaConnectors; using API.Schema.MangaContext.MangaConnectors;
using API.Schema.NotificationsContext;
using Asp.Versioning; using Asp.Versioning;
using Asp.Versioning.Builder; using Asp.Versioning.Builder;
using Asp.Versioning.Conventions; using Asp.Versioning.Conventions;
using log4net;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -25,7 +27,7 @@ builder.Services.AddCors(options =>
}); });
builder.Services.AddApiVersioning(option => builder.Services.AddApiVersioning(option =>
{ {
option.AssumeDefaultVersionWhenUnspecified = true; option.AssumeDefaultVersionWhenUnspecified = true;
option.DefaultApiVersion = new ApiVersion(2); option.DefaultApiVersion = new ApiVersion(2);
option.ReportApiVersions = true; option.ReportApiVersions = true;
@ -34,16 +36,18 @@ builder.Services.AddApiVersioning(option =>
new QueryStringApiVersionReader("api-version"), new QueryStringApiVersionReader("api-version"),
new HeaderApiVersionReader("X-Version"), new HeaderApiVersionReader("X-Version"),
new MediaTypeApiVersionReader("x-version")); new MediaTypeApiVersionReader("x-version"));
}) })
.AddMvc(options => .AddMvc(options =>
{ {
options.Conventions.Add(new VersionByNamespaceConvention()); options.Conventions.Add(new VersionByNamespaceConvention());
}) })
.AddApiExplorer(options => { .AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'V"; options.GroupNameFormat = "'v'V";
options.SubstituteApiVersionInUrl = true; options.SubstituteApiVersionInUrl = true;
}); });
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGenNewtonsoftSupport();
builder.Services.AddSwaggerGen(opt => builder.Services.AddSwaggerGen(opt =>
{ {
var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
@ -51,16 +55,28 @@ builder.Services.AddSwaggerGen(opt =>
}); });
builder.Services.ConfigureOptions<NamedSwaggerGenOptions>(); builder.Services.ConfigureOptions<NamedSwaggerGenOptions>();
builder.Services.AddDbContext<PgsqlContext>(options => string connectionString = $"Host={Environment.GetEnvironmentVariable("POSTGRES_HOST") ?? "localhost:5432"}; " +
options.UseNpgsql($"Host={Environment.GetEnvironmentVariable("POSTGRES_HOST")??"localhost:5432"}; " + $"Database={Environment.GetEnvironmentVariable("POSTGRES_DB") ?? "postgres"}; " +
$"Database={Environment.GetEnvironmentVariable("POSTGRES_DB")??"postgres"}; " + $"Username={Environment.GetEnvironmentVariable("POSTGRES_USER") ?? "postgres"}; " +
$"Username={Environment.GetEnvironmentVariable("POSTGRES_USER")??"postgres"}; " + $"Password={Environment.GetEnvironmentVariable("POSTGRES_PASSWORD") ?? "postgres"}";
$"Password={Environment.GetEnvironmentVariable("POSTGRES_PASSWORD")??"postgres"}"));
builder.Services.AddDbContext<MangaContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.AddDbContext<NotificationsContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.AddDbContext<LibraryContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.AddControllers(options =>
{
options.AllowEmptyInputInBodyModelBinding = true;
});
builder.Services.AddControllers().AddNewtonsoftJson(opts => builder.Services.AddControllers().AddNewtonsoftJson(opts =>
{ {
opts.SerializerSettings.Converters.Add(new StringEnumConverter()); opts.SerializerSettings.Converters.Add(new StringEnumConverter());
opts.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
}); });
builder.Services.AddScoped<ILog>(opts => LogManager.GetLogger("API"));
builder.WebHost.UseUrls("http://*:6531"); builder.WebHost.UseUrls("http://*:6531");
@ -81,51 +97,55 @@ app.MapControllers()
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(options => app.UseSwaggerUI(options =>
{ {
options.SwaggerEndpoint( options.SwaggerEndpoint($"/swagger/v2/swagger.json", "v2");
$"/swagger/v2/swagger.json", "v2");
}); });
app.UseHttpsRedirection(); app.UseHttpsRedirection();
using (var scope = app.Services.CreateScope()) app.UseMiddleware<RequestTimeMiddleware>();
{
var db = scope.ServiceProvider.GetRequiredService<PgsqlContext>();
db.Database.Migrate();
}
using (var scope = app.Services.CreateScope()) using (IServiceScope scope = app.Services.CreateScope())
{ {
PgsqlContext context = scope.ServiceProvider.GetService<PgsqlContext>()!; MangaContext context = scope.ServiceProvider.GetRequiredService<MangaContext>();
context.Database.Migrate();
MangaConnector[] connectors = MangaConnector[] connectors =
[ [
new AsuraToon(),
new Bato(),
new MangaDex(), new MangaDex(),
new MangaHere(), new ComickIo(),
new MangaKatana(), new Global(scope.ServiceProvider.GetService<MangaContext>()!)
new MangaLife(),
new Manganato(),
new Mangaworld(),
new ManhuaPlus(),
new Weebcentral()
]; ];
MangaConnector[] newConnectors = connectors.Where(c => !context.MangaConnectors.Contains(c)).ToArray(); MangaConnector[] newConnectors = connectors.Where(c => !context.MangaConnectors.Contains(c)).ToArray();
context.MangaConnectors.AddRange(newConnectors); context.MangaConnectors.AddRange(newConnectors);
if (!context.FileLibraries.Any())
context.FileLibraries.Add(new FileLibrary(Tranga.Settings.DownloadLocation, "Default FileLibrary"));
context.Jobs.RemoveRange(context.Jobs.Where(j => j.state == JobState.Completed && j.RecurrenceMs < 1)); context.Sync();
}
using (IServiceScope scope = app.Services.CreateScope())
{
NotificationsContext context = scope.ServiceProvider.GetRequiredService<NotificationsContext>();
context.Database.Migrate();
context.Notifications.RemoveRange(context.Notifications);
string[] emojis = { "(•‿•)", "(づ \u25d5‿\u25d5 )づ", "( \u02d8\u25bd\u02d8)っ\u2668", "=\uff3e\u25cf \u22cf \u25cf\uff3e=", "(ΦωΦ)", "(\u272a\u3268\u272a)", "( ノ・o・ )ノ", "(〜^\u2207^ )〜", "~(\u2267ω\u2266)~","૮ \u00b4• ﻌ \u00b4• ა", "(\u02c3ᆺ\u02c2)", "(=\ud83d\udf66 \u0f1d \ud83d\udf66=)"}; 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.SaveChanges(); context.Sync();
} }
using (IServiceScope scope = app.Services.CreateScope())
{
LibraryContext context = scope.ServiceProvider.GetRequiredService<LibraryContext>();
context.Database.Migrate();
context.Sync();
}
TrangaSettings.Load();
Tranga.StartLogger(); Tranga.StartLogger();
Tranga.JobStarterThread.Start(app.Services);
Tranga.NotificationSenderThread.Start(app.Services.CreateScope().ServiceProvider.GetService<PgsqlContext>()); Tranga.PeriodicWorkerStarterThread.Start(app.Services);
app.UseCors("AllowAll"); app.UseCors("AllowAll");

View File

@ -1,12 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace API.Schema;
[PrimaryKey("AuthorId")]
public class Author(string authorName)
{
[MaxLength(64)]
public string AuthorId { get; init; } = TokenGen.CreateToken(typeof(Author), authorName);
public string AuthorName { get; init; } = authorName;
}

View File

@ -1,136 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.Xml.Linq;
using API.Schema.Jobs;
using Microsoft.EntityFrameworkCore;
namespace API.Schema;
[PrimaryKey("ChapterId")]
public class Chapter : IComparable<Chapter>
{
public Chapter(Manga parentManga, string url, string chapterNumber, int? volumeNumber = null, string? title = null)
: this(parentManga.MangaId, url, chapterNumber, volumeNumber, title)
{
ParentManga = parentManga;
ArchiveFileName = BuildArchiveFileName();
}
public Chapter(string parentMangaId, string url, string chapterNumber,
int? volumeNumber = null, string? title = null)
{
ChapterId = TokenGen.CreateToken(typeof(Chapter), parentMangaId, (volumeNumber ?? 0).ToString(), chapterNumber);
ParentMangaId = parentMangaId;
Url = url;
ChapterNumber = chapterNumber;
VolumeNumber = volumeNumber;
Title = title;
}
[MaxLength(64)] public string ChapterId { get; init; }
public int? VolumeNumber { get; private set; }
[MaxLength(10)] public string ChapterNumber { get; private set; }
public string Url { get; internal set; }
public string? Title { get; private set; }
public string ArchiveFileName { get; private set; }
public bool Downloaded { get; internal set; } = false;
public string ParentMangaId { get; internal set; }
public Manga? ParentManga { get; init; }
public int CompareTo(Chapter? other)
{
if (other is not { } otherChapter)
throw new ArgumentException($"{other} can not be compared to {this}");
return VolumeNumber?.CompareTo(otherChapter.VolumeNumber) switch
{
< 0 => -1,
> 0 => 1,
_ => CompareChapterNumbers(ChapterNumber, otherChapter.ChapterNumber)
};
}
public MoveFileOrFolderJob? UpdateChapterNumber(string chapterNumber)
{
ChapterNumber = chapterNumber;
return UpdateArchiveFileName();
}
public MoveFileOrFolderJob? UpdateVolumeNumber(int? volumeNumber)
{
VolumeNumber = volumeNumber;
return UpdateArchiveFileName();
}
public MoveFileOrFolderJob? UpdateTitle(string? title)
{
Title = title;
return UpdateArchiveFileName();
}
private string BuildArchiveFileName()
{
return
$"{ParentManga.Name} - Vol.{VolumeNumber ?? 0} Ch.{ChapterNumber}{(Title is null ? "" : $" - {Title}")}.cbz";
}
private MoveFileOrFolderJob? UpdateArchiveFileName()
{
string oldPath = GetArchiveFilePath();
ArchiveFileName = BuildArchiveFileName();
return Downloaded ? new MoveFileOrFolderJob(oldPath, GetArchiveFilePath()) : null;
}
/// <summary>
/// Creates full file path of chapter-archive
/// </summary>
/// <returns>Filepath</returns>
internal string GetArchiveFilePath()
{
return Path.Join(TrangaSettings.downloadLocation, ParentManga.FolderName, ArchiveFileName);
}
public bool IsDownloaded()
{
string path = GetArchiveFilePath();
return File.Exists(path);
}
private static int CompareChapterNumbers(string ch1, string ch2)
{
int[] ch1Arr = ch1.Split('.').Select(c => int.TryParse(c, out int result) ? result : -1).ToArray();
int[] ch2Arr = ch2.Split('.').Select(c => int.TryParse(c, out int result) ? result : -1).ToArray();
if (ch1Arr.Contains(-1) || ch2Arr.Contains(-1))
throw new ArgumentException("Chapter number is not in correct format");
int i = 0, j = 0;
while (i < ch1Arr.Length && j < ch2Arr.Length)
{
if (ch1Arr[i] < ch2Arr[j])
return -1;
if (ch1Arr[i] > ch2Arr[j])
return 1;
i++;
j++;
}
return 0;
}
internal string GetComicInfoXmlString()
{
XElement comicInfo = new("ComicInfo",
new XElement("Tags", string.Join(',', ParentManga.Tags.Select(tag => tag.Tag))),
new XElement("LanguageISO", ParentManga.OriginalLanguage),
new XElement("Title", Title),
new XElement("Writer", string.Join(',', ParentManga.Authors.Select(author => author.AuthorName))),
new XElement("Volume", VolumeNumber),
new XElement("Number", ChapterNumber)
);
return comicInfo.ToString();
}
}

View File

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

View File

@ -1,135 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.IO.Compression;
using System.Runtime.InteropServices;
using API.MangaDownloadClients;
using API.Schema.MangaConnectors;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Binarization;
using static System.IO.UnixFileMode;
namespace API.Schema.Jobs;
public class DownloadMangaCoverJob(string chapterId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
: Job(TokenGen.CreateToken(typeof(DownloadMangaCoverJob)), JobType.DownloadMangaCoverJob, 0, parentJobId, dependsOnJobsIds)
{
[MaxLength(64)]
public string ChapterId { get; init; } = chapterId;
public Chapter? Chapter { get; init; }
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
MangaConnector connector = Chapter.ParentManga?.MangaConnector ?? context.MangaConnectors.Find(context.Manga.Find(Chapter.ParentMangaId)?.MangaId)!;
DownloadChapterImages(Chapter, connector);
return [];
}
private bool DownloadChapterImages(Chapter chapter, MangaConnector connector)
{
string[] imageUrls = connector.GetChapterImageUrls(Chapter);
string saveArchiveFilePath = chapter.GetArchiveFilePath();
//Check if Publication Directory already exists
string directoryPath = Path.GetDirectoryName(saveArchiveFilePath)!;
if (!Directory.Exists(directoryPath))
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
Directory.CreateDirectory(directoryPath,
UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute );
else
Directory.CreateDirectory(directoryPath);
if (File.Exists(saveArchiveFilePath)) //Don't download twice. Redownload
File.Delete(saveArchiveFilePath);
//Create a temporary folder to store images
string tempFolder = Directory.CreateTempSubdirectory("trangatemp").FullName;
int chapterNum = 0;
//Download all Images to temporary Folder
if (imageUrls.Length == 0)
{
Directory.Delete(tempFolder, true);
return false;
}
foreach (string imageUrl in imageUrls)
{
string extension = imageUrl.Split('.')[^1].Split('?')[0];
string imagePath = Path.Join(tempFolder, $"{chapterNum++}.{extension}");
bool status = DownloadImage(imageUrl, imagePath);
if (status is false)
return false;
}
CopyCoverFromCacheToDownloadLocation();
File.WriteAllText(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString());
//ZIP-it and ship-it
ZipFile.CreateFromDirectory(tempFolder, saveArchiveFilePath);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(saveArchiveFilePath, UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute | OtherRead | OtherExecute);
Directory.Delete(tempFolder, true); //Cleanup
return true;
}
private void ProcessImage(string imagePath)
{
if (!TrangaSettings.bwImages && TrangaSettings.compression == 100)
return;
using Image image = Image.Load(imagePath);
File.Delete(imagePath);
if(TrangaSettings.bwImages)
image.Mutate(i => i.ApplyProcessor(new AdaptiveThresholdProcessor()));
image.SaveAsJpeg(imagePath, new JpegEncoder()
{
Quality = TrangaSettings.compression
});
}
private void CopyCoverFromCacheToDownloadLocation(int? retries = 1)
{
//Check if Publication already has a Folder and cover
string publicationFolder = Chapter.ParentManga.CreatePublicationFolder();
DirectoryInfo dirInfo = new (publicationFolder);
if (dirInfo.EnumerateFiles().Any(info => info.Name.Contains("cover", StringComparison.InvariantCultureIgnoreCase)))
{
return;
}
string? fileInCache = Chapter.ParentManga.CoverFileNameInCache;
if (fileInCache is null || !File.Exists(fileInCache))
{
if (retries > 0 && Chapter.ParentManga.CoverUrl is not null)
{
Chapter.ParentManga.SaveCoverImageToCache();
CopyCoverFromCacheToDownloadLocation(--retries);
}
return;
}
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" );
File.Copy(fileInCache, newFilePath, true);
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | UserRead | UserWrite);
}
private bool DownloadImage(string imageUrl, string savePath)
{
HttpDownloadClient downloadClient = new();
RequestResult requestResult = downloadClient.MakeRequest(imageUrl, RequestType.MangaImage);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return false;
if (requestResult.result == Stream.Null)
return false;
FileStream fs = new (savePath, FileMode.Create);
requestResult.result.CopyTo(fs);
fs.Close();
ProcessImage(savePath);
return true;
}
}

View File

@ -1,33 +0,0 @@
using System.ComponentModel.DataAnnotations;
using API.Schema.MangaConnectors;
namespace API.Schema.Jobs;
public class DownloadNewChaptersJob(ulong recurrenceMs, string mangaId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
: Job(TokenGen.CreateToken(typeof(DownloadNewChaptersJob)), JobType.DownloadNewChaptersJob, recurrenceMs, parentJobId, dependsOnJobsIds)
{
[MaxLength(64)]
public string MangaId { get; init; } = mangaId;
public Manga? Manga { get; init; }
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
/*
* For some reason, directly using Manga from above instead of finding it again causes DBContext to consider
* Manga as a new entity and Postgres throws a Duplicate PK exception.
* m.MangaConnector does not have this issue (IDK why).
*/
Manga m = context.Manga.Find(MangaId)!;
MangaConnector connector = context.MangaConnectors.Find(m.MangaConnectorId)!;
// This gets all chapters that are not downloaded
Chapter[] allNewChapters = connector.GetNewChapters(m);
// This filters out chapters that are not downloaded but already exist in the DB
string[] chapterIds = context.Chapters.Where(chapter => chapter.ParentMangaId == m.MangaId).Select(chapter => chapter.ChapterId).ToArray();
Chapter[] newChapters = allNewChapters.Where(chapter => !chapterIds.Contains(chapter.ChapterId)).ToArray();
context.Chapters.AddRangeAsync(newChapters).Wait();
context.SaveChangesAsync().Wait();
return allNewChapters.Select(chapter => new DownloadSingleChapterJob(chapter.ChapterId, this.JobId));
}
}

View File

@ -1,138 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.IO.Compression;
using System.Runtime.InteropServices;
using API.MangaDownloadClients;
using API.Schema.MangaConnectors;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Binarization;
using static System.IO.UnixFileMode;
namespace API.Schema.Jobs;
public class DownloadSingleChapterJob(string chapterId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
: Job(TokenGen.CreateToken(typeof(DownloadSingleChapterJob)), JobType.DownloadSingleChapterJob, 0, parentJobId, dependsOnJobsIds)
{
[MaxLength(64)]
public string ChapterId { get; init; } = chapterId;
public Chapter? Chapter { get; init; }
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
Chapter c = Chapter ?? context.Chapters.Find(ChapterId)!;
Manga m = c.ParentManga ?? context.Manga.Find(c.ParentMangaId)!;
MangaConnector connector = m.MangaConnector ?? context.MangaConnectors.Find(m.MangaConnectorId)!;
DownloadChapterImages(c, connector, m);
return [];
}
private bool DownloadChapterImages(Chapter chapter, MangaConnector connector, Manga manga)
{
string[] imageUrls = connector.GetChapterImageUrls(chapter);
string saveArchiveFilePath = chapter.GetArchiveFilePath();
//Check if Publication Directory already exists
string directoryPath = Path.GetDirectoryName(saveArchiveFilePath)!;
if (!Directory.Exists(directoryPath))
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
Directory.CreateDirectory(directoryPath,
UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute );
else
Directory.CreateDirectory(directoryPath);
if (File.Exists(saveArchiveFilePath)) //Don't download twice. Redownload
File.Delete(saveArchiveFilePath);
//Create a temporary folder to store images
string tempFolder = Directory.CreateTempSubdirectory("trangatemp").FullName;
int chapterNum = 0;
//Download all Images to temporary Folder
if (imageUrls.Length == 0)
{
Directory.Delete(tempFolder, true);
return false;
}
foreach (string imageUrl in imageUrls)
{
string extension = imageUrl.Split('.')[^1].Split('?')[0];
string imagePath = Path.Join(tempFolder, $"{chapterNum++}.{extension}");
bool status = DownloadImage(imageUrl, imagePath);
if (status is false)
return false;
}
CopyCoverFromCacheToDownloadLocation(manga);
File.WriteAllText(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString());
//ZIP-it and ship-it
ZipFile.CreateFromDirectory(tempFolder, saveArchiveFilePath);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(saveArchiveFilePath, UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute | OtherRead | OtherExecute);
Directory.Delete(tempFolder, true); //Cleanup
return true;
}
private void ProcessImage(string imagePath)
{
if (!TrangaSettings.bwImages && TrangaSettings.compression == 100)
return;
DateTime start = DateTime.UtcNow;
using Image image = Image.Load(imagePath);
File.Delete(imagePath);
if(TrangaSettings.bwImages)
image.Mutate(i => i.ApplyProcessor(new AdaptiveThresholdProcessor()));
image.SaveAsJpeg(imagePath, new JpegEncoder()
{
Quality = TrangaSettings.compression
});
}
private void CopyCoverFromCacheToDownloadLocation(Manga manga, int? retries = 1)
{
//Check if Publication already has a Folder and cover
string publicationFolder = manga.CreatePublicationFolder();
DirectoryInfo dirInfo = new (publicationFolder);
if (dirInfo.EnumerateFiles().Any(info => info.Name.Contains("cover", StringComparison.InvariantCultureIgnoreCase)))
{
return;
}
string? fileInCache = manga.CoverFileNameInCache;
if (fileInCache is null || !File.Exists(fileInCache))
{
if (retries > 0)
{
manga.SaveCoverImageToCache();
CopyCoverFromCacheToDownloadLocation(manga, --retries);
}
return;
}
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" );
File.Copy(fileInCache, newFilePath, true);
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | UserRead | UserWrite);
}
private bool DownloadImage(string imageUrl, string savePath)
{
HttpDownloadClient downloadClient = new();
RequestResult requestResult = downloadClient.MakeRequest(imageUrl, RequestType.MangaImage);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return false;
if (requestResult.result == Stream.Null)
return false;
FileStream fs = new (savePath, FileMode.Create);
requestResult.result.CopyTo(fs);
fs.Close();
ProcessImage(savePath);
return true;
}
}

View File

@ -1,58 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.Jobs;
[PrimaryKey("JobId")]
public abstract class Job
{
[MaxLength(64)]
public string JobId { get; init; }
[MaxLength(64)]
public string? ParentJobId { get; init; }
public Job? ParentJob { get; init; }
[MaxLength(64)]
public ICollection<string>? DependsOnJobsIds { get; init; }
public ICollection<Job>? DependsOnJobs { get; init; }
public JobType JobType { get; init; }
public ulong RecurrenceMs { get; set; }
public DateTime LastExecution { get; internal set; } = DateTime.UnixEpoch;
[NotMapped]
public DateTime NextExecution => LastExecution.AddMilliseconds(RecurrenceMs);
public JobState state { get; internal set; } = JobState.Waiting;
public Job(string jobId, JobType jobType, ulong recurrenceMs, Job? parentJob = null, ICollection<Job>? dependsOnJobs = null)
: this(jobId, jobType, recurrenceMs, parentJob?.JobId, dependsOnJobs?.Select(j => j.JobId).ToList())
{
this.ParentJob = parentJob;
this.DependsOnJobs = dependsOnJobs;
}
public Job(string jobId, JobType jobType, ulong recurrenceMs, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
{
JobId = jobId;
ParentJobId = parentJobId;
DependsOnJobsIds = dependsOnJobsIds;
JobType = jobType;
RecurrenceMs = recurrenceMs;
}
public IEnumerable<Job> Run(IServiceProvider serviceProvider)
{
using IServiceScope scope = serviceProvider.CreateScope();
PgsqlContext context = scope.ServiceProvider.GetRequiredService<PgsqlContext>();
this.state = JobState.Running;
IEnumerable<Job> newJobs = RunInternal(context);
this.state = JobState.Completed;
return newJobs;
}
protected abstract IEnumerable<Job> RunInternal(PgsqlContext context);
}

View File

@ -1,29 +0,0 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using JsonSerializer = Newtonsoft.Json.JsonSerializer;
namespace API.Schema.Jobs;
public class JobJsonDeserializer : JsonConverter<Job>
{
public override bool CanWrite { get; } = false;
public override void WriteJson(JsonWriter writer, Job? value, JsonSerializer serializer)
{
throw new NotImplementedException();
}
public override Job? ReadJson(JsonReader reader, Type objectType, Job? existingValue, bool hasExistingValue, JsonSerializer serializer)
{
JObject j = JObject.Load(reader);
JobType? type = Enum.Parse<JobType>(j.GetValue("jobType")!.Value<string>()!);
return type switch
{
JobType.DownloadSingleChapterJob => j.ToObject<DownloadSingleChapterJob>(),
JobType.DownloadNewChaptersJob => j.ToObject<DownloadNewChaptersJob>(),
JobType.UpdateMetaDataJob => j.ToObject<UpdateMetadataJob>(),
JobType.MoveFileOrFolderJob => j.ToObject<MoveFileOrFolderJob>(),
_ => null
};
}
}

View File

@ -1,8 +0,0 @@
namespace API.Schema.Jobs;
public enum JobState
{
Waiting,
Running,
Completed
}

View File

@ -1,11 +0,0 @@
namespace API.Schema.Jobs;
public enum JobType : byte
{
DownloadSingleChapterJob = 0,
DownloadNewChaptersJob = 1,
UpdateMetaDataJob = 2,
MoveFileOrFolderJob = 3,
DownloadMangaCoverJob = 4
}

View File

@ -1,13 +0,0 @@
namespace API.Schema.Jobs;
public class MoveFileOrFolderJob(string fromLocation, string toLocation, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
: Job(TokenGen.CreateToken(typeof(MoveFileOrFolderJob)), JobType.MoveFileOrFolderJob, 0, parentJobId, dependsOnJobsIds)
{
public string FromLocation { get; init; } = fromLocation;
public string ToLocation { get; init; } = toLocation;
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
throw new NotImplementedException();
}
}

View File

@ -1,17 +0,0 @@
using System.ComponentModel.DataAnnotations;
using API.Schema.MangaConnectors;
namespace API.Schema.Jobs;
public class UpdateMetadataJob(ulong recurrenceMs, string mangaId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
: Job(TokenGen.CreateToken(typeof(UpdateMetadataJob)), JobType.UpdateMetaDataJob, recurrenceMs, parentJobId, dependsOnJobsIds)
{
[MaxLength(64)]
public string MangaId { get; init; } = mangaId;
public virtual Manga Manga { get; init; }
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
throw new NotImplementedException();
}
}

View File

@ -1,18 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.LibraryConnectors;
[PrimaryKey("LibraryConnectorId")]
public abstract class LibraryConnector(string libraryConnectorId, LibraryType libraryType, string baseUrl, string auth)
{
[MaxLength(64)]
public string LibraryConnectorId { get; } = libraryConnectorId;
public LibraryType LibraryType { get; init; } = libraryType;
public string BaseUrl { get; init; } = baseUrl;
public string Auth { get; init; } = auth;
protected abstract void UpdateLibraryInternal();
internal abstract bool Test();
}

View File

@ -1,7 +0,0 @@
namespace API.Schema.LibraryConnectors;
public enum LibraryType : byte
{
Komga = 0,
Kavita = 1
}

View File

@ -1,69 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
namespace API.Schema.LibraryConnectors;
public class NetClient
{
public static Stream MakeRequest(string url, string authScheme, string auth)
{
HttpClient client = new();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(authScheme, auth);
HttpRequestMessage requestMessage = new ()
{
Method = HttpMethod.Get,
RequestUri = new Uri(url)
};
try
{
HttpResponseMessage response = client.Send(requestMessage);
if (response.StatusCode is HttpStatusCode.Unauthorized &&
response.RequestMessage!.RequestUri!.AbsoluteUri != url)
return MakeRequest(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth);
else if (response.IsSuccessStatusCode)
return response.Content.ReadAsStream();
else
return Stream.Null;
}
catch (Exception e)
{
switch (e)
{
case HttpRequestException:
break;
default:
throw;
}
return Stream.Null;
}
}
public static bool MakePost(string url, string authScheme, string auth)
{
HttpClient client = new()
{
DefaultRequestHeaders =
{
{ "Accept", "application/json" },
{ "Authorization", new AuthenticationHeaderValue(authScheme, auth).ToString() }
}
};
HttpRequestMessage requestMessage = new ()
{
Method = HttpMethod.Post,
RequestUri = new Uri(url)
};
HttpResponseMessage response = client.Send(requestMessage);
if(response.StatusCode is HttpStatusCode.Unauthorized && response.RequestMessage!.RequestUri!.AbsoluteUri != url)
return MakePost(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth);
else if (response.IsSuccessStatusCode)
return true;
else
return false;
}
}

View File

@ -1,12 +1,12 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
namespace API.Schema.LibraryConnectors; namespace API.Schema.LibraryContext.LibraryConnectors;
public class Kavita : LibraryConnector public class Kavita : LibraryConnector
{ {
public Kavita(string baseUrl, string auth) : base(TokenGen.CreateToken(typeof(Kavita), baseUrl), LibraryType.Kavita, baseUrl, auth) public Kavita(string baseUrl, string auth) : base(LibraryType.Kavita, baseUrl, auth)
{ {
} }
@ -44,8 +44,9 @@ public class Kavita : LibraryConnector
{ {
} }
} }
catch (HttpRequestException e) catch (HttpRequestException)
{ {
} }
return ""; return "";
} }
@ -53,13 +54,13 @@ public class Kavita : LibraryConnector
protected override void UpdateLibraryInternal() protected override void UpdateLibraryInternal()
{ {
foreach (KavitaLibrary lib in GetLibraries()) foreach (KavitaLibrary lib in GetLibraries())
NetClient.MakePost($"{BaseUrl}/api/Library/scan?libraryId={lib.id}", "Bearer", Auth); NetClient.MakePost($"{BaseUrl}/api/ToFileLibrary/scan?libraryId={lib.id}", "Bearer", Auth);
} }
internal override bool Test() internal override bool Test()
{ {
foreach (KavitaLibrary lib in GetLibraries()) foreach (KavitaLibrary lib in GetLibraries())
if (NetClient.MakePost($"{BaseUrl}/api/Library/scan?libraryId={lib.id}", "Bearer", Auth)) if (NetClient.MakePost($"{BaseUrl}/api/ToFileLibrary/scan?libraryId={lib.id}", "Bearer", Auth))
return true; return true;
return false; return false;
} }
@ -70,15 +71,17 @@ public class Kavita : LibraryConnector
/// <returns>Array of KavitaLibrary</returns> /// <returns>Array of KavitaLibrary</returns>
private IEnumerable<KavitaLibrary> GetLibraries() private IEnumerable<KavitaLibrary> GetLibraries()
{ {
Stream data = NetClient.MakeRequest($"{BaseUrl}/api/Library/libraries", "Bearer", Auth); Stream data = NetClient.MakeRequest($"{BaseUrl}/api/ToFileLibrary/libraries", "Bearer", Auth);
if (data == Stream.Null) if (data == Stream.Null)
{ {
return Array.Empty<KavitaLibrary>(); Log.Info("No libraries found");
return [];
} }
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data); JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
if (result is null) if (result is null)
{ {
return Array.Empty<KavitaLibrary>(); Log.Info("No libraries found");
return [];
} }
List<KavitaLibrary> ret = new(); List<KavitaLibrary> ret = new();
@ -88,7 +91,7 @@ public class Kavita : LibraryConnector
JsonObject? jObject = (JsonObject?)jsonNode; JsonObject? jObject = (JsonObject?)jsonNode;
if(jObject is null) if(jObject is null)
continue; continue;
int libraryId = jObject!["id"]!.GetValue<int>(); int libraryId = jObject["id"]!.GetValue<int>();
string libraryName = jObject["name"]!.GetValue<string>(); string libraryName = jObject["name"]!.GetValue<string>();
ret.Add(new KavitaLibrary(libraryId, libraryName)); ret.Add(new KavitaLibrary(libraryId, libraryName));
} }

View File

@ -1,12 +1,11 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
namespace API.Schema.LibraryConnectors; namespace API.Schema.LibraryContext.LibraryConnectors;
public class Komga : LibraryConnector public class Komga : LibraryConnector
{ {
public Komga(string baseUrl, string auth) : base(TokenGen.CreateToken(typeof(Komga), baseUrl), LibraryType.Komga, public Komga(string baseUrl, string auth) : base(LibraryType.Komga, baseUrl, auth)
baseUrl, auth)
{ {
} }
@ -38,12 +37,14 @@ public class Komga : LibraryConnector
Stream data = NetClient.MakeRequest($"{BaseUrl}/api/v1/libraries", "Basic", Auth); Stream data = NetClient.MakeRequest($"{BaseUrl}/api/v1/libraries", "Basic", Auth);
if (data == Stream.Null) if (data == Stream.Null)
{ {
return Array.Empty<KomgaLibrary>(); Log.Info("No libraries found");
return [];
} }
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data); JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
if (result is null) if (result is null)
{ {
return Array.Empty<KomgaLibrary>(); Log.Info("No libraries found");
return [];
} }
HashSet<KomgaLibrary> ret = new(); HashSet<KomgaLibrary> ret = new();

View File

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

View File

@ -0,0 +1,73 @@
using System.Net;
using System.Net.Http.Headers;
using log4net;
namespace API.Schema.LibraryContext.LibraryConnectors;
public class NetClient
{
private static ILog Log = LogManager.GetLogger(typeof(NetClient));
public static Stream MakeRequest(string url, string authScheme, string auth)
{
Log.Debug($"Requesting {url}");
HttpClient client = new();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(authScheme, auth);
HttpRequestMessage requestMessage = new()
{
Method = HttpMethod.Get,
RequestUri = new Uri(url)
};
try
{
HttpResponseMessage response = client.Send(requestMessage);
if (response.StatusCode is HttpStatusCode.Unauthorized &&
response.RequestMessage!.RequestUri!.AbsoluteUri != url)
return MakeRequest(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth);
else if (response.IsSuccessStatusCode)
return response.Content.ReadAsStream();
else
return Stream.Null;
}
catch (Exception e)
{
switch (e)
{
case HttpRequestException:
Log.Debug(e);
break;
default:
throw;
}
Log.Info("Failed to make request");
return Stream.Null;
}
}
public static bool MakePost(string url, string authScheme, string auth)
{
HttpClient client = new()
{
DefaultRequestHeaders =
{
{ "Accept", "application/json" },
{ "Authorization", new AuthenticationHeaderValue(authScheme, auth).ToString() }
}
};
HttpRequestMessage requestMessage = new ()
{
Method = HttpMethod.Post,
RequestUri = new Uri(url)
};
HttpResponseMessage response = client.Send(requestMessage);
if(response.StatusCode is HttpStatusCode.Unauthorized && response.RequestMessage!.RequestUri!.AbsoluteUri != url)
return MakePost(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth);
else if (response.IsSuccessStatusCode)
return true;
else
return false;
}
}

View File

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

View File

@ -1,20 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace API.Schema;
[PrimaryKey("LinkId")]
public class Link(string linkProvider, string linkUrl)
{
[MaxLength(64)]
public string LinkId { get; init; } = TokenGen.CreateToken(typeof(Link), linkProvider, linkUrl);
public string LinkProvider { get; init; } = linkProvider;
public string LinkUrl { get; init; } = linkUrl;
public override bool Equals(object? obj)
{
if (obj is not Link other)
return false;
return other.LinkProvider == LinkProvider && other.LinkUrl == LinkUrl;
}
}

View File

@ -1,132 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using API.Schema.Jobs;
using API.Schema.MangaConnectors;
using Microsoft.EntityFrameworkCore;
using static System.IO.UnixFileMode;
namespace API.Schema;
[PrimaryKey("MangaId")]
public class Manga
{
[MaxLength(64)]
public string MangaId { get; init; }
[MaxLength(64)]
public string ConnectorId { get; init; }
public string Name { get; internal set; }
public string Description { get; internal set; }
public string WebsiteUrl { get; internal set; }
public string CoverUrl { get; internal set; }
public string? CoverFileNameInCache { get; internal set; }
public uint Year { get; internal set; }
public string? OriginalLanguage { get; internal set; }
public MangaReleaseStatus ReleaseStatus { get; internal set; }
public string FolderName { get; private set; }
public float IgnoreChapterBefore { get; internal set; }
public string MangaConnectorId { get; private set; }
public MangaConnector? MangaConnector { get; private set; }
public ICollection<Author>? Authors { get; internal set; }
public ICollection<MangaTag>? Tags { get; internal set; }
public ICollection<Link>? Links { get; internal set; }
public ICollection<MangaAltTitle>? AltTitles { get; internal set; }
public Manga(string connectorId, string name, string description, string websiteUrl, string coverUrl,
string? coverFileNameInCache, uint year, string? originalLanguage, MangaReleaseStatus releaseStatus,
float ignoreChapterBefore, MangaConnector mangaConnector, ICollection<Author> authors,
ICollection<MangaTag> tags, ICollection<Link> links, ICollection<MangaAltTitle> altTitles)
: this(connectorId, name, description, websiteUrl, coverUrl, coverFileNameInCache, year, originalLanguage,
releaseStatus, ignoreChapterBefore, mangaConnector.Name)
{
this.Authors = authors;
this.Tags = tags;
this.Links = links;
this.AltTitles = altTitles;
}
public Manga(string connectorId, string name, string description, string websiteUrl, string coverUrl,
string? coverFileNameInCache, uint year, string? originalLanguage, MangaReleaseStatus releaseStatus,
float ignoreChapterBefore, string mangaConnectorId)
{
MangaId = TokenGen.CreateToken(typeof(Manga), mangaConnectorId, connectorId);
ConnectorId = connectorId;
Name = name;
Description = description;
WebsiteUrl = websiteUrl;
CoverUrl = coverUrl;
CoverFileNameInCache = coverFileNameInCache;
Year = year;
OriginalLanguage = originalLanguage;
ReleaseStatus = releaseStatus;
IgnoreChapterBefore = ignoreChapterBefore;
MangaConnectorId = mangaConnectorId;
FolderName = BuildFolderName(name);
}
public MoveFileOrFolderJob UpdateFolderName(string downloadLocation, string newName)
{
string oldName = this.FolderName;
this.FolderName = newName;
return new MoveFileOrFolderJob(Path.Join(downloadLocation, oldName), Path.Join(downloadLocation, this.FolderName));
}
internal void UpdateWithInfo(Manga other)
{
this.Name = other.Name;
this.Year = other.Year;
this.Description = other.Description;
this.CoverUrl = other.CoverUrl;
this.OriginalLanguage = other.OriginalLanguage;
this.Authors = other.Authors;
this.Links = other.Links;
this.Tags = other.Tags;
this.AltTitles = other.AltTitles;
this.ReleaseStatus = other.ReleaseStatus;
}
private static string BuildFolderName(string mangaName)
{
return mangaName;
}
internal string SaveCoverImageToCache()
{
Regex urlRex = new (@"https?:\/\/((?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]+)\/(?:.+\/)*(.+\.([a-zA-Z]+))");
//https?:\/\/[a-zA-Z0-9-]+\.([a-zA-Z0-9-]+\.[a-zA-Z0-9]+)\/(?:.+\/)*(.+\.([a-zA-Z]+)) for only second level domains
Match match = urlRex.Match(CoverUrl);
string filename = $"{match.Groups[1].Value}-{MangaId}.{match.Groups[3].Value}";
string saveImagePath = Path.Join(TrangaSettings.coverImageCache, filename);
if (File.Exists(saveImagePath))
return saveImagePath;
RequestResult coverResult = new HttpDownloadClient().MakeRequest(CoverUrl, RequestType.MangaCover);
using MemoryStream ms = new();
coverResult.result.CopyTo(ms);
Directory.CreateDirectory(TrangaSettings.coverImageCache);
File.WriteAllBytes(saveImagePath, ms.ToArray());
return saveImagePath;
}
public string CreatePublicationFolder()
{
string publicationFolder = Path.Join(TrangaSettings.downloadLocation, this.FolderName);
if(!Directory.Exists(publicationFolder))
Directory.CreateDirectory(publicationFolder);
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(publicationFolder, GroupRead | GroupWrite | GroupExecute | OtherRead | OtherWrite | OtherExecute | UserRead | UserWrite | UserExecute);
return publicationFolder;
}
//TODO onchanges create job to update metadata files in archives, etc.
}

View File

@ -1,15 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace API.Schema;
[PrimaryKey("AltTitleId")]
public class MangaAltTitle(string language, string title)
{
[MaxLength(64)]
public string AltTitleId { get; init; } = TokenGen.CreateToken("AltTitle", language, title);
[MaxLength(8)]
public string Language { get; init; } = language;
public string Title { get; set; } = title;
}

View File

@ -1,190 +0,0 @@
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using HtmlAgilityPack;
namespace API.Schema.MangaConnectors;
public class AsuraToon : MangaConnector
{
public AsuraToon() : base("AsuraToon", ["en"], ["https://asuracomic.net"])
{
this.downloadClient = new ChromiumDownloadClient();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower();
string requestUrl = $"https://asuracomic.net/series?name={sanitizedTitle}";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return [];
if (requestResult.htmlDocument is null)
{
return [];
}
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
return publications;
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{
return GetMangaFromUrl($"https://asuracomic.net/series/{publicationId}");
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{
RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null;
if (requestResult.htmlDocument is null)
{
return null;
}
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url);
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
{
HtmlNodeCollection mangaList = document.DocumentNode.SelectNodes("//a[starts-with(@href,'series')]");
if (mangaList is null || mangaList.Count < 1)
return [];
IEnumerable<string> urls = mangaList.Select(a => $"https://asuracomic.net/{a.GetAttributeValue("href", "")}");
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls)
{
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is { } x)
ret.Add(x);
}
return ret.ToArray();
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
{
string? originalLanguage = null;
Dictionary<string, string> altTitles = new(), links = new();
HtmlNodeCollection genreNodes = document.DocumentNode.SelectNodes("//h3[text()='Genres']/../div/button");
string[] tags = genreNodes.Select(b => b.InnerText).ToArray();
List<MangaTag> mangaTags = tags.Select(t => new MangaTag(t)).ToList();
HtmlNode statusNode = document.DocumentNode.SelectSingleNode("//h3[text()='Status']/../h3[2]");
MangaReleaseStatus releaseStatus = statusNode.InnerText.ToLower() switch
{
"ongoing" => MangaReleaseStatus.Continuing,
"hiatus" => MangaReleaseStatus.OnHiatus,
"completed" => MangaReleaseStatus.Completed,
"dropped" => MangaReleaseStatus.Cancelled,
"season end" => MangaReleaseStatus.Continuing,
"coming soon" => MangaReleaseStatus.Unreleased,
_ => MangaReleaseStatus.Unreleased
};
HtmlNode coverNode =
document.DocumentNode.SelectSingleNode("//img[@alt='poster']");
string coverUrl = coverNode.GetAttributeValue("src", "");
HtmlNode titleNode =
document.DocumentNode.SelectSingleNode("//title");
string sortName = Regex.Match(titleNode.InnerText, @"(.*) - Asura Scans").Groups[1].Value;
HtmlNode descriptionNode =
document.DocumentNode.SelectSingleNode("//h3[starts-with(text(),'Synopsis')]/../span");
string description = descriptionNode?.InnerText??"";
HtmlNodeCollection authorNodes = document.DocumentNode.SelectNodes("//h3[text()='Author']/../h3[not(text()='Author' or text()='_')]");
HtmlNodeCollection artistNodes = document.DocumentNode.SelectNodes("//h3[text()='Artist']/../h3[not(text()='Artist' or text()='_')]");
IEnumerable<string> authorNames = authorNodes is null ? [] : authorNodes.Select(a => a.InnerText);
IEnumerable<string> artistNames = artistNodes is null ? [] : artistNodes.Select(a => a.InnerText);
List<string> authorStrings = authorNames.Concat(artistNames).ToList();
List<Author> authors = authorStrings.Select(author => new Author(author)).ToList();
HtmlNode? firstChapterNode = document.DocumentNode.SelectSingleNode("//a[contains(@href, 'chapter/1')]/../following-sibling::h3");
uint year = uint.Parse(firstChapterNode?.InnerText.Split(' ')[^1] ?? "2000");
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
originalLanguage, releaseStatus, -1,
this,
authors,
mangaTags,
[],
[]);
return (manga, authors, mangaTags, [], []);
}
public override Chapter[] GetChapters(Manga manga, string language="en")
{
string requestUrl = $"https://asuracomic.net/series/{manga.MangaId}";
// Leaving this in for verification if the page exists
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return [];
//Return Chapters ordered by Chapter-Number
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestUrl);
return chapters.Order().ToArray();
}
private List<Chapter> ParseChaptersFromHtml(Manga manga, string mangaUrl)
{
RequestResult result = downloadClient.MakeRequest(mangaUrl, RequestType.Default);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null)
{
return new List<Chapter>();
}
List<Chapter> ret = new();
HtmlNodeCollection chapterURLNodes = result.htmlDocument.DocumentNode.SelectNodes("//a[contains(@href, '/chapter/')]");
Regex infoRex = new(@"Chapter ([0-9]+)(.*)?");
foreach (HtmlNode chapterInfo in chapterURLNodes)
{
string chapterUrl = chapterInfo.GetAttributeValue("href", "");
Match match = infoRex.Match(chapterInfo.InnerText);
string chapterNumber = new(match.Groups[1].Value);
string? chapterName = match.Groups[2].Success && match.Groups[2].Length > 1 ? match.Groups[2].Value : null;
string url = $"https://asuracomic.net/series/{chapterUrl}";
try
{
ret.Add(new Chapter(manga, url, chapterNumber, null, chapterName));
}
catch (Exception e)
{
}
}
return ret;
}
internal override string[] GetChapterImageUrls(Chapter chapter)
{
string requestUrl = chapter.Url;
// Leaving this in to check if the page exists
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
{
return [];
}
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
return imageUrls;
}
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
{
HtmlNodeCollection images = document.DocumentNode.SelectNodes("//img[contains(@alt, 'chapter page')]");
return images.Select(i => i.GetAttributeValue("src", "")).ToArray();
}
}

View File

@ -1,203 +0,0 @@
using System.Net;
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using HtmlAgilityPack;
namespace API.Schema.MangaConnectors;
public class Bato : MangaConnector
{
public Bato() : base("Bato", ["en"], ["bato.to"])
{
this.downloadClient = new HttpDownloadClient();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower();
string requestUrl = $"https://bato.to/v3x-search?word={sanitizedTitle}&lang=en";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return [];
if (requestResult.htmlDocument is null)
{
return [];
}
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
return publications;
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{
return GetMangaFromUrl($"https://bato.to/title/{publicationId}");
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{
RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null;
if (requestResult.htmlDocument is null)
{
return null;
}
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url);
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
{
HtmlNode mangaList = document.DocumentNode.SelectSingleNode("//div[@data-hk='0-0-2']");
if (!mangaList.ChildNodes.Any(node => node.Name == "div"))
return [];
List<string> urls = mangaList.ChildNodes
.Select(node => $"https://bato.to{node.Descendants("div").First().FirstChild.GetAttributeValue("href", "")}").ToList();
HashSet<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls)
{
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is { } x)
ret.Add(x);
}
return ret.ToArray();
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
{
HtmlNode infoNode = document.DocumentNode.SelectSingleNode("/html/body/div/main/div[1]/div[2]");
string sortName = infoNode.Descendants("h3").First().InnerText;
string description = document.DocumentNode
.SelectSingleNode("//div[contains(concat(' ',normalize-space(@class),' '),'prose')]").InnerText;
string[] altTitlesList = infoNode.ChildNodes[1].ChildNodes[2].InnerText.Split('/');
int i = 0;
List<MangaAltTitle> altTitles = altTitlesList.Select(a => new MangaAltTitle(i++.ToString(), a)).ToList();
string coverUrl = document.DocumentNode.SelectNodes("//img")
.First(child => child.GetAttributeValue("data-hk", "") == "0-1-0").GetAttributeValue("src", "").Replace("&amp;", "&");
List<HtmlNode> genreNodes = document.DocumentNode.SelectSingleNode("//b[text()='Genres:']/..").SelectNodes("span").ToList();
string[] tags = genreNodes.Select(node => node.FirstChild.InnerText).ToArray();
List<MangaTag> mangaTags = tags.Select(s => new MangaTag(s)).ToList();
List<HtmlNode> authorsNodes = infoNode.ChildNodes[1].ChildNodes[3].Descendants("a").ToList();
List<string> authorNames = authorsNodes.Select(node => node.InnerText.Replace("amp;", "")).ToList();
List<Author> authors = authorNames.Select(n => new Author(n)).ToList();
HtmlNode? originalLanguageNode = document.DocumentNode.SelectSingleNode("//span[text()='Tr From']/..");
string originalLanguage = originalLanguageNode is not null ? originalLanguageNode.LastChild.InnerText : "";
if (!uint.TryParse(
document.DocumentNode.SelectSingleNode("//span[text()='Original Publication:']/..").LastChild.InnerText.Split('-')[0],
out uint year))
year = (uint)DateTime.UtcNow.Year;
string status = document.DocumentNode.SelectSingleNode("//span[text()='Original Publication:']/..")
.ChildNodes[2].InnerText;
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
switch (status.ToLower())
{
case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
case "completed": releaseStatus = MangaReleaseStatus.Completed; break;
case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break;
case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "pending": releaseStatus = MangaReleaseStatus.Unreleased; break;
}
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
originalLanguage, releaseStatus, -1,
this,
authors,
mangaTags,
[],
altTitles);
return (manga, authors, mangaTags, [], altTitles);
}
public override Chapter[] GetChapters(Manga manga, string language="en")
{
string requestUrl = $"https://bato.to/title/{manga.MangaId}";
// Leaving this in for verification if the page exists
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return [];
//Return Chapters ordered by Chapter-Number
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestUrl);
return chapters.Order().ToArray();
}
private List<Chapter> ParseChaptersFromHtml(Manga manga, string mangaUrl)
{
RequestResult result = downloadClient.MakeRequest(mangaUrl, RequestType.Default);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null)
{
return new List<Chapter>();
}
List<Chapter> ret = new();
HtmlNode chapterList =
result.htmlDocument.DocumentNode.SelectSingleNode("/html/body/div/main/div[3]/astro-island/div/div[2]/div/div/astro-slot");
Regex numberRex = new(@"\/title\/.+\/([0-9])+(?:-vol_([0-9]+))?-ch_([0-9\.]+)");
foreach (HtmlNode chapterInfo in chapterList.SelectNodes("div"))
{
HtmlNode infoNode = chapterInfo.FirstChild.FirstChild;
string chapterUrl = infoNode.GetAttributeValue("href", "");
Match match = numberRex.Match(chapterUrl);
string id = match.Groups[1].Value;
int? volumeNumber = match.Groups[2].Success ? int.Parse(match.Groups[2].Value) : null;
string chapterNumber = new(match.Groups[3].Value);
string url = $"https://bato.to{chapterUrl}?load=2";
try
{
ret.Add(new Chapter(manga, url, chapterNumber, volumeNumber, null));
}
catch (Exception e)
{
}
}
return ret;
}
internal override string[] GetChapterImageUrls(Chapter chapter)
{
string requestUrl = chapter.Url;
// Leaving this in to check if the page exists
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
{
return [];
}
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
return imageUrls;
}
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
{
HtmlNode images = document.DocumentNode.SelectNodes("//astro-island").First(node =>
node.GetAttributeValue("component-url", "").Contains("/_astro/ImageList."));
string weirdString = images.OuterHtml;
string weirdString2 = Regex.Match(weirdString, @"props=\""(.*)}\""").Groups[1].Value;
string[] urls = Regex.Matches(weirdString2, @"(https:\/\/[A-z\-0-9\.\?\&\;\=\/]+)\\")
.Select(match => match.Groups[1].Value.Replace("&amp;", "&")).ToArray();
return urls;
}
}

View File

@ -1,39 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using API.MangaDownloadClients;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.MangaConnectors;
[PrimaryKey("Name")]
public abstract class MangaConnector(string name, string[] supportedLanguages, string[] baseUris)
{
[MaxLength(32)]
public string Name { get; init; } = name;
public string[] SupportedLanguages { get; init; } = supportedLanguages;
public string[] BaseUris { get; init; } = baseUris;
public abstract (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "");
public abstract (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url);
public abstract (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId);
public abstract Chapter[] GetChapters(Manga manga, string language="en");
[JsonIgnore]
[NotMapped]
internal DownloadClient downloadClient { get; init; } = null!;
public Chapter[] GetNewChapters(Manga manga)
{
Chapter[] allChapters = GetChapters(manga);
if (allChapters.Length < 1)
return [];
return allChapters.Where(chapter => !chapter.IsDownloaded()).ToArray();
}
internal abstract string[] GetChapterImageUrls(Chapter chapter);
}

View File

@ -1,279 +0,0 @@
using System.Net;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace API.Schema.MangaConnectors;
public class MangaDex : MangaConnector
{
//https://api.mangadex.org/docs/3-enumerations/#language-codes--localization
//https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
//https://gist.github.com/Josantonius/b455e315bc7f790d14b136d61d9ae469
public MangaDex() : base("MangaDex", ["en","pt","pt-br","it","de","ru","aa","ab","ae","af","ak","am","an","ar-ae","ar-bh","ar-dz","ar-eg","ar-iq","ar-jo","ar-kw","ar-lb","ar-ly","ar-ma","ar-om","ar-qa","ar-sa","ar-sy","ar-tn","ar-ye","ar","as","av","ay","az","ba","be","bg","bh","bi","bm","bn","bo","br","bs","ca","ce","ch","co","cr","cs","cu","cv","cy","da","de-at","de-ch","de-de","de-li","de-lu","div","dv","dz","ee","el","en-au","en-bz","en-ca","en-cb","en-gb","en-ie","en-jm","en-nz","en-ph","en-tt","en-us","en-za","en-zw","eo","es-ar","es-bo","es-cl","es-co","es-cr","es-do","es-ec","es-es","es-gt","es-hn","es-la","es-mx","es-ni","es-pa","es-pe","es-pr","es-py","es-sv","es-us","es-uy","es-ve","es","et","eu","fa","ff","fi","fj","fo","fr-be","fr-ca","fr-ch","fr-fr","fr-lu","fr-mc","fr","fy","ga","gd","gl","gn","gu","gv","ha","he","hi","ho","hr-ba","hr-hr","hr","ht","hu","hy","hz","ia","id","ie","ig","ii","ik","in","io","is","it-ch","it-it","iu","iw","ja","ja-ro","ji","jv","jw","ka","kg","ki","kj","kk","kl","km","kn","ko","ko-ro","kr","ks","ku","kv","kw","ky","kz","la","lb","lg","li","ln","lo","ls","lt","lu","lv","mg","mh","mi","mk","ml","mn","mo","mr","ms-bn","ms-my","ms","mt","my","na","nb","nd","ne","ng","nl-be","nl-nl","nl","nn","no","nr","ns","nv","ny","oc","oj","om","or","os","pa","pi","pl","ps","pt-pt","qu-bo","qu-ec","qu-pe","qu","rm","rn","ro","rw","sa","sb","sc","sd","se-fi","se-no","se-se","se","sg","sh","si","sk","sl","sm","sn","so","sq","sr-ba","sr-sp","sr","ss","st","su","sv-fi","sv-se","sv","sw","sx","syr","ta","te","tg","th","ti","tk","tl","tn","to","tr","ts","tt","tw","ty","ug","uk","ur","us","uz","ve","vi","vo","wa","wo","xh","yi","yo","za","zh-cn","zh-hk","zh-mo","zh-ro","zh-sg","zh-tw","zh","zu"], ["mangadex.org"])
{
this.downloadClient = new HttpDownloadClient();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{
const int limit = 100; //How many values we want returned at once
int offset = 0; //"Page"
int total = int.MaxValue; //How many total results are there, is updated on first request
HashSet<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> retManga = new();
int loadedPublicationData = 0;
List<JsonNode> results = new();
//Request all search-results
while (offset < total) //As long as we haven't requested all "Pages"
{
//Request next Page
RequestResult requestResult = downloadClient.MakeRequest(
$"https://api.mangadex.org/manga?limit={limit}&title={publicationTitle}&offset={offset}" +
$"&contentRating%5B%5D=safe&contentRating%5B%5D=suggestive&contentRating%5B%5D=erotica" +
$"&contentRating%5B%5D=pornographic" +
$"&includes%5B%5D=manga&includes%5B%5D=cover_art&includes%5B%5D=author" +
$"&includes%5B%5D=artist&includes%5B%5D=tag", RequestType.MangaInfo);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
break;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
offset += limit;
if (result is null)
break;
if(result.ContainsKey("total"))
total = result["total"]!.GetValue<int>(); //Update the total number of Publications
else continue;
if (result.ContainsKey("data"))
results.AddRange(result["data"]!.AsArray()!);//Manga-data-Array
}
foreach (JsonNode mangaNode in results)
{
if(MangaFromJsonObject(mangaNode.AsObject()) is { } manga)
retManga.Add(manga); //Add Publication (Manga) to result
}
return retManga.ToArray();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{
RequestResult requestResult =
downloadClient.MakeRequest($"https://api.mangadex.org/manga/{publicationId}?includes%5B%5D=manga&includes%5B%5D=cover_art&includes%5B%5D=author&includes%5B%5D=artist&includes%5B%5D=tag", RequestType.MangaInfo);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
if(result is not null)
return MangaFromJsonObject(result["data"]!.AsObject());
return null;
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{
Regex idRex = new (@"https:\/\/mangadex.org\/title\/([A-z0-9-]*)\/.*");
string id = idRex.Match(url).Groups[1].Value;
return GetMangaFromId(id);
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? MangaFromJsonObject(JsonObject manga)
{
if (!manga.TryGetPropertyValue("id", out JsonNode? idNode))
return null;
string publicationId = idNode!.GetValue<string>();
if (!manga.TryGetPropertyValue("attributes", out JsonNode? attributesNode))
return null;
JsonObject attributes = attributesNode!.AsObject();
if (!attributes.TryGetPropertyValue("title", out JsonNode? titleNode))
return null;
string sortName = titleNode!.AsObject().ContainsKey("en") switch
{
true => titleNode.AsObject()["en"]!.GetValue<string>(),
false => titleNode.AsObject().First().Value!.GetValue<string>()
};
Dictionary<string, string> altTitlesDict = new();
if (attributes.TryGetPropertyValue("altTitles", out JsonNode? altTitlesNode))
{
foreach (JsonNode? altTitleNode in altTitlesNode!.AsArray())
{
JsonObject altTitleNodeObject = altTitleNode!.AsObject();
altTitlesDict.TryAdd(altTitleNodeObject.First().Key, altTitleNodeObject.First().Value!.GetValue<string>());
}
}
List<MangaAltTitle> altTitles = altTitlesDict.Select(t => new MangaAltTitle(t.Key, t.Value)).ToList();
if (!attributes.TryGetPropertyValue("description", out JsonNode? descriptionNode))
return null;
string description = descriptionNode!.AsObject().ContainsKey("en") switch
{
true => descriptionNode.AsObject()["en"]!.GetValue<string>(),
false => descriptionNode.AsObject().FirstOrDefault().Value?.GetValue<string>() ?? ""
};
Dictionary<string, string> linksDict = new();
if (attributes.TryGetPropertyValue("links", out JsonNode? linksNode) && linksNode is not null)
foreach (KeyValuePair<string, JsonNode?> linkKv in linksNode!.AsObject())
linksDict.TryAdd(linkKv.Key, linkKv.Value.GetValue<string>());
List<Link> links = linksDict.Select(x => new Link(x.Key, x.Value)).ToList();
string? originalLanguage =
attributes.TryGetPropertyValue("originalLanguage", out JsonNode? originalLanguageNode) switch
{
true => originalLanguageNode?.GetValue<string>(),
false => null
};
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
if (attributes.TryGetPropertyValue("status", out JsonNode? statusNode))
{
releaseStatus = statusNode?.GetValue<string>().ToLower() switch
{
"ongoing" => MangaReleaseStatus.Continuing,
"completed" => MangaReleaseStatus.Completed,
"hiatus" => MangaReleaseStatus.OnHiatus,
"cancelled" => MangaReleaseStatus.Cancelled,
_ => MangaReleaseStatus.Unreleased
};
}
uint year = attributes.TryGetPropertyValue("year", out JsonNode? yearNode) switch
{
true => yearNode?.GetValue<uint>()??0,
false => 0
};
HashSet<string> tags = new(128);
if (attributes.TryGetPropertyValue("tags", out JsonNode? tagsNode))
foreach (JsonNode? tagNode in tagsNode!.AsArray())
tags.Add(tagNode!["attributes"]!["name"]!["en"]!.GetValue<string>());
List<MangaTag> mangaTags = tags.Select(t => new MangaTag(t)).ToList();
if (!manga.TryGetPropertyValue("relationships", out JsonNode? relationshipsNode))
return null;
JsonNode? coverNode = relationshipsNode!.AsArray()
.FirstOrDefault(rel => rel!["type"]!.GetValue<string>().Equals("cover_art"));
if (coverNode is null)
return null;
string fileName = coverNode["attributes"]!["fileName"]!.GetValue<string>();
string coverUrl = $"https://uploads.mangadex.org/covers/{publicationId}/{fileName}";
List<string> authorNames = new();
JsonNode?[] authorNodes = relationshipsNode.AsArray()
.Where(rel => rel!["type"]!.GetValue<string>().Equals("author") || rel!["type"]!.GetValue<string>().Equals("artist")).ToArray();
foreach (JsonNode? authorNode in authorNodes)
{
string authorName = authorNode!["attributes"]!["name"]!.GetValue<string>();
if(!authorNames.Contains(authorName))
authorNames.Add(authorName);
}
List<Author> authors = authorNames.Select(a => new Author(a)).ToList();
Manga pub = new (publicationId, sortName, description, $"https://mangadex.org/title/{publicationId}", coverUrl, null, year,
originalLanguage, releaseStatus, -1,
this,
authors,
mangaTags,
links,
altTitles);
return (pub, authors, mangaTags, links, altTitles);
}
public override Chapter[] GetChapters(Manga manga, string language="en")
{
const int limit = 100; //How many values we want returned at once
int offset = 0; //"Page"
int total = int.MaxValue; //How many total results are there, is updated on first request
List<Chapter> chapters = new();
//As long as we haven't requested all "Pages"
while (offset < total)
{
//Request next "Page"
RequestResult requestResult =
downloadClient.MakeRequest(
$"https://api.mangadex.org/manga/{manga.ConnectorId}/feed?limit={limit}&offset={offset}&translatedLanguage%5B%5D={language}&contentRating%5B%5D=safe&contentRating%5B%5D=suggestive&contentRating%5B%5D=erotica&contentRating%5B%5D=pornographic", RequestType.MangaDexFeed);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
break;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
offset += limit;
if (result is null)
break;
total = result["total"]!.GetValue<int>();
JsonArray chaptersInResult = result["data"]!.AsArray();
//Loop through all Chapters in result and extract information from JSON
foreach (JsonNode? jsonNode in chaptersInResult)
{
JsonObject chapter = (JsonObject)jsonNode!;
JsonObject attributes = chapter["attributes"]!.AsObject();
string chapterId = chapter["id"]!.GetValue<string>();
string url = $"https://mangadex.org/chapter/{chapterId}";
string? title = attributes.ContainsKey("title") && attributes["title"] is not null
? attributes["title"]!.GetValue<string>()
: null;
int? volume = attributes.ContainsKey("volume") && attributes["volume"] is not null
? int.Parse(attributes["volume"]!.GetValue<string>())
: null;
string? chapterNumStr = attributes.ContainsKey("chapter") && attributes["chapter"] is not null
? attributes["chapter"]!.GetValue<string>()
: null;
string chapterNumber = new(chapterNumStr);
if (attributes.ContainsKey("pages") && attributes["pages"] is not null &&
attributes["pages"]!.GetValue<int>() < 1)
{
continue;
}
try
{
Chapter newChapter = new Chapter(manga, url, chapterNumber, volume, title);
if(!chapters.Contains(newChapter))
chapters.Add(newChapter);
}
catch (Exception e)
{
}
}
}
//Return Chapters ordered by Chapter-Number
return chapters.Order().ToArray();
}
internal override string[] GetChapterImageUrls(Chapter chapter)
{//Request URLs for Chapter-Images
RequestResult requestResult =
downloadClient.MakeRequest($"https://api.mangadex.org/at-home/server/{chapter.ChapterId}?forcePort443=false", RequestType.MangaDexImage);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
{
return [];
}
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
if (result is null)
{
return [];
}
string baseUrl = result["baseUrl"]!.GetValue<string>();
string hash = result["chapter"]!["hash"]!.GetValue<string>();
JsonArray imageFileNames = result["chapter"]!["data"]!.AsArray();
//Loop through all imageNames and construct urls (imageUrl)
List<string> imageUrls = new();
foreach (JsonNode? image in imageFileNames)
imageUrls.Add($"{baseUrl}/data/{hash}/{image!.GetValue<string>()}");
return imageUrls.ToArray();
}
}

View File

@ -1,183 +0,0 @@
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using HtmlAgilityPack;
namespace API.Schema.MangaConnectors;
public class MangaHere : MangaConnector
{
public MangaHere() : base("MangaHere", ["en"], ["www.mangahere.cc"])
{
this.downloadClient = new ChromiumDownloadClient();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{
string sanitizedTitle = string.Join('+', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower();
string requestUrl = $"https://www.mangahere.cc/search?title={sanitizedTitle}";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
return [];
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
return publications;
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
{
if (document.DocumentNode.SelectNodes("//div[contains(concat(' ',normalize-space(@class),' '),' container ')]").Any(node => node.ChildNodes.Any(cNode => cNode.HasClass("search-keywords"))))
return [];
List<string> urls = document.DocumentNode
.SelectNodes("//a[contains(@href, '/manga/') and not(contains(@href, '.html'))]")
.Select(thumb => $"https://www.mangahere.cc{thumb.GetAttributeValue("href", "")}").Distinct().ToList();
HashSet<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls)
{
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is { } x)
ret.Add(x);
}
return ret.ToArray();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{
return GetMangaFromUrl($"https://www.mangahere.cc/manga/{publicationId}");
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{
RequestResult requestResult =
downloadClient.MakeRequest(url, RequestType.MangaInfo);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
return null;
Regex idRex = new (@"https:\/\/www\.mangahere\.[a-z]{0,63}\/manga\/([0-9A-z\-]+).*");
string id = idRex.Match(url).Groups[1].Value;
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, id, url);
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
{
string originalLanguage = "", status = "";
Dictionary<string, string> altTitles = new(), links = new();
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
//We dont get posters, because same origin bs HtmlNode posterNode = document.DocumentNode.SelectSingleNode("//img[contains(concat(' ',normalize-space(@class),' '),' detail-info-cover-img ')]");
string coverUrl = "http://static.mangahere.cc/v20230914/mangahere/images/nopicture.jpg";
HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//span[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-title-font ')]");
string sortName = titleNode.InnerText;
List<string> authorNames = document.DocumentNode
.SelectNodes("//p[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-say ')]/a")
.Select(node => node.InnerText)
.ToList();
List<Author> authors = authorNames.Select(n => new Author(n)).ToList();
HashSet<string> tags = document.DocumentNode
.SelectNodes("//p[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-tag-list ')]/a")
.Select(node => node.InnerText)
.ToHashSet();
List<MangaTag> mangaTags = tags.Select(n => new MangaTag(n)).ToList();
status = document.DocumentNode.SelectSingleNode("//span[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-title-tip ')]").InnerText;
switch (status.ToLower())
{
case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break;
case "discontinued": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "complete": releaseStatus = MangaReleaseStatus.Completed; break;
case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
}
HtmlNode descriptionNode = document.DocumentNode
.SelectSingleNode("//p[contains(concat(' ',normalize-space(@class),' '),' fullcontent ')]");
string description = descriptionNode.InnerText;
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, 0,
originalLanguage, releaseStatus, -1,
this,
authors,
mangaTags,
[],
[]);
return (manga, authors, mangaTags, [], []);
}
public override Chapter[] GetChapters(Manga manga, string language="en")
{
string requestUrl = $"https://www.mangahere.cc/manga/{manga.MangaId}";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
return Array.Empty<Chapter>();
List<string> urls = requestResult.htmlDocument.DocumentNode.SelectNodes("//div[@id='list-1']/ul//li//a[contains(@href, '/manga/')]")
.Select(node => node.GetAttributeValue("href", "")).ToList();
Regex chapterRex = new(@".*\/manga\/[a-zA-Z0-9\-\._\~\!\$\&\'\(\)\*\+\,\;\=\:\@]+\/v([0-9(TBD)]+)\/c([0-9\.]+)\/.*");
List<Chapter> chapters = new();
foreach (string url in urls)
{
Match rexMatch = chapterRex.Match(url);
int? volumeNumber = rexMatch.Groups[1].Value == "TBD" ? null : int.Parse(rexMatch.Groups[1].Value);
string chapterNumber = new(rexMatch.Groups[2].Value);
string fullUrl = $"https://www.mangahere.cc{url}";
try
{
chapters.Add(new Chapter(manga, fullUrl, chapterNumber, volumeNumber, null));
}
catch (Exception e)
{
}
}
//Return Chapters ordered by Chapter-Number
return chapters.Order().ToArray();
}
internal override string[] GetChapterImageUrls(Chapter chapter)
{
List<string> imageUrls = new();
int downloaded = 1;
int images = 1;
string url = string.Join('/', chapter.Url.Split('/')[..^1]);
do
{
RequestResult requestResult =
downloadClient.MakeRequest($"{url}/{downloaded}.html", RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
{
return [];
}
imageUrls.AddRange(ParseImageUrlsFromHtml(requestResult.htmlDocument));
images = requestResult.htmlDocument.DocumentNode
.SelectNodes("//a[contains(@href, '/manga/')]")
.MaxBy(node => node.GetAttributeValue("data-page", 0))!.GetAttributeValue("data-page", 0);
} while (downloaded++ <= images);
return imageUrls.ToArray();
}
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
{
return document.DocumentNode
.SelectNodes("//img[contains(concat(' ',normalize-space(@class),' '),' reader-main-img ')]")
.Select(node =>
{
string url = node.GetAttributeValue("src", "");
return url.StartsWith("//") ? $"https:{url}" : url;
})
.ToArray();
}
}

View File

@ -1,233 +0,0 @@
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using HtmlAgilityPack;
namespace API.Schema.MangaConnectors;
public class MangaKatana : MangaConnector
{
public MangaKatana() : base("MangaKatana", ["en"], ["mangakatana.com"])
{
this.downloadClient = new HttpDownloadClient();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{
string sanitizedTitle = string.Join("%20", Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower();
string requestUrl = $"https://mangakatana.com/?search={sanitizedTitle}&search_by=book_name";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return [];
// ReSharper disable once MergeIntoPattern
// If a single result is found, the user will be redirected to the results directly instead of a result page
if(requestResult.hasBeenRedirected
&& requestResult.redirectedToUrl is not null
&& requestResult.redirectedToUrl.Contains("mangakatana.com/manga"))
{
return new [] { ParseSinglePublicationFromHtml(requestResult.result, requestResult.redirectedToUrl.Split('/')[^1], requestResult.redirectedToUrl) };
}
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.result);
return publications;
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{
return GetMangaFromUrl($"https://mangakatana.com/manga/{publicationId}");
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{
RequestResult requestResult =
downloadClient.MakeRequest(url, RequestType.MangaInfo);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null;
return ParseSinglePublicationFromHtml(requestResult.result, url.Split('/')[^1], url);
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(Stream html)
{
StreamReader reader = new(html);
string htmlString = reader.ReadToEnd();
HtmlDocument document = new();
document.LoadHtml(htmlString);
IEnumerable<HtmlNode> searchResults = document.DocumentNode.SelectNodes("//*[@id='book_list']/div");
if (searchResults is null || !searchResults.Any())
return [];
List<string> urls = new();
foreach (HtmlNode mangaResult in searchResults)
{
urls.Add(mangaResult.Descendants("a").First().GetAttributes()
.First(a => a.Name == "href").Value);
}
HashSet<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls)
{
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is { } x)
ret.Add(x);
}
return ret.ToArray();
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(Stream html, string publicationId, string websiteUrl)
{
StreamReader reader = new(html);
string htmlString = reader.ReadToEnd();
HtmlDocument document = new();
document.LoadHtml(htmlString);
Dictionary<string, string> altTitlesDict = new();
Dictionary<string, string>? links = null;
HashSet<string> tags = new();
string[] authorNames = [];
string originalLanguage = "";
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
HtmlNode infoNode = document.DocumentNode.SelectSingleNode("//*[@id='single_book']");
string sortName = infoNode.Descendants("h1").First(n => n.HasClass("heading")).InnerText;
HtmlNode infoTable = infoNode.SelectSingleNode("//*[@id='single_book']/div[2]/div/ul");
foreach (HtmlNode row in infoTable.Descendants("li"))
{
string key = row.SelectNodes("div").First().InnerText.ToLower();
string value = row.SelectNodes("div").Last().InnerText;
string keySanitized = string.Concat(Regex.Matches(key, "[a-z]"));
switch (keySanitized)
{
case "altnames":
string[] alts = value.Split(" ; ");
for (int i = 0; i < alts.Length; i++)
altTitlesDict.Add(i.ToString(), alts[i]);
break;
case "authorsartists":
authorNames = value.Split(',');
break;
case "status":
switch (value.ToLower())
{
case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
case "completed": releaseStatus = MangaReleaseStatus.Completed; break;
}
break;
case "genres":
tags = row.SelectNodes("div").Last().Descendants("a").Select(a => a.InnerText).ToHashSet();
break;
}
}
string coverUrl = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[1]/div").Descendants("img").First()
.GetAttributes().First(a => a.Name == "src").Value;
string description = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[3]/p").InnerText;
while (description.StartsWith('\n'))
description = description.Substring(1);
uint year = (uint)DateTime.UtcNow.Year;
string yearString = infoTable.Descendants("div").First(d => d.HasClass("updateAt"))
.InnerText.Split('-')[^1];
if(yearString.Contains("ago") == false)
{
year = uint.Parse(yearString);
}
List<Author> authors = authorNames.Select(n => new Author(n)).ToList();
List<MangaTag> mangaTags = tags.Select(n => new MangaTag(n)).ToList();
List<MangaAltTitle> altTitles = altTitlesDict.Select(x => new MangaAltTitle(x.Key, x.Value)).ToList();
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
originalLanguage, releaseStatus, -1,
this,
authors,
mangaTags,
[],
altTitles);
return (manga, authors, mangaTags, [], altTitles);
}
public override Chapter[] GetChapters(Manga manga, string language="en")
{
string requestUrl = $"https://mangakatana.com/manga/{manga.MangaId}";
// Leaving this in for verification if the page exists
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Chapter>();
//Return Chapters ordered by Chapter-Number
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestUrl);
return chapters.Order().ToArray();
}
private List<Chapter> ParseChaptersFromHtml(Manga manga, string mangaUrl)
{
// Using HtmlWeb will include the chapters since they are loaded with js
HtmlWeb web = new();
HtmlDocument document = web.Load(mangaUrl);
List<Chapter> ret = new();
HtmlNode chapterList = document.DocumentNode.SelectSingleNode("//div[contains(@class, 'chapters')]/table/tbody");
Regex volumeRex = new(@"[0-9a-z\-\.]+\/[0-9a-z\-]*v([0-9\.]+)");
Regex chapterNumRex = new(@"[0-9a-z\-\.]+\/[0-9a-z\-]*c([0-9\.]+)");
Regex chapterNameRex = new(@"Chapter [0-9\.]+:? (.*)");
foreach (HtmlNode chapterInfo in chapterList.Descendants("tr"))
{
string fullString = chapterInfo.Descendants("a").First().InnerText;
string url = chapterInfo.Descendants("a").First()
.GetAttributeValue("href", "");
int? volumeNumber = volumeRex.IsMatch(url) ? int.Parse(volumeRex.Match(url).Groups[1].Value) : null;
string chapterNumber = new(chapterNumRex.Match(url).Groups[1].Value);
string chapterName = chapterNameRex.Match(fullString).Groups[1].Value;
try
{
ret.Add(new Chapter(manga, url, chapterNumber, volumeNumber, chapterName));
}
catch (Exception e)
{
}
}
return ret;
}
internal override string[] GetChapterImageUrls(Chapter chapter)
{
string requestUrl = chapter.Url;
// Leaving this in to check if the page exists
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
{
return [];
}
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
return imageUrls;
}
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
{
// Images are loaded dynamically, but the urls are present in a piece of js code on the page
string js = document.DocumentNode.SelectSingleNode("//script[contains(., 'data-src')]").InnerText
.Replace("\r", "")
.Replace("\n", "")
.Replace("\t", "");
// ReSharper disable once StringLiteralTypo
string regexPat = @"(var thzq=\[')(.*)(,];function)";
var group = Regex.Matches(js, regexPat).First().Groups[2].Value.Replace("'", "");
var urls = group.Split(',');
return urls;
}
}

View File

@ -1,187 +0,0 @@
using System.Net;
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using HtmlAgilityPack;
namespace API.Schema.MangaConnectors;
public class MangaLife : MangaConnector
{
public MangaLife() : base("Manga4Life", ["en"], ["manga4life.com"])
{
this.downloadClient = new ChromiumDownloadClient();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{
string sanitizedTitle = WebUtility.UrlEncode(publicationTitle);
string requestUrl = $"https://manga4life.com/search/?name={sanitizedTitle}";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return [];
if (requestResult.htmlDocument is null)
return [];
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
return publications;
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{
return GetMangaFromUrl($"https://manga4life.com/manga/{publicationId}");
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{
Regex publicationIdRex = new(@"https:\/\/(www\.)?manga4life.com\/manga\/(.*)(\/.*)*");
string publicationId = publicationIdRex.Match(url).Groups[2].Value;
RequestResult requestResult = this.downloadClient.MakeRequest(url, RequestType.MangaInfo);
if(requestResult.htmlDocument is not null)
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, publicationId, url);
return null;
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
{
HtmlNode resultsNode = document.DocumentNode.SelectSingleNode("//div[@class='BoxBody']/div[last()]/div[1]/div");
if (resultsNode.Descendants("div").Count() == 1 && resultsNode.Descendants("div").First().HasClass("NoResults"))
{
return [];
}
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (HtmlNode resultNode in resultsNode.SelectNodes("div"))
{
string url = resultNode.Descendants().First(d => d.HasClass("SeriesName")).GetAttributeValue("href", "");
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl($"https://manga4life.com{url}");
if (manga is { } x)
ret.Add(x);
}
return ret.ToArray();
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
{
string originalLanguage = "", status = "";
Dictionary<string, string> altTitles = new(), links = new();
HashSet<string> tags = new();
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
HtmlNode posterNode = document.DocumentNode.SelectSingleNode("//div[@class='BoxBody']//div[@class='row']//img");
string coverUrl = posterNode.GetAttributeValue("src", "");
HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//div[@class='BoxBody']//div[@class='row']//h1");
string sortName = titleNode.InnerText;
HtmlNode[] authorsNodes = document.DocumentNode
.SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Author(s):']/..").Descendants("a")
.ToArray();
List<string> authorNames = new();
foreach (HtmlNode authorNode in authorsNodes)
authorNames.Add(authorNode.InnerText);
List<Author> authors = authorNames.Select(a => new Author(a)).ToList();
HtmlNode[] genreNodes = document.DocumentNode
.SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Genre(s):']/..").Descendants("a")
.ToArray();
foreach (HtmlNode genreNode in genreNodes)
tags.Add(genreNode.InnerText);
List<MangaTag> mangaTags = tags.Select(t => new MangaTag(t)).ToList();
HtmlNode yearNode = document.DocumentNode
.SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Released:']/..").Descendants("a")
.First();
uint year = uint.Parse(yearNode.InnerText);
HtmlNode[] statusNodes = document.DocumentNode
.SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Status:']/..").Descendants("a")
.ToArray();
foreach (HtmlNode statusNode in statusNodes)
if (statusNode.InnerText.Contains("publish", StringComparison.CurrentCultureIgnoreCase))
status = statusNode.InnerText.Split(' ')[0];
switch (status.ToLower())
{
case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break;
case "discontinued": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "complete": releaseStatus = MangaReleaseStatus.Completed; break;
case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
}
HtmlNode descriptionNode = document.DocumentNode
.SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Description:']/..")
.Descendants("div").First();
string description = descriptionNode.InnerText;
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
originalLanguage, releaseStatus, -1,
this,
authors,
mangaTags,
[],
[]);
return (manga, authors, mangaTags, [], []);
}
public override Chapter[] GetChapters(Manga manga, string language="en")
{
RequestResult result = downloadClient.MakeRequest($"https://manga4life.com/manga/{manga.MangaId}", RequestType.Default, clickButton:"[class*='ShowAllChapters']");
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null)
{
return Array.Empty<Chapter>();
}
HtmlNodeCollection chapterNodes = result.htmlDocument.DocumentNode.SelectNodes(
"//a[contains(concat(' ',normalize-space(@class),' '),' ChapterLink ')]");
string[] urls = chapterNodes.Select(node => node.GetAttributeValue("href", "")).ToArray();
Regex urlRex = new (@"-chapter-([0-9\\.]+)(-index-([0-9\\.]+))?");
List<Chapter> chapters = new();
foreach (string url in urls)
{
Match rexMatch = urlRex.Match(url);
int? volumeNumber = rexMatch.Groups[3].Success && rexMatch.Groups[3].Value.Length > 0
? int.Parse(rexMatch.Groups[3].Value)
: null;
string chapterNumber = new(rexMatch.Groups[1].Value);
string fullUrl = $"https://manga4life.com{url}";
fullUrl = fullUrl.Replace(Regex.Match(url,"(-page-[0-9])").Value,"");
try
{
chapters.Add(new Chapter(manga, fullUrl, chapterNumber, volumeNumber, null));
}
catch (Exception e)
{
}
}
//Return Chapters ordered by Chapter-Number
return chapters.Order().ToArray();
}
internal override string[] GetChapterImageUrls(Chapter chapter)
{
RequestResult requestResult = this.downloadClient.MakeRequest(chapter.Url, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
{
return [];
}
HtmlDocument document = requestResult.htmlDocument;
HtmlNode gallery = document.DocumentNode.Descendants("div").First(div => div.HasClass("ImageGallery"));
HtmlNode[] images = gallery.Descendants("img").Where(img => img.HasClass("img-fluid")).ToArray();
List<string> urls = new();
foreach(HtmlNode galleryImage in images)
urls.Add(galleryImage.GetAttributeValue("src", ""));
return urls.ToArray();
}
}

View File

@ -1,224 +0,0 @@
using System.Globalization;
using System.Net;
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using HtmlAgilityPack;
namespace API.Schema.MangaConnectors;
public class Manganato : MangaConnector
{
public Manganato() : base("Manganato", ["en"], ["manganato.com"])
{
this.downloadClient = new HttpDownloadClient();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{
string sanitizedTitle = string.Join('_', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower();
string requestUrl = $"https://manganato.com/search/story/{sanitizedTitle}";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 ||requestResult.htmlDocument is null)
return [];
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
return publications;
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
{
List<HtmlNode> searchResults = document.DocumentNode.Descendants("div").Where(n => n.HasClass("search-story-item")).ToList();
List<string> urls = new();
foreach (HtmlNode mangaResult in searchResults)
{
urls.Add(mangaResult.Descendants("a").First(n => n.HasClass("item-title")).GetAttributes()
.First(a => a.Name == "href").Value);
}
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls)
{
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is { } x)
ret.Add(x);
}
return ret.ToArray();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{
return GetMangaFromUrl($"https://chapmanganato.com/{publicationId}");
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{
RequestResult requestResult =
downloadClient.MakeRequest(url, RequestType.MangaInfo);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null;
if (requestResult.htmlDocument is null)
return null;
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url);
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
{
Dictionary<string, string> altTitlesDict = new();
Dictionary<string, string>? links = null;
HashSet<string> tags = new();
string[] authorNames = [];
string originalLanguage = "";
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
HtmlNode infoNode = document.DocumentNode.Descendants("div").First(d => d.HasClass("story-info-right"));
string sortName = infoNode.Descendants("h1").First().InnerText;
HtmlNode infoTable = infoNode.Descendants().First(d => d.Name == "table");
foreach (HtmlNode row in infoTable.Descendants("tr"))
{
string key = row.SelectNodes("td").First().InnerText.ToLower();
string value = row.SelectNodes("td").Last().InnerText;
string keySanitized = string.Concat(Regex.Matches(key, "[a-z]"));
switch (keySanitized)
{
case "alternative":
string[] alts = value.Split(" ; ");
for(int i = 0; i < alts.Length; i++)
altTitlesDict.Add(i.ToString(), alts[i]);
break;
case "authors":
authorNames = value.Split('-');
for (int i = 0; i < authorNames.Length; i++)
authorNames[i] = authorNames[i].Replace("\r\n", "");
break;
case "status":
switch (value.ToLower())
{
case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
case "completed": releaseStatus = MangaReleaseStatus.Completed; break;
}
break;
case "genres":
string[] genres = value.Split(" - ");
for (int i = 0; i < genres.Length; i++)
genres[i] = genres[i].Replace("\r\n", "");
tags = genres.ToHashSet();
break;
}
}
List<Author> authors = authorNames.Select(n => new Author(n)).ToList();
List<MangaTag> mangaTags = tags.Select(n => new MangaTag(n)).ToList();
List<MangaAltTitle> mangaAltTitles = altTitlesDict.Select(a => new MangaAltTitle(a.Key, a.Value)).ToList();
string coverUrl = document.DocumentNode.Descendants("span").First(s => s.HasClass("info-image")).Descendants("img").First()
.GetAttributes().First(a => a.Name == "src").Value;
string description = document.DocumentNode.Descendants("div").First(d => d.HasClass("panel-story-info-description"))
.InnerText.Replace("Description :", "");
while (description.StartsWith('\n'))
description = description.Substring(1);
string pattern = "MMM dd,yyyy HH:mm";
HtmlNode? oldestChapter = document.DocumentNode
.SelectNodes("//span[contains(concat(' ',normalize-space(@class),' '),' chapter-time ')]").MaxBy(
node => DateTime.ParseExact(node.GetAttributeValue("title", "Dec 31 2400, 23:59"), pattern,
CultureInfo.InvariantCulture).Millisecond);
uint year = (uint)DateTime.ParseExact(oldestChapter?.GetAttributeValue("title", "Dec 31 2400, 23:59")??"Dec 31 2400, 23:59", pattern,
CultureInfo.InvariantCulture).Year;
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
originalLanguage, releaseStatus, -1,
this,
authors,
mangaTags,
[],
mangaAltTitles);
return (manga, authors, mangaTags, [], mangaAltTitles);
}
public override Chapter[] GetChapters(Manga manga, string language="en")
{
string requestUrl = $"https://chapmanganato.com/{manga.MangaId}";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return [];
//Return Chapters ordered by Chapter-Number
if (requestResult.htmlDocument is null)
return [];
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument);
return chapters.Order().ToArray();
}
private List<Chapter> ParseChaptersFromHtml(Manga manga, HtmlDocument document)
{
List<Chapter> ret = new();
HtmlNode chapterList = document.DocumentNode.Descendants("ul").First(l => l.HasClass("row-content-chapter"));
Regex volRex = new(@"Vol\.([0-9]+).*");
Regex chapterRex = new(@"https:\/\/chapmanganato.[A-z]+\/manga-[A-z0-9]+\/chapter-([0-9\.]+)");
Regex nameRex = new(@"Chapter ([0-9]+(\.[0-9]+)*){1}:? (.*)");
foreach (HtmlNode chapterInfo in chapterList.Descendants("li"))
{
string fullString = chapterInfo.Descendants("a").First(d => d.HasClass("chapter-name")).InnerText;
string url = chapterInfo.Descendants("a").First(d => d.HasClass("chapter-name"))
.GetAttributeValue("href", "");
int? volumeNumber = volRex.IsMatch(fullString)
? int.Parse(volRex.Match(fullString).Groups[1].Value)
: null;
string chapterNumber = new(chapterRex.Match(url).Groups[1].Value);
string chapterName = nameRex.Match(fullString).Groups[3].Value;
try
{
ret.Add(new Chapter(manga, url, chapterNumber, volumeNumber, chapterName));
}
catch (Exception e)
{
}
}
ret.Reverse();
return ret;
}
internal override string[] GetChapterImageUrls(Chapter chapter)
{
string requestUrl = chapter.Url;
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 ||
requestResult.htmlDocument is null)
{
return [];
}
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
return imageUrls;
}
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
{
List<string> ret = new();
HtmlNode imageContainer =
document.DocumentNode.Descendants("div").First(i => i.HasClass("container-chapter-reader"));
foreach(HtmlNode imageNode in imageContainer.Descendants("img"))
ret.Add(imageNode.GetAttributeValue("src", ""));
return ret.ToArray();
}
}

View File

@ -1,223 +0,0 @@
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using HtmlAgilityPack;
namespace API.Schema.MangaConnectors;
public class Mangaworld : MangaConnector
{
public Mangaworld() : base("Mangaworld", ["it"], ["www.mangaworld.ac"])
{
this.downloadClient = new ChromiumDownloadClient();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower();
string requestUrl = $"https://www.mangaworld.ac/archive?keyword={sanitizedTitle}";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return [];
if (requestResult.htmlDocument is null)
return [];
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
return publications;
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
{
if (!document.DocumentNode.SelectSingleNode("//div[@class='comics-grid']").ChildNodes
.Any(node => node.HasClass("entry")))
return [];
List<string> urls = document.DocumentNode
.SelectNodes(
"//div[@class='comics-grid']//div[@class='entry']//a[contains(concat(' ',normalize-space(@class),' '),'thumb')]")
.Select(thumb => thumb.GetAttributeValue("href", "")).ToList();
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls)
{
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is { } x)
ret.Add(x);
}
return ret.ToArray();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{
return GetMangaFromUrl($"https://www.mangaworld.ac/manga/{publicationId}");
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{
RequestResult requestResult =
downloadClient.MakeRequest(url, RequestType.MangaInfo);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null;
if (requestResult.htmlDocument is null)
return null;
Regex idRex = new (@"https:\/\/www\.mangaworld\.[a-z]{0,63}\/manga\/([0-9]+\/[0-9A-z\-]+).*");
string id = idRex.Match(url).Groups[1].Value;
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, id, url);
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
{
Dictionary<string, string> altTitlesDict = new();
string originalLanguage = "";
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
HtmlNode infoNode = document.DocumentNode.Descendants("div").First(d => d.HasClass("info"));
string sortName = infoNode.Descendants("h1").First().InnerText;
HtmlNode metadata = infoNode.Descendants().First(d => d.HasClass("meta-data"));
HtmlNode altTitlesNode = metadata.SelectSingleNode("//span[text()='Titoli alternativi: ' or text()='Titolo alternativo: ']/..").ChildNodes[1];
string[] alts = altTitlesNode.InnerText.Split(", ");
for(int i = 0; i < alts.Length; i++)
altTitlesDict.Add(i.ToString(), alts[i]);
List<MangaAltTitle> altTitles = altTitlesDict.Select(a => new MangaAltTitle(a.Key, a.Value)).ToList();
HtmlNode genresNode =
metadata.SelectSingleNode("//span[text()='Generi: ' or text()='Genero: ']/..");
HashSet<string> tags = genresNode.SelectNodes("a").Select(node => node.InnerText).ToHashSet();
List<MangaTag> mangaTags = tags.Select(t => new MangaTag(t)).ToList();
HtmlNode authorsNode =
metadata.SelectSingleNode("//span[text()='Autore: ' or text()='Autori: ']/..");
string[] authorNames = authorsNode.SelectNodes("a").Select(node => node.InnerText).ToArray();
List<Author> authors = authorNames.Select(n => new Author(n)).ToList();
string status = metadata.SelectSingleNode("//span[text()='Stato: ']/..").SelectNodes("a").First().InnerText;
// ReSharper disable 5 times StringLiteralTypo
switch (status.ToLower())
{
case "cancellato": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "in pausa": releaseStatus = MangaReleaseStatus.OnHiatus; break;
case "droppato": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "finito": releaseStatus = MangaReleaseStatus.Completed; break;
case "in corso": releaseStatus = MangaReleaseStatus.Continuing; break;
}
string coverUrl = document.DocumentNode.SelectSingleNode("//img[@class='rounded']").GetAttributeValue("src", "");
string description = document.DocumentNode.SelectSingleNode("//div[@id='noidungm']").InnerText;
string yearString = metadata.SelectSingleNode("//span[text()='Anno di uscita: ']/..").SelectNodes("a").First().InnerText;
uint year = uint.Parse(yearString);
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
originalLanguage, releaseStatus, -1,
this,
authors,
mangaTags,
[],
altTitles);
return (manga, authors, mangaTags, [], altTitles);
}
public override Chapter[] GetChapters(Manga manga, string language="en")
{
string requestUrl = $"https://www.mangaworld.ac/manga/{manga.MangaId}";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
return [];
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument);
return chapters.Order().ToArray();
}
private List<Chapter> ParseChaptersFromHtml(Manga manga, HtmlDocument document)
{
List<Chapter> ret = new();
HtmlNode chaptersWrapper =
document.DocumentNode.SelectSingleNode(
"//div[contains(concat(' ',normalize-space(@class),' '),'chapters-wrapper')]");
Regex volumeRex = new(@"[Vv]olume ([0-9]+).*");
Regex chapterRex = new(@"[Cc]apitolo ([0-9]+(?:\.[0-9]+)?).*");
Regex idRex = new(@".*\/read\/([a-z0-9]+)(?:[?\/].*)?");
if (chaptersWrapper.Descendants("div").Any(descendant => descendant.HasClass("volume-element")))
{
foreach (HtmlNode volNode in document.DocumentNode.SelectNodes("//div[contains(concat(' ',normalize-space(@class),' '),'volume-element')]"))
{
string volumeStr = volumeRex.Match(volNode.SelectNodes("div").First(node => node.HasClass("volume")).SelectSingleNode("p").InnerText).Groups[1].Value;
int volume = int.Parse(volumeStr);
foreach (HtmlNode chNode in volNode.SelectNodes("div").First(node => node.HasClass("volume-chapters")).SelectNodes("div"))
{
string numberStr = chapterRex.Match(chNode.SelectSingleNode("a").SelectSingleNode("span").InnerText).Groups[1].Value;
string chapterNumber = new(numberStr);
string url = chNode.SelectSingleNode("a").GetAttributeValue("href", "");
string id = idRex.Match(chNode.SelectSingleNode("a").GetAttributeValue("href", "")).Groups[1].Value;
try
{
ret.Add(new Chapter(manga, url, chapterNumber, volume, null));
}
catch (Exception e)
{
}
}
}
}
else
{
foreach (HtmlNode chNode in chaptersWrapper.SelectNodes("div").Where(node => node.HasClass("chapter")))
{
string numberStr = chapterRex.Match(chNode.SelectSingleNode("a").SelectSingleNode("span").InnerText).Groups[1].Value;
string chapterNumber = new(numberStr);
string url = chNode.SelectSingleNode("a").GetAttributeValue("href", "");
string id = idRex.Match(chNode.SelectSingleNode("a").GetAttributeValue("href", "")).Groups[1].Value;
try
{
ret.Add(new Chapter(manga, url, chapterNumber, null, null));
}
catch (Exception e)
{
}
}
}
ret.Reverse();
return ret;
}
internal override string[] GetChapterImageUrls(Chapter chapter)
{
string requestUrl = $"{chapter.Url}?style=list";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
{
return [];
}
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
return imageUrls;
}
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
{
List<string> ret = new();
HtmlNode imageContainer =
document.DocumentNode.SelectSingleNode("//div[@id='page']");
foreach(HtmlNode imageNode in imageContainer.Descendants("img"))
ret.Add(imageNode.GetAttributeValue("src", ""));
return ret.ToArray();
}
}

View File

@ -1,179 +0,0 @@
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using HtmlAgilityPack;
namespace API.Schema.MangaConnectors;
public class ManhuaPlus : MangaConnector
{
public ManhuaPlus() : base("ManhuaPlus", ["en"], ["manhuaplus.org"])
{
this.downloadClient = new ChromiumDownloadClient();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower();
string requestUrl = $"https://manhuaplus.org/search?keyword={sanitizedTitle}";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
return [];
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
return publications;
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
{
if (document.DocumentNode.SelectSingleNode("//h1/../..").ChildNodes//I already want to not.
.Any(node => node.InnerText.Contains("No manga found")))
return [];
List<string> urls = document.DocumentNode
.SelectNodes("//h1/../..//a[contains(@href, 'https://manhuaplus.org/manga/') and contains(concat(' ',normalize-space(@class),' '),' clamp ') and not(contains(@href, '/chapter'))]")
.Select(mangaNode => mangaNode.GetAttributeValue("href", "")).ToList();
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls)
{
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is { } x)
ret.Add(x);
}
return ret.ToArray();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{
return GetMangaFromUrl($"https://manhuaplus.org/manga/{publicationId}");
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{
Regex publicationIdRex = new(@"https:\/\/manhuaplus.org\/manga\/(.*)(\/.*)*");
string publicationId = publicationIdRex.Match(url).Groups[1].Value;
RequestResult requestResult = this.downloadClient.MakeRequest(url, RequestType.MangaInfo);
if((int)requestResult.statusCode < 300 && (int)requestResult.statusCode >= 200 && requestResult.htmlDocument is not null && requestResult.redirectedToUrl != "https://manhuaplus.org/home") //When manga doesnt exists it redirects to home
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, publicationId, url);
return null;
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
{
string originalLanguage = "", status = "";
Dictionary<string, string> altTitles = new(), links = new();
HashSet<string> tags = new();
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
HtmlNode posterNode = document.DocumentNode.SelectSingleNode("/html/body/main/div/div/div[2]/div[1]/figure/a/img");//BRUH
Regex posterRex = new(@".*(\/uploads/covers/[a-zA-Z0-9\-\._\~\!\$\&\'\(\)\*\+\,\;\=\:\@]+).*");
string coverUrl = $"https://manhuaplus.org/{posterRex.Match(posterNode.GetAttributeValue("src", "")).Groups[1].Value}";
HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//h1");
string sortName = titleNode.InnerText.Replace("\n", "");
List<string> authorNames = new();
try
{
HtmlNode[] authorsNodes = document.DocumentNode
.SelectNodes("//a[contains(@href, 'https://manhuaplus.org/authors/')]")
.ToArray();
foreach (HtmlNode authorNode in authorsNodes)
authorNames.Add(authorNode.InnerText);
}
catch (ArgumentNullException e)
{
}
List<Author> authors = authorNames.Select(a => new Author(a)).ToList();
try
{
HtmlNode[] genreNodes = document.DocumentNode
.SelectNodes("//a[contains(@href, 'https://manhuaplus.org/genres/')]").ToArray();
foreach (HtmlNode genreNode in genreNodes)
tags.Add(genreNode.InnerText.Replace("\n", ""));
}
catch (ArgumentNullException e)
{
}
List<MangaTag> mangaTags = tags.Select(t => new MangaTag(t)).ToList();
Regex yearRex = new(@"(?:[0-9]{1,2}\/){2}([0-9]{2,4}) [0-9]{1,2}:[0-9]{1,2}");
HtmlNode yearNode = document.DocumentNode.SelectSingleNode("//aside//i[contains(concat(' ',normalize-space(@class),' '),' fa-clock ')]/../span");
Match match = yearRex.Match(yearNode.InnerText);
uint year = match.Success && match.Groups[1].Success ? uint.Parse(match.Groups[1].Value) : 0;
status = document.DocumentNode.SelectSingleNode("//aside//i[contains(concat(' ',normalize-space(@class),' '),' fa-rss ')]/../span").InnerText.Replace("\n", "");
switch (status.ToLower())
{
case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break;
case "discontinued": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "complete": releaseStatus = MangaReleaseStatus.Completed; break;
case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
}
HtmlNode descriptionNode = document.DocumentNode
.SelectSingleNode("//div[@id='syn-target']");
string description = descriptionNode.InnerText;
Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
originalLanguage, releaseStatus, -1,
this,
authors,
mangaTags,
[],
[]);
return (manga, authors, mangaTags, [], []);
}
public override Chapter[] GetChapters(Manga manga, string language="en")
{
RequestResult result = downloadClient.MakeRequest($"https://manhuaplus.org/manga/{manga.MangaId}", RequestType.Default);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null)
{
return Array.Empty<Chapter>();
}
HtmlNodeCollection chapterNodes = result.htmlDocument.DocumentNode.SelectNodes("//li[contains(concat(' ',normalize-space(@class),' '),' chapter ')]//a");
string[] urls = chapterNodes.Select(node => node.GetAttributeValue("href", "")).ToArray();
Regex urlRex = new (@".*\/chapter-([0-9\-]+).*");
List<Chapter> chapters = new();
foreach (string url in urls)
{
Match rexMatch = urlRex.Match(url);
string chapterNumber = new(rexMatch.Groups[1].Value);
string fullUrl = url;
try
{
chapters.Add(new Chapter(manga, fullUrl, chapterNumber, null, null));
}
catch (Exception e)
{
}
}
//Return Chapters ordered by Chapter-Number
return chapters.Order().ToArray();
}
internal override string[] GetChapterImageUrls(Chapter chapter)
{
RequestResult requestResult = this.downloadClient.MakeRequest(chapter.Url, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
{
return [];
}
HtmlDocument document = requestResult.htmlDocument;
HtmlNode[] images = document.DocumentNode.SelectNodes("//a[contains(concat(' ',normalize-space(@class),' '),' readImg ')]/img").ToArray();
List<string> urls = images.Select(node => node.GetAttributeValue("src", "")).ToList();
return urls.ToArray();
}
}

View File

@ -1,230 +0,0 @@
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using HtmlAgilityPack;
using Soenneker.Utils.String.NeedlemanWunsch;
namespace API.Schema.MangaConnectors;
public class Weebcentral : MangaConnector
{
private readonly string _baseUrl = "https://weebcentral.com";
private readonly string[] _filterWords =
{ "a", "the", "of", "as", "to", "no", "for", "on", "with", "be", "and", "in", "wa", "at", "be", "ni" };
public Weebcentral() : base("Weebcentral", ["en"], ["https://weebcentral.com"])
{
downloadClient = new ChromiumDownloadClient();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(
string publicationTitle = "")
{
const int limit = 32; //How many values we want returned at once
int offset = 0; //"Page"
string requestUrl =
$"{_baseUrl}/search/data?limit={limit}&offset={offset}&text={publicationTitle}&sort=Best+Match&order=Ascending&official=Any&display_mode=Minimal%20Display";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 ||
requestResult.htmlDocument == null)
return [];
(Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)[] publications =
ParsePublicationsFromHtml(requestResult.htmlDocument);
return publications;
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(
HtmlDocument document)
{
if (document.DocumentNode.SelectNodes("//article") == null)
return [];
List<string> urls = document.DocumentNode.SelectNodes("/html/body/article/a[@class='link link-hover']")
.Select(elem => elem.GetAttributeValue("href", "")).ToList();
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls)
{
(Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)? manga = GetMangaFromUrl(url);
if (manga is { } x)
ret.Add(x);
}
return ret.ToArray();
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)?
GetMangaFromUrl(string url)
{
Regex publicationIdRex = new(@"https:\/\/weebcentral\.com\/series\/(\w*)\/(.*)");
string publicationId = publicationIdRex.Match(url).Groups[1].Value;
RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo);
if ((int)requestResult.statusCode < 300 && (int)requestResult.statusCode >= 200 &&
requestResult.htmlDocument is not null)
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, publicationId, url);
return null;
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(
HtmlDocument document, string publicationId, string websiteUrl)
{
HtmlNode? posterNode =
document.DocumentNode.SelectSingleNode("//section[@class='flex items-center justify-center']/picture/img");
string coverUrl = posterNode?.GetAttributeValue("src", "") ?? "";
HtmlNode? titleNode = document.DocumentNode.SelectSingleNode("//section/h1");
string sortName = titleNode?.InnerText ?? "Undefined";
HtmlNode[] authorsNodes =
document.DocumentNode.SelectNodes("//ul/li[strong/text() = 'Author(s): ']/span")?.ToArray() ?? [];
List<string> authorNames = authorsNodes.Select(n => n.InnerText).ToList();
List<Author> authors = authorNames.Select(n => new Author(n)).ToList();
HtmlNode[] genreNodes =
document.DocumentNode.SelectNodes("//ul/li[strong/text() = 'Tags(s): ']/span")?.ToArray() ?? [];
HashSet<string> tags = genreNodes.Select(n => n.InnerText).ToHashSet();
List<MangaTag> mangaTags = tags.Select(t => new MangaTag(t)).ToList();
HtmlNode? statusNode = document.DocumentNode.SelectSingleNode("//ul/li[strong/text() = 'Status: ']/a");
string status = statusNode?.InnerText ?? "";
MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
switch (status.ToLower())
{
case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break;
case "complete": releaseStatus = MangaReleaseStatus.Completed; break;
case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
}
HtmlNode? yearNode = document.DocumentNode.SelectSingleNode("//ul/li[strong/text() = 'Released: ']/span");
uint year = uint.Parse(yearNode?.InnerText ?? "0");
HtmlNode? descriptionNode = document.DocumentNode.SelectSingleNode("//ul/li[strong/text() = 'Description']/p");
string description = descriptionNode?.InnerText ?? "Undefined";
HtmlNode[] altTitleNodes = document.DocumentNode
.SelectNodes("//ul/li[strong/text() = 'Associated Name(s)']/ul/li")?.ToArray() ?? [];
Dictionary<string, string> altTitlesDict = new(), links = new();
for (int i = 0; i < altTitleNodes.Length; i++)
altTitlesDict.Add(i.ToString(), altTitleNodes[i].InnerText);
List<MangaAltTitle> altTitles = altTitlesDict.Select(a => new MangaAltTitle(a.Key, a.Value)).ToList();
string originalLanguage = "";
Manga manga = new(publicationId, sortName, description, websiteUrl, coverUrl, null, year,
originalLanguage, releaseStatus, -1,
this,
authors,
mangaTags,
[],
altTitles);
return (manga, authors, mangaTags, [], altTitles);
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(
string publicationId)
{
return GetMangaFromUrl($"https://weebcentral.com/series/{publicationId}");
}
private string ToFilteredString(string input)
{
return string.Join(' ', input.ToLower().Split(' ').Where(word => _filterWords.Contains(word) == false));
}
private SearchResult[] FilteredResults(string publicationTitle, SearchResult[] unfilteredSearchResults)
{
Dictionary<SearchResult, int> similarity = new();
foreach (SearchResult sr in unfilteredSearchResults)
{
List<int> scores = new();
string filteredPublicationString = ToFilteredString(publicationTitle);
string filteredSString = ToFilteredString(sr.s);
scores.Add(NeedlemanWunschStringUtil.CalculateSimilarity(filteredSString, filteredPublicationString));
foreach (string srA in sr.a)
{
string filteredAString = ToFilteredString(srA);
scores.Add(NeedlemanWunschStringUtil.CalculateSimilarity(filteredAString, filteredPublicationString));
}
similarity.Add(sr, scores.Sum() / scores.Count);
}
List<SearchResult> ret = similarity.OrderBy(s => s.Value).Take(10).Select(s => s.Key).ToList();
return ret.ToArray();
}
public override Chapter[] GetChapters(Manga manga, string language = "en")
{
string requestUrl = $"{_baseUrl}/series/{manga.ConnectorId}/full-chapter-list";
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return [];
//Return Chapters ordered by Chapter-Number
if (requestResult.htmlDocument is null)
return [];
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument);
return chapters.Order().ToArray();
}
private List<Chapter> ParseChaptersFromHtml(Manga manga, HtmlDocument document)
{
HtmlNode? chaptersWrapper = document.DocumentNode.SelectSingleNode("/html/body");
Regex chapterRex = new(@"(\d+(?:\.\d+)*)");
Regex idRex = new(@"https:\/\/weebcentral\.com\/chapters\/(\w*)");
List<Chapter> ret = chaptersWrapper.Descendants("a").Select(elem =>
{
string url = elem.GetAttributeValue("href", "") ?? "Undefined";
if (!url.StartsWith("https://") && !url.StartsWith("http://"))
return new Chapter(manga, "undefined", "-1");
Match idMatch = idRex.Match(url);
string? id = idMatch.Success ? idMatch.Groups[1].Value : null;
string chapterNode = elem.SelectSingleNode("span[@class='grow flex items-center gap-2']/span")?.InnerText ??
"Undefined";
Match chapterNumberMatch = chapterRex.Match(chapterNode);
if (!chapterNumberMatch.Success)
return new Chapter(manga, "undefined", "-1");
string chapterNumber = chapterNumberMatch.Groups[1].Value;
return new Chapter(manga, url, chapterNumber);
}).Where(elem => elem.ChapterNumber.CompareTo("-1") != 0 && elem.Url != "undefined").ToList();
ret.Reverse();
return ret;
}
internal override string[] GetChapterImageUrls(Chapter chapter)
{
RequestResult requestResult = downloadClient.MakeRequest(chapter.Url, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 ||
requestResult.htmlDocument is null) return [];
HtmlDocument? document = requestResult.htmlDocument;
HtmlNode[] imageNodes =
document.DocumentNode.SelectNodes($"//section[@hx-get='{chapter.Url}/images']/img")?.ToArray() ?? [];
string[] urls = imageNodes.Select(imgNode => imgNode.GetAttributeValue("src", "")).ToArray();
return urls;
}
private struct SearchResult
{
public string i { get; set; }
public string s { get; set; }
public string[] a { get; set; }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,258 @@
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using Newtonsoft.Json.Linq;
namespace API.Schema.MangaContext.MangaConnectors;
public class ComickIo : MangaConnector
{
//https://api.comick.io/docs/
//https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
public ComickIo() : base("ComickIo",
["en","pt","pt-br","it","de","ru","aa","ab","ae","af","ak","am","an","ar-ae","ar-bh","ar-dz","ar-eg","ar-iq","ar-jo","ar-kw","ar-lb","ar-ly","ar-ma","ar-om","ar-qa","ar-sa","ar-sy","ar-tn","ar-ye","ar","as","av","ay","az","ba","be","bg","bh","bi","bm","bn","bo","br","bs","ca","ce","ch","co","cr","cs","cu","cv","cy","da","de-at","de-ch","de-de","de-li","de-lu","div","dv","dz","ee","el","en-au","en-bz","en-ca","en-cb","en-gb","en-ie","en-jm","en-nz","en-ph","en-tt","en-us","en-za","en-zw","eo","es-ar","es-bo","es-cl","es-co","es-cr","es-do","es-ec","es-es","es-gt","es-hn","es-la","es-mx","es-ni","es-pa","es-pe","es-pr","es-py","es-sv","es-us","es-uy","es-ve","es","et","eu","fa","ff","fi","fj","fo","fr-be","fr-ca","fr-ch","fr-fr","fr-lu","fr-mc","fr","fy","ga","gd","gl","gn","gu","gv","ha","he","hi","ho","hr-ba","hr-hr","hr","ht","hu","hy","hz","ia","id","ie","ig","ii","ik","in","io","is","it-ch","it-it","iu","iw","ja","ja-ro","ji","jv","jw","ka","kg","ki","kj","kk","kl","km","kn","ko","ko-ro","kr","ks","ku","kv","kw","ky","kz","la","lb","lg","li","ln","lo","ls","lt","lu","lv","mg","mh","mi","mk","ml","mn","mo","mr","ms-bn","ms-my","ms","mt","my","na","nb","nd","ne","ng","nl-be","nl-nl","nl","nn","no","nr","ns","nv","ny","oc","oj","om","or","os","pa","pi","pl","ps","pt-pt","qu-bo","qu-ec","qu-pe","qu","rm","rn","ro","rw","sa","sb","sc","sd","se-fi","se-no","se-se","se","sg","sh","si","sk","sl","sm","sn","so","sq","sr-ba","sr-sp","sr","ss","st","su","sv-fi","sv-se","sv","sw","sx","syr","ta","te","tg","th","ti","tk","tl","tn","to","tr","ts","tt","tw","ty","ug","uk","ur","us","uz","ve","vi","vo","wa","wo","xh","yi","yo","za","zh-cn","zh-hk","zh-mo","zh-ro","zh-sg","zh-tw","zh","zu"],
["comick.io"],
"https://comick.io/static/icons/unicorn-64.png")
{
this.downloadClient = new HttpDownloadClient();
}
public override (Manga, MangaConnectorId<Manga>)[] SearchManga(string mangaSearchName)
{
Log.Info($"Searching Obj: {mangaSearchName}");
List<string> slugs = new();
int page = 1;
while(page < 50)
{
string requestUrl = $"https://api.comick.fun/v1.0/search/?type=comic&t=false&limit=100&showall=true&" +
$"page={page}&q={mangaSearchName}";
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return [];
}
using StreamReader sr = new (result.result);
JArray data = JArray.Parse(sr.ReadToEnd());
if (data.Count < 1)
break;
slugs.AddRange(data.Select(token => token.Value<string>("slug")!));
page++;
}
Log.Debug($"Search {mangaSearchName} yielded {slugs.Count} slugs. Requesting mangas now...");
List<(Manga, MangaConnectorId<Manga>)> mangas = new ();
foreach (string slug in slugs)
{
if(GetMangaFromId(slug) is { } entry)
mangas.Add(entry);
}
Log.Info($"Search {mangaSearchName} yielded {mangas.Count} results.");
return mangas.ToArray();
}
private readonly Regex _getSlugFromTitleRex = new(@"https?:\/\/comick\.io\/comic\/(.+)(?:\/.*)*");
public override (Manga, MangaConnectorId<Manga>)? GetMangaFromUrl(string url)
{
Match m = _getSlugFromTitleRex.Match(url);
return m.Groups[1].Success ? GetMangaFromId(m.Groups[1].Value) : null;
}
public override (Manga, MangaConnectorId<Manga>)? GetMangaFromId(string mangaIdOnSite)
{
string requestUrl = $"https://api.comick.fun/comic/{mangaIdOnSite}";
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaInfo);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return null;
}
using StreamReader sr = new (result.result);
JToken data = JToken.Parse(sr.ReadToEnd());
return ParseMangaFromJToken(data);
}
public override (Chapter, MangaConnectorId<Chapter>)[] GetChapters(MangaConnectorId<Manga> mangaConnectorId,
string? language = null)
{
Log.Info($"Getting Chapters: {mangaConnectorId.IdOnConnectorSite}");
List<(Chapter, MangaConnectorId<Chapter>)> chapters = new();
int page = 1;
while(page < 50)
{
string requestUrl = $"https://api.comick.fun/comic/{mangaConnectorId.IdOnConnectorSite}/chapters?limit=100&page={page}&lang={language}";
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaInfo);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return [];
}
using StreamReader sr = new (result.result);
JToken data = JToken.Parse(sr.ReadToEnd());
JArray? chaptersArray = data["chapters"] as JArray;
if (chaptersArray is null || chaptersArray.Count < 1)
break;
chapters.AddRange(ParseChapters(mangaConnectorId, chaptersArray));
page++;
}
return chapters.ToArray();
}
private readonly Regex _hidFromUrl = new(@"https?:\/\/comick\.io\/comic\/.+\/([^-]+).*");
internal override string[] GetChapterImageUrls(MangaConnectorId<Chapter> chapterId)
{
Log.Info($"Getting Chapter Image-Urls: {chapterId.Obj}");
if (chapterId.WebsiteUrl is null || !UrlMatchesConnector(chapterId.WebsiteUrl))
{
Log.Debug($"Url is not for Connector. {chapterId.WebsiteUrl}");
return [];
}
Match m = _hidFromUrl.Match(chapterId.WebsiteUrl);
if (!m.Groups[1].Success)
{
Log.Debug($"Could not parse hid from url. {chapterId.WebsiteUrl}");
return [];
}
string hid = m.Groups[1].Value;
string requestUrl = $"https://api.comick.fun/chapter/{hid}/get_images";
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaInfo);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return [];
}
using StreamReader sr = new (result.result);
JArray data = JArray.Parse(sr.ReadToEnd());
return data.Select(token =>
{
string url = $"https://meo.comick.pictures/{token.Value<string>("b2key")}";
return url;
}).ToArray();
}
private (Manga manga, MangaConnectorId<Manga> id) ParseMangaFromJToken(JToken json)
{
string? hid = json["comic"]?.Value<string>("hid");
string? slug = json["comic"]?.Value<string>("slug");
string? name = json["comic"]?.Value<string>("title");
string? description = json["comic"]?.Value<string>("desc");
string? originalLanguage = json["comic"]?.Value<string>("country");
string url = $"https://comick.io/comic/{slug}";
string? coverName = json["comic"]?["md_covers"]?.First?.Value<string>("b2key");
string coverUrl = $"https://meo.comick.pictures/{coverName}";
int? releaseStatusStr = json["comic"]?.Value<int>("status");
MangaReleaseStatus status = releaseStatusStr switch
{
1 => MangaReleaseStatus.Continuing,
2 => MangaReleaseStatus.Completed,
3 => MangaReleaseStatus.Cancelled,
4 => MangaReleaseStatus.OnHiatus,
_ => MangaReleaseStatus.Unreleased
};
uint? year = json["comic"]?.Value<uint?>("year");
JArray? altTitlesArray = json["comic"]?["md_titles"] as JArray;
//Cant let language be null, so fill with whatever.
byte whatever = 0;
List<AltTitle> altTitles = altTitlesArray?
.Select(token => new AltTitle(token.Value<string>("lang")??whatever++.ToString(), token.Value<string>("title")!))
.ToList()!;
JArray? authorsArray = json["authors"] as JArray;
JArray? artistsArray = json["artists"] as JArray;
List<Author> authors = authorsArray?.Concat(artistsArray!)
.Select(token => new Author(token.Value<string>("name")!))
.DistinctBy(a => a.Key)
.ToList()!;
JArray? genreArray = json["comic"]?["md_comic_md_genres"] as JArray;
List<MangaTag> tags = genreArray?
.Select(token => new MangaTag(token["md_genres"]?.Value<string>("name")!))
.ToList()!;
JArray? linksArray = json["comic"]?["links"] as JArray;
List<Link> links = linksArray?
.ToObject<Dictionary<string,string>>()?
.Select(kv =>
{
string fullUrl = kv.Key switch
{
"al" => $"https://anilist.co/manga/{kv.Value}",
"ap" => $"https://www.anime-planet.com/manga/{kv.Value}",
"bw" => $"https://bookwalker.jp/{kv.Value}",
"mu" => $"https://www.mangaupdates.com/series.html?id={kv.Value}",
"nu" => $"https://www.novelupdates.com/series/{kv.Value}",
"mal" => $"https://myanimelist.net/manga/{kv.Value}",
_ => kv.Value
};
string key = kv.Key switch
{
"al" => "AniList",
"ap" => "Anime Planet",
"bw" => "BookWalker",
"mu" => "Obj Updates",
"nu" => "Novel Updates",
"kt" => "Kitsu.io",
"amz" => "Amazon",
"ebj" => "eBookJapan",
"mal" => "MyAnimeList",
"cdj" => "CDJapan",
_ => kv.Key
};
return new Link(key, fullUrl);
}).ToList()!;
if(hid is null)
throw new Exception("hid is null");
if(slug is null)
throw new Exception("slug is null");
if(name is null)
throw new Exception("name is null");
Manga manga = new (name, description??"", coverUrl, status, authors, tags, links, altTitles,
year: year, originalLanguage: originalLanguage);
return (manga, new MangaConnectorId<Manga>(manga, this, hid, url));
}
private List<(Chapter, MangaConnectorId<Chapter>)> ParseChapters(MangaConnectorId<Manga> mcIdManga, JArray chaptersArray)
{
List<(Chapter, MangaConnectorId<Chapter>)> chapters = new ();
foreach (JToken chapter in chaptersArray)
{
string? chapterNum = chapter.Value<string>("chap");
string? volumeNumStr = chapter.Value<string>("vol");
int? volumeNum = volumeNumStr is null ? null : int.Parse(volumeNumStr);
string? title = chapter.Value<string>("title");
string? hid = chapter.Value<string>("hid");
string url = $"https://comick.io/comic/{mcIdManga.IdOnConnectorSite}/{hid}";
if(chapterNum is null || hid is null)
continue;
Chapter ch = new (mcIdManga.Obj, chapterNum, volumeNum, title);
chapters.Add((ch, new (ch, this, hid, url)));
}
return chapters;
}
}

View File

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

View File

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

View File

@ -0,0 +1,338 @@
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using Newtonsoft.Json.Linq;
namespace API.Schema.MangaContext.MangaConnectors;
public class MangaDex : MangaConnector
{
//https://api.mangadex.org/docs/3-enumerations/#language-codes--localization
//https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
//https://gist.github.com/Josantonius/b455e315bc7f790d14b136d61d9ae469
public MangaDex() : base("MangaDex",
["en","pt","pt-br","it","de","ru","aa","ab","ae","af","ak","am","an","ar-ae","ar-bh","ar-dz","ar-eg","ar-iq","ar-jo","ar-kw","ar-lb","ar-ly","ar-ma","ar-om","ar-qa","ar-sa","ar-sy","ar-tn","ar-ye","ar","as","av","ay","az","ba","be","bg","bh","bi","bm","bn","bo","br","bs","ca","ce","ch","co","cr","cs","cu","cv","cy","da","de-at","de-ch","de-de","de-li","de-lu","div","dv","dz","ee","el","en-au","en-bz","en-ca","en-cb","en-gb","en-ie","en-jm","en-nz","en-ph","en-tt","en-us","en-za","en-zw","eo","es-ar","es-bo","es-cl","es-co","es-cr","es-do","es-ec","es-es","es-gt","es-hn","es-la","es-mx","es-ni","es-pa","es-pe","es-pr","es-py","es-sv","es-us","es-uy","es-ve","es","et","eu","fa","ff","fi","fj","fo","fr-be","fr-ca","fr-ch","fr-fr","fr-lu","fr-mc","fr","fy","ga","gd","gl","gn","gu","gv","ha","he","hi","ho","hr-ba","hr-hr","hr","ht","hu","hy","hz","ia","id","ie","ig","ii","ik","in","io","is","it-ch","it-it","iu","iw","ja","ja-ro","ji","jv","jw","ka","kg","ki","kj","kk","kl","km","kn","ko","ko-ro","kr","ks","ku","kv","kw","ky","kz","la","lb","lg","li","ln","lo","ls","lt","lu","lv","mg","mh","mi","mk","ml","mn","mo","mr","ms-bn","ms-my","ms","mt","my","na","nb","nd","ne","ng","nl-be","nl-nl","nl","nn","no","nr","ns","nv","ny","oc","oj","om","or","os","pa","pi","pl","ps","pt-pt","qu-bo","qu-ec","qu-pe","qu","rm","rn","ro","rw","sa","sb","sc","sd","se-fi","se-no","se-se","se","sg","sh","si","sk","sl","sm","sn","so","sq","sr-ba","sr-sp","sr","ss","st","su","sv-fi","sv-se","sv","sw","sx","syr","ta","te","tg","th","ti","tk","tl","tn","to","tr","ts","tt","tw","ty","ug","uk","ur","us","uz","ve","vi","vo","wa","wo","xh","yi","yo","za","zh-cn","zh-hk","zh-mo","zh-ro","zh-sg","zh-tw","zh","zu"],
["mangadex.org"],
"https://mangadex.org/favicon.ico")
{
this.downloadClient = new HttpDownloadClient();
}
private const int Limit = 100;
public override (Manga, MangaConnectorId<Manga>)[] SearchManga(string mangaSearchName)
{
Log.Info($"Searching Obj: {mangaSearchName}");
List<(Manga, MangaConnectorId<Manga>)> mangas = new ();
int offset = 0;
int total = int.MaxValue;
while(offset < total)
{
string requestUrl =
$"https://api.mangadex.org/manga?limit={Limit}&offset={offset}&title={mangaSearchName}" +
$"&contentRating%5B%5D=safe&contentRating%5B%5D=suggestive&contentRating%5B%5D=erotica" +
$"&includes%5B%5D=manga&includes%5B%5D=cover_art&includes%5B%5D=author&includes%5B%5D=artist&includes%5B%5D=tag'";
offset += Limit;
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaDexFeed);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return [];
}
using StreamReader sr = new (result.result);
JObject jObject = JObject.Parse(sr.ReadToEnd());
if (jObject.Value<string>("result") != "ok")
{
JArray? errors = jObject["errors"] as JArray;
Log.Error($"Request failed: {string.Join(',', errors?.Select(e => e.Value<string>("title")) ?? [])}");
return [];
}
total = jObject.Value<int>("total");
JArray? data = jObject.Value<JArray>("data");
if (data is null)
{
Log.Error("Data was null");
return [];
}
mangas.AddRange(data.Select(ParseMangaFromJToken));
}
Log.Info($"Search {mangaSearchName} yielded {mangas.Count} results.");
return mangas.ToArray();
}
private static readonly Regex GetMangaIdFromUrl = new(@"https?:\/\/mangadex\.org\/title\/([a-z0-9-]+)\/?.*");
public override (Manga, MangaConnectorId<Manga>)? GetMangaFromUrl(string url)
{
Log.Info($"Getting Obj: {url}");
if (!UrlMatchesConnector(url))
{
Log.Debug($"Url is not for Connector. {url}");
return null;
}
Match match = GetMangaIdFromUrl.Match(url);
if (!match.Success || !match.Groups[1].Success)
{
Log.Debug($"Url is not for Connector (Could not retrieve id). {url}");
return null;
}
string id = match.Groups[1].Value;
return GetMangaFromId(id);
}
public override (Manga, MangaConnectorId<Manga>)? GetMangaFromId(string mangaIdOnSite)
{
Log.Info($"Getting Obj: {mangaIdOnSite}");
string requestUrl =
$"https://api.mangadex.org/manga/{mangaIdOnSite}" +
$"?includes%5B%5D=manga&includes%5B%5D=cover_art&includes%5B%5D=author&includes%5B%5D=artist&includes%5B%5D=tag'";
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaDexFeed);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return null;
}
using StreamReader sr = new (result.result);
JObject jObject = JObject.Parse(sr.ReadToEnd());
if (jObject.Value<string>("result") != "ok")
{
JArray? errors = jObject["errors"] as JArray;
Log.Error($"Request failed: {string.Join(',', errors?.Select(e => e.Value<string>("title")) ?? [])}");
return null;
}
JObject? data = jObject["data"] as JObject;
if (data is null)
{
Log.Error("Data was null");
return null;
}
return ParseMangaFromJToken(data);
}
public override (Chapter, MangaConnectorId<Chapter>)[] GetChapters(MangaConnectorId<Manga> manga, string? language = null)
{
Log.Info($"Getting Chapters: {manga.IdOnConnectorSite}");
List<(Chapter, MangaConnectorId<Chapter>)> chapters = new ();
int offset = 0;
int total = int.MaxValue;
while(offset < total)
{
string requestUrl =
$"https://api.mangadex.org/manga/{manga.IdOnConnectorSite}/feed?limit={Limit}&offset={offset}&" +
$"translatedLanguage%5B%5D={language}&" +
$"contentRating%5B%5D=safe&contentRating%5B%5D=suggestive&contentRating%5B%5D=erotica&includeFutureUpdates=0&includes%5B%5D=";
offset += Limit;
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.MangaDexFeed);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return [];
}
using StreamReader sr = new (result.result);
JObject jObject = JObject.Parse(sr.ReadToEnd());
if (jObject.Value<string>("result") != "ok")
{
JArray? errors = jObject["errors"] as JArray;
Log.Error($"Request failed: {string.Join(',', errors?.Select(e => e.Value<string>("title")) ?? [])}");
return [];
}
total = jObject.Value<int>("total");
JArray? data = jObject.Value<JArray>("data");
if (data is null)
{
Log.Error("Data was null");
return [];
}
chapters.AddRange(data.Select(d => ParseChapterFromJToken(manga, d)));
}
Log.Info($"Request for chapters for {manga.Obj.Name} yielded {chapters.Count} results.");
return chapters.ToArray();
}
private static readonly Regex GetChapterIdFromUrl = new(@"https?:\/\/mangadex\.org\/chapter\/([a-z0-9-]+)\/?.*");
internal override string[] GetChapterImageUrls(MangaConnectorId<Chapter> chapterId)
{
Log.Info($"Getting Chapter Image-Urls: {chapterId.Obj}");
if (chapterId.WebsiteUrl is null || !UrlMatchesConnector(chapterId.WebsiteUrl))
{
Log.Debug($"Url is not for Connector. {chapterId.WebsiteUrl}");
return [];
}
Match match = GetChapterIdFromUrl.Match(chapterId.WebsiteUrl);
if (!match.Success || !match.Groups[1].Success)
{
Log.Debug($"Url is not for Connector (Could not retrieve id). {chapterId.WebsiteUrl}");
return [];
}
string id = match.Groups[1].Value;
string requestUrl = $"https://api.mangadex.org/at-home/server/{id}";
RequestResult result = downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300)
{
Log.Error("Request failed");
return [];
}
using StreamReader sr = new (result.result);
JObject jObject = JObject.Parse(sr.ReadToEnd());
if (jObject.Value<string>("result") != "ok")
{
JArray? errors = jObject["errors"] as JArray;
Log.Error($"Request failed: {string.Join(',', errors?.Select(e => e.Value<string>("title")) ?? [])}");
return [];
}
string? baseUrl = jObject.Value<string>("baseUrl");
JToken? chapterToken = jObject["chapter"];
string? hash = chapterToken?.Value<string>("hash");
JArray? data = chapterToken?["data"] as JArray;
if (baseUrl is null || hash is null || data is null)
{
Log.Error("Data was null");
return [];
}
IEnumerable<string> urls = data.Select(t => $"{baseUrl}/data/{hash}/{t.Value<string>()}");
return urls.ToArray();
}
private (Manga manga, MangaConnectorId<Manga> id) ParseMangaFromJToken(JToken jToken)
{
string? id = jToken.Value<string>("id");
if(id is null)
throw new Exception("jToken was not in expected format");
JObject? attributes = jToken["attributes"] as JObject;
if(attributes is null)
throw new Exception("jToken was not in expected format");
string? name = attributes["title"]?.Value<string>("en") ?? attributes["title"]?.First?.First?.Value<string>();
string description = attributes["description"]?.Value<string>("en")??attributes["description"]?.First?.First?.Value<string>()??"";
string? status = attributes["status"]?.Value<string>();
uint? year = attributes["year"]?.Value<uint?>();
string? originalLanguage = attributes["originalLanguage"]?.Value<string>();
JArray? altTitlesJArray = attributes.TryGetValue("altTitles", out JToken? altTitlesArray) ? altTitlesArray as JArray : null;
JArray? tagsJArray = attributes.TryGetValue("tags", out JToken? tagsArray) ? tagsArray as JArray : null;
JArray? relationships = jToken["relationships"] as JArray;
if (name is null || status is null || relationships is null)
throw new Exception("jToken was not in expected format");
string? coverFileName = relationships.FirstOrDefault(r => r["type"]?.Value<string>() == "cover_art")?["attributes"]?.Value<string>("fileName");
if(coverFileName is null)
throw new Exception("jToken was not in expected format");
List<Link> links = attributes["links"]?
.ToObject<Dictionary<string,string>>()?
.Select(kv =>
{
//https://api.mangadex.org/docs/3-enumerations/#manga-links-data
string url = kv.Key switch
{
"al" => $"https://anilist.co/manga/{kv.Value}",
"ap" => $"https://www.anime-planet.com/manga/{kv.Value}",
"bw" => $"https://bookwalker.jp/{kv.Value}",
"mu" => $"https://www.mangaupdates.com/series.html?id={kv.Value}",
"nu" => $"https://www.novelupdates.com/series/{kv.Value}",
"mal" => $"https://myanimelist.net/manga/{kv.Value}",
_ => kv.Value
};
string key = kv.Key switch
{
"al" => "AniList",
"ap" => "Anime Planet",
"bw" => "BookWalker",
"mu" => "Obj Updates",
"nu" => "Novel Updates",
"kt" => "Kitsu.io",
"amz" => "Amazon",
"ebj" => "eBookJapan",
"mal" => "MyAnimeList",
"cdj" => "CDJapan",
_ => kv.Key
};
return new Link(key, url);
}).ToList()!;
List<AltTitle> altTitles = (altTitlesJArray??[])
.Select(t =>
{
JObject? j = t as JObject;
JProperty? p = j?.Properties().First();
if (p is null)
return null;
return new AltTitle(p.Name, p.Value.ToString());
}).Where(x => x is not null).ToList()!;
List<MangaTag> tags = (tagsJArray??[])
.Where(t => t.Value<string>("type") == "tag")
.Select(t => t["attributes"]?["name"]?.Value<string>("en")??t["attributes"]?["name"]?.First?.First?.Value<string>())
.Select(str => str is not null ? new MangaTag(str) : null)
.Where(x => x is not null).ToList()!;
List<Author> authors = relationships
.Where(r => r["type"]?.Value<string>() == "author")
.Select(t => t["attributes"]?.Value<string>("name"))
.Select(str => str is not null ? new Author(str) : null)
.Where(x => x is not null).ToList()!;
MangaReleaseStatus releaseStatus = status switch
{
"completed" => MangaReleaseStatus.Completed,
"ongoing" => MangaReleaseStatus.Continuing,
"cancelled" => MangaReleaseStatus.Cancelled,
"hiatus" => MangaReleaseStatus.OnHiatus,
_ => MangaReleaseStatus.Unreleased
};
string websiteUrl = $"https://mangadex.org/title/{id}";
string coverUrl = $"https://uploads.mangadex.org/covers/{id}/{coverFileName}";
Manga manga = new Manga(name, description, coverUrl, releaseStatus, authors, tags, links,altTitles,
null, 0f, year, originalLanguage);
return (manga, new MangaConnectorId<Manga>(manga, this, id, websiteUrl));
}
private (Chapter chapter, MangaConnectorId<Chapter> id) ParseChapterFromJToken(MangaConnectorId<Manga> mcIdManga, JToken jToken)
{
string? id = jToken.Value<string>("id");
JToken? attributes = jToken["attributes"];
string? chapterStr = attributes?.Value<string>("chapter");
string? volumeStr = attributes?.Value<string>("volume");
int? volumeNumber = null;
string? title = attributes?.Value<string>("title");
if(id is null || chapterStr is null)
throw new Exception("jToken was not in expected format");
if(volumeStr is not null)
volumeNumber = int.Parse(volumeStr);
string websiteUrl = $"https://mangadex.org/chapter/{id}";
Chapter chapter = new (mcIdManga.Obj, chapterStr, volumeNumber, title);
return (chapter, new MangaConnectorId<Chapter>(chapter, this, id, websiteUrl));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +0,0 @@
namespace API.Schema;
public enum MangaReleaseStatus : byte
{
Continuing = 0,
Completed = 1,
OnHiatus = 2,
Cancelled = 3,
Unreleased = 4
}

View File

@ -1,9 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace API.Schema;
[PrimaryKey("Tag")]
public class MangaTag(string tag)
{
public string Tag { get; init; } = tag;
}

View File

@ -1,21 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace API.Schema;
[PrimaryKey("NotificationId")]
public class Notification(string title, string message = "", NotificationUrgency urgency = NotificationUrgency.Normal, DateTime? date = null)
{
[MaxLength(64)]
public string NotificationId { get; init; } = TokenGen.CreateToken("Notification");
public NotificationUrgency Urgency { get; init; } = urgency;
public string Title { get; init; } = title;
public string Message { get; init; } = message;
public DateTime Date { get; init; } = date ?? DateTime.UtcNow;
public Notification() : this("") { }
}

View File

@ -1,42 +0,0 @@
using System.Text;
using Newtonsoft.Json;
namespace API.Schema.NotificationConnectors;
public class Gotify(string endpoint, string appToken)
: NotificationConnector(TokenGen.CreateToken(typeof(Gotify), endpoint), NotificationConnectorType.Gotify)
{
public string Endpoint { get; init; } = endpoint;
public string AppToken { get; init; } = appToken;
public override void SendNotification(string title, string notificationText)
{
MessageData message = new(title, notificationText);
HttpRequestMessage request = new(HttpMethod.Post, $"{endpoint}/message");
request.Headers.Add("X-Gotify-Key", this.AppToken);
request.Content = new StringContent(JsonConvert.SerializeObject(message, Formatting.None), Encoding.UTF8, "application/json");
HttpResponseMessage response = _client.Send(request);
if (!response.IsSuccessStatusCode)
{
StreamReader sr = new (response.Content.ReadAsStream());
//TODO
}
}
private class MessageData
{
// ReSharper disable four times UnusedAutoPropertyAccessor.Local
public string message { get; }
public long priority { get; }
public string title { get; }
public Dictionary<string, object> extras { get; }
public MessageData(string title, string message)
{
this.title = title;
this.message = message;
this.extras = new();
this.priority = 4;
}
}
}

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