329 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
6315940cd6 [postgres-Server-V2] fix: pk conflict error in chapter download (still not working though) 2025-02-02 00:47:46 +01:00
ef7ebf022d [postgres-Server-V2] Update Weebcentral with changes from V1 2025-02-02 00:18:38 +01:00
725813c2f3 [postgres-Server-V2] feat(Jobs): Instead of passing context to each job, pass service provider and let the job create its own context 2025-02-01 23:08:13 +01:00
a69e12179b [postgres-Server-V2] fix(Manga): Was using properties before initialization 2025-02-01 22:16:12 +01:00
45ca2695eb [postgres-Server-V2] feat: Convert DateTime.Now calls to UtcNow 2025-02-01 21:58:50 +01:00
bd9e79d026 [postgres-Server-V2] Use MangaId to check if manga exists instead of connector id 2025-02-01 21:53:16 +01:00
6bbd09072b [postgres-Server-V2] fix(Chapter): Use tryParse instead of parse when comparing chapters 2025-02-01 21:52:34 +01:00
d6018b60ae [postgres-Server-V2] Add parameter array to token gen 2025-01-31 21:24:35 +01:00
a713a006dd [postgres-Server-V2] fix: Updated Job logic to spawn a job only if a job with the same id is not already in the RunningJobs list 2025-01-25 12:10:32 +01:00
ebe7e145aa [postgres-Server-V2] feat: Convert chapterNumeber to string 2025-01-25 12:06:56 +01:00
be6b3da1be Merge branch 'cuttingedge-merge-ServerV2' into postgres-Server-V2
# Conflicts:
#	API/MangaDownloadClients/ChromiumDownloadClient.cs
#	Tranga/TrangaSettings.cs
2025-01-15 23:14:51 +01:00
b818f63f2a Merge branch 'cuttingedge-merge-ServerV2' into postgres-Server-V2
# Conflicts:
#	API/MangaDownloadClients/ChromiumDownloadClient.cs
#	Tranga/TrangaSettings.cs
2025-01-15 22:56:25 +01:00
110a0bf481 Remove Mangasee 2025-01-15 22:19:24 +01:00
fdbe585aa0 Merge branch 'cuttingedge-merge-ServerV2' into postgres-Server-V2
# Conflicts:
#	API/Schema/MangaConnectors/Mangasee.cs
#	Tranga/Jobs/JobBoss.cs
#	Tranga/MangaConnectors/MangaConnectorJsonConverter.cs
#	Tranga/Tranga.cs
2025-01-15 22:18:52 +01:00
6a8df2f5f8 TokenGen CreateTokenHash from array of strings. 2025-01-12 19:09:37 +01:00
524596ad85 Merge branch 'cuttingedge-merge-ServerV2' into postgres-Server-V2
# Conflicts:
#	API/Schema/MangaConnectors/Mangaworld.cs
2025-01-09 01:51:07 +01:00
94adefa8e6 Fix some Jobs 2025-01-09 01:34:03 +01:00
7cf7eb85d2 Fix #307 Chapternumbers
ChapterNumbers now can be sub-decimal, like version-numbers (x.y.z.a...)
2025-01-09 01:33:30 +01:00
55c0e2c4e7 Manga latest downloaded and available via SQL Queries 2024-12-18 16:42:59 +01:00
5494f2b754 Merge branch 'master' into postgres-Server-V2 2024-12-18 00:45:32 +01:00
80190e1286 Job Run pass context to add new Data 2024-12-17 17:24:25 +01:00
16dd1ffa97 Job Run pass context to add new Data 2024-12-16 23:29:57 +01:00
9cb5f636dd Fix MangaDex wrong Id in requests for new chapters 2024-12-16 23:29:42 +01:00
df319e9afb Jobs change RunningJobs to Dictionary of Thread and Job instead of just List of threads 2024-12-16 23:21:13 +01:00
84388a469a Remove unncessary nullable in Job 2024-12-16 23:20:46 +01:00
d322445550 Add "GetAll" Paths to Manga and Jobcontroller 2024-12-16 23:20:31 +01:00
81d22bc022 Newtonsoft Enum Json Converter 2024-12-16 23:20:17 +01:00
6b0cefbc7c Simplify SearchPaths 2024-12-16 23:19:59 +01:00
519030861d Move GetConnectors to new MiscController 2024-12-16 23:19:47 +01:00
6940e6c64d Include Manga and Chapter in jobs 2024-12-16 23:05:12 +01:00
e66ab49e7d ConnectorController.cs -> SearchController.cs 2024-12-16 22:59:52 +01:00
67a15cec7f Path for SearchManga 2024-12-16 22:58:44 +01:00
ae11c31b9d ConnectorController.AddMangaToContext existing also check for connector 2024-12-16 22:55:52 +01:00
60b128fc30 Fix Arrays shall not be added to context 2024-12-16 22:54:23 +01:00
729f018712 Fix relation Manga -> Tags and Manga -> Authors with many-many 2024-12-16 21:52:35 +01:00
03e89913e3 Scoped PGSql Contexts for Threads 2024-12-16 21:24:00 +01:00
c4fc2f436b Notification-Thread Implemented 2024-12-16 21:02:55 +01:00
ebc30c85bf Notification Date notnull 2024-12-16 20:35:25 +01:00
d6b0e3a366 Notification Date notnull 2024-12-16 20:34:20 +01:00
e1bfdd675b NotificationConnector SendNotification public 2024-12-16 20:33:59 +01:00
e6f8853b49 Add NotificationUrgency.cs 2024-12-16 20:14:28 +01:00
99ddb06d6d TrangaSettings remove old properties 2024-12-16 20:08:23 +01:00
62876498d0 Notification add Date 2024-12-16 20:03:45 +01:00
1044821147 Fix relation Manga->Chapter (latest) 2024-12-16 20:03:38 +01:00
7f946da1c3 Remove docs/ we have SWAGger 2024-12-16 19:50:56 +01:00
79e7941dda Remove docs/ we have SWAGger 2024-12-16 19:49:56 +01:00
faa235783c remove leftover .dockerignore 2024-12-16 19:49:17 +01:00
87c5ad001d AutoInclude Navigation on Manga and Chapter 2024-12-16 19:49:03 +01:00
3b58e0498b Fix redundant keys, MangaSearch 2024-12-16 19:25:22 +01:00
87274aca19 Remove APISerializable and APIJsonSerializer 2024-12-16 18:55:52 +01:00
77c5903cf1 Fixup Docker files 2024-12-16 18:29:57 +01:00
0d32f15ee9 PGSqlContext add MangaConnector Discriminator
API use Newtonsoft Json,
2024-12-16 18:28:58 +01:00
a0774841bc PrimaryKey Notification 2024-12-16 18:02:48 +01:00
3ee3a07565 Urgency 2024-12-16 18:02:14 +01:00
b9eecd3afd Remove excess 2024-12-16 17:47:12 +01:00
6534341fd5 Library Connector Constructors 2024-12-16 17:45:58 +01:00
6737be4a20 Notification Connector Methods 2024-12-16 17:42:16 +01:00
84833acdeb Schema add Notifications 2024-12-16 17:35:36 +01:00
538e6fa60b MangaConnectors in API 2024-12-15 23:00:35 +01:00
8c5bcd2665 Weebcentral 2024-12-14 22:02:32 +01:00
50dfd92c91 Merge branch 'Server-V2' into postgres-Server-V2 2024-12-14 21:57:47 +01:00
bf9fe517b0 Merge branch 'cuttingedge-merge-ServerV2' into Server-V2 2024-12-14 21:57:34 +01:00
1008da7ee8 Add API 2024-12-14 21:53:29 +01:00
ec884f888f Merge branch 'Server-V2' into postgres-Server-V2 2024-12-14 18:07:27 +01:00
57df419d65 Merge branch 'cuttingedge-merge-ServerV2' into Server-V2 2024-12-14 18:07:22 +01:00
395619acd3 Merge branch 'Server-V2' into postgres-Server-V2 2024-12-13 18:54:16 +01:00
502821c246 Merge branch 'cuttingedge-merge-ServerV2' into postgres-Server-V2 2024-12-13 18:54:02 +01:00
9d6a8ed686 Merge branch 'cuttingedge-merge-ServerV2' into Server-V2 2024-12-13 18:53:52 +01:00
afcc2cacaf merge 2024-12-12 22:48:46 +01:00
4040b5845c Merge branch 'cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/Jobs/DownloadNewChapters.cs
#	Tranga/Jobs/JobBoss.cs
#	Tranga/Jobs/UpdateMetadata.cs
2024-12-12 22:47:47 +01:00
7daebcb1c4 Merge branch 'cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/Tranga.csproj
2024-12-12 22:05:52 +01:00
44ff158c66 Merge branch 'cuttingedge-merge-ServerV2' into Server-V2 2024-12-04 19:58:48 +01:00
b5b45d0801 Merge branch 'cuttingedge-merge-ServerV2' into Server-V2 2024-12-04 19:49:56 +01:00
29f3f1a16e Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-11-28 21:35:48 +01:00
1bd914571c Asuratoon Server-V2 2024-11-11 17:09:19 +01:00
483dcc41df Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-11-02 17:52:26 +01:00
bc44a5333b Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-11-02 17:44:38 +01:00
5018800d09 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/Jobs/JobBoss.cs
2024-11-02 16:25:49 +01:00
c7dc5e75f2 Add "Expires" Header to image responses 2024-10-31 23:00:33 +01:00
3f37eefe72 Include modified date in image responses for cachecontrol 2024-10-31 22:53:05 +01:00
b7bc04a045 Add zstd compression to all API Traffic 2024-10-31 22:16:18 +01:00
f7daacf0d4 Use Robidoux algorithm for resizing covers 2024-10-31 21:50:46 +01:00
1cb8899195 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-10-31 20:43:21 +01:00
3e581e2ddb Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/Jobs/JobBoss.cs
2024-10-30 22:34:17 +01:00
07c6081c03 #236 2024-10-27 03:49:55 +01:00
585d7e3380 Fix order of startup: Load Manga first, the jobs 2024-10-27 03:42:50 +01:00
febce6b92a Downloaded Image processing:
- Compression
- B/W threshold
2024-10-27 03:40:07 +01:00
fb7ed21d82 Update Types doc with last merge for Chapters 2024-10-27 03:39:40 +01:00
2db85e5070 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-10-27 02:09:16 +01:00
96b5921ed6 GET LibraryTypes Create and Test set url to lowercase
Set Komga to also require username and password
2024-10-23 02:29:08 +02:00
9d47445339 Assign numbers to ProgressToken.State
Update type docs
2024-10-20 20:58:59 +02:00
93696fbac1 docs Types documentation NotificationConnector 2024-10-20 20:43:40 +02:00
582b3af89c Add docs types LibraryConnector 2024-10-20 18:53:03 +02:00
f57667bc8f add documentation types settings 2024-10-20 18:33:19 +02:00
f9a30f2587 Types documentation add quotation marks 2024-10-20 18:32:49 +02:00
240af81fa9 Add doc types chapter 2024-10-20 02:16:22 +02:00
26b2910000 Add GET /v2/Jobs/Standby 2024-10-20 01:30:50 +02:00
a88b85e599 Add numbers to JobTypes (and type documentation) 2024-10-20 01:08:22 +02:00
27f823cfeb GET V2Manga with internalIds return distinct array. 2024-10-20 01:06:24 +02:00
70993a692a Add ReleaseStatus to docs/types.md 2024-10-18 19:31:09 +02:00
1a631362c9 Use Sixlabors.Imagesharp for resizing coverimages. 2024-10-18 19:30:57 +02:00
00c4f0533f Update documentation 2024-10-18 17:57:59 +02:00
8670863810 Add Job and ProgressToken Types to docs 2024-10-18 17:51:53 +02:00
2c9bd2532e Fix order of RequestPaths 2024-10-18 17:51:37 +02:00
575fb739cc typo 2024-10-18 00:48:58 +02:00
d4af068f0e Add BaseUris: string[] field to MangaConnector, to match Connector to uri 2024-10-18 00:48:46 +02:00
6a4d454a08 Extend Types.md documentation 2024-10-18 00:29:29 +02:00
225db8beda Change return type of api request to get Connectors to get connector-list instead of dictionary 2024-10-17 21:03:37 +02:00
d80fcd9039 Manga website url nullable 2024-09-30 23:19:17 +02:00
4871bc801d Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-09-29 01:07:51 +02:00
1c5f105a4d Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-09-27 15:04:03 +02:00
26a07f4a2f Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-09-27 14:58:02 +02:00
48ab44c28d Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-09-27 14:51:20 +02:00
32ecdcda76 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-09-23 15:41:02 +02:00
a92eba2d14 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/TrangaSettings.cs
2024-09-22 00:17:30 +02:00
7b6724ad38 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-09-18 18:47:28 +02:00
be68ddc9b7 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-09-17 00:52:06 +02:00
96e2845a5b Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-09-17 00:23:49 +02:00
c36204c7a8 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-09-16 22:51:57 +02:00
18edcef1c3 Resolve #247
Modify API call:
`/v2/Connector/Types`
Returns: Dictionary with Connector-Names and supported languages.
2024-09-16 21:25:08 +02:00
73ad881600 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-09-16 21:19:30 +02:00
c6cfd9eb6c Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/Server.cs
2024-09-16 21:17:32 +02:00
99df9a9dfd Fix #248
Move contents of old DownloadLocation and WorkingDirectory to new paths. Overwrite existing files, and add from oldPath.
2024-09-16 20:10:38 +02:00
77bb309dfa Fix #248 double closing OutputStream in response 2024-09-16 19:58:26 +02:00
3b9d4a6735 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-09-11 14:41:49 +02:00
190fa8cba7 Fix #239 multiple enumeration on Export 2024-09-09 09:54:09 +02:00
217700d08d Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	.gitignore
2024-09-09 09:44:36 +02:00
75eea8c761 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-08-26 20:47:19 +02:00
06cdbbd283 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-08-26 20:28:57 +02:00
054c88712e Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-08-26 20:18:51 +02:00
e95eb0497c #229 Resize cover Images if requested 2024-08-26 19:34:29 +02:00
3c3f7bb95a Merge recent changes to TrangaSettings backend 2024-08-26 19:08:59 +02:00
032ee95716 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/Jobs/DownloadNewChapters.cs
#	Tranga/Jobs/JobBoss.cs
#	Tranga/Jobs/UpdateMetadata.cs
#	Tranga/Server.cs
#	Tranga/TrangaArgs.cs
#	Tranga/TrangaSettings.cs
2024-08-26 19:04:05 +02:00
fc884adc9f Fix HandleRequest trying to send more than one response 2024-08-10 21:52:14 +02:00
960d3f7c62 Fix Cover location 2024-08-10 21:45:47 +02:00
6520aebcdf Cleanup MangaCache 2024-08-10 21:42:09 +02:00
1ee9b644aa Fix Permissions for manga-directory 2024-08-10 21:37:43 +02:00
2f36701fef Reduce Logspam 2024-08-10 21:25:24 +02:00
b18f8e4059 Fix GET /v2/Job/Types 2024-08-10 21:00:38 +02:00
8145abb744 Fix workign Directory in TrangaArgsMain 2024-08-10 18:53:18 +02:00
9dd52178b9 Update MangaHere bad ManhuaPlus to v2 architecture 2024-08-10 17:34:45 +02:00
cf242f81e1 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/Manga.cs
2024-08-10 17:32:56 +02:00
6b9ddca711 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-07-31 17:44:42 +02:00
d73bf70868 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/Server.cs
2024-07-11 15:46:35 +02:00
d221532e0d Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-06-29 22:50:14 +02:00
5bc2a8909d Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/Server.cs
2024-06-29 19:38:01 +02:00
f3e0959be8 Merge pull request #198 from C9Glax/master
Merge Github Actions
2024-06-29 19:23:37 +02:00
8607bd2c89 #187 NTFY JsonConverter 2024-06-15 21:40:28 +02:00
fab30dc5a7 Documentation
https://github.com/C9Glax/tranga/issues/187
2024-06-15 21:27:24 +02:00
fd20b9febf NTFY use Username and Password
https://github.com/C9Glax/tranga/issues/187
2024-06-15 21:26:23 +02:00
ee6de661c8 Merge branch 'refs/heads/C9Glax-tranga-issue-187' into Server-V2 2024-06-15 21:24:41 +02:00
790e77b00c Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-06-02 01:05:29 +02:00
4f14903538 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-06-02 00:23:29 +02:00
6ae3918679 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-06-02 00:11:33 +02:00
8ccb6c0cb5 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-05-26 23:04:35 +02:00
beb455308f Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-05-26 22:52:05 +02:00
5c309131ad Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/Jobs/JobBoss.cs
2024-05-26 18:56:30 +02:00
27a559834f Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/Jobs/JobBoss.cs
2024-05-26 18:26:06 +02:00
2cfc7ac2c5 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-04-27 19:09:31 +02:00
017f31ca83 Clean 2024-04-26 16:39:39 +02:00
4021237888 Add Endpoint GET /v2/Manga/Search GlobalSearch
Resolves #124
#167
2024-04-26 00:51:18 +02:00
7ed3846c5f Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2 2024-04-26 00:49:08 +02:00
7f95ab9439 Add Endpoint GET /v2/Manga to request multiple Manga from internalIds #167 2024-04-26 00:22:17 +02:00
49a9b7ccb0 Corrected Job->Manga in return 2024-04-26 00:19:38 +02:00
0735e2c588 Change GET /v2/Manga to /v2/Mangas 2024-04-26 00:16:28 +02:00
5b22246c41 Add Endpoint GET /v2/Job returns list of jobs specified by jobid 2024-04-26 00:14:46 +02:00
2e1f633f40 Add Endpoint POST /v2/Manga/internalId/moveFolder #167 2024-04-26 00:05:48 +02:00
8887cea718 Add Endpoint POST /v2/Manga/internalId/ignoreChaptersBelow #167 2024-04-26 00:03:46 +02:00
061da1b4bf Add field customFolder and startChapter to CreateJob Endpoint
https://github.com/C9Glax/tranga/pull/167#issuecomment-2077909075
#167
2024-04-25 23:55:31 +02:00
80dc8fbe65 Resolves #176 Return 409 conflict if job already exists. 2024-04-25 23:50:06 +02:00
28a0efe488 Add Endpoint /v2/Manga/internalId/Chapters/Latest 2024-04-25 23:45:13 +02:00
3d08b1f9f2 Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/GlobalBase.cs
#	Tranga/Jobs/JobBoss.cs
#	Tranga/Jobs/UpdateMetadata.cs
#	Tranga/Manga.cs
#	Tranga/MangaConnectors/Bato.cs
#	Tranga/MangaConnectors/MangaKatana.cs
#	Tranga/MangaConnectors/MangaLife.cs
#	Tranga/MangaConnectors/Manganato.cs
#	Tranga/MangaConnectors/Mangasee.cs
#	Tranga/MangaConnectors/Mangaworld.cs
2024-04-25 23:34:56 +02:00
2651a0c53b Implemented /v2/NotificationConnector/* 2024-04-23 00:58:19 +02:00
0ced3a7dd9 Implement /v2/LibraryConnector/* 2024-04-23 00:51:24 +02:00
a56555eee4 Add LibraryConnector.Test to see if requests can be made to endpoint. 2024-04-23 00:48:08 +02:00
cee7870aad Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/Server.cs
2024-04-23 00:21:18 +02:00
bce77180bc Merge branch 'refs/heads/cuttingedge-merge-ServerV2' into Server-V2
# Conflicts:
#	Tranga/Jobs/UpdateMetadata.cs
#	Tranga/Manga.cs
2024-04-22 23:54:26 +02:00
8c66bbc89f Use publicationCache to store and update Manga 2024-04-22 23:45:51 +02:00
e360037fda Add "(?:/?)" to the end of all Regex RequestPaths 2024-04-22 04:43:08 +02:00
ea866e0136 Added Endpoint /v2/Manga lists all known Manga
Implemented /v2/Manga/*
2024-04-22 04:42:10 +02:00
c3231327f9 nullable 2024-04-22 04:21:39 +02:00
03e90eccd3 No longer require connector name to create job 2024-04-22 04:21:30 +02:00
64482931a3 Implemented GET /v2/LogFile 2024-04-22 03:19:56 +02:00
cce4901a5d Implement all /v2/Settings 2024-04-22 03:03:17 +02:00
3adb103fc4 Fix API-Path prematurely triggering match. 2024-04-22 03:02:49 +02:00
b6ffb97a04 Merge branch 'refs/heads/cuttingedge' into Server-V2 2024-04-22 02:27:34 +02:00
49cfff8a2f Changed the Creation Job API to a single Endpoint /v2/Job/Create/<Type>
Added and implemented GET /v2/Job/Types
Implemented /v2/Job/<jobId>
Implemented /v2/Job/<jobId>/StartNow
Implemented /v2/Job/<jobId>/Cancel
Implemented /v2/Job/<jobId>/SetInterval
2024-04-22 00:00:35 +02:00
6d48a100ca Implemented GET
/v2/Jobs
/v2/Jobs/Running
/v2/Jobs/Waiting
/v2/Jobs/Monitoring
/v2/Job/<jobId>
/v2/Job/<jobId>/Progress
2024-04-21 21:48:24 +02:00
4104169c19 Fix path excluding symbols that are used in requests 2024-04-21 21:46:52 +02:00
4cb7c941a2 Implemented /v2/Connector/<ConnectorName>/GetManga 2024-04-21 21:32:03 +02:00
b3fb53f6d8 Corrected link 2024-04-20 18:55:54 +02:00
8b9769b816 Merge branch 'refs/heads/master' into Server-V2 2024-04-20 18:49:08 +02:00
9a02859f6b Docker Image build 2024-04-20 18:46:00 +02:00
e96dd07521 Link API Documentation in README.md 2024-04-20 18:41:12 +02:00
a610eff8f0 Merge branch 'refs/heads/cuttingedge' into Server-V2 2024-04-20 18:39:56 +02:00
c41f04d92d All Valid Request Paths return "Not Implemented".
Ping returns Pong.
2024-04-20 18:34:20 +02:00
5e647099cd Spelling 2024-04-20 17:56:54 +02:00
011af9c7a8 #114 API Documentation 2024-04-20 16:59:51 +02:00
630e507564 #74 API Documentation 2024-04-20 16:59:32 +02:00
fa2598084f Hard cutover https://github.com/C9Glax/tranga/pull/167#issuecomment-2067689986 2024-04-20 16:54:58 +02:00
f79743ee93 actually use v2 API 2024-04-19 22:20:24 +02:00
2828fec316 Merge 2024-04-19 22:08:03 +02:00
bd14722791 Merge remote-tracking branch 'refs/remotes/db-2001/json-api' into Server-V2 2024-04-19 22:06:55 +02:00
d22b49cfa8 Change Method Header for Handlers to return the response to HandleRequest so we don't forget to send a response. 2024-04-19 21:58:29 +02:00
595051b0fe Merge remote-tracking branch 'origin/Server-V2' into Server-V2 2024-04-19 21:54:21 +02:00
238395a3da Return JobIds instead of full jobs.
/v2/Jobs
/v2/Jobs/Running
/v2/Jobs/Waiting
/v2/Jobs/Monitoring
2024-04-19 21:54:16 +02:00
0313d81204 Return JobIds instead of full jobs.
/v2/Jobs
/v2/Jobs/Running
/v2/Jobs/Waiting
/v2/Jobs/Monitoring
2024-04-19 21:40:31 +02:00
f5cecb9e30 Github Reference Link Style 2024-04-19 21:35:38 +02:00
7e5fa6ce41 API v2 2024-04-19 21:23:15 +02:00
a8aa7d3370 Okay, actually write request variables to log. 2024-04-18 18:45:19 -04:00
01bab62190 Log request if unknown 2024-04-18 18:32:49 -04:00
2768ab38e6 Merge remote-tracking branch 'upstream/cuttingedge' into json-api 2024-04-18 18:24:26 -04:00
33b8ede492 Use new requestParams variable for AprilFoolsMode setting 2024-04-18 17:58:23 -04:00
dbc1b94124 Merge branch 'cuttingedge' into json-api
Solved Merge conflicts with cuttingedge branch
2024-04-18 17:56:44 -04:00
6f5fb7e0bb API rewrite to parse JSON body for POST and DELETE 2024-04-07 18:20:28 -04:00
7628510b87 Documentation for API Calls 2024-04-06 18:13:31 -04:00
dd965d886a Revert "Added initial documentation for API Calls"
This reverts commit 7e54577c54.
2024-04-06 18:11:03 -04:00
7e54577c54 Added initial documentation for API Calls 2024-04-06 18:05:27 -04:00
125 changed files with 9901 additions and 4794 deletions

View File

@ -1,27 +0,0 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
Manga
settings

View File

@ -22,7 +22,7 @@ jobs:
# https://github.com/marketplace/actions/docker-setup-buildx # https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v3.11.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.18.0 uses: docker/build-push-action@v6.15.0
with: with:
context: ./ context: ./
file: ./Dockerfile file: ./Dockerfile

View File

@ -22,7 +22,7 @@ jobs:
# https://github.com/marketplace/actions/docker-setup-buildx # https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v3.11.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.18.0 uses: docker/build-push-action@v6.15.0
with: with:
context: ./ context: ./
file: ./Dockerfile file: ./Dockerfile

View File

@ -22,7 +22,7 @@ jobs:
# https://github.com/marketplace/actions/docker-setup-buildx # https://github.com/marketplace/actions/docker-setup-buildx
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx id: buildx
uses: docker/setup-buildx-action@v3.11.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.18.0 uses: docker/build-push-action@v6.15.0
with: with:
context: ./ context: ./
file: ./Dockerfile file: ./Dockerfile

2
.gitignore vendored
View File

@ -20,6 +20,8 @@ riderModule.iml
cover.jpg cover.jpg
cover.png cover.png
/.vscode /.vscode
/.vs/
Tranga/Properties/launchSettings.json
/Manga /Manga
/settings /settings
*.DotSettings.user *.DotSettings.user

38
API/API.csproj Normal file
View File

@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
<PackageReference Include="log4net" Version="3.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql" Version="9.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="PuppeteerSharp" Version="20.1.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="Soenneker.Utils.String.NeedlemanWunsch" Version="3.0.929" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="8.1.0" />
<PackageReference Include="System.Drawing.Common" Version="9.0.3" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>

6
API/API.http Normal file
View File

@ -0,0 +1,6 @@
@API_HostAddress = http://localhost:5105
GET {{API_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

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

View File

@ -0,0 +1,16 @@
namespace API.APIEndpointRecords;
public record GotifyRecord(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,16 @@
using System.Text.RegularExpressions;
namespace API.APIEndpointRecords;
public record LunaseaRecord(string id)
{
private static Regex validateRex = new(@"(?:device|user)\/[0-9a-zA-Z\-]+");
public bool Validate()
{
if (id == string.Empty)
return false;
if (!validateRex.IsMatch(id))
return false;
return true;
}
}

View File

@ -0,0 +1,3 @@
namespace API.APIEndpointRecords;
public record ModifyJobRecord(ulong? RecurrenceMs, bool? Enabled);

View File

@ -0,0 +1,13 @@
namespace API.APIEndpointRecords;
public record NewLibraryRecord(string path, string name)
{
public bool Validate()
{
if (path.Length < 1) //TODO Better Path validation
return false;
if (name.Length < 1)
return false;
return true;
}
}

View File

@ -0,0 +1,17 @@
namespace API.APIEndpointRecords;
public record NtfyRecord(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 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,373 @@
using API.APIEndpointRecords;
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]
[Route("v{version:apiVersion}/[controller]")]
public class JobController(PgsqlContext context) : Controller
{
/// <summary>
/// Returns all Jobs
/// </summary>
/// <response code="200"></response>
[HttpGet]
[ProducesResponseType<Job[]>(Status200OK, "application/json")]
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>
/// <response code="200"></response>
[HttpPost("WithIDs")]
[ProducesResponseType<Job[]>(Status200OK, "application/json")]
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="JobState">Requested Job-State</param>
/// <response code="200"></response>
[HttpGet("State/{JobState}")]
[ProducesResponseType<Job[]>(Status200OK, "application/json")]
public IActionResult GetJobsInState(JobState JobState)
{
Job[] jobsInState = context.Jobs.Where(job => job.state == JobState).ToArray();
return Ok(jobsInState);
}
/// <summary>
/// Returns all Jobs of requested Type
/// </summary>
/// <param name="JobType">Requested Job-Type</param>
/// <response code="200"></response>
[HttpGet("Type/{JobType}")]
[ProducesResponseType<Job[]>(Status200OK, "application/json")]
public IActionResult GetJobsOfType(JobType JobType)
{
Job[] jobsOfType = context.Jobs.Where(job => job.JobType == JobType).ToArray();
return Ok(jobsOfType);
}
/// <summary>
/// Returns all Jobs of requested Type and State
/// </summary>
/// <param name="JobType">Requested Job-Type</param>
/// <param name="JobState">Requested Job-State</param>
/// <response code="200"></response>
[HttpGet("TypeAndState/{JobType}/{JobState}")]
[ProducesResponseType<Job[]>(Status200OK, "application/json")]
public IActionResult GetJobsOfType(JobType JobType, JobState JobState)
{
Job[] jobsOfType = context.Jobs.Where(job => job.JobType == JobType && job.state == JobState).ToArray();
return Ok(jobsOfType);
}
/// <summary>
/// Return Job with ID
/// </summary>
/// <param name="JobId">Job-ID</param>
/// <response code="200"></response>
/// <response code="404">Job with ID could not be found</response>
[HttpGet("{JobId}")]
[ProducesResponseType<Job>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetJob(string JobId)
{
Job? ret = context.Jobs.Find(JobId);
return (ret is not null) switch
{
true => Ok(ret),
false => NotFound()
};
}
/// <summary>
/// Create a new DownloadAvailableChaptersJob
/// </summary>
/// <param name="MangaId">ID of Manga</param>
/// <param name="record">Job-Configuration</param>
/// <response code="201">Job-IDs</response>
/// <response code="400">Could not find Library with ID</response>
/// <response code="404">Could not find Manga with ID</response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("DownloadAvailableChaptersJob/{MangaId}")]
[ProducesResponseType<string[]>(Status201Created, "application/json")]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateDownloadAvailableChaptersJob(string MangaId, [FromBody]DownloadAvailableJobsRecord record)
{
if (context.Mangas.Find(MangaId) is not { } m)
return NotFound();
else
{
try
{
LocalLibrary? l = context.LocalLibraries.Find(record.localLibraryId);
if (l is null)
return BadRequest();
m.Library = l;
context.SaveChanges();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
Job dep = new RetrieveChaptersJob(record.recurrenceTimeMs, MangaId);
Job job = new DownloadAvailableChaptersJob(record.recurrenceTimeMs, MangaId, null, [dep.JobId]);
return AddJobs([dep, job]);
}
/// <summary>
/// Create a new DownloadSingleChapterJob
/// </summary>
/// <param name="ChapterId">ID of the Chapter</param>
/// <response code="201">Job-IDs</response>
/// <response code="404">Could not find Chapter with ID</response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("DownloadSingleChapterJob/{ChapterId}")]
[ProducesResponseType<string[]>(Status201Created, "application/json")]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateNewDownloadChapterJob(string ChapterId)
{
if(context.Chapters.Find(ChapterId) is null)
return NotFound();
Job job = new DownloadSingleChapterJob(ChapterId);
return AddJobs([job]);
}
/// <summary>
/// Create a new UpdateFilesDownloadedJob
/// </summary>
/// <param name="MangaId">ID of the Manga</param>
/// <response code="201">Job-IDs</response>
/// <response code="201">Could not find Manga with ID</response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("UpdateFilesJob/{MangaId}")]
[ProducesResponseType<string[]>(Status201Created, "application/json")]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateUpdateFilesDownloadedJob(string MangaId)
{
if(context.Mangas.Find(MangaId) is null)
return NotFound();
Job job = new UpdateFilesDownloadedJob(0, MangaId);
return AddJobs([job]);
}
/// <summary>
/// Create a new UpdateMetadataJob for all Manga
/// </summary>
/// <response code="201">Job-IDs</response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("UpdateAllFilesJob")]
[ProducesResponseType<string[]>(Status201Created, "application/json")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateUpdateAllFilesDownloadedJob()
{
List<string> ids = context.Mangas.Select(m => m.MangaId).ToList();
List<UpdateFilesDownloadedJob> jobs = ids.Select(id => new UpdateFilesDownloadedJob(0, id)).ToList();
try
{
context.Jobs.AddRange(jobs);
context.SaveChanges();
return Created();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
/// <summary>
/// Create a new UpdateMetadataJob
/// </summary>
/// <param name="MangaId">ID of the Manga</param>
/// <response code="201">Job-IDs</response>
/// <response code="404">Could not find Manga with ID</response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("UpdateMetadataJob/{MangaId}")]
[ProducesResponseType<string[]>(Status201Created, "application/json")]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateUpdateMetadataJob(string MangaId)
{
if(context.Mangas.Find(MangaId) is null)
return NotFound();
Job job = new UpdateMetadataJob(0, MangaId);
return AddJobs([job]);
}
/// <summary>
/// Create a new UpdateMetadataJob for all Manga
/// </summary>
/// <response code="201">Job-IDs</response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("UpdateAllMetadataJob")]
[ProducesResponseType<string[]>(Status201Created, "application/json")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateUpdateAllMetadataJob()
{
List<string> ids = context.Mangas.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 AddJobs(Job[] jobs)
{
try
{
context.Jobs.AddRange(jobs);
context.SaveChanges();
return new CreatedResult((string?)null, jobs.Select(j => j.JobId).ToArray());
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
/// <summary>
/// Delete Job with ID and all children
/// </summary>
/// <param name="JobId">Job-ID</param>
/// <response code="200">Job(s) deleted</response>
/// <response code="404">Job could not be found</response>
/// <response code="500">Error during Database Operation</response>
[HttpDelete("{JobId}")]
[ProducesResponseType<string[]>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult DeleteJob(string JobId)
{
try
{
Job? ret = context.Jobs.Find(JobId);
if(ret is null)
return NotFound();
IQueryable<Job> children = GetChildJobs(JobId);
context.RemoveRange(children);
context.Remove(ret);
context.SaveChanges();
return new OkObjectResult(children.Select(x => x.JobId).Append(ret.JobId).ToArray());
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
private IQueryable<Job> GetChildJobs(string parentJobId)
{
IQueryable<Job> children = context.Jobs.Where(j => j.ParentJobId == parentJobId);
foreach (Job child in children)
foreach (Job grandChild in GetChildJobs(child.JobId))
children.Append(grandChild);
return children;
}
/// <summary>
/// Modify Job with ID
/// </summary>
/// <param name="JobId">Job-ID</param>
/// <param name="modifyJobRecord">Fields to modify, set to null to keep previous value</param>
/// <response code="202">Job modified</response>
/// <response code="400">Malformed request</response>
/// <response code="404">Job with ID not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpPatch("{JobId}")]
[ProducesResponseType<Job>(Status202Accepted, "application/json")]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult ModifyJob(string JobId, [FromBody]ModifyJobRecord modifyJobRecord)
{
try
{
Job? ret = context.Jobs.Find(JobId);
if(ret is null)
return NotFound();
ret.RecurrenceMs = modifyJobRecord.RecurrenceMs ?? ret.RecurrenceMs;
ret.Enabled = modifyJobRecord.Enabled ?? ret.Enabled;
context.SaveChanges();
return new AcceptedResult(ret.JobId, ret);
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
/// <summary>
/// Starts the Job with the requested ID
/// </summary>
/// <param name="JobId">Job-ID</param>
/// <response code="202">Job started</response>
/// <response code="404">Job with ID not found</response>
/// <response code="409">Job was already running</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("{JobId}/Start")]
[ProducesResponseType(Status202Accepted)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status409Conflict)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult StartJob(string JobId)
{
Job? ret = context.Jobs.Find(JobId);
if (ret is null)
return NotFound();
try
{
if (ret.state >= JobState.Running && ret.state < JobState.Completed)
return new ConflictResult();
ret.LastExecution = DateTime.UnixEpoch;
context.SaveChanges();
return Accepted();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
/// <summary>
/// Stops the Job with the requested ID
/// </summary>
/// <param name="JobId">Job-ID</param>
/// <remarks><h1>NOT IMPLEMENTED</h1></remarks>
[HttpPost("{JobId}/Stop")]
[ProducesResponseType(Status501NotImplemented)]
public IActionResult StopJob(string JobId)
{
return StatusCode(501);
}
}

View File

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

View File

@ -0,0 +1,157 @@
using API.APIEndpointRecords;
using API.Schema;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class LocalLibrariesController(PgsqlContext context) : Controller
{
[HttpGet]
[ProducesResponseType<LocalLibrary[]>(Status200OK, "application/json")]
public IActionResult GetLocalLibraries()
{
return Ok(context.LocalLibraries);
}
[HttpGet("{LibraryId}")]
[ProducesResponseType<LocalLibrary>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetLocalLibrary(string LibraryId)
{
LocalLibrary? library = context.LocalLibraries.Find(LibraryId);
if (library is null)
return NotFound();
return Ok(library);
}
[HttpPatch("{LibraryId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult UpdateLocalLibrary(string LibraryId, [FromBody]NewLibraryRecord record)
{
LocalLibrary? library = context.LocalLibraries.Find(LibraryId);
if (library is null)
return NotFound();
if (record.Validate() == false)
return BadRequest();
try
{
library.LibraryName = record.name;
library.BasePath = record.path;
context.SaveChanges();
return Ok();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
[HttpPatch("{LibraryId}/ChangeBasePath")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult ChangeLibraryBasePath(string LibraryId, [FromBody] string newBasePath)
{
try
{
LocalLibrary? library = context.LocalLibraries.Find(LibraryId);
if (library is null)
return NotFound();
if (false) //TODO implement path check
return BadRequest();
library.BasePath = newBasePath;
context.SaveChanges();
return Ok();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
[HttpPatch("{LibraryId}/ChangeName")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult ChangeLibraryName(string LibraryId, [FromBody] string newName)
{
try
{
LocalLibrary? library = context.LocalLibraries.Find(LibraryId);
if (library is null)
return NotFound();
if(newName.Length < 1)
return BadRequest();
library.LibraryName = newName;
context.SaveChanges();
return Ok();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
[HttpPut]
[ProducesResponseType<LocalLibrary>(Status200OK, "application/json")]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateNewLibrary([FromBody]NewLibraryRecord library)
{
if (library.Validate() == false)
return BadRequest();
try
{
LocalLibrary newLibrary = new (library.path, library.name);
context.LocalLibraries.Add(newLibrary);
context.SaveChanges();
return Ok(newLibrary);
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
[HttpDelete("{LibraryId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult DeleteLocalLibrary(string LibraryId)
{
try
{
LocalLibrary? library = context.LocalLibraries.Find(LibraryId);
if (library is null)
return NotFound();
context.Remove(library);
context.SaveChanges();
return Ok();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
}

View File

@ -0,0 +1,80 @@
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]
[Route("v{v:apiVersion}/[controller]")]
public class MangaConnectorController(PgsqlContext context) : Controller
{
/// <summary>
/// Get all available Connectors (Scanlation-Sites)
/// </summary>
/// <response code="200"></response>
[HttpGet]
[ProducesResponseType<MangaConnector[]>(Status200OK, "application/json")]
public IActionResult GetConnectors()
{
MangaConnector[] connectors = context.MangaConnectors.ToArray();
return Ok(connectors);
}
/// <summary>
/// Get all enabled Connectors (Scanlation-Sites)
/// </summary>
/// <response code="200"></response>
[HttpGet("enabled")]
[ProducesResponseType<MangaConnector[]>(Status200OK, "application/json")]
public IActionResult GetEnabledConnectors()
{
MangaConnector[] connectors = context.MangaConnectors.Where(c => c.Enabled == true).ToArray();
return Ok(connectors);
}
/// <summary>
/// Get all disabled Connectors (Scanlation-Sites)
/// </summary>
/// <response code="200"></response>
[HttpGet("disabled")]
[ProducesResponseType<MangaConnector[]>(Status200OK, "application/json")]
public IActionResult GetDisabledConnectors()
{
MangaConnector[] connectors = context.MangaConnectors.Where(c => c.Enabled == false).ToArray();
return Ok(connectors);
}
/// <summary>
/// Enabled or disables a Connector
/// </summary>
/// <param name="MangaConnectorName">ID of the connector</param>
/// <param name="enabled">Set true to enable</param>
/// <response code="200"></response>
/// <response code="404">Connector with ID not found.</response>
/// <response code="500">Error during Database Operation</response>
[HttpPatch("{MangaConnectorName}/SetEnabled/{enabled}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult SetEnabled(string MangaConnectorName, bool enabled)
{
try
{
MangaConnector? connector = context.MangaConnectors.Find(MangaConnectorName);
if (connector is null)
return NotFound();
connector.Enabled = enabled;
context.SaveChanges();
return Ok();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
}

View File

@ -0,0 +1,355 @@
using API.Schema;
using API.Schema.Jobs;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Transforms;
using static Microsoft.AspNetCore.Http.StatusCodes;
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class MangaController(PgsqlContext context) : Controller
{
/// <summary>
/// Returns all cached Manga
/// </summary>
/// <response code="200"></response>
[HttpGet]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult GetAllManga()
{
Manga[] ret = context.Mangas.ToArray();
return Ok(ret);
}
/// <summary>
/// Returns all cached Manga with IDs
/// </summary>
/// <param name="ids">Array of Manga-IDs</param>
/// <response code="200"></response>
[HttpPost("WithIDs")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult GetManga([FromBody]string[] ids)
{
Manga[] ret = context.Mangas.Where(m => ids.Contains(m.MangaId)).ToArray();
return Ok(ret);
}
/// <summary>
/// Return Manga with ID
/// </summary>
/// <param name="MangaId">Manga-ID</param>
/// <response code="200"></response>
/// <response code="404">Manga with ID not found</response>
[HttpGet("{MangaId}")]
[ProducesResponseType<Manga>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetManga(string MangaId)
{
Manga? ret = context.Mangas.Find(MangaId);
if (ret is null)
return NotFound();
return Ok(ret);
}
/// <summary>
/// Delete Manga with ID
/// </summary>
/// <param name="MangaId">Manga-ID</param>
/// <response code="200"></response>
/// <response code="404">Manga with ID not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpDelete("{MangaId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult DeleteManga(string MangaId)
{
try
{
Manga? ret = context.Mangas.Find(MangaId);
if (ret is null)
return NotFound();
context.Remove(ret);
context.SaveChanges();
return Ok();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
/// <summary>
/// Returns Cover of Manga
/// </summary>
/// <param name="MangaId">Manga-ID</param>
/// <param name="width">If width is provided, height needs to also be provided</param>
/// <param name="height">If height is provided, width needs to also be provided</param>
/// <response code="200">JPEG Image</response>
/// <response code="204">Cover not loaded</response>
/// <response code="400">The formatting-request was invalid</response>
/// <response code="404">Manga with ID 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)
{
DateTime requestStarted = HttpContext.Features.Get<IHttpRequestTimeFeature>()?.RequestTime ?? DateTime.Now;
Manga? m = context.Mangas.Find(MangaId);
if (m is null)
return NotFound();
if (!System.IO.File.Exists(m.CoverFileNameInCache))
{
List<Job> coverDownloadJobs = context.Jobs.Where(j => j.JobType == JobType.DownloadMangaCoverJob).ToList();
if (coverDownloadJobs.Any(j => j is DownloadMangaCoverJob dmc && dmc.MangaId == MangaId))
{
Response.Headers.Add("Retry-After", $"{TrangaSettings.startNewJobTimeoutMs * coverDownloadJobs.Count() * 2 / 1000:D}");
return StatusCode(Status503ServiceUnavailable, TrangaSettings.startNewJobTimeoutMs * coverDownloadJobs.Count() * 2 / 1000);
}
else
return NoContent();
}
Image image = Image.Load(m.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});
return File(ms.GetBuffer(), "image/jpeg");
}
/// <summary>
/// Returns all Chapters of Manga
/// </summary>
/// <param name="MangaId">Manga-ID</param>
/// <response code="200"></response>
/// <response code="404">Manga with ID not found</response>
[HttpGet("{MangaId}/Chapters")]
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetChapters(string MangaId)
{
Manga? m = context.Mangas.Find(MangaId);
if (m is null)
return NotFound();
Chapter[] ret = context.Chapters.Where(c => c.ParentMangaId == m.MangaId).ToArray();
return Ok(ret);
}
/// <summary>
/// Returns all downloaded Chapters for Manga with ID
/// </summary>
/// <param name="MangaId">Manga-ID</param>
/// <response code="200"></response>
/// <response code="204">No available chapters</response>
/// <response code="404">Manga with ID not found.</response>
[HttpGet("{MangaId}/Chapters/Downloaded")]
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetChaptersDownloaded(string MangaId)
{
Manga? m = context.Mangas.Find(MangaId);
if (m is null)
return NotFound();
List<Chapter> chapters = context.Chapters.Where(c => c.ParentMangaId == m.MangaId && c.Downloaded == true).ToList();
if (chapters.Count == 0)
return NoContent();
return Ok(chapters);
}
/// <summary>
/// Returns all Chapters not downloaded for Manga with ID
/// </summary>
/// <param name="MangaId">Manga-ID</param>
/// <response code="200"></response>
/// <response code="204">No available chapters</response>
/// <response code="404">Manga with ID not found.</response>
[HttpGet("{MangaId}/Chapters/NotDownloaded")]
[ProducesResponseType<Chapter[]>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetChaptersNotDownloaded(string MangaId)
{
Manga? m = context.Mangas.Find(MangaId);
if (m is null)
return NotFound();
List<Chapter> chapters = context.Chapters.Where(c => c.ParentMangaId == m.MangaId && c.Downloaded == false).ToList();
if (chapters.Count == 0)
return NoContent();
return Ok(chapters);
}
/// <summary>
/// Returns the latest Chapter of requested Manga available on Website
/// </summary>
/// <param name="MangaId">Manga-ID</param>
/// <response code="200"></response>
/// <response code="204">No available chapters</response>
/// <response code="404">Manga with ID not found.</response>
/// <response code="500">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)
{
Manga? m = context.Mangas.Find(MangaId);
if (m is null)
return NotFound();
List<Chapter> chapters = context.Chapters.Where(c => c.ParentMangaId == m.MangaId).ToList();
if (chapters.Count == 0)
{
List<Job> retrieveChapterJobs = context.Jobs.Where(j => j.JobType == JobType.RetrieveChaptersJob).ToList();
if (retrieveChapterJobs.Any(j => j is RetrieveChaptersJob rcj && rcj.MangaId == MangaId))
{
Response.Headers.Add("Retry-After", $"{TrangaSettings.startNewJobTimeoutMs * retrieveChapterJobs.Count() * 2 / 1000:D}");
return StatusCode(Status503ServiceUnavailable, TrangaSettings.startNewJobTimeoutMs * retrieveChapterJobs.Count() * 2/ 1000);
}else
return NoContent();
}
Chapter? max = chapters.Max();
if (max is null)
return StatusCode(500, "Max chapter could not be found");
return Ok(max);
}
/// <summary>
/// Returns the latest Chapter of requested Manga that is downloaded
/// </summary>
/// <param name="MangaId">Manga-ID</param>
/// <response code="200"></response>
/// <response code="204">No available chapters</response>
/// <response code="404">Manga with ID not found.</response>
/// <response code="500">Could not retrieve the maximum chapter-number</response>
/// <response code="503">Retry after timeout, updating value</response>
[HttpGet("{MangaId}/Chapter/LatestDownloaded")]
[ProducesResponseType<Chapter>(Status200OK, "application/json")]
[ProducesResponseType(Status204NoContent)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
[ProducesResponseType<int>(Status503ServiceUnavailable, "text/plain")]
public IActionResult GetLatestChapterDownloaded(string MangaId)
{
Manga? m = context.Mangas.Find(MangaId);
if (m is null)
return NotFound();
List<Chapter> chapters = context.Chapters.Where(c => c.ParentMangaId == m.MangaId && c.Downloaded == true).ToList();
if (chapters.Count == 0)
{
List<Job> retrieveChapterJobs = context.Jobs.Where(j => j.JobType == JobType.RetrieveChaptersJob).ToList();
if (retrieveChapterJobs.Any(j => j is RetrieveChaptersJob rcj && rcj.MangaId == MangaId))
{
Response.Headers.Add("Retry-After", $"{TrangaSettings.startNewJobTimeoutMs * retrieveChapterJobs.Count() * 2 / 1000:D}");
return StatusCode(Status503ServiceUnavailable, TrangaSettings.startNewJobTimeoutMs * retrieveChapterJobs.Count() * 2 / 1000);
}else
return NoContent();
}
Chapter? max = chapters.Max();
if (max is null)
return StatusCode(500, "Max chapter could not be found");
return Ok(max);
}
/// <summary>
/// Configure the cut-off for Manga
/// </summary>
/// <param name="MangaId">Manga-ID</param>
/// <response code="200"></response>
/// <response code="404">Manga with ID not found.</response>
/// <response code="500">Error during Database Operation</response>
[HttpPatch("{MangaId}/IgnoreChaptersBefore")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult IgnoreChaptersBefore(string MangaId, [FromBody]float chapterThreshold)
{
Manga? m = context.Mangas.Find(MangaId);
if (m is null)
return NotFound();
try
{
m.IgnoreChapterBefore = chapterThreshold;
context.SaveChanges();
return Ok();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
/// <summary>
/// Move Manga to different Library
/// </summary>
/// <param name="MangaId">Manga-ID</param>
/// <param name="LibraryId">Library-Id</param>
/// <response code="202">Folder is going to be moved</response>
/// <response code="404">MangaId or LibraryId not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("{MangaId}/ChangeLibrary/{LibraryId}")]
[ProducesResponseType(Status202Accepted)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult MoveFolder(string MangaId, string LibraryId)
{
Manga? manga = context.Mangas.Find(MangaId);
if (manga is null)
return NotFound();
LocalLibrary? library = context.LocalLibraries.Find(LibraryId);
if (library is null)
return NotFound();
MoveMangaLibraryJob dep = new (MangaId, LibraryId);
UpdateFilesDownloadedJob up = new (0, manga.MangaId, null, [dep.JobId]);
try
{
context.Jobs.AddRange([dep, up]);
context.SaveChanges();
return Accepted();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
}

View File

@ -0,0 +1,215 @@
using System.Text;
using API.APIEndpointRecords;
using API.Schema;
using API.Schema.NotificationConnectors;
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}/[controller]")]
public class NotificationConnectorController(PgsqlContext context) : Controller
{
/// <summary>
/// Gets all configured Notification-Connectors
/// </summary>
/// <response code="200"></response>
[HttpGet]
[ProducesResponseType<NotificationConnector[]>(Status200OK, "application/json")]
public IActionResult GetAllConnectors()
{
NotificationConnector[] ret = context.NotificationConnectors.ToArray();
return Ok(ret);
}
/// <summary>
/// Returns Notification-Connector with requested ID
/// </summary>
/// <param name="NotificationConnectorId">Notification-Connector-ID</param>
/// <response code="200"></response>
/// <response code="404">NotificationConnector with ID not found</response>
[HttpGet("{NotificationConnectorId}")]
[ProducesResponseType<NotificationConnector>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetConnector(string NotificationConnectorId)
{
NotificationConnector? ret = context.NotificationConnectors.Find(NotificationConnectorId);
return (ret is not null) switch
{
true => Ok(ret),
false => NotFound()
};
}
/// <summary>
/// Creates a new REST-Notification-Connector
/// </summary>
/// <remarks>Formatting placeholders: "%title" and "%text" can be placed in url, header-values and body and will be replaced when notifications are sent</remarks>
/// <param name="notificationConnector">Notification-Connector</param>
/// <response code="201">ID of new connector</response>
/// <response code="409">A NotificationConnector with name already exists</response>
/// <response code="500">Error during Database Operation</response>
[HttpPut]
[ProducesResponseType<string>(Status201Created, "application/json")]
[ProducesResponseType(Status409Conflict)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateConnector([FromBody]NotificationConnector notificationConnector)
{
if (context.NotificationConnectors.Find(notificationConnector.Name) is not null)
return Conflict();
try
{
context.NotificationConnectors.Add(notificationConnector);
context.SaveChanges();
return Created(notificationConnector.Name, notificationConnector);
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
/// <summary>
/// Creates a new Gotify-Notification-Connector
/// </summary>
/// <remarks>Priority needs to be between 0 and 10</remarks>
/// <response code="201">ID of new connector</response>
/// <response code="400"></response>
/// <response code="409">A NotificationConnector with name already exists</response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("Gotify")]
[ProducesResponseType<string>(Status201Created, "application/json")]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status409Conflict)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateGotifyConnector([FromBody]GotifyRecord gotifyData)
{
if(!gotifyData.Validate())
return BadRequest();
NotificationConnector gotifyConnector = new NotificationConnector(TokenGen.CreateToken("Gotify"),
gotifyData.endpoint,
new Dictionary<string, string>() { { "X-Gotify-Key", gotifyData.appToken } },
"POST",
$"{{\"message\": \"%text\", \"title\": \"%title\", \"priority\": {gotifyData.priority}}}");
return CreateConnector(gotifyConnector);
}
/// <summary>
/// Creates a new Ntfy-Notification-Connector
/// </summary>
/// <remarks>Priority needs to be between 1 and 5</remarks>
/// <response code="201">ID of new connector</response>
/// <response code="400"></response>
/// <response code="409">A NotificationConnector with name already exists</response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("Ntfy")]
[ProducesResponseType<string>(Status201Created, "application/json")]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status409Conflict)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateNtfyConnector([FromBody]NtfyRecord ntfyRecord)
{
if(!ntfyRecord.Validate())
return BadRequest();
string authHeader = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ntfyRecord.username}:{ntfyRecord.password}"));
string auth = Convert.ToBase64String(Encoding.UTF8.GetBytes(authHeader)).Replace("=","");
NotificationConnector ntfyConnector = new (TokenGen.CreateToken("Ntfy"),
$"{ntfyRecord.endpoint}?auth={auth}",
new Dictionary<string, string>()
{
{"Title", "%title"},
{"Priority", ntfyRecord.priority.ToString()},
},
"POST",
"%text");
return CreateConnector(ntfyConnector);
}
/// <summary>
/// Creates a new Lunasea-Notification-Connector
/// </summary>
/// <remarks>https://docs.lunasea.app/lunasea/notifications/custom-notifications for id. Either device/:device_id or user/:user_id</remarks>
/// <response code="201">ID of new connector</response>
/// <response code="400"></response>
/// <response code="409">A NotificationConnector with name already exists</response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("Lunasea")]
[ProducesResponseType<string>(Status201Created, "application/json")]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status409Conflict)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreateLunaseaConnector([FromBody]LunaseaRecord lunaseaRecord)
{
if(!lunaseaRecord.Validate())
return BadRequest();
NotificationConnector lunaseaConnector = new (TokenGen.CreateToken("Lunasea"),
$"https://notify.lunasea.app/v1/custom/{lunaseaRecord.id}",
new Dictionary<string, string>(),
"POST",
"{\"title\": \"%title\", \"body\": \"%text\"}");
return CreateConnector(lunaseaConnector);
}
/// <summary>
/// Creates a new Pushover-Notification-Connector
/// </summary>
/// <remarks>https://pushover.net/api</remarks>
/// <response code="201">ID of new connector</response>
/// <response code="400"></response>
/// <response code="409">A NotificationConnector with name already exists</response>
/// <response code="500">Error during Database Operation</response>
[HttpPut("Pushover")]
[ProducesResponseType<string>(Status201Created, "application/json")]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status409Conflict)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult CreatePushoverConnector([FromBody]PushoverRecord pushoverRecord)
{
if(!pushoverRecord.Validate())
return BadRequest();
NotificationConnector pushoverConnector = new (TokenGen.CreateToken("Pushover"),
$"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 Notification-Connector with the requested ID
/// </summary>
/// <param name="NotificationConnectorId">Notification-Connector-ID</param>
/// <response code="200"></response>
/// <response code="404">NotificationConnector with ID not found</response>
/// <response code="500">Error during Database Operation</response>
[HttpDelete("{NotificationConnectorId}")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult DeleteConnector(string NotificationConnectorId)
{
try
{
NotificationConnector? ret = context.NotificationConnectors.Find(NotificationConnectorId);
if(ret is null)
return NotFound();
context.Remove(ret);
context.SaveChanges();
return Ok();
}
catch (Exception e)
{
return StatusCode(500, e.Message);
}
}
}

View File

@ -0,0 +1,103 @@
using API.Schema;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using static Microsoft.AspNetCore.Http.StatusCodes;
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class QueryController(PgsqlContext context) : Controller
{
/// <summary>
/// Returns the Author-Information for Author-ID
/// </summary>
/// <param name="AuthorId">Author-Id</param>
/// <response code="200"></response>
/// <response code="404">Author with ID not found</response>
[HttpGet("Author/{AuthorId}")]
[ProducesResponseType<Author>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetAuthor(string AuthorId)
{
Author? ret = context.Authors.Find(AuthorId);
if (ret is null)
return NotFound();
return Ok(ret);
}
/// <summary>
/// Returns all Mangas which where Authored by Author with AuthorId
/// </summary>
/// <param name="AuthorId">Author-ID</param>
/// <response code="200"></response>
[HttpGet("Mangas/WithAuthorId/{AuthorId}")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult GetMangaWithAuthorIds(string AuthorId)
{
return Ok(context.Mangas.Where(m => m.AuthorIds.Contains(AuthorId)));
}
/// <summary>
/// Returns Link-Information for Link-Id
/// </summary>
/// <param name="LinkId"></param>
/// <response code="200"></response>
/// <response code="404">Link with ID not found</response>
[HttpGet("Link/{LinkId}")]
[ProducesResponseType<Link>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetLink(string LinkId)
{
Link? ret = context.Links.Find(LinkId);
if (ret is null)
return NotFound();
return Ok(ret);
}
/// <summary>
/// Returns AltTitle-Information for AltTitle-Id
/// </summary>
/// <param name="AltTitleId"></param>
/// <response code="200"></response>
/// <response code="404">AltTitle with ID not found</response>
[HttpGet("AltTitle/{AltTitleId}")]
[ProducesResponseType<MangaAltTitle>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
public IActionResult GetAltTitle(string AltTitleId)
{
MangaAltTitle? ret = context.AltTitles.Find(AltTitleId);
if (ret is null)
return NotFound();
return Ok(ret);
}
/// <summary>
/// Returns all Manga with Tag
/// </summary>
/// <param name="Tag"></param>
/// <response code="200"></response>
[HttpGet("Mangas/WithTag/{Tag}")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
public IActionResult GetMangasWithTag(string Tag)
{
return Ok(context.Mangas.Where(m => m.Tags.Contains(Tag)));
}
/// <summary>
/// Returns Chapter-Information for Chapter-Id
/// </summary>
/// <param name="ChapterId"></param>
/// <response code="200"></response>
/// <response code="404">Chapter with ID not found</response>
[HttpGet("Chapter/{ChapterId}")]
[ProducesResponseType<Chapter>(Status200OK, "application/json")]
public IActionResult GetChapter(string ChapterId)
{
Chapter? ret = context.Chapters.Find(ChapterId);
if (ret is null)
return NotFound();
return Ok(ret);
}
}

View File

@ -0,0 +1,201 @@
using API.Schema;
using API.Schema.Jobs;
using API.Schema.MangaConnectors;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using static Microsoft.AspNetCore.Http.StatusCodes;
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class SearchController(PgsqlContext context) : Controller
{
/// <summary>
/// Initiate a search for a Manga on all Connectors
/// </summary>
/// <param name="name">Name/Title of the Manga</param>
/// <response code="200"></response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("Name")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult SearchMangaGlobal([FromBody]string name)
{
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> allManga = new();
foreach (MangaConnector contextMangaConnector in context.MangaConnectors.Where(connector => connector.Enabled))
allManga.AddRange(contextMangaConnector.GetManga(name));
List<Manga> retMangas = new();
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 e)
{
return StatusCode(500, e);
}
}
return Ok(retMangas.ToArray());
}
/// <summary>
/// Initiate a search for a Manga on a specific Connector
/// </summary>
/// <param name="MangaConnectorName">Manga-Connector-ID</param>
/// <param name="name">Name/Title of the Manga</param>
/// <response code="200"></response>
/// <response code="404">MangaConnector with ID not found</response>
/// <response code="406">MangaConnector with ID is disabled</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("{MangaConnectorName}")]
[ProducesResponseType<Manga[]>(Status200OK, "application/json")]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType(Status406NotAcceptable)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult SearchManga(string MangaConnectorName, [FromBody]string name)
{
MangaConnector? connector = context.MangaConnectors.Find(MangaConnectorName);
if (connector is null)
return NotFound();
else if (connector.Enabled is false)
return StatusCode(406);
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] mangas = connector.GetManga(name);
List<Manga> retMangas = new();
foreach ((Manga? manga, List<Author>? authors, List<MangaTag>? tags, List<Link>? links, List<MangaAltTitle>? altTitles) in mangas)
{
try
{
Manga? add = AddMangaToContext(manga, authors, tags, links, altTitles);
if(add is not null)
retMangas.Add(add);
}
catch (DbUpdateException e)
{
return StatusCode(500, e.Message);
}
}
return Ok(retMangas.ToArray());
}
/// <summary>
/// Returns Manga from MangaConnector associated with URL
/// </summary>
/// <param name="url">Manga-Page URL</param>
/// <response code="200"></response>
/// <response code="300">Multiple connectors found for URL</response>
/// <response code="400">No Manga at URL</response>
/// <response code="404">No connector found for URL</response>
/// <response code="500">Error during Database Operation</response>
[HttpPost("Url")]
[ProducesResponseType<Manga>(Status200OK, "application/json")]
[ProducesResponseType(Status300MultipleChoices)]
[ProducesResponseType(Status400BadRequest)]
[ProducesResponseType(Status404NotFound)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult GetMangaFromUrl([FromBody]string url)
{
List<MangaConnector> connectors = context.MangaConnectors.AsEnumerable().Where(c => c.ValidateUrl(url)).ToList();
if (connectors.Count == 0)
return NotFound();
else if (connectors.Count > 1)
return StatusCode(Status300MultipleChoices);
(Manga manga, List<Author>? authors, List<MangaTag>? tags, List<Link>? links, List<MangaAltTitle>? altTitles)? x = connectors.First().GetMangaFromUrl(url);
if (x is null)
return BadRequest();
try
{
Manga? add = AddMangaToContext(x.Value.manga, x.Value.authors, x.Value.tags, x.Value.links, x.Value.altTitles);
if (add is not null)
return Ok(add);
return StatusCode(500);
}
catch (DbUpdateException e)
{
return StatusCode(500, e.Message);
}
}
private Manga? AddMangaToContext(Manga? manga, List<Author>? authors, List<MangaTag>? tags, List<Link>? links,
List<MangaAltTitle>? altTitles)
{
if (manga is null)
return null;
Manga? existing = context.Mangas.Find(manga.MangaId);
if (tags is not null)
{
IEnumerable<MangaTag> mergedTags = tags.Select(mt =>
{
MangaTag? inDb = context.Tags.Find(mt.Tag);
return inDb ?? mt;
});
manga.MangaTags = mergedTags.ToList();
IEnumerable<MangaTag> newTags = manga.MangaTags
.Where(mt => !context.Tags.Select(t => t.Tag).Contains(mt.Tag));
context.Tags.AddRange(newTags);
}
if (authors is not null)
{
IEnumerable<Author> mergedAuthors = authors.Select(ma =>
{
Author? inDb = context.Authors.Find(ma.AuthorId);
return inDb ?? ma;
});
manga.Authors = mergedAuthors.ToList();
IEnumerable<Author> newAuthors = manga.Authors
.Where(ma => !context.Authors.Select(a => a.AuthorId).Contains(ma.AuthorId));
context.Authors.AddRange(newAuthors);
}
if (links is not null)
{
IEnumerable<Link> mergedLinks = links.Select(ml =>
{
Link? inDb = context.Links.Find(ml.LinkId);
return inDb ?? ml;
});
manga.Links = mergedLinks.ToList();
IEnumerable<Link> newLinks = manga.Links
.Where(ml => !context.Links.Select(l => l.LinkId).Contains(ml.LinkId));
context.Links.AddRange(newLinks);
}
if (altTitles is not null)
{
IEnumerable<MangaAltTitle> mergedAltTitles = altTitles.Select(mat =>
{
MangaAltTitle? inDb = context.AltTitles.Find(mat.AltTitleId);
return inDb ?? mat;
});
manga.AltTitles = mergedAltTitles.ToList();
IEnumerable<MangaAltTitle> newAltTitles = manga.AltTitles
.Where(mat => !context.AltTitles.Select(at => at.AltTitleId).Contains(mat.AltTitleId));
context.AltTitles.AddRange(newAltTitles);
}
existing?.UpdateWithInfo(manga);
if(existing is not null)
context.Mangas.Update(existing);
else
context.Mangas.Add(manga);
context.Jobs.Add(new DownloadMangaCoverJob(manga.MangaId));
context.Jobs.Add(new RetrieveChaptersJob(0, manga.MangaId));
context.SaveChanges();
return existing ?? manga;
}
}

View File

@ -0,0 +1,266 @@
using API.MangaDownloadClients;
using API.Schema;
using API.Schema.Jobs;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using static Microsoft.AspNetCore.Http.StatusCodes;
namespace API.Controllers;
[ApiVersion(2)]
[ApiController]
[Route("v{v:apiVersion}/[controller]")]
public class SettingsController(PgsqlContext context) : Controller
{
/// <summary>
/// Get all Settings
/// </summary>
/// <response code="200"></response>
[HttpGet]
[ProducesResponseType<JObject>(Status200OK, "application/json")]
public IActionResult GetSettings()
{
return Ok(JObject.Parse(TrangaSettings.Serialize()));
}
/// <summary>
/// Get the current UserAgent used by Tranga
/// </summary>
/// <response code="200"></response>
[HttpGet("UserAgent")]
[ProducesResponseType<string>(Status200OK, "text/plain")]
public IActionResult GetUserAgent()
{
return Ok(TrangaSettings.userAgent);
}
/// <summary>
/// Set a new UserAgent
/// </summary>
/// <response code="200"></response>
[HttpPatch("UserAgent")]
[ProducesResponseType(Status200OK)]
public IActionResult SetUserAgent([FromBody]string userAgent)
{
TrangaSettings.UpdateUserAgent(userAgent);
return Ok();
}
/// <summary>
/// Reset the UserAgent to default
/// </summary>
/// <response code="200"></response>
[HttpDelete("UserAgent")]
[ProducesResponseType(Status200OK)]
public IActionResult ResetUserAgent()
{
TrangaSettings.UpdateUserAgent(TrangaSettings.DefaultUserAgent);
return Ok();
}
/// <summary>
/// Get all Request-Limits
/// </summary>
/// <response code="200"></response>
[HttpGet("RequestLimits")]
[ProducesResponseType<Dictionary<RequestType,int>>(Status200OK, "application/json")]
public IActionResult GetRequestLimits()
{
return Ok(TrangaSettings.requestLimits);
}
/// <summary>
/// Update all Request-Limits to new values
/// </summary>
/// <remarks><h1>NOT IMPLEMENTED</h1></remarks>
[HttpPatch("RequestLimits")]
[ProducesResponseType(Status501NotImplemented)]
public IActionResult SetRequestLimits()
{
return StatusCode(501);
}
/// <summary>
/// Updates a Request-Limit value
/// </summary>
/// <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();
TrangaSettings.UpdateRequestLimit(RequestType, requestLimit);
return Ok();
}
/// <summary>
/// Reset Request-Limit
/// </summary>
/// <response code="200"></response>
[HttpDelete("RequestLimits/{RequestType}")]
[ProducesResponseType<string>(Status200OK)]
public IActionResult ResetRequestLimits(RequestType RequestType)
{
TrangaSettings.UpdateRequestLimit(RequestType, TrangaSettings.DefaultRequestLimits[RequestType]);
return Ok();
}
/// <summary>
/// Reset Request-Limit
/// </summary>
/// <response code="200"></response>
[HttpDelete("RequestLimits")]
[ProducesResponseType<string>(Status200OK)]
public IActionResult ResetRequestLimits()
{
TrangaSettings.ResetRequestLimits();
return Ok();
}
/// <summary>
/// Returns Level of Image-Compression for Images
/// </summary>
/// <response code="200">JPEG compression-level as Integer</response>
[HttpGet("ImageCompression")]
[ProducesResponseType<int>(Status200OK, "text/plain")]
public IActionResult GetImageCompression()
{
return Ok(TrangaSettings.compression);
}
/// <summary>
/// Set the Image-Compression-Level for Images
/// </summary>
/// <param name="level">100 to disable, 0-99 for JPEG compression-Level</param>
/// <response code="200"></response>
/// <response code="400">Level outside permitted range</response>
[HttpPatch("ImageCompression")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType(Status400BadRequest)]
public IActionResult SetImageCompression([FromBody]int level)
{
if (level < 1 || level > 100)
return BadRequest();
TrangaSettings.UpdateCompressImages(level);
return Ok();
}
/// <summary>
/// Get state of Black/White-Image setting
/// </summary>
/// <response code="200">True if enabled</response>
[HttpGet("BWImages")]
[ProducesResponseType<bool>(Status200OK, "text/plain")]
public IActionResult GetBwImagesToggle()
{
return Ok(TrangaSettings.bwImages);
}
/// <summary>
/// Enable/Disable conversion of Images to Black and White
/// </summary>
/// <param name="enabled">true to enable</param>
/// <response code="200"></response>
[HttpPatch("BWImages")]
[ProducesResponseType(Status200OK)]
public IActionResult SetBwImagesToggle([FromBody]bool enabled)
{
TrangaSettings.UpdateBwImages(enabled);
return Ok();
}
/// <summary>
/// Get state of April Fools Mode
/// </summary>
/// <remarks>April Fools Mode disables all downloads on April 1st</remarks>
/// <response code="200">True if enabled</response>
[HttpGet("AprilFoolsMode")]
[ProducesResponseType<bool>(Status200OK, "text/plain")]
public IActionResult GetAprilFoolsMode()
{
return Ok(TrangaSettings.aprilFoolsMode);
}
/// <summary>
/// Enable/Disable April Fools Mode
/// </summary>
/// <remarks>April Fools Mode disables all downloads on April 1st</remarks>
/// <param name="enabled">true to enable</param>
/// <response code="200"></response>
[HttpPatch("AprilFoolsMode")]
[ProducesResponseType(Status200OK)]
public IActionResult SetAprilFoolsMode([FromBody]bool enabled)
{
TrangaSettings.UpdateAprilFoolsMode(enabled);
return Ok();
}
/// <summary>
/// Gets the Chapter Naming Scheme
/// </summary>
/// <remarks>
/// Placeholders:
/// %M Manga Name
/// %V Volume
/// %C Chapter
/// %T Title
/// %A Author (first in list)
/// %I Chapter Internal ID
/// %i Manga Internal ID
/// %Y Year (Manga)
///
/// ?_(...) 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 Ok(TrangaSettings.chapterNamingScheme);
}
/// <summary>
/// Sets the Chapter Naming Scheme
/// </summary>
/// <remarks>
/// Placeholders:
/// %M Manga Name
/// %V Volume
/// %C Chapter
/// %T Title
/// %A Author (first in list)
/// %I Chapter Internal ID
/// %i Manga Internal ID
/// %Y Year (Manga)
///
/// ?_(...) 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>
/// <response code="500">Error during Database Operation</response>
[HttpPatch("ChapterNamingScheme")]
[ProducesResponseType(Status200OK)]
[ProducesResponseType<string>(Status500InternalServerError, "text/plain")]
public IActionResult SetCustomNamingScheme([FromBody]string namingScheme)
{
try
{
TrangaSettings.UpdateChapterNamingScheme(namingScheme);
MoveFileOrFolderJob[] newJobs =
context.Chapters.Where(c => c.Downloaded).Select(c => c.UpdateArchiveFileName()).Where(x => x != null).ToArray()!;
context.Jobs.AddRange(newJobs);
return Ok();
}
catch (Exception e)
{
return StatusCode(500, e);
}
}
}

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,19 +2,19 @@
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using HtmlAgilityPack; using HtmlAgilityPack;
using Microsoft.Extensions.Logging;
using PuppeteerSharp; using PuppeteerSharp;
namespace Tranga.MangaConnectors; namespace API.MangaDownloadClients;
internal class ChromiumDownloadClient : DownloadClient 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(Logging.Logger? logger = null) private static async Task<IBrowser> StartBrowser()
{ {
logger?.WriteLine("Starting ChromiumDownloadClient Puppeteer");
return await Puppeteer.LaunchAsync(new LaunchOptions return await Puppeteer.LaunchAsync(new LaunchOptions
{ {
Headless = true, Headless = true,
@ -23,40 +23,29 @@ internal class ChromiumDownloadClient : DownloadClient
"--disable-dev-shm-usage", "--disable-dev-shm-usage",
"--disable-setuid-sandbox", "--disable-setuid-sandbox",
"--no-sandbox"}, "--no-sandbox"},
Timeout = TrangaSettings.ChromiumStartupTimeoutMs Timeout = 30000
}, new LoggerFactory([new LogProvider(logger)])); });
} }
private class LogProvider : GlobalBase, ILoggerProvider public ChromiumDownloadClient()
{ {
public LogProvider(Logging.Logger? logger) : base(logger) { } _httpDownloadClient = new();
public void Dispose() { }
public ILogger CreateLogger(string categoryName) => new Logger(logger);
}
private class Logger : GlobalBase, ILogger
{
public Logger(Logging.Logger? logger) : base(logger) { }
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
if (logLevel <= LogLevel.Information)
return;
logger?.WriteLine("Puppeteer", formatter.Invoke(state, exception));
}
public bool IsEnabled(LogLevel logLevel) => true;
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
}
public ChromiumDownloadClient(GlobalBase clone) : base(clone)
{
_httpDownloadClient = new(this);
if(_browser is null) if(_browser is null)
_browser = StartBrowser(this.logger).Result; _browser = StartBrowser().Result;
_closeStalePagesThread = new Thread(CheckStalePages);
_closeStalePagesThread.Start();
}
private void CheckStalePages()
{
while (true)
{
Thread.Sleep(TimeSpan.FromHours(1));
foreach ((IPage? key, DateTime value) in _openPages.Where(kv => kv.Value.Subtract(DateTime.Now) > TimeSpan.FromHours(1)))
{
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)(\?.*)?");
@ -72,18 +61,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 = TrangaSettings.ChromiumPageTimeoutMs; _openPages.Add(new(page, DateTime.Now));
page.SetExtraHttpHeadersAsync(new() { { "Referer", referrer } }); 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($"Page loaded. {url}");
} }
catch (Exception e) catch (Exception e)
{ {
Log($"Could not load Page {url}\n{e.Message}"); //Log($"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);
} }
@ -107,11 +98,13 @@ 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, "");
} }
} }

View File

@ -1,14 +1,16 @@
using System.Net; using System.Net;
using HtmlAgilityPack; using log4net;
namespace Tranga.MangaConnectors; namespace API.MangaDownloadClients;
internal abstract class DownloadClient : GlobalBase internal abstract class DownloadClient
{ {
private readonly Dictionary<RequestType, DateTime> _lastExecutedRateLimit; private readonly Dictionary<RequestType, DateTime> _lastExecutedRateLimit;
protected ILog Log { get; init; }
protected DownloadClient(GlobalBase clone) : base(clone) protected DownloadClient()
{ {
this.Log = LogManager.GetLogger(GetType());
this._lastExecutedRateLimit = new(); this._lastExecutedRateLimit = new();
} }
@ -16,7 +18,6 @@ internal abstract class DownloadClient : GlobalBase
{ {
if (!TrangaSettings.requestLimits.ContainsKey(requestType)) if (!TrangaSettings.requestLimits.ContainsKey(requestType))
{ {
Log("RequestType not configured for rate-limit.");
return new RequestResult(HttpStatusCode.NotAcceptable, null, Stream.Null); return new RequestResult(HttpStatusCode.NotAcceptable, null, Stream.Null);
} }
@ -25,18 +26,17 @@ internal abstract class DownloadClient : GlobalBase
: TrangaSettings.requestLimits[requestType]; : TrangaSettings.requestLimits[requestType];
TimeSpan timeBetweenRequests = TimeSpan.FromMinutes(1).Divide(rateLimit); TimeSpan timeBetweenRequests = TimeSpan.FromMinutes(1).Divide(rateLimit);
_lastExecutedRateLimit.TryAdd(requestType, DateTime.Now.Subtract(timeBetweenRequests)); _lastExecutedRateLimit.TryAdd(requestType, DateTime.UtcNow.Subtract(timeBetweenRequests));
TimeSpan rateLimitTimeout = timeBetweenRequests.Subtract(DateTime.Now.Subtract(_lastExecutedRateLimit[requestType])); TimeSpan rateLimitTimeout = timeBetweenRequests.Subtract(DateTime.UtcNow.Subtract(_lastExecutedRateLimit[requestType]));
if (rateLimitTimeout > TimeSpan.Zero) if (rateLimitTimeout > TimeSpan.Zero)
{ {
Log($"Waiting {rateLimitTimeout.TotalSeconds} seconds");
Thread.Sleep(rateLimitTimeout); Thread.Sleep(rateLimitTimeout);
} }
RequestResult result = MakeRequestInternal(url, referrer, clickButton); RequestResult result = MakeRequestInternal(url, referrer, clickButton);
_lastExecutedRateLimit[requestType] = DateTime.Now; _lastExecutedRateLimit[requestType] = DateTime.UtcNow;
return result; return result;
} }

View File

@ -1,8 +1,8 @@
using System.Net; using System.Net;
using System.Net.Http.Headers; using API.Schema;
using HtmlAgilityPack; using HtmlAgilityPack;
namespace Tranga.MangaConnectors; namespace API.MangaDownloadClients;
internal class HttpDownloadClient : DownloadClient internal class HttpDownloadClient : DownloadClient
{ {
@ -11,15 +11,16 @@ internal class HttpDownloadClient : DownloadClient
Timeout = TimeSpan.FromSeconds(10) Timeout = TimeSpan.FromSeconds(10)
}; };
public HttpDownloadClient(GlobalBase clone) : base(clone) public HttpDownloadClient()
{ {
Client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", TrangaSettings.userAgent); 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)
{ {
if(clickButton is not null) //TODO
Log("Can not click button on static site."); //if (clickButton is not null)
//Log("Can not click button on static site.");
HttpResponseMessage? response = null; HttpResponseMessage? response = null;
while (response is null) while (response is null)
{ {
@ -36,10 +37,8 @@ internal class HttpDownloadClient : DownloadClient
switch (e) switch (e)
{ {
case TaskCanceledException: case TaskCanceledException:
Log($"Request timed out {url}.\n\r{e}");
return new RequestResult(HttpStatusCode.RequestTimeout, null, Stream.Null); return new RequestResult(HttpStatusCode.RequestTimeout, null, Stream.Null);
case HttpRequestException: case HttpRequestException:
Log($"Request failed {url}\n\r{e}");
return new RequestResult(HttpStatusCode.BadRequest, null, Stream.Null); return new RequestResult(HttpStatusCode.BadRequest, null, Stream.Null);
} }
} }
@ -47,11 +46,19 @@ internal class HttpDownloadClient : DownloadClient
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
Log($"Request-Error {response.StatusCode}: {url}");
return new RequestResult(response.StatusCode, null, Stream.Null); return new RequestResult(response.StatusCode, null, Stream.Null);
} }
Stream stream = response.Content.ReadAsStream(); Stream stream;
try
{
stream = response.Content.ReadAsStream();
}
catch (Exception e)
{
Log.Error(e);
return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null);
}
HtmlDocument? document = null; HtmlDocument? document = null;

View File

@ -1,7 +1,7 @@
using System.Net; using System.Net;
using HtmlAgilityPack; using HtmlAgilityPack;
namespace Tranga.MangaConnectors; namespace API.MangaDownloadClients;
public struct RequestResult public struct RequestResult
{ {

View File

@ -1,4 +1,4 @@
namespace Tranga.MangaConnectors; namespace API.MangaDownloadClients;
public enum RequestType : byte public enum RequestType : byte
{ {

View File

@ -0,0 +1,821 @@
// <auto-generated />
using System;
using System.Collections.Generic;
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("20250316143014_dev-160325-Initial")]
partial class dev160325Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.Author", b =>
{
b.Property<string>("AuthorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("AuthorName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
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>("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<string>("Url")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
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<bool>("Enabled")
.HasColumnType("boolean");
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<byte>("state")
.HasColumnType("smallint");
b.HasKey("JobId");
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()
.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("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()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LinkUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.HasKey("LinkId");
b.HasIndex("MangaId");
b.ToTable("Links");
});
modelBuilder.Entity("API.Schema.LocalLibrary", b =>
{
b.Property<string>("LocalLibraryId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("BasePath")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LibraryName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("LocalLibraryId");
b.ToTable("LocalLibraries");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Property<string>("MangaId")
.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>("DirectoryName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("IdOnConnectorSite")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<float>("IgnoreChapterBefore")
.HasColumnType("real");
b.Property<string>("LibraryLocalLibraryId")
.HasColumnType("character varying(64)");
b.Property<string>("MangaConnectorId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("OriginalLanguage")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<byte>("ReleaseStatus")
.HasColumnType("smallint");
b.Property<string>("WebsiteUrl")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<long>("Year")
.HasColumnType("bigint");
b.HasKey("MangaId");
b.HasIndex("LibraryLocalLibraryId");
b.HasIndex("MangaConnectorId");
b.ToTable("Mangas");
});
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()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
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()
.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.MangaTag", b =>
{
b.Property<string>("Tag")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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()
.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("NotificationId");
b.ToTable("Notifications");
});
modelBuilder.Entity("API.Schema.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");
});
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("JobJob", b =>
{
b.Property<string>("DependsOnJobsJobId")
.HasColumnType("character varying(64)");
b.Property<string>("JobId")
.HasColumnType("character varying(64)");
b.HasKey("DependsOnJobsJobId", "JobId");
b.HasIndex("JobId");
b.ToTable("JobJob");
});
modelBuilder.Entity("MangaMangaTag", b =>
{
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.Property<string>("MangaTagsTag")
.HasColumnType("character varying(64)");
b.HasKey("MangaId", "MangaTagsTag");
b.HasIndex("MangaTagsTag");
b.ToTable("MangaMangaTag");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", 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("DownloadAvailableChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", 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)4);
});
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()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ToLocation")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasDiscriminator().HasValue((byte)3);
});
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", 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("RetrieveChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)5);
});
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", 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("UpdateFilesDownloadedJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)6);
});
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.Manganato", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Manganato");
});
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.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", "ParentJob")
.WithMany()
.HasForeignKey("ParentJobId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("ParentJob");
});
modelBuilder.Entity("API.Schema.Link", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("Links")
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.HasOne("API.Schema.LocalLibrary", "Library")
.WithMany()
.HasForeignKey("LibraryLocalLibraryId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("API.Schema.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
b.Navigation("MangaConnector");
});
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("AltTitles")
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade);
});
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("JobJob", b =>
{
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("DependsOnJobsJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("JobId")
.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("MangaTagsTag")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", 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.RetrieveChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
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.Manga", b =>
{
b.Navigation("AltTitles");
b.Navigation("Links");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,478 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations
{
/// <inheritdoc />
public partial class dev160325Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:hstore", ",,");
migrationBuilder.CreateTable(
name: "Authors",
columns: table => new
{
AuthorId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
AuthorName = table.Column<string>(type: "character varying(128)", maxLength: 128, 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: "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.LibraryConnectorId);
});
migrationBuilder.CreateTable(
name: "LocalLibraries",
columns: table => new
{
LocalLibraryId = table.Column<string>(type: "character varying(64)", maxLength: 64, 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_LocalLibraries", x => x.LocalLibraryId);
});
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: "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
{
NotificationId = table.Column<string>(type: "character varying(64)", maxLength: 64, 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)
},
constraints: table =>
{
table.PrimaryKey("PK_Notifications", x => x.NotificationId);
});
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
{
MangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
IdOnConnectorSite = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "text", nullable: false),
WebsiteUrl = table.Column<string>(type: "character varying(256)", maxLength: 256, 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: "character varying(8)", maxLength: 8, nullable: false),
ReleaseStatus = table.Column<byte>(type: "smallint", nullable: false),
DirectoryName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
LibraryLocalLibraryId = table.Column<string>(type: "character varying(64)", nullable: true),
IgnoreChapterBefore = table.Column<float>(type: "real", nullable: false),
MangaConnectorId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Mangas", x => x.MangaId);
table.ForeignKey(
name: "FK_Mangas_LocalLibraries_LibraryLocalLibraryId",
column: x => x.LibraryLocalLibraryId,
principalTable: "LocalLibraries",
principalColumn: "LocalLibraryId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Mangas_MangaConnectors_MangaConnectorId",
column: x => x.MangaConnectorId,
principalTable: "MangaConnectors",
principalColumn: "Name",
onDelete: ReferentialAction.Cascade);
});
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: "character varying(256)", maxLength: 256, nullable: false),
MangaId = table.Column<string>(type: "character varying(64)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AltTitles", x => x.AltTitleId);
table.ForeignKey(
name: "FK_AltTitles_Mangas_MangaId",
column: x => x.MangaId,
principalTable: "Mangas",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AuthorManga",
columns: table => new
{
AuthorsAuthorId = table.Column<string>(type: "character varying(64)", nullable: false),
MangaId = table.Column<string>(type: "character varying(64)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AuthorManga", x => new { x.AuthorsAuthorId, x.MangaId });
table.ForeignKey(
name: "FK_AuthorManga_Authors_AuthorsAuthorId",
column: x => x.AuthorsAuthorId,
principalTable: "Authors",
principalColumn: "AuthorId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AuthorManga_Mangas_MangaId",
column: x => x.MangaId,
principalTable: "Mangas",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Chapters",
columns: table => new
{
ChapterId = 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),
Url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, 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),
ParentMangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Chapters", x => x.ChapterId);
table.ForeignKey(
name: "FK_Chapters_Mangas_ParentMangaId",
column: x => x.ParentMangaId,
principalTable: "Mangas",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Links",
columns: table => new
{
LinkId = table.Column<string>(type: "character varying(64)", maxLength: 64, 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),
MangaId = table.Column<string>(type: "character varying(64)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Links", x => x.LinkId);
table.ForeignKey(
name: "FK_Links_Mangas_MangaId",
column: x => x.MangaId,
principalTable: "Mangas",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MangaMangaTag",
columns: table => new
{
MangaId = table.Column<string>(type: "character varying(64)", nullable: false),
MangaTagsTag = table.Column<string>(type: "character varying(64)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MangaMangaTag", x => new { x.MangaId, x.MangaTagsTag });
table.ForeignKey(
name: "FK_MangaMangaTag_Mangas_MangaId",
column: x => x.MangaId,
principalTable: "Mangas",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MangaMangaTag_Tags_MangaTagsTag",
column: x => x.MangaTagsTag,
principalTable: "Tags",
principalColumn: "Tag",
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),
DependsOnJobsIds = 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),
state = table.Column<byte>(type: "smallint", nullable: false),
Enabled = table.Column<bool>(type: "boolean", nullable: false),
DownloadAvailableChaptersJob_MangaId = 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: "character varying(256)", maxLength: 256, nullable: true),
ToLocation = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
RetrieveChaptersJob_MangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
UpdateFilesDownloadedJob_MangaId = table.Column<string>(type: "character varying(64)", maxLength: 64, 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_Jobs_ParentJobId",
column: x => x.ParentJobId,
principalTable: "Jobs",
principalColumn: "JobId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Jobs_Mangas_DownloadAvailableChaptersJob_MangaId",
column: x => x.DownloadAvailableChaptersJob_MangaId,
principalTable: "Mangas",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Jobs_Mangas_MangaId",
column: x => x.MangaId,
principalTable: "Mangas",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Jobs_Mangas_RetrieveChaptersJob_MangaId",
column: x => x.RetrieveChaptersJob_MangaId,
principalTable: "Mangas",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Jobs_Mangas_UpdateFilesDownloadedJob_MangaId",
column: x => x.UpdateFilesDownloadedJob_MangaId,
principalTable: "Mangas",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Jobs_Mangas_UpdateMetadataJob_MangaId",
column: x => x.UpdateMetadataJob_MangaId,
principalTable: "Mangas",
principalColumn: "MangaId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "JobJob",
columns: table => new
{
DependsOnJobsJobId = table.Column<string>(type: "character varying(64)", nullable: false),
JobId = table.Column<string>(type: "character varying(64)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_JobJob", x => new { x.DependsOnJobsJobId, x.JobId });
table.ForeignKey(
name: "FK_JobJob_Jobs_DependsOnJobsJobId",
column: x => x.DependsOnJobsJobId,
principalTable: "Jobs",
principalColumn: "JobId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_JobJob_Jobs_JobId",
column: x => x.JobId,
principalTable: "Jobs",
principalColumn: "JobId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AltTitles_MangaId",
table: "AltTitles",
column: "MangaId");
migrationBuilder.CreateIndex(
name: "IX_AuthorManga_MangaId",
table: "AuthorManga",
column: "MangaId");
migrationBuilder.CreateIndex(
name: "IX_Chapters_ParentMangaId",
table: "Chapters",
column: "ParentMangaId");
migrationBuilder.CreateIndex(
name: "IX_JobJob_JobId",
table: "JobJob",
column: "JobId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_ChapterId",
table: "Jobs",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_DownloadAvailableChaptersJob_MangaId",
table: "Jobs",
column: "DownloadAvailableChaptersJob_MangaId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_MangaId",
table: "Jobs",
column: "MangaId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_ParentJobId",
table: "Jobs",
column: "ParentJobId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_RetrieveChaptersJob_MangaId",
table: "Jobs",
column: "RetrieveChaptersJob_MangaId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_UpdateFilesDownloadedJob_MangaId",
table: "Jobs",
column: "UpdateFilesDownloadedJob_MangaId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_UpdateMetadataJob_MangaId",
table: "Jobs",
column: "UpdateMetadataJob_MangaId");
migrationBuilder.CreateIndex(
name: "IX_Links_MangaId",
table: "Links",
column: "MangaId");
migrationBuilder.CreateIndex(
name: "IX_MangaMangaTag_MangaTagsTag",
table: "MangaMangaTag",
column: "MangaTagsTag");
migrationBuilder.CreateIndex(
name: "IX_Mangas_LibraryLocalLibraryId",
table: "Mangas",
column: "LibraryLocalLibraryId");
migrationBuilder.CreateIndex(
name: "IX_Mangas_MangaConnectorId",
table: "Mangas",
column: "MangaConnectorId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AltTitles");
migrationBuilder.DropTable(
name: "AuthorManga");
migrationBuilder.DropTable(
name: "JobJob");
migrationBuilder.DropTable(
name: "LibraryConnectors");
migrationBuilder.DropTable(
name: "Links");
migrationBuilder.DropTable(
name: "MangaMangaTag");
migrationBuilder.DropTable(
name: "NotificationConnectors");
migrationBuilder.DropTable(
name: "Notifications");
migrationBuilder.DropTable(
name: "Authors");
migrationBuilder.DropTable(
name: "Jobs");
migrationBuilder.DropTable(
name: "Tags");
migrationBuilder.DropTable(
name: "Chapters");
migrationBuilder.DropTable(
name: "Mangas");
migrationBuilder.DropTable(
name: "LocalLibraries");
migrationBuilder.DropTable(
name: "MangaConnectors");
}
}
}

View File

@ -0,0 +1,821 @@
// <auto-generated />
using System;
using System.Collections.Generic;
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("20250316150158_dev-160325-2")]
partial class dev1603252
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.Author", b =>
{
b.Property<string>("AuthorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("AuthorName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
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>("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<string>("Url")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
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<bool>("Enabled")
.HasColumnType("boolean");
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<byte>("state")
.HasColumnType("smallint");
b.HasKey("JobId");
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()
.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("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()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LinkUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.HasKey("LinkId");
b.HasIndex("MangaId");
b.ToTable("Links");
});
modelBuilder.Entity("API.Schema.LocalLibrary", b =>
{
b.Property<string>("LocalLibraryId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("BasePath")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LibraryName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("LocalLibraryId");
b.ToTable("LocalLibraries");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Property<string>("MangaId")
.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>("DirectoryName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("IdOnConnectorSite")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<float>("IgnoreChapterBefore")
.HasColumnType("real");
b.Property<string>("LibraryLocalLibraryId")
.HasColumnType("character varying(64)");
b.Property<string>("MangaConnectorId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("OriginalLanguage")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<byte>("ReleaseStatus")
.HasColumnType("smallint");
b.Property<string>("WebsiteUrl")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<long>("Year")
.HasColumnType("bigint");
b.HasKey("MangaId");
b.HasIndex("LibraryLocalLibraryId");
b.HasIndex("MangaConnectorId");
b.ToTable("Mangas");
});
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()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
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()
.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.MangaTag", b =>
{
b.Property<string>("Tag")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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()
.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("NotificationId");
b.ToTable("Notifications");
});
modelBuilder.Entity("API.Schema.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");
});
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("JobJob", b =>
{
b.Property<string>("DependsOnJobsJobId")
.HasColumnType("character varying(64)");
b.Property<string>("JobId")
.HasColumnType("character varying(64)");
b.HasKey("DependsOnJobsJobId", "JobId");
b.HasIndex("JobId");
b.ToTable("JobJob");
});
modelBuilder.Entity("MangaMangaTag", b =>
{
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.Property<string>("MangaTagsTag")
.HasColumnType("character varying(64)");
b.HasKey("MangaId", "MangaTagsTag");
b.HasIndex("MangaTagsTag");
b.ToTable("MangaMangaTag");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", 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("DownloadAvailableChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", 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)4);
});
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()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ToLocation")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasDiscriminator().HasValue((byte)3);
});
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", 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("RetrieveChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)5);
});
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", 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("UpdateFilesDownloadedJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)6);
});
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.Manganato", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Manganato");
});
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.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", "ParentJob")
.WithMany()
.HasForeignKey("ParentJobId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("ParentJob");
});
modelBuilder.Entity("API.Schema.Link", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("Links")
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.HasOne("API.Schema.LocalLibrary", "Library")
.WithMany()
.HasForeignKey("LibraryLocalLibraryId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("API.Schema.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
b.Navigation("MangaConnector");
});
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("AltTitles")
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade);
});
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("JobJob", b =>
{
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("DependsOnJobsJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("JobId")
.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("MangaTagsTag")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", 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.RetrieveChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
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.Manga", b =>
{
b.Navigation("AltTitles");
b.Navigation("Links");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations
{
/// <inheritdoc />
public partial class dev1603252 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Mangas_LocalLibraries_LibraryLocalLibraryId",
table: "Mangas");
migrationBuilder.AddForeignKey(
name: "FK_Mangas_LocalLibraries_LibraryLocalLibraryId",
table: "Mangas",
column: "LibraryLocalLibraryId",
principalTable: "LocalLibraries",
principalColumn: "LocalLibraryId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Mangas_LocalLibraries_LibraryLocalLibraryId",
table: "Mangas");
migrationBuilder.AddForeignKey(
name: "FK_Mangas_LocalLibraries_LibraryLocalLibraryId",
table: "Mangas",
column: "LibraryLocalLibraryId",
principalTable: "LocalLibraries",
principalColumn: "LocalLibraryId",
onDelete: ReferentialAction.Cascade);
}
}
}

View File

@ -0,0 +1,827 @@
// <auto-generated />
using System;
using System.Collections.Generic;
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("20250401001439_dev-010425-1")]
partial class dev0104251
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.Author", b =>
{
b.Property<string>("AuthorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("AuthorName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
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>("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<string>("Url")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
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<bool>("Enabled")
.HasColumnType("boolean");
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<byte>("state")
.HasColumnType("smallint");
b.HasKey("JobId");
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()
.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("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()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LinkUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.HasKey("LinkId");
b.HasIndex("MangaId");
b.ToTable("Links");
});
modelBuilder.Entity("API.Schema.LocalLibrary", b =>
{
b.Property<string>("LocalLibraryId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("BasePath")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LibraryName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("LocalLibraryId");
b.ToTable("LocalLibraries");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Property<string>("MangaId")
.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>("DirectoryName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("IdOnConnectorSite")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<float>("IgnoreChapterBefore")
.HasColumnType("real");
b.Property<string>("LibraryLocalLibraryId")
.HasColumnType("character varying(64)");
b.Property<string>("MangaConnectorId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("OriginalLanguage")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<byte>("ReleaseStatus")
.HasColumnType("smallint");
b.Property<string>("WebsiteUrl")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<long>("Year")
.HasColumnType("bigint");
b.HasKey("MangaId");
b.HasIndex("LibraryLocalLibraryId");
b.HasIndex("MangaConnectorId");
b.ToTable("Mangas");
});
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()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
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()
.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.MangaTag", b =>
{
b.Property<string>("Tag")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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()
.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("NotificationId");
b.ToTable("Notifications");
});
modelBuilder.Entity("API.Schema.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");
});
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("JobJob", b =>
{
b.Property<string>("DependsOnJobsJobId")
.HasColumnType("character varying(64)");
b.Property<string>("JobId")
.HasColumnType("character varying(64)");
b.HasKey("DependsOnJobsJobId", "JobId");
b.HasIndex("JobId");
b.ToTable("JobJob");
});
modelBuilder.Entity("MangaMangaTag", b =>
{
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.Property<string>("MangaTagsTag")
.HasColumnType("character varying(64)");
b.HasKey("MangaId", "MangaTagsTag");
b.HasIndex("MangaTagsTag");
b.ToTable("MangaMangaTag");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", 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("DownloadAvailableChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", 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)4);
});
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()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ToLocation")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasDiscriminator().HasValue((byte)3);
});
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", 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("RetrieveChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)5);
});
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", 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("UpdateFilesDownloadedJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)6);
});
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.Global", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Global");
});
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.Manganato", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Manganato");
});
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.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", "ParentJob")
.WithMany()
.HasForeignKey("ParentJobId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("ParentJob");
});
modelBuilder.Entity("API.Schema.Link", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("Links")
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.HasOne("API.Schema.LocalLibrary", "Library")
.WithMany()
.HasForeignKey("LibraryLocalLibraryId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("API.Schema.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
b.Navigation("MangaConnector");
});
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("AltTitles")
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade);
});
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("JobJob", b =>
{
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("DependsOnJobsJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("JobId")
.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("MangaTagsTag")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", 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.RetrieveChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
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.Manga", b =>
{
b.Navigation("AltTitles");
b.Navigation("Links");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations
{
/// <inheritdoc />
public partial class dev0104251 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "OriginalLanguage",
table: "Mangas",
type: "character varying(8)",
maxLength: 8,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(8)",
oldMaxLength: 8);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "OriginalLanguage",
table: "Mangas",
type: "character varying(8)",
maxLength: 8,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "character varying(8)",
oldMaxLength: 8,
oldNullable: true);
}
}
}

View File

@ -0,0 +1,827 @@
// <auto-generated />
using System;
using System.Collections.Generic;
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("20250401162026_dev-010425-2-Longer_Var_Chars")]
partial class dev0104252Longer_Var_Chars
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.Author", b =>
{
b.Property<string>("AuthorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("AuthorName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
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>("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<string>("Url")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
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<bool>("Enabled")
.HasColumnType("boolean");
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<byte>("state")
.HasColumnType("smallint");
b.HasKey("JobId");
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()
.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("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()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LinkUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.HasKey("LinkId");
b.HasIndex("MangaId");
b.ToTable("Links");
});
modelBuilder.Entity("API.Schema.LocalLibrary", b =>
{
b.Property<string>("LocalLibraryId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("BasePath")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LibraryName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("LocalLibraryId");
b.ToTable("LocalLibraries");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Property<string>("MangaId")
.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>("DirectoryName")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("IdOnConnectorSite")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<float>("IgnoreChapterBefore")
.HasColumnType("real");
b.Property<string>("LibraryLocalLibraryId")
.HasColumnType("character varying(64)");
b.Property<string>("MangaConnectorId")
.IsRequired()
.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<string>("WebsiteUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<long>("Year")
.HasColumnType("bigint");
b.HasKey("MangaId");
b.HasIndex("LibraryLocalLibraryId");
b.HasIndex("MangaConnectorId");
b.ToTable("Mangas");
});
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()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
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()
.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.MangaTag", b =>
{
b.Property<string>("Tag")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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()
.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("NotificationId");
b.ToTable("Notifications");
});
modelBuilder.Entity("API.Schema.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");
});
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("JobJob", b =>
{
b.Property<string>("DependsOnJobsJobId")
.HasColumnType("character varying(64)");
b.Property<string>("JobId")
.HasColumnType("character varying(64)");
b.HasKey("DependsOnJobsJobId", "JobId");
b.HasIndex("JobId");
b.ToTable("JobJob");
});
modelBuilder.Entity("MangaMangaTag", b =>
{
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.Property<string>("MangaTagsTag")
.HasColumnType("character varying(64)");
b.HasKey("MangaId", "MangaTagsTag");
b.HasIndex("MangaTagsTag");
b.ToTable("MangaMangaTag");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", 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("DownloadAvailableChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", 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)4);
});
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()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ToLocation")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasDiscriminator().HasValue((byte)3);
});
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", 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("RetrieveChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)5);
});
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", 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("UpdateFilesDownloadedJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)6);
});
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.Global", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Global");
});
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.Manganato", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Manganato");
});
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.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", "ParentJob")
.WithMany()
.HasForeignKey("ParentJobId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("ParentJob");
});
modelBuilder.Entity("API.Schema.Link", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("Links")
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.HasOne("API.Schema.LocalLibrary", "Library")
.WithMany()
.HasForeignKey("LibraryLocalLibraryId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("API.Schema.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
b.Navigation("MangaConnector");
});
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("AltTitles")
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade);
});
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("JobJob", b =>
{
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("DependsOnJobsJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("JobId")
.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("MangaTagsTag")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", 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.RetrieveChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
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.Manga", b =>
{
b.Navigation("AltTitles");
b.Navigation("Links");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Migrations
{
/// <inheritdoc />
public partial class dev0104252Longer_Var_Chars : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "WebsiteUrl",
table: "Mangas",
type: "character varying(512)",
maxLength: 512,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256);
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Mangas",
type: "character varying(512)",
maxLength: 512,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256);
migrationBuilder.AlterColumn<string>(
name: "IdOnConnectorSite",
table: "Mangas",
type: "character varying(256)",
maxLength: 256,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(128)",
oldMaxLength: 128);
migrationBuilder.AlterColumn<string>(
name: "DirectoryName",
table: "Mangas",
type: "character varying(1024)",
maxLength: 1024,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "WebsiteUrl",
table: "Mangas",
type: "character varying(256)",
maxLength: 256,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(512)",
oldMaxLength: 512);
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Mangas",
type: "character varying(256)",
maxLength: 256,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(512)",
oldMaxLength: 512);
migrationBuilder.AlterColumn<string>(
name: "IdOnConnectorSite",
table: "Mangas",
type: "character varying(128)",
maxLength: 128,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256);
migrationBuilder.AlterColumn<string>(
name: "DirectoryName",
table: "Mangas",
type: "character varying(256)",
maxLength: 256,
nullable: false,
oldClrType: typeof(string),
oldType: "character varying(1024)",
oldMaxLength: 1024);
}
}
}

View File

@ -0,0 +1,824 @@
// <auto-generated />
using System;
using System.Collections.Generic;
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.3")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("API.Schema.Author", b =>
{
b.Property<string>("AuthorId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("AuthorName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
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>("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<string>("Url")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
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<bool>("Enabled")
.HasColumnType("boolean");
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<byte>("state")
.HasColumnType("smallint");
b.HasKey("JobId");
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()
.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("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()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LinkUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.HasKey("LinkId");
b.HasIndex("MangaId");
b.ToTable("Links");
});
modelBuilder.Entity("API.Schema.LocalLibrary", b =>
{
b.Property<string>("LocalLibraryId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("BasePath")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LibraryName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.HasKey("LocalLibraryId");
b.ToTable("LocalLibraries");
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.Property<string>("MangaId")
.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>("DirectoryName")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<string>("IdOnConnectorSite")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<float>("IgnoreChapterBefore")
.HasColumnType("real");
b.Property<string>("LibraryLocalLibraryId")
.HasColumnType("character varying(64)");
b.Property<string>("MangaConnectorId")
.IsRequired()
.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<string>("WebsiteUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<long>("Year")
.HasColumnType("bigint");
b.HasKey("MangaId");
b.HasIndex("LibraryLocalLibraryId");
b.HasIndex("MangaConnectorId");
b.ToTable("Mangas");
});
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()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
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()
.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.MangaTag", b =>
{
b.Property<string>("Tag")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
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()
.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("NotificationId");
b.ToTable("Notifications");
});
modelBuilder.Entity("API.Schema.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");
});
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("JobJob", b =>
{
b.Property<string>("DependsOnJobsJobId")
.HasColumnType("character varying(64)");
b.Property<string>("JobId")
.HasColumnType("character varying(64)");
b.HasKey("DependsOnJobsJobId", "JobId");
b.HasIndex("JobId");
b.ToTable("JobJob");
});
modelBuilder.Entity("MangaMangaTag", b =>
{
b.Property<string>("MangaId")
.HasColumnType("character varying(64)");
b.Property<string>("MangaTagsTag")
.HasColumnType("character varying(64)");
b.HasKey("MangaId", "MangaTagsTag");
b.HasIndex("MangaTagsTag");
b.ToTable("MangaMangaTag");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", 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("DownloadAvailableChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)1);
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", 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)4);
});
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()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ToLocation")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasDiscriminator().HasValue((byte)3);
});
modelBuilder.Entity("API.Schema.Jobs.RetrieveChaptersJob", 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("RetrieveChaptersJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)5);
});
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", 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("UpdateFilesDownloadedJob_MangaId");
});
b.HasDiscriminator().HasValue((byte)6);
});
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.Global", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Global");
});
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.Manganato", b =>
{
b.HasBaseType("API.Schema.MangaConnectors.MangaConnector");
b.HasDiscriminator().HasValue("Manganato");
});
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.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", "ParentJob")
.WithMany()
.HasForeignKey("ParentJobId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("ParentJob");
});
modelBuilder.Entity("API.Schema.Link", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("Links")
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("API.Schema.Manga", b =>
{
b.HasOne("API.Schema.LocalLibrary", "Library")
.WithMany()
.HasForeignKey("LibraryLocalLibraryId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("API.Schema.MangaConnectors.MangaConnector", "MangaConnector")
.WithMany()
.HasForeignKey("MangaConnectorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
b.Navigation("MangaConnector");
});
modelBuilder.Entity("API.Schema.MangaAltTitle", b =>
{
b.HasOne("API.Schema.Manga", null)
.WithMany("AltTitles")
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade);
});
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("JobJob", b =>
{
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("DependsOnJobsJobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Schema.Jobs.Job", null)
.WithMany()
.HasForeignKey("JobId")
.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("MangaTagsTag")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Schema.Jobs.DownloadAvailableChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.DownloadMangaCoverJob", 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.RetrieveChaptersJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
modelBuilder.Entity("API.Schema.Jobs.UpdateFilesDownloadedJob", b =>
{
b.HasOne("API.Schema.Manga", "Manga")
.WithMany()
.HasForeignKey("MangaId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Manga");
});
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.Manga", b =>
{
b.Navigation("AltTitles");
b.Navigation("Links");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,46 @@
using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace API;
public class NamedSwaggerGenOptions : IConfigureNamedOptions<SwaggerGenOptions>
{
private readonly IApiVersionDescriptionProvider provider;
public NamedSwaggerGenOptions(IApiVersionDescriptionProvider provider)
{
this.provider = provider;
}
public void Configure(string? name, SwaggerGenOptions options)
{
Configure(options);
}
public void Configure(SwaggerGenOptions options)
{
// add swagger document for every API version discovered
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(
description.GroupName,
CreateVersionInfo(description));
}
}
private OpenApiInfo CreateVersionInfo(
ApiVersionDescription description)
{
var info = new OpenApiInfo()
{
Title = "Test API " + description.GroupName,
Version = description.ApiVersion.ToString()
};
if (description.IsDeprecated)
{
info.Description += " This API version has been deprecated.";
}
return info;
}
}

147
API/Program.cs Normal file
View File

@ -0,0 +1,147 @@
using System.Reflection;
using System.Text.Json.Serialization;
using API;
using API.Schema;
using API.Schema.Jobs;
using API.Schema.MangaConnectors;
using Asp.Versioning;
using Asp.Versioning.Builder;
using Asp.Versioning.Conventions;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll",
policy =>
{
policy
.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
builder.Services.AddApiVersioning(option =>
{
option.AssumeDefaultVersionWhenUnspecified = true;
option.DefaultApiVersion = new ApiVersion(2);
option.ReportApiVersions = true;
option.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new QueryStringApiVersionReader("api-version"),
new HeaderApiVersionReader("X-Version"),
new MediaTypeApiVersionReader("x-version"));
})
.AddMvc(options =>
{
options.Conventions.Add(new VersionByNamespaceConvention());
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'V";
options.SubstituteApiVersionInUrl = true;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGenNewtonsoftSupport();
builder.Services.AddSwaggerGen(opt =>
{
var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
opt.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
});
builder.Services.ConfigureOptions<NamedSwaggerGenOptions>();
builder.Services.AddDbContext<PgsqlContext>(options =>
options.UseNpgsql($"Host={Environment.GetEnvironmentVariable("POSTGRES_HOST")??"localhost:5432"}; " +
$"Database={Environment.GetEnvironmentVariable("POSTGRES_DB")??"postgres"}; " +
$"Username={Environment.GetEnvironmentVariable("POSTGRES_USER")??"postgres"}; " +
$"Password={Environment.GetEnvironmentVariable("POSTGRES_PASSWORD")??"postgres"}"));
builder.Services.AddControllers(options =>
{
options.AllowEmptyInputInBodyModelBinding = true;
});
builder.Services.AddControllers().AddNewtonsoftJson(opts =>
{
opts.SerializerSettings.Converters.Add(new StringEnumConverter());
opts.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
});
builder.WebHost.UseUrls("http://*:6531");
var app = builder.Build();
ApiVersionSet apiVersionSet = app.NewApiVersionSet()
.HasApiVersion(new ApiVersion(2))
.ReportApiVersions()
.Build();
app.UseCors("AllowAll");
app.MapControllers()
.WithApiVersionSet(apiVersionSet)
.MapToApiVersion(2);
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint(
$"/swagger/v2/swagger.json", "v2");
});
app.UseHttpsRedirection();
app.UseMiddleware<RequestTimeMiddleware>();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<PgsqlContext>();
db.Database.Migrate();
}
using (var scope = app.Services.CreateScope())
{
PgsqlContext context = scope.ServiceProvider.GetService<PgsqlContext>()!;
MangaConnector[] connectors =
[
new AsuraToon(),
new Bato(),
new MangaDex(),
new MangaHere(),
new MangaKatana(),
new Mangaworld(),
new ManhuaPlus(),
new Weebcentral(),
new Manganato(),
new Global(scope.ServiceProvider.GetService<PgsqlContext>()!)
];
MangaConnector[] newConnectors = connectors.Where(c => !context.MangaConnectors.Contains(c)).ToArray();
context.MangaConnectors.AddRange(newConnectors);
context.Jobs.AddRange(context.Mangas.AsEnumerable().Select(m => new UpdateFilesDownloadedJob(0, m.MangaId)));
context.Jobs.RemoveRange(context.Jobs.Where(j => j.state == JobState.Completed && j.RecurrenceMs < 1));
if (!context.LocalLibraries.Any())
context.LocalLibraries.Add(new LocalLibrary(TrangaSettings.downloadLocation, "Default Library"));
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.SaveChanges();
}
TrangaSettings.Load();
Tranga.StartLogger();
Tranga.JobStarterThread.Start(app.Services);
Tranga.NotificationSenderThread.Start(app.Services);
app.UseCors("AllowAll");
app.Run();

View File

@ -0,0 +1,47 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:5976",
"sslPort": 44332,
"environmentVariables": {
"POSTGRES_Host": "localhost:5432"
}
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5287",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"POSTGRES_Host": "localhost:5432"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7206;http://localhost:5287",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"POSTGRES_Host": "localhost:5432"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"POSTGRES_Host": "localhost:5432"
}
}
}
}

15
API/Schema/Author.cs Normal file
View File

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

218
API/Schema/Chapter.cs Normal file
View File

@ -0,0 +1,218 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using API.Schema.Jobs;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
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;
FileName = GetArchiveFilePath(parentManga.Name);
}
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;
}
[StringLength(64)]
[Required]
public string ChapterId { get; init; }
public int? VolumeNumber { get; private set; }
[StringLength(10)]
[Required]
public string ChapterNumber { get; private set; }
[StringLength(2048)]
[Required]
[Url]
public string Url { get; internal set; }
[StringLength(256)]
public string? Title { get; private set; }
[StringLength(256)]
[Required]
public string FileName { get; private set; }
[JsonIgnore]
[NotMapped]
public string? FullArchiveFilePath => ParentManga is { } m ? Path.Join(m.FullDirectoryPath, FileName) : null;
[Required]
public bool Downloaded { get; internal set; } = false;
[Required]
[StringLength(64)]
public string ParentMangaId { get; internal set; }
[JsonIgnore]
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();
}
internal MoveFileOrFolderJob? UpdateArchiveFileName()
{
string? oldPath = FullArchiveFilePath;
if (oldPath is null)
return null;
string newPath = GetArchiveFilePath();
FileName = newPath;
return Downloaded ? new MoveFileOrFolderJob(oldPath, newPath) : null;
}
/// <summary>
/// Checks the filesystem if an archive at the ArchiveFilePath exists
/// </summary>
/// <returns>True if archive exists on disk</returns>
public bool IsDownloaded()
{
string path = GetArchiveFilePath();
return File.Exists(path);
}
/// Placeholders:
/// %M Manga Name
/// %V Volume
/// %C Chapter
/// %T Title
/// %A Author (first in list)
/// %I Chapter Internal ID
/// %i Manga Internal ID
/// %Y Year (Manga)
private static readonly Regex NullableRex = new(@"\?([a-zA-Z])\(([^\)]*)\)|(.+?)");
private static readonly Regex ReplaceRexx = new(@"%([a-zA-Z])|(.+?)");
private string GetArchiveFilePath(string? parentMangaName = null)
{
string archiveNamingScheme = TrangaSettings.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 && parentMangaName is null,
'V' => VolumeNumber is null,
'C' => ChapterNumber is null,
'T' => Title is null,
'A' => ParentManga?.Authors?.FirstOrDefault()?.AuthorName is null,
'I' => ChapterId is null,
'i' => ParentMangaId 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 ?? parentMangaName,
'V' => VolumeNumber?.ToString(),
'C' => ChapterNumber,
'T' => Title,
'A' => ParentManga?.Authors?.FirstOrDefault()?.AuthorName,
'I' => ChapterId,
'i' => ParentMangaId,
'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("Tags", string.Join(',', ParentManga.MangaTags.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 System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
namespace API.Schema.Jobs;
public class DownloadAvailableChaptersJob(ulong recurrenceMs, string mangaId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
: Job(TokenGen.CreateToken(typeof(DownloadAvailableChaptersJob)), JobType.DownloadAvailableChaptersJob, recurrenceMs, parentJobId, dependsOnJobsIds)
{
[StringLength(64)]
[Required]
public string MangaId { get; init; } = mangaId;
[JsonIgnore]
public Manga? Manga { get; init; }
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
return context.Chapters.Where(c => c.ParentMangaId == MangaId).AsEnumerable()
.Select(chapter => new DownloadSingleChapterJob(chapter.ChapterId, this.JobId));
}
}

View File

@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
namespace API.Schema.Jobs;
public class DownloadMangaCoverJob(string mangaId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
: Job(TokenGen.CreateToken(typeof(DownloadMangaCoverJob)), JobType.DownloadMangaCoverJob, 0, parentJobId, dependsOnJobsIds)
{
[StringLength(64)]
[Required]
public string MangaId { get; init; } = mangaId;
[JsonIgnore]
public Manga? Manga { get; init; }
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
Manga? manga = Manga ?? context.Mangas.Find(this.MangaId);
if (manga is null)
{
Log.Error($"Manga {this.MangaId} not found.");
return [];
}
manga.CoverFileNameInCache = manga.SaveCoverImageToCache();
context.SaveChanges();
Log.Info($"Saved cover for Manga {this.MangaId} to cache at {manga.CoverFileNameInCache}.");
return [];
}
}

View File

@ -0,0 +1,175 @@
using System.ComponentModel.DataAnnotations;
using System.IO.Compression;
using System.Runtime.InteropServices;
using API.MangaDownloadClients;
using API.Schema.MangaConnectors;
using Newtonsoft.Json;
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)
{
[StringLength(64)]
[Required]
public string ChapterId { get; init; } = chapterId;
[JsonIgnore]
public Chapter? Chapter { get; init; }
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
Chapter? chapter = Chapter ?? context.Chapters.Find(ChapterId);
if (chapter is null)
{
Log.Error("Chapter is null.");
return [];
}
Manga? manga = chapter.ParentManga ?? context.Mangas.Find(chapter.ParentMangaId);
if (manga is null)
{
Log.Error("Manga is null.");
return [];
}
MangaConnector? connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId);
if (connector is null)
{
Log.Error("Connector is null.");
return [];
}
string[] imageUrls = connector.GetChapterImageUrls(chapter);
if (imageUrls.Length < 1)
{
Log.Info($"No imageUrls for chapter {chapterId}");
return [];
}
string? saveArchiveFilePath = chapter.FullArchiveFilePath;
if (saveArchiveFilePath is null)
{
Log.Error("saveArchiveFilePath is null.");
return [];
}
//Check if Publication Directory already exists
string directoryPath = Path.GetDirectoryName(saveArchiveFilePath)!;
if (!Directory.Exists(directoryPath))
{
Log.Info($"Creating publication Directory: {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
{
Log.Info($"Archive {saveArchiveFilePath} already existed, but deleting and re-downloading.");
File.Delete(saveArchiveFilePath);
}
//Create a temporary folder to store images
string tempFolder = Directory.CreateTempSubdirectory("trangatemp").FullName;
Log.Debug($"Created temp folder: {tempFolder}");
Log.Info($"Downloading images: {ChapterId}");
int chapterNum = 0;
//Download all Images to temporary Folder
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)
{
Log.Error($"Failed to download image: {imageUrl}");
return [];
}
}
CopyCoverFromCacheToDownloadLocation(manga);
Log.Debug($"Creating ComicInfo.xml {ChapterId}");
File.WriteAllText(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString());
Log.Debug($"Packaging images to archive {ChapterId}");
//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
chapter.Downloaded = true;
context.SaveChanges();
return [new UpdateFilesDownloadedJob(0, manga.MangaId, this.JobId)];
}
private void ProcessImage(string imagePath)
{
if (!TrangaSettings.bwImages && TrangaSettings.compression == 100)
{
Log.Debug($"No processing requested for image");
return;
}
Log.Debug($"Processing image: {imagePath}");
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)
{
//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)))
{
Log.Debug($"Cover already exists at {publicationFolder}");
return;
}
Log.Info($"Copying cover to {publicationFolder}");
string? fileInCache = manga.CoverFileNameInCache ?? manga.SaveCoverImageToCache();
if (fileInCache is null)
{
Log.Error($"File {fileInCache} does not exist");
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);
Log.Debug($"Copied cover from {fileInCache} to {newFilePath}");
}
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, FileAccess.Write, FileShare.None);
requestResult.result.CopyTo(fs);
fs.Close();
ProcessImage(savePath);
return true;
}
}

86
API/Schema/Jobs/Job.cs Normal file
View File

@ -0,0 +1,86 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using log4net;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.Jobs;
[PrimaryKey("JobId")]
public abstract class Job
{
[StringLength(64)]
[Required]
public string JobId { get; init; }
[StringLength(64)]
public string? ParentJobId { get; init; }
[JsonIgnore]
public Job? ParentJob { get; init; }
[StringLength(64)]
public ICollection<string>? DependsOnJobsIds { get; init; }
[JsonIgnore]
public ICollection<Job>? DependsOnJobs { get; init; }
[Required]
public JobType JobType { get; init; }
[Required]
public ulong RecurrenceMs { get; set; }
[Required]
public DateTime LastExecution { get; internal set; } = DateTime.UnixEpoch;
[NotMapped]
[Required]
public DateTime NextExecution => LastExecution.AddMilliseconds(RecurrenceMs);
[Required]
public JobState state { get; internal set; } = JobState.Waiting;
[Required]
public bool Enabled { get; internal set; } = true;
[NotMapped]
[JsonIgnore]
protected ILog Log { get; init; }
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)
{
Log = LogManager.GetLogger(GetType());
JobId = jobId;
ParentJobId = parentJobId;
DependsOnJobsIds = dependsOnJobsIds;
JobType = jobType;
RecurrenceMs = recurrenceMs;
}
public IEnumerable<Job> Run(IServiceProvider serviceProvider)
{
Log.Debug($"Running job {JobId}");
using IServiceScope scope = serviceProvider.CreateScope();
PgsqlContext context = scope.ServiceProvider.GetRequiredService<PgsqlContext>();
try
{
this.state = JobState.Running;
context.SaveChanges();
Job[] newJobs = RunInternal(context).ToArray();
this.state = JobState.Completed;
context.Jobs.AddRange(newJobs);
context.SaveChanges();
Log.Info($"Job {JobId} completed. Generated {newJobs.Length} new jobs.");
return newJobs;
}
catch (DbUpdateException e)
{
this.state = JobState.Failed;
Log.Error($"Failed to run job {JobId}", e);
return [];
}
}
protected abstract IEnumerable<Job> RunInternal(PgsqlContext context);
}

View File

@ -0,0 +1,13 @@
namespace API.Schema.Jobs;
public enum JobState : byte
{
//Values 0-63 Preparation Stages
Waiting = 0,
//64-127 Running Stages
Running = 64,
//128-191 Completion Stages
Completed = 128,
//192-255 Error stages
Failed = 192
}

View File

@ -0,0 +1,14 @@
namespace API.Schema.Jobs;
public enum JobType : byte
{
DownloadSingleChapterJob = 0,
DownloadAvailableChaptersJob = 1,
UpdateMetaDataJob = 2,
MoveFileOrFolderJob = 3,
DownloadMangaCoverJob = 4,
RetrieveChaptersJob = 5,
UpdateFilesDownloadedJob = 6,
MoveMangaLibraryJob = 7
}

View File

@ -0,0 +1,53 @@
using System.ComponentModel.DataAnnotations;
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)
{
[StringLength(256)]
[Required]
public string FromLocation { get; init; } = fromLocation;
[StringLength(256)]
[Required]
public string ToLocation { get; init; } = toLocation;
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
try
{
FileInfo fi = new (FromLocation);
if (!fi.Exists)
{
Log.Error($"File does not exist at {FromLocation}");
return [];
}
if (File.Exists(ToLocation))//Do not override existing
{
Log.Error($"File already exists at {ToLocation}");
return [];
}
if(fi.Attributes.HasFlag(FileAttributes.Directory))
MoveDirectory(fi, ToLocation);
else
MoveFile(fi, ToLocation);
}
catch (Exception e)
{
Log.Error(e);
}
return [];
}
private void MoveDirectory(FileInfo from, string toLocation)
{
Directory.Move(from.FullName, toLocation);
}
private void MoveFile(FileInfo from, string toLocation)
{
File.Move(from.FullName, toLocation);
}
}

View File

@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace API.Schema.Jobs;
public class MoveMangaLibraryJob(string mangaId, string toLibraryId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
: Job(TokenGen.CreateToken(typeof(MoveMangaLibraryJob)), JobType.MoveMangaLibraryJob, 0, parentJobId, dependsOnJobsIds)
{
[StringLength(64)]
[Required]
public string MangaId { get; init; } = mangaId;
[StringLength(64)]
[Required]
public string ToLibraryId { get; init; } = toLibraryId;
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
Manga? manga = context.Mangas.Find(MangaId);
if (manga is null)
{
Log.Error("Manga not found");
return [];
}
LocalLibrary? library = context.LocalLibraries.Find(ToLibraryId);
if (library is null)
{
Log.Error("LocalLibrary not found");
return [];
}
Chapter[] chapters = context.Chapters.Where(c => c.ParentMangaId == MangaId).ToArray();
Dictionary<Chapter, string> oldPath = chapters.ToDictionary(c => c, c => c.FullArchiveFilePath!);
manga.Library = library;
try
{
context.SaveChanges();
}
catch (DbUpdateException e)
{
Log.Error(e);
return [];
}
return chapters.Select(c => new MoveFileOrFolderJob(oldPath[c], c.FullArchiveFilePath!));
}
}

View File

@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
using API.Schema.MangaConnectors;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.Jobs;
public class RetrieveChaptersJob(ulong recurrenceMs, string mangaId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
: Job(TokenGen.CreateToken(typeof(RetrieveChaptersJob)), JobType.RetrieveChaptersJob, recurrenceMs, parentJobId, dependsOnJobsIds)
{
[StringLength(64)]
[Required]
public string MangaId { get; init; } = mangaId;
[JsonIgnore]
public Manga? Manga { get; init; }
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
Manga? manga = Manga ?? context.Mangas.Find(MangaId);
if (manga is null)
{
Log.Error("Manga is null.");
return [];
}
MangaConnector? connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId);
if (connector is null)
{
Log.Error("Connector is null.");
return [];
}
// This gets all chapters that are not downloaded
Chapter[] allNewChapters = connector.GetNewChapters(manga).DistinctBy(c => c.ChapterId).ToArray();
Log.Info($"{allNewChapters.Length} new chapters.");
try
{
// This filters out chapters that are not downloaded but already exist in the DB
string[] chapterIds = context.Chapters.Where(chapter => chapter.ParentMangaId == manga.MangaId)
.Select(chapter => chapter.ChapterId).ToArray();
Chapter[] newChapters = allNewChapters.Where(chapter => !chapterIds.Contains(chapter.ChapterId)).ToArray();
context.Chapters.AddRange(newChapters);
context.SaveChanges();
}
catch (DbUpdateException e)
{
Log.Error(e);
}
return [];
}
}

View File

@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
namespace API.Schema.Jobs;
public class UpdateFilesDownloadedJob(ulong recurrenceMs, string mangaId, string? parentJobId = null, ICollection<string>? dependsOnJobsIds = null)
: Job(TokenGen.CreateToken(typeof(UpdateFilesDownloadedJob)), JobType.UpdateFilesDownloadedJob, recurrenceMs, parentJobId, dependsOnJobsIds)
{
[StringLength(64)]
[Required]
public string MangaId { get; init; } = mangaId;
[JsonIgnore]
public virtual Manga? Manga { get; init; }
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
IQueryable<Chapter> chapters = context.Chapters.Where(c => c.ParentMangaId == MangaId);
foreach (Chapter chapter in chapters)
chapter.Downloaded = chapter.IsDownloaded();
context.SaveChanges();
return [];
}
}

View File

@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
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)
{
[StringLength(64)]
[Required]
public string MangaId { get; init; } = mangaId;
[JsonIgnore]
public virtual Manga? Manga { get; init; }
/// <summary>
/// Updates all data related to Manga.
/// Retrieves data from Mangaconnector
/// Updates Chapter-info
/// </summary>
/// <param name="context"></param>
protected override IEnumerable<Job> RunInternal(PgsqlContext context)
{
Log.Warn("NOT IMPLEMENTED.");
return [];//TODO
}
}

View File

@ -1,29 +1,22 @@
using System.Text.Json.Nodes; using System.Text.Json;
using Logging; using System.Text.Json.Nodes;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace Tranga.LibraryConnectors; namespace API.Schema.LibraryConnectors;
public class Kavita : LibraryConnector public class Kavita : LibraryConnector
{ {
public Kavita(GlobalBase clone, string baseUrl, string username, string password) : public Kavita(string baseUrl, string auth) : base(TokenGen.CreateToken(typeof(Kavita), baseUrl), LibraryType.Kavita, baseUrl, auth)
base(clone, baseUrl, GetToken(baseUrl, username, password, clone.logger), LibraryType.Kavita)
{ {
} }
[JsonConstructor] public Kavita(string baseUrl, string username, string password) :
public Kavita(GlobalBase clone, string baseUrl, string auth) : base(clone, baseUrl, auth, LibraryType.Kavita) this(baseUrl, GetToken(baseUrl, username, password))
{ {
} }
public override string ToString()
{
return $"Kavita {baseUrl}";
}
private static string GetToken(string baseUrl, string username, string password, Logger? logger = null) private static string GetToken(string baseUrl, string username, string password)
{ {
HttpClient client = new() HttpClient client = new()
{ {
@ -41,7 +34,6 @@ public class Kavita : LibraryConnector
try try
{ {
HttpResponseMessage response = client.Send(requestMessage); HttpResponseMessage response = client.Send(requestMessage);
logger?.WriteLine($"Kavita | GetToken {requestMessage.RequestUri} -> {response.StatusCode}");
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(response.Content.ReadAsStream()); JsonObject? result = JsonSerializer.Deserialize<JsonObject>(response.Content.ReadAsStream());
@ -50,28 +42,24 @@ public class Kavita : LibraryConnector
} }
else else
{ {
logger?.WriteLine($"Kavita | {response.Content}");
} }
} }
catch (HttpRequestException e) catch (HttpRequestException e)
{ {
logger?.WriteLine($"Kavita | Unable to retrieve token:\n\r{e}");
} }
logger?.WriteLine("Kavita | Did not receive token.");
return ""; return "";
} }
protected override void UpdateLibraryInternal() protected override void UpdateLibraryInternal()
{ {
Log("Updating libraries.");
foreach (KavitaLibrary lib in GetLibraries()) foreach (KavitaLibrary lib in GetLibraries())
NetClient.MakePost($"{baseUrl}/api/Library/scan?libraryId={lib.id}", "Bearer", auth, logger); NetClient.MakePost($"{BaseUrl}/api/Library/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, logger)) if (NetClient.MakePost($"{BaseUrl}/api/Library/scan?libraryId={lib.id}", "Bearer", Auth))
return true; return true;
return false; return false;
} }
@ -82,17 +70,14 @@ public class Kavita : LibraryConnector
/// <returns>Array of KavitaLibrary</returns> /// <returns>Array of KavitaLibrary</returns>
private IEnumerable<KavitaLibrary> GetLibraries() private IEnumerable<KavitaLibrary> GetLibraries()
{ {
Log("Getting libraries."); Stream data = NetClient.MakeRequest($"{BaseUrl}/api/Library/libraries", "Bearer", Auth);
Stream data = NetClient.MakeRequest($"{baseUrl}/api/Library/libraries", "Bearer", auth, logger);
if (data == Stream.Null) if (data == Stream.Null)
{ {
Log("No libraries returned");
return Array.Empty<KavitaLibrary>(); return Array.Empty<KavitaLibrary>();
} }
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data); JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
if (result is null) if (result is null)
{ {
Log("No libraries returned");
return Array.Empty<KavitaLibrary>(); return Array.Empty<KavitaLibrary>();
} }

View File

@ -1,41 +1,30 @@
using System.Text.Json.Nodes; using System.Text.Json;
using Newtonsoft.Json; using System.Text.Json.Nodes;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace Tranga.LibraryConnectors; namespace API.Schema.LibraryConnectors;
/// <summary>
/// Provides connectivity to Komga-API
/// Can fetch and update libraries
/// </summary>
public class Komga : LibraryConnector public class Komga : LibraryConnector
{ {
public Komga(GlobalBase clone, string baseUrl, string username, string password) public Komga(string baseUrl, string auth) : base(TokenGen.CreateToken(typeof(Komga), baseUrl), LibraryType.Komga,
: base(clone, baseUrl, Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($"{username}:{password}")), LibraryType.Komga) baseUrl, auth)
{ {
} }
[JsonConstructor] public Komga(string baseUrl, string username, string password)
public Komga(GlobalBase clone, string baseUrl, string auth) : base(clone, baseUrl, auth, LibraryType.Komga) : this(baseUrl, Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($"{username}:{password}")))
{ {
} }
public override string ToString()
{
return $"Komga {baseUrl}";
}
protected override void UpdateLibraryInternal() protected override void UpdateLibraryInternal()
{ {
Log("Updating libraries.");
foreach (KomgaLibrary lib in GetLibraries()) foreach (KomgaLibrary lib in GetLibraries())
NetClient.MakePost($"{baseUrl}/api/v1/libraries/{lib.id}/scan", "Basic", auth, logger); NetClient.MakePost($"{BaseUrl}/api/v1/libraries/{lib.id}/scan", "Basic", Auth);
} }
internal override bool Test() internal override bool Test()
{ {
foreach (KomgaLibrary lib in GetLibraries()) foreach (KomgaLibrary lib in GetLibraries())
if (NetClient.MakePost($"{baseUrl}/api/v1/libraries/{lib.id}/scan", "Basic", auth, logger)) if (NetClient.MakePost($"{BaseUrl}/api/v1/libraries/{lib.id}/scan", "Basic", Auth))
return true; return true;
return false; return false;
} }
@ -46,17 +35,14 @@ public class Komga : LibraryConnector
/// <returns>Array of KomgaLibraries</returns> /// <returns>Array of KomgaLibraries</returns>
private IEnumerable<KomgaLibrary> GetLibraries() private IEnumerable<KomgaLibrary> GetLibraries()
{ {
Log("Getting Libraries"); Stream data = NetClient.MakeRequest($"{BaseUrl}/api/v1/libraries", "Basic", Auth);
Stream data = NetClient.MakeRequest($"{baseUrl}/api/v1/libraries", "Basic", auth, logger);
if (data == Stream.Null) if (data == Stream.Null)
{ {
Log("No libraries returned");
return Array.Empty<KomgaLibrary>(); return Array.Empty<KomgaLibrary>();
} }
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data); JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
if (result is null) if (result is null)
{ {
Log("No libraries returned");
return Array.Empty<KomgaLibrary>(); return Array.Empty<KomgaLibrary>();
} }

View File

@ -0,0 +1,25 @@
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)
{
[StringLength(64)]
[Required]
public string LibraryConnectorId { get; } = libraryConnectorId;
[Required]
public LibraryType LibraryType { get; init; } = libraryType;
[StringLength(256)]
[Required]
[Url]
public string BaseUrl { get; init; } = baseUrl;
[StringLength(256)]
[Required]
public string Auth { get; init; } = auth;
protected abstract void UpdateLibraryInternal();
internal abstract bool Test();
}

View File

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

View File

@ -0,0 +1,69 @@
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;
}
}

19
API/Schema/Link.cs Normal file
View File

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace API.Schema;
[PrimaryKey("LinkId")]
public class Link(string linkProvider, string linkUrl)
{
[StringLength(64)]
[Required]
public string LinkId { get; init; } = TokenGen.CreateToken(typeof(Link), linkProvider, linkUrl);
[StringLength(64)]
[Required]
public string LinkProvider { get; init; } = linkProvider;
[StringLength(2048)]
[Required]
[Url]
public string LinkUrl { get; init; } = linkUrl;
}

View File

@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace API.Schema;
public class LocalLibrary(string basePath, string libraryName)
{
[StringLength(64)]
[Required]
public string LocalLibraryId { get; init; } = TokenGen.CreateToken(typeof(LocalLibrary), basePath);
[StringLength(256)]
[Required]
public string BasePath { get; internal set; } = basePath;
[StringLength(512)]
[Required]
public string LibraryName { get; internal set; } = libraryName;
}

185
API/Schema/Manga.cs Normal file
View File

@ -0,0 +1,185 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using API.Schema.Jobs;
using API.Schema.MangaConnectors;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using static System.IO.UnixFileMode;
namespace API.Schema;
[PrimaryKey("MangaId")]
public class Manga
{
[StringLength(64)]
[Required]
public string MangaId { get; init; }
[StringLength(256)]
[Required]
public string IdOnConnectorSite { get; init; }
[StringLength(512)]
[Required]
public string Name { get; internal set; }
[Required]
public string Description { get; internal set; }
[Url]
[StringLength(512)]
[Required]
public string WebsiteUrl { get; internal set; }
[JsonIgnore]
[Url]
public string CoverUrl { get; internal set; }
[JsonIgnore]
public string? CoverFileNameInCache { get; internal set; }
[Required]
public uint Year { get; internal set; }
[StringLength(8)]
public string? OriginalLanguage { get; internal set; }
[Required]
public MangaReleaseStatus ReleaseStatus { get; internal set; }
[StringLength(1024)]
[Required]
public string DirectoryName { get; private set; }
public LocalLibrary? Library { get; internal set; }
[JsonIgnore]
[NotMapped]
public string LibraryPath => Library is null ? TrangaSettings.downloadLocation : Library.BasePath;
[JsonIgnore]
[NotMapped]
public string FullDirectoryPath => Path.Join(LibraryPath, DirectoryName);
[Required]
public float IgnoreChapterBefore { get; internal set; }
[StringLength(64)]
[Required]
public string MangaConnectorId { get; private set; }
[JsonIgnore] public MangaConnector? MangaConnector { get; private set; }
[JsonIgnore] public ICollection<Author>? Authors { get; internal set; }
[NotMapped]
[StringLength(64)]
[Required]
public IEnumerable<string> AuthorIds => Authors?.Select(a => a.AuthorId) ?? [];
[JsonIgnore] public ICollection<MangaTag>? MangaTags { get; internal set; }
[NotMapped]
[StringLength(64)]
[Required]
public IEnumerable<string> Tags => MangaTags?.Select(t => t.Tag) ?? [];
[JsonIgnore] public ICollection<Link>? Links { get; internal set; }
[NotMapped]
[StringLength(64)]
[Required]
public IEnumerable<string> LinkIds => Links?.Select(l => l.LinkId) ?? [];
[JsonIgnore] public ICollection<MangaAltTitle>? AltTitles { get; internal set; }
[NotMapped]
[StringLength(64)]
[Required]
public IEnumerable<string> AltTitleIds => AltTitles?.Select(a => a.AltTitleId) ?? [];
public Manga(string idOnConnectorSite, 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> mangaTags, ICollection<Link> links, ICollection<MangaAltTitle> altTitles,
LocalLibrary? library = null)
: this(idOnConnectorSite, name, description, websiteUrl, coverUrl, coverFileNameInCache, year, originalLanguage,
releaseStatus, ignoreChapterBefore, mangaConnector.Name)
{
this.Authors = authors;
this.MangaTags = mangaTags;
this.Links = links;
this.AltTitles = altTitles;
this.Library = library;
}
public Manga(string idOnConnectorSite, 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, idOnConnectorSite);
IdOnConnectorSite = idOnConnectorSite;
Name = name;
Description = description;
WebsiteUrl = websiteUrl;
CoverUrl = coverUrl;
CoverFileNameInCache = coverFileNameInCache;
Year = year;
OriginalLanguage = originalLanguage;
ReleaseStatus = releaseStatus;
IgnoreChapterBefore = ignoreChapterBefore;
MangaConnectorId = mangaConnectorId;
DirectoryName = BuildFolderName(name);
}
public MoveFileOrFolderJob UpdateFolderName(string downloadLocation, string newName)
{
string oldName = this.DirectoryName;
this.DirectoryName = newName;
return new MoveFileOrFolderJob(Path.Join(downloadLocation, oldName), Path.Join(downloadLocation, this.DirectoryName));
}
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.MangaTags = other.MangaTags;
this.AltTitles = other.AltTitles;
this.ReleaseStatus = other.ReleaseStatus;
}
private static string BuildFolderName(string mangaName)
{
return mangaName;
}
internal string? SaveCoverImageToCache(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(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, $"https://{match.Groups[1].Value}");
if (coverResult.statusCode is < HttpStatusCode.OK or >= HttpStatusCode.Ambiguous)
return SaveCoverImageToCache(--retries);
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(LibraryPath, this.DirectoryName);
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

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

View File

@ -1,58 +1,54 @@
using System.Net; using System.Text.RegularExpressions;
using System.Text.RegularExpressions; using API.MangaDownloadClients;
using HtmlAgilityPack; using HtmlAgilityPack;
using Tranga.Jobs; using log4net;
namespace Tranga.MangaConnectors; namespace API.Schema.MangaConnectors;
public class AsuraToon : MangaConnector public class AsuraToon : MangaConnector
{ {
public AsuraToon(GlobalBase clone) : base(clone, "AsuraToon", ["en"]) public AsuraToon() : base("AsuraToon", ["en"], ["asuracomic.net"], "https://asuracomic.net/images/logo.webp")
{ {
this.downloadClient = new ChromiumDownloadClient(clone); this.downloadClient = new ChromiumDownloadClient();
} }
public override Manga[] GetManga(string publicationTitle = "") public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{ {
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower(); string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower();
string requestUrl = $"https://asuracomic.net/series?name={sanitizedTitle}"; string requestUrl = $"https://asuracomic.net/series?name={sanitizedTitle}";
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Manga>(); return [];
if (requestResult.htmlDocument is null) if (requestResult.htmlDocument is null)
{ {
Log($"Failed to retrieve site"); return [];
return Array.Empty<Manga>();
} }
Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument); (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
return publications; return publications;
} }
public override Manga? GetMangaFromId(string publicationId) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{ {
return GetMangaFromUrl($"https://asuracomic.net/series/{publicationId}"); return GetMangaFromUrl($"https://asuracomic.net/series/{publicationId}");
} }
public override Manga? GetMangaFromUrl(string url) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{ {
RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo); RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null; return null;
if (requestResult.htmlDocument is null) if (requestResult.htmlDocument is null)
{ {
Log($"Failed to retrieve site");
return null; return null;
} }
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url); return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url);
} }
private Manga[] ParsePublicationsFromHtml(HtmlDocument document) private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
{ {
HtmlNodeCollection mangaList = document.DocumentNode.SelectNodes("//a[starts-with(@href,'series')]"); HtmlNodeCollection mangaList = document.DocumentNode.SelectNodes("//a[starts-with(@href,'series')]");
if (mangaList is null || mangaList.Count < 1) if (mangaList is null || mangaList.Count < 1)
@ -60,41 +56,41 @@ public class AsuraToon : MangaConnector
IEnumerable<string> urls = mangaList.Select(a => $"https://asuracomic.net/{a.GetAttributeValue("href", "")}"); IEnumerable<string> urls = mangaList.Select(a => $"https://asuracomic.net/{a.GetAttributeValue("href", "")}");
List<Manga> ret = new(); List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls) foreach (string url in urls)
{ {
Manga? manga = GetMangaFromUrl(url); (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is not null) if (manga is { } x)
ret.Add((Manga)manga); ret.Add(x);
} }
return ret.ToArray(); return ret.ToArray();
} }
private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
{ {
string? originalLanguage = null; string? originalLanguage = null;
Dictionary<string, string> altTitles = new(), links = new(); Dictionary<string, string> altTitles = new(), links = new();
HtmlNodeCollection genreNodes = document.DocumentNode.SelectNodes("//h3[text()='Genres']/../div/button"); HtmlNodeCollection genreNodes = document.DocumentNode.SelectNodes("//h3[text()='Genres']/../div/button");
string[] tags = genreNodes.Select(b => b.InnerText).ToArray(); 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]"); HtmlNode statusNode = document.DocumentNode.SelectSingleNode("//h3[text()='Status']/../h3[2]");
Manga.ReleaseStatusByte releaseStatus = statusNode.InnerText.ToLower() switch MangaReleaseStatus releaseStatus = statusNode.InnerText.ToLower() switch
{ {
"ongoing" => Manga.ReleaseStatusByte.Continuing, "ongoing" => MangaReleaseStatus.Continuing,
"hiatus" => Manga.ReleaseStatusByte.OnHiatus, "hiatus" => MangaReleaseStatus.OnHiatus,
"completed" => Manga.ReleaseStatusByte.Completed, "completed" => MangaReleaseStatus.Completed,
"dropped" => Manga.ReleaseStatusByte.Cancelled, "dropped" => MangaReleaseStatus.Cancelled,
"season end" => Manga.ReleaseStatusByte.Continuing, "season end" => MangaReleaseStatus.Continuing,
"coming soon" => Manga.ReleaseStatusByte.Unreleased, "coming soon" => MangaReleaseStatus.Unreleased,
_ => Manga.ReleaseStatusByte.Unreleased _ => MangaReleaseStatus.Unreleased
}; };
HtmlNode coverNode = HtmlNode coverNode =
document.DocumentNode.SelectSingleNode("//img[@alt='poster']"); document.DocumentNode.SelectSingleNode("//img[@alt='poster']");
string coverUrl = coverNode.GetAttributeValue("src", ""); string coverUrl = coverNode.GetAttributeValue("src", "");
string coverFileNameInCache = SaveCoverImageToCache(coverUrl, publicationId, RequestType.MangaCover);
HtmlNode titleNode = HtmlNode titleNode =
document.DocumentNode.SelectSingleNode("//title"); document.DocumentNode.SelectSingleNode("//title");
@ -108,30 +104,34 @@ public class AsuraToon : MangaConnector
HtmlNodeCollection artistNodes = document.DocumentNode.SelectNodes("//h3[text()='Artist']/../h3[not(text()='Artist' 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> authorNames = authorNodes is null ? [] : authorNodes.Select(a => a.InnerText);
IEnumerable<string> artistNames = artistNodes is null ? [] : artistNodes.Select(a => a.InnerText); IEnumerable<string> artistNames = artistNodes is null ? [] : artistNodes.Select(a => a.InnerText);
List<string> authors = authorNames.Concat(artistNames).ToList(); 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"); HtmlNode? firstChapterNode = document.DocumentNode.SelectSingleNode("//a[contains(@href, 'chapter/1')]/../following-sibling::h3");
int? year = int.Parse(firstChapterNode?.InnerText.Split(' ')[^1] ?? "2000"); uint year = uint.Parse(firstChapterNode?.InnerText.Split(' ')[^1] ?? "2000");
Manga manga = new (sortName, authors, description, altTitles, tags, coverUrl, coverFileNameInCache, links, Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
year, originalLanguage, publicationId, releaseStatus, websiteUrl); originalLanguage, releaseStatus, -1,
AddMangaToCache(manga); this,
return manga; authors,
mangaTags,
[],
[]);
return (manga, authors, mangaTags, [], []);
} }
public override Chapter[] GetChapters(Manga manga, string language="en") public override Chapter[] GetChapters(Manga manga, string language="en")
{ {
Log($"Getting chapters {manga}"); string requestUrl = $"https://asuracomic.net/series/{manga.MangaId}";
string requestUrl = $"https://asuracomic.net/series/{manga.publicationId}";
// Leaving this in for verification if the page exists // Leaving this in for verification if the page exists
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Chapter>(); return [];
//Return Chapters ordered by Chapter-Number //Return Chapters ordered by Chapter-Number
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestUrl); List<Chapter> chapters = ParseChaptersFromHtml(manga, requestUrl);
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.Order().ToArray(); return chapters.Order().ToArray();
} }
@ -140,7 +140,6 @@ public class AsuraToon : MangaConnector
RequestResult result = downloadClient.MakeRequest(mangaUrl, RequestType.Default); RequestResult result = downloadClient.MakeRequest(mangaUrl, RequestType.Default);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null) if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null)
{ {
Log("Failed to load site");
return new List<Chapter>(); return new List<Chapter>();
} }
@ -154,63 +153,38 @@ public class AsuraToon : MangaConnector
string chapterUrl = chapterInfo.GetAttributeValue("href", ""); string chapterUrl = chapterInfo.GetAttributeValue("href", "");
Match match = infoRex.Match(chapterInfo.InnerText); Match match = infoRex.Match(chapterInfo.InnerText);
string chapterNumber = match.Groups[1].Value; string chapterNumber = new(match.Groups[1].Value);
string? chapterName = match.Groups[2].Success && match.Groups[2].Length > 1 ? match.Groups[2].Value : null; string? chapterName = match.Groups[2].Success && match.Groups[2].Length > 1 ? match.Groups[2].Value : null;
string url = $"https://asuracomic.net/series/{chapterUrl}"; string url = $"https://asuracomic.net/series/{chapterUrl}";
try try
{ {
ret.Add(new Chapter(manga, chapterName, null, chapterNumber, url)); ret.Add(new Chapter(manga, url, chapterNumber, null, chapterName));
} }
catch (Exception e) catch (Exception e)
{ {
Log($"Failed to load chapter {chapterNumber}: {e.Message}");
} }
} }
return ret; return ret;
} }
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null) internal override string[] GetChapterImageUrls(Chapter chapter)
{ {
if (progressToken?.cancellationRequested ?? false) string requestUrl = chapter.Url;
{
progressToken.Cancel();
return HttpStatusCode.RequestTimeout;
}
Manga chapterParentManga = chapter.parentManga;
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
string requestUrl = chapter.url;
// Leaving this in to check if the page exists // Leaving this in to check if the page exists
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
{ {
progressToken?.Cancel(); return [];
return requestResult.statusCode; }
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
return imageUrls;
} }
string[] imageUrls = ParseImageUrlsFromHtml(requestUrl); private string[] ParseImageUrlsFromHtml(HtmlDocument document)
return DownloadChapterImages(imageUrls, chapter, RequestType.MangaImage, progressToken:progressToken);
}
private string[] ParseImageUrlsFromHtml(string mangaUrl)
{ {
RequestResult requestResult = HtmlNodeCollection images = document.DocumentNode.SelectNodes("//img[contains(@alt, 'chapter page')]");
downloadClient.MakeRequest(mangaUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
{
return Array.Empty<string>();
}
if (requestResult.htmlDocument is null)
{
Log($"Failed to retrieve site");
return Array.Empty<string>();
}
HtmlNodeCollection images =
requestResult.htmlDocument.DocumentNode.SelectNodes("//img[contains(@alt, 'chapter page')]");
return images.Select(i => i.GetAttributeValue("src", "")).ToArray(); return images.Select(i => i.GetAttributeValue("src", "")).ToArray();
} }

View File

@ -1,78 +1,74 @@
using System.Net; using System.Net;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using HtmlAgilityPack; using HtmlAgilityPack;
using Tranga.Jobs;
namespace Tranga.MangaConnectors; namespace API.Schema.MangaConnectors;
public class Bato : MangaConnector public class Bato : MangaConnector
{ {
public Bato(GlobalBase clone) : base(clone, "Bato", ["en"]) public Bato() : base("Bato", ["en"], ["bato.to"], "https://bato.to/amsta/img/batoto/favicon.ico")
{ {
this.downloadClient = new HttpDownloadClient(clone); this.downloadClient = new HttpDownloadClient();
} }
public override Manga[] GetManga(string publicationTitle = "") public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{ {
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower(); 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"; string requestUrl = $"https://bato.to/v3x-search?word={sanitizedTitle}&lang=en";
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Manga>(); return [];
if (requestResult.htmlDocument is null) if (requestResult.htmlDocument is null)
{ {
Log($"Failed to retrieve site"); return [];
return Array.Empty<Manga>();
} }
Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument); (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
return publications; return publications;
} }
public override Manga? GetMangaFromId(string publicationId) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{ {
return GetMangaFromUrl($"https://bato.to/title/{publicationId}"); return GetMangaFromUrl($"https://bato.to/title/{publicationId}");
} }
public override Manga? GetMangaFromUrl(string url) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{ {
RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo); RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null; return null;
if (requestResult.htmlDocument is null) if (requestResult.htmlDocument is null)
{ {
Log($"Failed to retrieve site");
return null; return null;
} }
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url); return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url);
} }
private Manga[] ParsePublicationsFromHtml(HtmlDocument document) private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
{ {
HtmlNode mangaList = document.DocumentNode.SelectSingleNode("//div[@data-hk='0-0-2']"); HtmlNode mangaList = document.DocumentNode.SelectSingleNode("//div[@data-hk='0-0-2']");
if (!mangaList.ChildNodes.Any(node => node.Name == "div")) if (!mangaList.ChildNodes.Any(node => node.Name == "div"))
return Array.Empty<Manga>(); return [];
List<string> urls = mangaList.ChildNodes List<string> urls = mangaList.ChildNodes
.Select(node => $"https://bato.to{node.Descendants("div").First().FirstChild.GetAttributeValue("href", "")}").ToList(); .Select(node => $"https://bato.to{node.Descendants("div").First().FirstChild.GetAttributeValue("href", "")}").ToList();
HashSet<Manga> ret = new(); HashSet<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls) foreach (string url in urls)
{ {
Manga? manga = GetMangaFromUrl(url); (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is not null) if (manga is { } x)
ret.Add((Manga)manga); ret.Add(x);
} }
return ret.ToArray(); return ret.ToArray();
} }
private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) 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]"); HtmlNode infoNode = document.DocumentNode.SelectSingleNode("/html/body/div/main/div[1]/div[2]");
@ -82,57 +78,61 @@ public class Bato : MangaConnector
string[] altTitlesList = infoNode.ChildNodes[1].ChildNodes[2].InnerText.Split('/'); string[] altTitlesList = infoNode.ChildNodes[1].ChildNodes[2].InnerText.Split('/');
int i = 0; int i = 0;
Dictionary<string, string> altTitles = altTitlesList.ToDictionary(s => i++.ToString(), s => s); List<MangaAltTitle> altTitles = altTitlesList.Select(a => new MangaAltTitle(i++.ToString(), a)).ToList();
string posterUrl = document.DocumentNode.SelectNodes("//img") string coverUrl = document.DocumentNode.SelectNodes("//img")
.First(child => child.GetAttributeValue("data-hk", "") == "0-1-0").GetAttributeValue("src", "").Replace("&amp;", "&"); .First(child => child.GetAttributeValue("data-hk", "") == "0-1-0").GetAttributeValue("src", "").Replace("&amp;", "&");
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, publicationId, RequestType.MangaCover);
List<HtmlNode> genreNodes = document.DocumentNode.SelectSingleNode("//b[text()='Genres:']/..").SelectNodes("span").ToList(); List<HtmlNode> genreNodes = document.DocumentNode.SelectSingleNode("//b[text()='Genres:']/..").SelectNodes("span").ToList();
string[] tags = genreNodes.Select(node => node.FirstChild.InnerText).ToArray(); 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<HtmlNode> authorsNodes = infoNode.ChildNodes[1].ChildNodes[3].Descendants("a").ToList();
List<string> authors = authorsNodes.Select(node => node.InnerText.Replace("amp;", "")).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']/.."); HtmlNode? originalLanguageNode = document.DocumentNode.SelectSingleNode("//span[text()='Tr From']/..");
string originalLanguage = originalLanguageNode is not null ? originalLanguageNode.LastChild.InnerText : ""; string originalLanguage = originalLanguageNode is not null ? originalLanguageNode.LastChild.InnerText : "";
if (!int.TryParse( if (!uint.TryParse(
document.DocumentNode.SelectSingleNode("//span[text()='Original Publication:']/..").LastChild.InnerText.Split('-')[0], document.DocumentNode.SelectSingleNode("//span[text()='Original Publication:']/..").LastChild.InnerText.Split('-')[0],
out int year)) out uint year))
year = DateTime.Now.Year; year = (uint)DateTime.UtcNow.Year;
string status = document.DocumentNode.SelectSingleNode("//span[text()='Original Publication:']/..") string status = document.DocumentNode.SelectSingleNode("//span[text()='Original Publication:']/..")
.ChildNodes[2].InnerText; .ChildNodes[2].InnerText;
Manga.ReleaseStatusByte releaseStatus = Manga.ReleaseStatusByte.Unreleased; MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
switch (status.ToLower()) switch (status.ToLower())
{ {
case "ongoing": releaseStatus = Manga.ReleaseStatusByte.Continuing; break; case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
case "completed": releaseStatus = Manga.ReleaseStatusByte.Completed; break; case "completed": releaseStatus = MangaReleaseStatus.Completed; break;
case "hiatus": releaseStatus = Manga.ReleaseStatusByte.OnHiatus; break; case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break;
case "cancelled": releaseStatus = Manga.ReleaseStatusByte.Cancelled; break; case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "pending": releaseStatus = Manga.ReleaseStatusByte.Unreleased; break; case "pending": releaseStatus = MangaReleaseStatus.Unreleased; break;
} }
Manga manga = new (sortName, authors, description, altTitles, tags, posterUrl, coverFileNameInCache, new Dictionary<string, string>(), Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
year, originalLanguage, publicationId, releaseStatus, websiteUrl: websiteUrl); originalLanguage, releaseStatus, -1,
AddMangaToCache(manga); this,
return manga; authors,
mangaTags,
[],
altTitles);
return (manga, authors, mangaTags, [], altTitles);
} }
public override Chapter[] GetChapters(Manga manga, string language="en") public override Chapter[] GetChapters(Manga manga, string language="en")
{ {
Log($"Getting chapters {manga}"); string requestUrl = $"https://bato.to/title/{manga.MangaId}";
string requestUrl = $"https://bato.to/title/{manga.publicationId}";
// Leaving this in for verification if the page exists // Leaving this in for verification if the page exists
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Chapter>(); return [];
//Return Chapters ordered by Chapter-Number //Return Chapters ordered by Chapter-Number
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestUrl); List<Chapter> chapters = ParseChaptersFromHtml(manga, requestUrl);
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.Order().ToArray(); return chapters.Order().ToArray();
} }
@ -141,7 +141,6 @@ public class Bato : MangaConnector
RequestResult result = downloadClient.MakeRequest(mangaUrl, RequestType.Default); RequestResult result = downloadClient.MakeRequest(mangaUrl, RequestType.Default);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null) if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null)
{ {
Log("Failed to load site");
return new List<Chapter>(); return new List<Chapter>();
} }
@ -159,64 +158,38 @@ public class Bato : MangaConnector
Match match = numberRex.Match(chapterUrl); Match match = numberRex.Match(chapterUrl);
string id = match.Groups[1].Value; string id = match.Groups[1].Value;
string? volumeNumber = match.Groups[2].Success ? match.Groups[2].Value : null; int? volumeNumber = match.Groups[2].Success ? int.Parse(match.Groups[2].Value) : null;
string chapterNumber = match.Groups[3].Value; string chapterNumber = new(match.Groups[3].Value);
string chapterName = chapterNumber;
string url = $"https://bato.to{chapterUrl}?load=2"; string url = $"https://bato.to{chapterUrl}?load=2";
try try
{ {
ret.Add(new Chapter(manga, chapterName, volumeNumber, chapterNumber, url)); ret.Add(new Chapter(manga, url, chapterNumber, volumeNumber, null));
} }
catch (Exception e) catch (Exception e)
{ {
Log($"Failed to load chapter {chapterNumber}: {e.Message}");
} }
} }
return ret; return ret;
} }
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null) internal override string[] GetChapterImageUrls(Chapter chapter)
{ {
if (progressToken?.cancellationRequested ?? false) string requestUrl = chapter.Url;
{
progressToken.Cancel();
return HttpStatusCode.RequestTimeout;
}
Manga chapterParentManga = chapter.parentManga;
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
string requestUrl = chapter.url;
// Leaving this in to check if the page exists // Leaving this in to check if the page exists
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
{ {
progressToken?.Cancel(); return [];
return requestResult.statusCode;
} }
string[] imageUrls = ParseImageUrlsFromHtml(requestUrl); string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
return imageUrls;
return DownloadChapterImages(imageUrls, chapter, RequestType.MangaImage, progressToken:progressToken);
} }
private string[] ParseImageUrlsFromHtml(string mangaUrl) private string[] ParseImageUrlsFromHtml(HtmlDocument document)
{ {
RequestResult requestResult =
downloadClient.MakeRequest(mangaUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
{
return Array.Empty<string>();
}
if (requestResult.htmlDocument is null)
{
Log($"Failed to retrieve site");
return Array.Empty<string>();
}
HtmlDocument document = requestResult.htmlDocument;
HtmlNode images = document.DocumentNode.SelectNodes("//astro-island").First(node => HtmlNode images = document.DocumentNode.SelectNodes("//astro-island").First(node =>
node.GetAttributeValue("component-url", "").Contains("/_astro/ImageList.")); node.GetAttributeValue("component-url", "").Contains("/_astro/ImageList."));

View File

@ -0,0 +1,55 @@
namespace API.Schema.MangaConnectors;
public class Global : MangaConnector
{
private PgsqlContext context { get; init; }
public Global(PgsqlContext context) : base("Global", ["all"], [""], "")
{
this.context = context;
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{
//Get all enabled Connectors
MangaConnector[] enabledConnectors = context.MangaConnectors.Where(c => c.Enabled && c.Name != "Global").ToArray();
//Create Task for each MangaConnector to search simulatneously
Task<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[]>[] tasks =
enabledConnectors.Select(c =>
new Task<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[]>(() => c.GetManga(publicationTitle))).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, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ret =
tasks.Select(t => t.IsCompletedSuccessfully ? t.Result : []).ToArray().SelectMany(i => i).ToArray();
return ret;
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{
MangaConnector? mc = context.MangaConnectors.ToArray().FirstOrDefault(c => c.ValidateUrl(url));
return mc?.GetMangaFromUrl(url) ?? null;
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{
return null;
}
public override Chapter[] GetChapters(Manga manga, string language = "en")
{
return manga.MangaConnector?.GetChapters(manga) ?? [];
}
internal override string[] GetChapterImageUrls(Chapter chapter)
{
return chapter.ParentManga?.MangaConnector?.GetChapterImageUrls(chapter) ?? [];
}
}

View File

@ -0,0 +1,57 @@
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.MangaConnectors;
[PrimaryKey("Name")]
public abstract class MangaConnector(string name, string[] supportedLanguages, string[] baseUris, string iconUrl)
{
[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, 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!;
[JsonIgnore]
[NotMapped]
protected ILog Log { get; init; } = LogManager.GetLogger(name);
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);
public bool ValidateUrl(string url) => BaseUris.Any(baseUri => Regex.IsMatch(url, "https?://" + baseUri + "/.*"));
}

View File

@ -1,27 +1,27 @@
using System.Net; using System.Net;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Tranga.Jobs; using API.MangaDownloadClients;
using JsonSerializer = System.Text.Json.JsonSerializer; using JsonSerializer = System.Text.Json.JsonSerializer;
namespace Tranga.MangaConnectors; namespace API.Schema.MangaConnectors;
public class MangaDex : MangaConnector public class MangaDex : MangaConnector
{ {
//https://api.mangadex.org/docs/3-enumerations/#language-codes--localization //https://api.mangadex.org/docs/3-enumerations/#language-codes--localization
//https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes //https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
//https://gist.github.com/Josantonius/b455e315bc7f790d14b136d61d9ae469 //https://gist.github.com/Josantonius/b455e315bc7f790d14b136d61d9ae469
public MangaDex(GlobalBase clone) : base(clone, "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"]) 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(clone); this.downloadClient = new HttpDownloadClient();
} }
public override Manga[] GetManga(string publicationTitle = "") public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{ {
Log($"Searching Publications. Term={publicationTitle}");
const int limit = 100; //How many values we want returned at once const int limit = 100; //How many values we want returned at once
int offset = 0; //"Page" int offset = 0; //"Page"
int total = int.MaxValue; //How many total results are there, is updated on first request int total = int.MaxValue; //How many total results are there, is updated on first request
HashSet<Manga> retManga = new(); HashSet<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> retManga = new();
int loadedPublicationData = 0; int loadedPublicationData = 0;
List<JsonNode> results = new(); List<JsonNode> results = new();
@ -53,15 +53,13 @@ public class MangaDex : MangaConnector
foreach (JsonNode mangaNode in results) foreach (JsonNode mangaNode in results)
{ {
Log($"Getting publication data. {++loadedPublicationData}/{total}");
if(MangaFromJsonObject(mangaNode.AsObject()) is { } manga) if(MangaFromJsonObject(mangaNode.AsObject()) is { } manga)
retManga.Add(manga); //Add Publication (Manga) to result retManga.Add(manga); //Add Publication (Manga) to result
} }
Log($"Retrieved {retManga.Count} publications. Term={publicationTitle}");
return retManga.ToArray(); return retManga.ToArray();
} }
public override Manga? GetMangaFromId(string publicationId) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{ {
RequestResult requestResult = 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); 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);
@ -73,15 +71,14 @@ public class MangaDex : MangaConnector
return null; return null;
} }
public override Manga? GetMangaFromUrl(string url) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{ {
Regex idRex = new (@"https:\/\/mangadex.org\/title\/([A-z0-9-]*)\/.*"); Regex idRex = new (@"https:\/\/mangadex.org\/title\/([A-z0-9-]*)\/.*");
string id = idRex.Match(url).Groups[1].Value; string id = idRex.Match(url).Groups[1].Value;
Log($"Got id {id} from {url}");
return GetMangaFromId(id); return GetMangaFromId(id);
} }
private Manga? MangaFromJsonObject(JsonObject manga) private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? MangaFromJsonObject(JsonObject manga)
{ {
if (!manga.TryGetPropertyValue("id", out JsonNode? idNode)) if (!manga.TryGetPropertyValue("id", out JsonNode? idNode))
return null; return null;
@ -93,7 +90,7 @@ public class MangaDex : MangaConnector
if (!attributes.TryGetPropertyValue("title", out JsonNode? titleNode)) if (!attributes.TryGetPropertyValue("title", out JsonNode? titleNode))
return null; return null;
string title = titleNode!.AsObject().ContainsKey("en") switch string sortName = titleNode!.AsObject().ContainsKey("en") switch
{ {
true => titleNode.AsObject()["en"]!.GetValue<string>(), true => titleNode.AsObject()["en"]!.GetValue<string>(),
false => titleNode.AsObject().First().Value!.GetValue<string>() false => titleNode.AsObject().First().Value!.GetValue<string>()
@ -108,6 +105,7 @@ public class MangaDex : MangaConnector
altTitlesDict.TryAdd(altTitleNodeObject.First().Key, altTitleNodeObject.First().Value!.GetValue<string>()); 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)) if (!attributes.TryGetPropertyValue("description", out JsonNode? descriptionNode))
return null; return null;
@ -121,6 +119,7 @@ public class MangaDex : MangaConnector
if (attributes.TryGetPropertyValue("links", out JsonNode? linksNode) && linksNode is not null) if (attributes.TryGetPropertyValue("links", out JsonNode? linksNode) && linksNode is not null)
foreach (KeyValuePair<string, JsonNode?> linkKv in linksNode!.AsObject()) foreach (KeyValuePair<string, JsonNode?> linkKv in linksNode!.AsObject())
linksDict.TryAdd(linkKv.Key, linkKv.Value.GetValue<string>()); linksDict.TryAdd(linkKv.Key, linkKv.Value.GetValue<string>());
List<Link> links = linksDict.Select(x => new Link(x.Key, x.Value)).ToList();
string? originalLanguage = string? originalLanguage =
attributes.TryGetPropertyValue("originalLanguage", out JsonNode? originalLanguageNode) switch attributes.TryGetPropertyValue("originalLanguage", out JsonNode? originalLanguageNode) switch
@ -129,30 +128,30 @@ public class MangaDex : MangaConnector
false => null false => null
}; };
Manga.ReleaseStatusByte status = Manga.ReleaseStatusByte.Unreleased; MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
if (attributes.TryGetPropertyValue("status", out JsonNode? statusNode)) if (attributes.TryGetPropertyValue("status", out JsonNode? statusNode))
{ {
status = statusNode?.GetValue<string>().ToLower() switch releaseStatus = statusNode?.GetValue<string>().ToLower() switch
{ {
"ongoing" => Manga.ReleaseStatusByte.Continuing, "ongoing" => MangaReleaseStatus.Continuing,
"completed" => Manga.ReleaseStatusByte.Completed, "completed" => MangaReleaseStatus.Completed,
"hiatus" => Manga.ReleaseStatusByte.OnHiatus, "hiatus" => MangaReleaseStatus.OnHiatus,
"cancelled" => Manga.ReleaseStatusByte.Cancelled, "cancelled" => MangaReleaseStatus.Cancelled,
_ => Manga.ReleaseStatusByte.Unreleased _ => MangaReleaseStatus.Unreleased
}; };
} }
int? year = attributes.TryGetPropertyValue("year", out JsonNode? yearNode) switch uint year = attributes.TryGetPropertyValue("year", out JsonNode? yearNode) switch
{ {
true => yearNode?.GetValue<int>(), true => yearNode?.GetValue<uint>()??0,
false => null false => 0
}; };
HashSet<string> tags = new(128); HashSet<string> tags = new(128);
if (attributes.TryGetPropertyValue("tags", out JsonNode? tagsNode)) if (attributes.TryGetPropertyValue("tags", out JsonNode? tagsNode))
foreach (JsonNode? tagNode in tagsNode!.AsArray()) foreach (JsonNode? tagNode in tagsNode!.AsArray())
tags.Add(tagNode!["attributes"]!["name"]!["en"]!.GetValue<string>()); 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)) if (!manga.TryGetPropertyValue("relationships", out JsonNode? relationshipsNode))
return null; return null;
@ -163,40 +162,31 @@ public class MangaDex : MangaConnector
return null; return null;
string fileName = coverNode["attributes"]!["fileName"]!.GetValue<string>(); string fileName = coverNode["attributes"]!["fileName"]!.GetValue<string>();
string coverUrl = $"https://uploads.mangadex.org/covers/{publicationId}/{fileName}"; string coverUrl = $"https://uploads.mangadex.org/covers/{publicationId}/{fileName}";
string coverCacheName = SaveCoverImageToCache(coverUrl, publicationId, RequestType.MangaCover);
List<string> authors = new(); List<string> authorNames = new();
JsonNode?[] authorNodes = relationshipsNode.AsArray() JsonNode?[] authorNodes = relationshipsNode.AsArray()
.Where(rel => rel!["type"]!.GetValue<string>().Equals("author") || rel!["type"]!.GetValue<string>().Equals("artist")).ToArray(); .Where(rel => rel!["type"]!.GetValue<string>().Equals("author") || rel!["type"]!.GetValue<string>().Equals("artist")).ToArray();
foreach (JsonNode? authorNode in authorNodes) foreach (JsonNode? authorNode in authorNodes)
{ {
string authorName = authorNode!["attributes"]!["name"]!.GetValue<string>(); string authorName = authorNode!["attributes"]!["name"]!.GetValue<string>();
if(!authors.Contains(authorName)) if(!authorNames.Contains(authorName))
authors.Add(authorName); authorNames.Add(authorName);
} }
List<Author> authors = authorNames.Select(a => new Author(a)).ToList();
Manga pub = new( Manga pub = new (publicationId, sortName, description, $"https://mangadex.org/title/{publicationId}", coverUrl, null, year,
title, originalLanguage, releaseStatus, -1,
this,
authors, authors,
description, mangaTags,
altTitlesDict, links,
tags.ToArray(), altTitles);
coverUrl,
coverCacheName, return (pub, authors, mangaTags, links, altTitles);
linksDict,
year,
originalLanguage,
publicationId,
status,
websiteUrl: $"https://mangadex.org/title/{publicationId}"
);
AddMangaToCache(pub);
return pub;
} }
public override Chapter[] GetChapters(Manga manga, string language="en") public override Chapter[] GetChapters(Manga manga, string language="en")
{ {
Log($"Getting chapters {manga}");
const int limit = 100; //How many values we want returned at once const int limit = 100; //How many values we want returned at once
int offset = 0; //"Page" int offset = 0; //"Page"
int total = int.MaxValue; //How many total results are there, is updated on first request int total = int.MaxValue; //How many total results are there, is updated on first request
@ -207,7 +197,7 @@ public class MangaDex : MangaConnector
//Request next "Page" //Request next "Page"
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest( downloadClient.MakeRequest(
$"https://api.mangadex.org/manga/{manga.publicationId}/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); $"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&contentRating%5B%5D=pornographic", RequestType.MangaDexFeed);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
break; break;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result); JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
@ -225,80 +215,68 @@ public class MangaDex : MangaConnector
JsonObject attributes = chapter["attributes"]!.AsObject(); JsonObject attributes = chapter["attributes"]!.AsObject();
string chapterId = chapter["id"]!.GetValue<string>(); string chapterId = chapter["id"]!.GetValue<string>();
string url = $"https://mangadex.org/chapter/{chapterId}";
string? title = attributes.ContainsKey("title") && attributes["title"] is not null string? title = attributes.ContainsKey("title") && attributes["title"] is not null
? attributes["title"]!.GetValue<string>() ? attributes["title"]!.GetValue<string>()
: null; : null;
string? volume = attributes.ContainsKey("volume") && attributes["volume"] is not null int? volume = attributes.ContainsKey("volume") && attributes["volume"] is not null
? attributes["volume"]!.GetValue<string>() ? int.Parse(attributes["volume"]!.GetValue<string>())
: null; : null;
string chapterNum = attributes.ContainsKey("chapter") && attributes["chapter"] is not null string? chapterNumStr = attributes.ContainsKey("chapter") && attributes["chapter"] is not null
? attributes["chapter"]!.GetValue<string>() ? attributes["chapter"]!.GetValue<string>()
: "null"; : null;
string chapterNumber = new(chapterNumStr);
if (attributes.ContainsKey("pages") && attributes["pages"] is not null && if (attributes.ContainsKey("pages") && attributes["pages"] is not null &&
attributes["pages"]!.GetValue<int>() < 1) attributes["pages"]!.GetValue<int>() < 1)
{ {
Log($"Skipping {chapterId} Vol.{volume} Ch.{chapterNum} {title} because it has no pages or is externally linked.");
continue; continue;
} }
try try
{ {
if(!chapters.Any(chp => Chapter newChapter = new Chapter(manga, url, chapterNumber, volume, title);
chp.volumeNumber.Equals(float.Parse(volume??"0", numberFormatDecimalPoint)) && if(!chapters.Contains(newChapter))
chp.chapterNumber.Equals(float.Parse(chapterNum, numberFormatDecimalPoint)))) chapters.Add(newChapter);
chapters.Add(new Chapter(manga, title, volume, chapterNum, chapterId, chapterId));
} }
catch (Exception e) catch (Exception e)
{ {
Log($"Failed to load chapter {chapterNum}: {e.Message}");
} }
} }
} }
//Return Chapters ordered by Chapter-Number //Return Chapters ordered by Chapter-Number
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.Order().ToArray(); return chapters.Order().ToArray();
} }
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null) internal override string[] GetChapterImageUrls(Chapter chapter)
{ {//Request URLs for Chapter-Images
if (progressToken?.cancellationRequested ?? false) Match m = Regex.Match(chapter.Url, @"https?:\/\/mangadex.org\/chapter\/([0-9\-a-z]+)");
{ if (!m.Success)
progressToken.Cancel(); return [];
return HttpStatusCode.RequestTimeout;
}
Manga chapterParentManga = chapter.parentManga;
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
//Request URLs for Chapter-Images
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest($"https://api.mangadex.org/at-home/server/{chapter.url}?forcePort443=false", RequestType.MangaDexImage); downloadClient.MakeRequest($"https://api.mangadex.org/at-home/server/{m.Groups[1].Value}?forcePort443=false", RequestType.MangaDexImage);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
{ {
progressToken?.Cancel(); return [];
return requestResult.statusCode;
} }
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result); JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
if (result is null) if (result is null)
{ {
progressToken?.Cancel(); return [];
return HttpStatusCode.NoContent;
} }
string baseUrl = result["baseUrl"]!.GetValue<string>(); string baseUrl = result["baseUrl"]!.GetValue<string>();
string hash = result["chapter"]!["hash"]!.GetValue<string>(); string hash = result["chapter"]!["hash"]!.GetValue<string>();
JsonArray imageFileNames = result["chapter"]!["data"]!.AsArray(); JsonArray imageFileNames = result["chapter"]!["data"]!.AsArray();
//Loop through all imageNames and construct urls (imageUrl) //Loop through all imageNames and construct urls (imageUrl)
HashSet<string> imageUrls = new(); List<string> imageUrls = new();
foreach (JsonNode? image in imageFileNames) foreach (JsonNode? image in imageFileNames)
imageUrls.Add($"{baseUrl}/data/{hash}/{image!.GetValue<string>()}"); imageUrls.Add($"{baseUrl}/data/{hash}/{image!.GetValue<string>()}");
return imageUrls.ToArray();
//Download Chapter-Images
return DownloadChapterImages(imageUrls.ToArray(), chapter, RequestType.MangaImage, progressToken:progressToken);
} }
} }

View File

@ -1,58 +1,55 @@
using System.Net; using System.Text.RegularExpressions;
using System.Text.RegularExpressions; using API.MangaDownloadClients;
using HtmlAgilityPack; using HtmlAgilityPack;
using Tranga.Jobs;
namespace Tranga.MangaConnectors; namespace API.Schema.MangaConnectors;
public class MangaHere : MangaConnector public class MangaHere : MangaConnector
{ {
public MangaHere(GlobalBase clone) : base(clone, "MangaHere", ["en"]) public MangaHere() : base("MangaHere", ["en"], ["www.mangahere.cc"], "http://www.mangahere.cc/favicon.ico")
{ {
this.downloadClient = new ChromiumDownloadClient(clone); this.downloadClient = new ChromiumDownloadClient();
} }
public override Manga[] GetManga(string publicationTitle = "") public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{ {
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string sanitizedTitle = string.Join('+', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower(); string sanitizedTitle = string.Join('+', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower();
string requestUrl = $"https://www.mangahere.cc/search?title={sanitizedTitle}"; string requestUrl = $"https://www.mangahere.cc/search?title={sanitizedTitle}";
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
return Array.Empty<Manga>(); return [];
Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument); (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
return publications; return publications;
} }
private Manga[] ParsePublicationsFromHtml(HtmlDocument document) 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")))) if (document.DocumentNode.SelectNodes("//div[contains(concat(' ',normalize-space(@class),' '),' container ')]").Any(node => node.ChildNodes.Any(cNode => cNode.HasClass("search-keywords"))))
return Array.Empty<Manga>(); return [];
List<string> urls = document.DocumentNode List<string> urls = document.DocumentNode
.SelectNodes("//a[contains(@href, '/manga/') and not(contains(@href, '.html'))]") .SelectNodes("//a[contains(@href, '/manga/') and not(contains(@href, '.html'))]")
.Select(thumb => $"https://www.mangahere.cc{thumb.GetAttributeValue("href", "")}").Distinct().ToList(); .Select(thumb => $"https://www.mangahere.cc{thumb.GetAttributeValue("href", "")}").Distinct().ToList();
HashSet<Manga> ret = new(); HashSet<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls) foreach (string url in urls)
{ {
Manga? manga = GetMangaFromUrl(url); (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is not null) if (manga is { } x)
ret.Add((Manga)manga); ret.Add(x);
} }
return ret.ToArray(); return ret.ToArray();
} }
public override Manga? GetMangaFromId(string publicationId) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{ {
return GetMangaFromUrl($"https://www.mangahere.cc/manga/{publicationId}"); return GetMangaFromUrl($"https://www.mangahere.cc/manga/{publicationId}");
} }
public override Manga? GetMangaFromUrl(string url) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{ {
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(url, RequestType.MangaInfo); downloadClient.MakeRequest(url, RequestType.MangaInfo);
@ -64,54 +61,58 @@ public class MangaHere : MangaConnector
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, id, url); return ParseSinglePublicationFromHtml(requestResult.htmlDocument, id, url);
} }
private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
{ {
string originalLanguage = "", status = ""; string originalLanguage = "", status = "";
Dictionary<string, string> altTitles = new(), links = new(); Dictionary<string, string> altTitles = new(), links = new();
Manga.ReleaseStatusByte releaseStatus = Manga.ReleaseStatusByte.Unreleased; 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 ')]"); //We dont get posters, because same origin bs HtmlNode posterNode = document.DocumentNode.SelectSingleNode("//img[contains(concat(' ',normalize-space(@class),' '),' detail-info-cover-img ')]");
string posterUrl = "http://static.mangahere.cc/v20230914/mangahere/images/nopicture.jpg"; string coverUrl = "http://static.mangahere.cc/v20230914/mangahere/images/nopicture.jpg";
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, publicationId, RequestType.MangaCover);
HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//span[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-title-font ')]"); HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//span[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-title-font ')]");
string sortName = titleNode.InnerText; string sortName = titleNode.InnerText;
List<string> authors = document.DocumentNode List<string> authorNames = document.DocumentNode
.SelectNodes("//p[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-say ')]/a") .SelectNodes("//p[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-say ')]/a")
.Select(node => node.InnerText) .Select(node => node.InnerText)
.ToList(); .ToList();
List<Author> authors = authorNames.Select(n => new Author(n)).ToList();
HashSet<string> tags = document.DocumentNode HashSet<string> tags = document.DocumentNode
.SelectNodes("//p[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-tag-list ')]/a") .SelectNodes("//p[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-tag-list ')]/a")
.Select(node => node.InnerText) .Select(node => node.InnerText)
.ToHashSet(); .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; status = document.DocumentNode.SelectSingleNode("//span[contains(concat(' ',normalize-space(@class),' '),' detail-info-right-title-tip ')]").InnerText;
switch (status.ToLower()) switch (status.ToLower())
{ {
case "cancelled": releaseStatus = Manga.ReleaseStatusByte.Cancelled; break; case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "hiatus": releaseStatus = Manga.ReleaseStatusByte.OnHiatus; break; case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break;
case "discontinued": releaseStatus = Manga.ReleaseStatusByte.Cancelled; break; case "discontinued": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "complete": releaseStatus = Manga.ReleaseStatusByte.Completed; break; case "complete": releaseStatus = MangaReleaseStatus.Completed; break;
case "ongoing": releaseStatus = Manga.ReleaseStatusByte.Continuing; break; case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
} }
HtmlNode descriptionNode = document.DocumentNode HtmlNode descriptionNode = document.DocumentNode
.SelectSingleNode("//p[contains(concat(' ',normalize-space(@class),' '),' fullcontent ')]"); .SelectSingleNode("//p[contains(concat(' ',normalize-space(@class),' '),' fullcontent ')]");
string description = descriptionNode.InnerText; string description = descriptionNode.InnerText;
Manga manga = new(sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, 0,
coverFileNameInCache, links, originalLanguage, releaseStatus, -1,
null, originalLanguage, publicationId, releaseStatus, websiteUrl: websiteUrl); this,
AddMangaToCache(manga); authors,
return manga; mangaTags,
[],
[]);
return (manga, authors, mangaTags, [], []);
} }
public override Chapter[] GetChapters(Manga manga, string language="en") public override Chapter[] GetChapters(Manga manga, string language="en")
{ {
Log($"Getting chapters {manga}"); string requestUrl = $"https://www.mangahere.cc/manga/{manga.MangaId}";
string requestUrl = $"https://www.mangahere.cc/manga/{manga.publicationId}";
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
@ -126,54 +127,36 @@ public class MangaHere : MangaConnector
{ {
Match rexMatch = chapterRex.Match(url); Match rexMatch = chapterRex.Match(url);
string volumeNumber = rexMatch.Groups[1].Value == "TBD" ? "0" : rexMatch.Groups[1].Value; int? volumeNumber = rexMatch.Groups[1].Value == "TBD" ? null : int.Parse(rexMatch.Groups[1].Value);
string chapterNumber = rexMatch.Groups[2].Value; string chapterNumber = new(rexMatch.Groups[2].Value);
string fullUrl = $"https://www.mangahere.cc{url}"; string fullUrl = $"https://www.mangahere.cc{url}";
try try
{ {
chapters.Add(new Chapter(manga, "", volumeNumber, chapterNumber, fullUrl)); chapters.Add(new Chapter(manga, fullUrl, chapterNumber, volumeNumber, null));
} }
catch (Exception e) catch (Exception e)
{ {
Log($"Failed to load chapter {chapterNumber}: {e.Message}");
} }
} }
//Return Chapters ordered by Chapter-Number //Return Chapters ordered by Chapter-Number
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.Order().ToArray(); return chapters.Order().ToArray();
} }
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null) internal override string[] GetChapterImageUrls(Chapter chapter)
{ {
if (progressToken?.cancellationRequested ?? false)
{
progressToken.Cancel();
return HttpStatusCode.RequestTimeout;
}
Manga chapterParentManga = chapter.parentManga;
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
List<string> imageUrls = new(); List<string> imageUrls = new();
int downloaded = 1; int downloaded = 1;
int images = 1; int images = 1;
string url = string.Join('/', chapter.url.Split('/')[..^1]); string url = string.Join('/', chapter.Url.Split('/')[..^1]);
do do
{ {
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest($"{url}/{downloaded}.html", RequestType.Default); downloadClient.MakeRequest($"{url}/{downloaded}.html", RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
{ {
progressToken?.Cancel(); return [];
return requestResult.statusCode;
}
if (requestResult.htmlDocument is null)
{
progressToken?.Cancel();
return HttpStatusCode.InternalServerError;
} }
imageUrls.AddRange(ParseImageUrlsFromHtml(requestResult.htmlDocument)); imageUrls.AddRange(ParseImageUrlsFromHtml(requestResult.htmlDocument));
@ -181,17 +164,9 @@ public class MangaHere : MangaConnector
images = requestResult.htmlDocument.DocumentNode images = requestResult.htmlDocument.DocumentNode
.SelectNodes("//a[contains(@href, '/manga/')]") .SelectNodes("//a[contains(@href, '/manga/')]")
.MaxBy(node => node.GetAttributeValue("data-page", 0))!.GetAttributeValue("data-page", 0); .MaxBy(node => node.GetAttributeValue("data-page", 0))!.GetAttributeValue("data-page", 0);
logger?.WriteLine($"MangaHere speciality: Get Image-url {downloaded}/{images}");
if (progressToken is not null)
{
progressToken.increments = images * 2;//we also have to download the images later
progressToken.Increment();
}
} while (downloaded++ <= images); } while (downloaded++ <= images);
if (progressToken is not null) return imageUrls.ToArray();
progressToken.increments = images;//we blip to normal length, in downloadchapterimages it is increasaed by the amount of urls again
return DownloadChapterImages(imageUrls.ToArray(), chapter, RequestType.MangaImage, progressToken:progressToken);
} }
private string[] ParseImageUrlsFromHtml(HtmlDocument document) private string[] ParseImageUrlsFromHtml(HtmlDocument document)

View File

@ -1,26 +1,24 @@
using System.Net; using System.Text.RegularExpressions;
using System.Text.RegularExpressions; using API.MangaDownloadClients;
using HtmlAgilityPack; using HtmlAgilityPack;
using Tranga.Jobs;
namespace Tranga.MangaConnectors; namespace API.Schema.MangaConnectors;
public class MangaKatana : MangaConnector public class MangaKatana : MangaConnector
{ {
public MangaKatana(GlobalBase clone) : base(clone, "MangaKatana", ["en"]) public MangaKatana() : base("MangaKatana", ["en"], ["mangakatana.com"], "https://mangakatana.com/static/img/fav.png")
{ {
this.downloadClient = new HttpDownloadClient(clone); this.downloadClient = new HttpDownloadClient();
} }
public override Manga[] GetManga(string publicationTitle = "") public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{ {
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string sanitizedTitle = string.Join("%20", Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower(); 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"; string requestUrl = $"https://mangakatana.com/?search={sanitizedTitle}&search_by=book_name";
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Manga>(); return [];
// ReSharper disable once MergeIntoPattern // 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 a single result is found, the user will be redirected to the results directly instead of a result page
@ -31,17 +29,16 @@ public class MangaKatana : MangaConnector
return new [] { ParseSinglePublicationFromHtml(requestResult.result, requestResult.redirectedToUrl.Split('/')[^1], requestResult.redirectedToUrl) }; return new [] { ParseSinglePublicationFromHtml(requestResult.result, requestResult.redirectedToUrl.Split('/')[^1], requestResult.redirectedToUrl) };
} }
Manga[] publications = ParsePublicationsFromHtml(requestResult.result); (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.result);
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
return publications; return publications;
} }
public override Manga? GetMangaFromId(string publicationId) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{ {
return GetMangaFromUrl($"https://mangakatana.com/manga/{publicationId}"); return GetMangaFromUrl($"https://mangakatana.com/manga/{publicationId}");
} }
public override Manga? GetMangaFromUrl(string url) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{ {
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(url, RequestType.MangaInfo); downloadClient.MakeRequest(url, RequestType.MangaInfo);
@ -50,7 +47,7 @@ public class MangaKatana : MangaConnector
return ParseSinglePublicationFromHtml(requestResult.result, url.Split('/')[^1], url); return ParseSinglePublicationFromHtml(requestResult.result, url.Split('/')[^1], url);
} }
private Manga[] ParsePublicationsFromHtml(Stream html) private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(Stream html)
{ {
StreamReader reader = new(html); StreamReader reader = new(html);
string htmlString = reader.ReadToEnd(); string htmlString = reader.ReadToEnd();
@ -58,7 +55,7 @@ public class MangaKatana : MangaConnector
document.LoadHtml(htmlString); document.LoadHtml(htmlString);
IEnumerable<HtmlNode> searchResults = document.DocumentNode.SelectNodes("//*[@id='book_list']/div"); IEnumerable<HtmlNode> searchResults = document.DocumentNode.SelectNodes("//*[@id='book_list']/div");
if (searchResults is null || !searchResults.Any()) if (searchResults is null || !searchResults.Any())
return Array.Empty<Manga>(); return [];
List<string> urls = new(); List<string> urls = new();
foreach (HtmlNode mangaResult in searchResults) foreach (HtmlNode mangaResult in searchResults)
{ {
@ -66,29 +63,29 @@ public class MangaKatana : MangaConnector
.First(a => a.Name == "href").Value); .First(a => a.Name == "href").Value);
} }
HashSet<Manga> ret = new(); HashSet<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls) foreach (string url in urls)
{ {
Manga? manga = GetMangaFromUrl(url); (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is not null) if (manga is { } x)
ret.Add((Manga)manga); ret.Add(x);
} }
return ret.ToArray(); return ret.ToArray();
} }
private Manga ParseSinglePublicationFromHtml(Stream html, string publicationId, string websiteUrl) private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(Stream html, string publicationId, string websiteUrl)
{ {
StreamReader reader = new(html); StreamReader reader = new(html);
string htmlString = reader.ReadToEnd(); string htmlString = reader.ReadToEnd();
HtmlDocument document = new(); HtmlDocument document = new();
document.LoadHtml(htmlString); document.LoadHtml(htmlString);
Dictionary<string, string> altTitles = new(); Dictionary<string, string> altTitlesDict = new();
Dictionary<string, string>? links = null; Dictionary<string, string>? links = null;
HashSet<string> tags = new(); HashSet<string> tags = new();
string[] authors = Array.Empty<string>(); string[] authorNames = [];
string originalLanguage = ""; string originalLanguage = "";
Manga.ReleaseStatusByte releaseStatus = Manga.ReleaseStatusByte.Unreleased; MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
HtmlNode infoNode = document.DocumentNode.SelectSingleNode("//*[@id='single_book']"); HtmlNode infoNode = document.DocumentNode.SelectSingleNode("//*[@id='single_book']");
string sortName = infoNode.Descendants("h1").First(n => n.HasClass("heading")).InnerText; string sortName = infoNode.Descendants("h1").First(n => n.HasClass("heading")).InnerText;
@ -105,16 +102,16 @@ public class MangaKatana : MangaConnector
case "altnames": case "altnames":
string[] alts = value.Split(" ; "); string[] alts = value.Split(" ; ");
for (int i = 0; i < alts.Length; i++) for (int i = 0; i < alts.Length; i++)
altTitles.Add(i.ToString(), alts[i]); altTitlesDict.Add(i.ToString(), alts[i]);
break; break;
case "authorsartists": case "authorsartists":
authors = value.Split(','); authorNames = value.Split(',');
break; break;
case "status": case "status":
switch (value.ToLower()) switch (value.ToLower())
{ {
case "ongoing": releaseStatus = Manga.ReleaseStatusByte.Continuing; break; case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
case "completed": releaseStatus = Manga.ReleaseStatusByte.Completed; break; case "completed": releaseStatus = MangaReleaseStatus.Completed; break;
} }
break; break;
case "genres": case "genres":
@ -123,34 +120,39 @@ public class MangaKatana : MangaConnector
} }
} }
string posterUrl = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[1]/div").Descendants("img").First() string coverUrl = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[1]/div").Descendants("img").First()
.GetAttributes().First(a => a.Name == "src").Value; .GetAttributes().First(a => a.Name == "src").Value;
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, publicationId, RequestType.MangaCover);
string description = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[3]/p").InnerText; string description = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[3]/p").InnerText;
while (description.StartsWith('\n')) while (description.StartsWith('\n'))
description = description.Substring(1); description = description.Substring(1);
int year = DateTime.Now.Year; uint year = (uint)DateTime.UtcNow.Year;
string yearString = infoTable.Descendants("div").First(d => d.HasClass("updateAt")) string yearString = infoTable.Descendants("div").First(d => d.HasClass("updateAt"))
.InnerText.Split('-')[^1]; .InnerText.Split('-')[^1];
if(yearString.Contains("ago") == false) if(yearString.Contains("ago") == false)
{ {
year = Convert.ToInt32(yearString); 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 (sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links, Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
year, originalLanguage, publicationId, releaseStatus, websiteUrl: websiteUrl); originalLanguage, releaseStatus, -1,
AddMangaToCache(manga); this,
return manga; authors,
mangaTags,
[],
altTitles);
return (manga, authors, mangaTags, [], altTitles);
} }
public override Chapter[] GetChapters(Manga manga, string language="en") public override Chapter[] GetChapters(Manga manga, string language="en")
{ {
Log($"Getting chapters {manga}"); string requestUrl = $"https://mangakatana.com/manga/{manga.MangaId}";
string requestUrl = $"https://mangakatana.com/manga/{manga.publicationId}";
// Leaving this in for verification if the page exists // Leaving this in for verification if the page exists
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
@ -159,7 +161,6 @@ public class MangaKatana : MangaConnector
//Return Chapters ordered by Chapter-Number //Return Chapters ordered by Chapter-Number
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestUrl); List<Chapter> chapters = ParseChaptersFromHtml(manga, requestUrl);
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.Order().ToArray(); return chapters.Order().ToArray();
} }
@ -183,52 +184,39 @@ public class MangaKatana : MangaConnector
string url = chapterInfo.Descendants("a").First() string url = chapterInfo.Descendants("a").First()
.GetAttributeValue("href", ""); .GetAttributeValue("href", "");
string? volumeNumber = volumeRex.IsMatch(url) ? volumeRex.Match(url).Groups[1].Value : null; int? volumeNumber = volumeRex.IsMatch(url) ? int.Parse(volumeRex.Match(url).Groups[1].Value) : null;
string chapterNumber = chapterNumRex.Match(url).Groups[1].Value;
string chapterNumber = new(chapterNumRex.Match(url).Groups[1].Value);
string chapterName = chapterNameRex.Match(fullString).Groups[1].Value; string chapterName = chapterNameRex.Match(fullString).Groups[1].Value;
try try
{ {
ret.Add(new Chapter(manga, chapterName, volumeNumber, chapterNumber, url)); ret.Add(new Chapter(manga, url, chapterNumber, volumeNumber, chapterName));
} }
catch (Exception e) catch (Exception e)
{ {
Log($"Failed to load chapter {chapterNumber}: {e.Message}");
} }
} }
return ret; return ret;
} }
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null) internal override string[] GetChapterImageUrls(Chapter chapter)
{ {
if (progressToken?.cancellationRequested ?? false) string requestUrl = chapter.Url;
{
progressToken.Cancel();
return HttpStatusCode.RequestTimeout;
}
Manga chapterParentManga = chapter.parentManga;
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
string requestUrl = chapter.url;
// Leaving this in to check if the page exists // Leaving this in to check if the page exists
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
{ {
progressToken?.Cancel(); return [];
return requestResult.statusCode;
} }
string[] imageUrls = ParseImageUrlsFromHtml(requestUrl); string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
return imageUrls;
return DownloadChapterImages(imageUrls, chapter, RequestType.MangaImage, progressToken:progressToken);
} }
private string[] ParseImageUrlsFromHtml(string mangaUrl) private string[] ParseImageUrlsFromHtml(HtmlDocument document)
{ {
HtmlWeb web = new();
HtmlDocument document = web.Load(mangaUrl);
// Images are loaded dynamically, but the urls are present in a piece of js code on the page // 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 string js = document.DocumentNode.SelectSingleNode("//script[contains(., 'data-src')]").InnerText
.Replace("\r", "") .Replace("\r", "")

View File

@ -1,39 +1,43 @@
using System.Globalization; using System.Globalization;
using System.Net; using System.Net;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using HtmlAgilityPack; using HtmlAgilityPack;
using Tranga.Jobs;
namespace Tranga.MangaConnectors; namespace API.Schema.MangaConnectors;
public class Manganato : MangaConnector public class Manganato : MangaConnector
{ {
public Manganato(GlobalBase clone) : base(clone, "Manganato", ["en"]) public Manganato() : base("Manganato", ["en"],
["natomanga.com", "manganato.gg", "mangakakalot.gg", "nelomanga.com"],
"https://www.manganato.gg/images/favicon-manganato.webp")
{ {
this.downloadClient = new HttpDownloadClient(clone); this.downloadClient = new HttpDownloadClient();
} }
public override Manga[] GetManga(string publicationTitle = "") public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(
string publicationTitle = "")
{ {
Log($"Searching Publications. Term=\"{publicationTitle}\""); string sanitizedTitle = string.Join('_', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0))
string sanitizedTitle = string.Join('_', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower(); .ToLower();
string requestUrl = $"https://manganato.gg/search/story/{sanitizedTitle}"; string requestUrl = $"https://manganato.gg/search/story/{sanitizedTitle}";
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Manga>(); return [];
if (requestResult.htmlDocument is null) if (requestResult.htmlDocument is null)
return Array.Empty<Manga>(); return [];
Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument); (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications =
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\""); ParsePublicationsFromHtml(requestResult.htmlDocument);
return publications; return publications;
} }
private Manga[] ParsePublicationsFromHtml(HtmlDocument document) private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(
HtmlDocument document)
{ {
List<HtmlNode> searchResults = document.DocumentNode.Descendants("div").Where(n => n.HasClass("story_item")).ToList(); List<HtmlNode> searchResults =
Log($"{searchResults.Count} items."); document.DocumentNode.Descendants("div").Where(n => n.HasClass("story_item")).ToList();
List<string> urls = new(); List<string> urls = new();
foreach (HtmlNode mangaResult in searchResults) foreach (HtmlNode mangaResult in searchResults)
{ {
@ -41,29 +45,32 @@ public class Manganato : MangaConnector
{ {
urls.Add(mangaResult.Descendants("h3").First(n => n.HasClass("story_name")) urls.Add(mangaResult.Descendants("h3").First(n => n.HasClass("story_name"))
.Descendants("a").First().GetAttributeValue("href", "")); .Descendants("a").First().GetAttributeValue("href", ""));
} catch }
catch
{ {
//failed to get a url, send it to the void //failed to get a url, send it to the void
} }
} }
HashSet<Manga> ret = new(); List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls) foreach (string url in urls)
{ {
Manga? manga = GetMangaFromUrl(url); (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is not null) if (manga is { } m)
ret.Add((Manga)manga); ret.Add(m);
} }
return ret.ToArray(); return ret.ToArray();
} }
public override Manga? GetMangaFromId(string publicationId) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(
string publicationId)
{ {
return GetMangaFromUrl($"https://chapmanganato.com/{publicationId}"); return GetMangaFromUrl($"https://chapmanganato.com/{publicationId}");
} }
public override Manga? GetMangaFromUrl(string url) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)?
GetMangaFromUrl(string url)
{ {
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(url, RequestType.MangaInfo); downloadClient.MakeRequest(url, RequestType.MangaInfo);
@ -75,14 +82,13 @@ public class Manganato : MangaConnector
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url); return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1], url);
} }
private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(
HtmlDocument document, string publicationId, string websiteUrl)
{ {
Dictionary<string, string> altTitles = new(); Dictionary<string, string> altTitles = new();
Dictionary<string, string>? links = null; List<MangaTag> tags = new();
HashSet<string> tags = new(); List<Author> authors = new();
string[] authors = Array.Empty<string>(); MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
string originalLanguage = "";
Manga.ReleaseStatusByte releaseStatus = Manga.ReleaseStatusByte.Unreleased;
HtmlNode infoNode = document.DocumentNode.Descendants("ul").First(d => d.HasClass("manga-info-text")); HtmlNode infoNode = document.DocumentNode.Descendants("ul").First(d => d.HasClass("manga-info-text"));
@ -94,29 +100,28 @@ public class Manganato : MangaConnector
if (text.StartsWith("author(s) :")) if (text.StartsWith("author(s) :"))
{ {
authors = li.Descendants("a").Select(a => a.InnerText.Trim()).ToArray(); authors = li.Descendants("a").Select(a => a.InnerText.Trim()).Select(a => new Author(a)).ToList();
} }
else if (text.StartsWith("status :")) else if (text.StartsWith("status :"))
{ {
string status = text.Replace("status :", "").Trim().ToLower(); string status = text.Replace("status :", "").Trim().ToLower();
if (string.IsNullOrWhiteSpace(status)) if (string.IsNullOrWhiteSpace(status))
releaseStatus = Manga.ReleaseStatusByte.Continuing; releaseStatus = MangaReleaseStatus.Continuing;
else if (status == "ongoing") else if (status == "ongoing")
releaseStatus = Manga.ReleaseStatusByte.Continuing; releaseStatus = MangaReleaseStatus.Continuing;
else else
releaseStatus = Enum.Parse<Manga.ReleaseStatusByte>(status, true); releaseStatus = Enum.Parse<MangaReleaseStatus>(status, true);
} }
else if (li.HasClass("genres")) else if (li.HasClass("genres"))
{ {
tags = li.Descendants("a").Select(a => a.InnerText.Trim()).ToHashSet(); tags = li.Descendants("a").Select(a => new MangaTag(a.InnerText.Trim())).ToList();
} }
} }
string posterUrl = document.DocumentNode.Descendants("div").First(s => s.HasClass("manga-info-pic")).Descendants("img").First() string posterUrl = document.DocumentNode.Descendants("div").First(s => s.HasClass("manga-info-pic"))
.Descendants("img").First()
.GetAttributes().First(a => a.Name == "src").Value; .GetAttributes().First(a => a.Name == "src").Value;
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, publicationId, RequestType.MangaCover, "https://www.manganato.gg/");
string description = document.DocumentNode.SelectSingleNode("//div[@id='contentBox']") string description = document.DocumentNode.SelectSingleNode("//div[@id='contentBox']")
.InnerText.Replace("Description :", ""); .InnerText.Replace("Description :", "");
while (description.StartsWith('\n')) while (description.StartsWith('\n'))
@ -130,19 +135,18 @@ public class Manganato : MangaConnector
CultureInfo.InvariantCulture).Millisecond); CultureInfo.InvariantCulture).Millisecond);
int year = DateTime.ParseExact(oldestChapter?.GetAttributeValue("title", "Dec 31 2400, 23:59")??"Dec 31 2400, 23:59", pattern, uint year = Convert.ToUInt32(DateTime.ParseExact(
CultureInfo.InvariantCulture).Year; oldestChapter?.GetAttributeValue("title", "Dec 31 2400, 23:59") ?? "Dec 31 2400, 23:59", pattern,
CultureInfo.InvariantCulture).Year);
Manga manga = new (sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links, Manga manga = new(publicationId, sortName, description, websiteUrl, posterUrl, null, year, null, releaseStatus,
year, originalLanguage, publicationId, releaseStatus, websiteUrl: websiteUrl); -1, this, authors, tags, [], []);
AddMangaToCache(manga); return (manga, authors, tags, [], []);
return manga;
} }
public override Chapter[] GetChapters(Manga manga, string language = "en") public override Chapter[] GetChapters(Manga manga, string language = "en")
{ {
Log($"Getting chapters {manga}"); string requestUrl = manga.WebsiteUrl;
string requestUrl = manga.websiteUrl;
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
@ -152,10 +156,23 @@ public class Manganato : MangaConnector
if (requestResult.htmlDocument is null) if (requestResult.htmlDocument is null)
return Array.Empty<Chapter>(); return Array.Empty<Chapter>();
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument); List<Chapter> chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument);
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.Order().ToArray(); return chapters.Order().ToArray();
} }
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 List<Chapter> ParseChaptersFromHtml(Manga manga, HtmlDocument document) private List<Chapter> ParseChaptersFromHtml(Manga manga, HtmlDocument document)
{ {
List<Chapter> ret = new(); List<Chapter> ret = new();
@ -177,47 +194,17 @@ public class Manganato : MangaConnector
volumeNumber = "0"; volumeNumber = "0";
try try
{ {
ret.Add(new Chapter(manga, chapterName, volumeNumber, chapterNumber, url)); ret.Add(new Chapter(manga, url, chapterNumber, int.Parse(volumeNumber), chapterName));
} }
catch (Exception e) catch (Exception e)
{ {
Log($"Failed to load chapter {chapterNumber}: {e.Message}");
} }
} }
ret.Reverse(); ret.Reverse();
return ret; return ret;
} }
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
{
if (progressToken?.cancellationRequested ?? false)
{
progressToken.Cancel();
return HttpStatusCode.RequestTimeout;
}
Manga chapterParentManga = chapter.parentManga;
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
string requestUrl = chapter.url;
RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
{
progressToken?.Cancel();
return requestResult.statusCode;
}
if (requestResult.htmlDocument is null)
{
progressToken?.Cancel();
return HttpStatusCode.InternalServerError;
}
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
return DownloadChapterImages(imageUrls, chapter, RequestType.MangaImage, "https://www.manganato.gg", progressToken:progressToken);
}
private string[] ParseImageUrlsFromHtml(HtmlDocument document) private string[] ParseImageUrlsFromHtml(HtmlDocument document)
{ {
List<string> ret = new(); List<string> ret = new();

View File

@ -1,62 +1,59 @@
using System.Net; using System.Text.RegularExpressions;
using System.Text.RegularExpressions; using API.MangaDownloadClients;
using HtmlAgilityPack; using HtmlAgilityPack;
using Tranga.Jobs;
namespace Tranga.MangaConnectors; namespace API.Schema.MangaConnectors;
public class Mangaworld : MangaConnector public class Mangaworld : MangaConnector
{ {
public Mangaworld(GlobalBase clone) : base(clone, "Mangaworld", ["it"]) public Mangaworld() : base("Mangaworld", ["it"], ["www.mangaworld.ac", "www.mangaworld.nz"], "https://www.mangaworld.nz/public/assets/seo/android-icon-192x192.png")
{ {
this.downloadClient = new ChromiumDownloadClient(clone); this.downloadClient = new ChromiumDownloadClient();
} }
public override Manga[] GetManga(string publicationTitle = "") public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{ {
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower(); string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower();
string requestUrl = $"https://www.mangaworld.ac/archive?keyword={sanitizedTitle}"; string requestUrl = $"https://www.mangaworld.ac/archive?keyword={sanitizedTitle}";
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Manga>(); return [];
if (requestResult.htmlDocument is null) if (requestResult.htmlDocument is null)
return Array.Empty<Manga>(); return [];
Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument); (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
return publications; return publications;
} }
private Manga[] ParsePublicationsFromHtml(HtmlDocument document) private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
{ {
if (!document.DocumentNode.SelectSingleNode("//div[@class='comics-grid']").ChildNodes if (!document.DocumentNode.SelectSingleNode("//div[@class='comics-grid']").ChildNodes
.Any(node => node.HasClass("entry"))) .Any(node => node.HasClass("entry")))
return Array.Empty<Manga>(); return [];
List<string> urls = document.DocumentNode List<string> urls = document.DocumentNode
.SelectNodes( .SelectNodes(
"//div[@class='comics-grid']//div[@class='entry']//a[contains(concat(' ',normalize-space(@class),' '),'thumb')]") "//div[@class='comics-grid']//div[@class='entry']//a[contains(concat(' ',normalize-space(@class),' '),'thumb')]")
.Select(thumb => thumb.GetAttributeValue("href", "")).ToList(); .Select(thumb => thumb.GetAttributeValue("href", "")).ToList();
HashSet<Manga> ret = new(); List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls) foreach (string url in urls)
{ {
Manga? manga = GetMangaFromUrl(url); (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is not null) if (manga is { } x)
ret.Add((Manga)manga); ret.Add(x);
} }
return ret.ToArray(); return ret.ToArray();
} }
public override Manga? GetMangaFromId(string publicationId) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{ {
return GetMangaFromUrl($"https://www.mangaworld.ac/manga/{publicationId}"); return GetMangaFromUrl($"https://www.mangaworld.ac/manga/{publicationId}");
} }
public override Manga? GetMangaFromUrl(string url) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{ {
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(url, RequestType.MangaInfo); downloadClient.MakeRequest(url, RequestType.MangaInfo);
@ -71,12 +68,11 @@ public class Mangaworld: MangaConnector
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, id, url); return ParseSinglePublicationFromHtml(requestResult.htmlDocument, id, url);
} }
private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
{ {
Dictionary<string, string> altTitles = new(); Dictionary<string, string> altTitlesDict = new();
Dictionary<string, string>? links = null;
string originalLanguage = ""; string originalLanguage = "";
Manga.ReleaseStatusByte releaseStatus = Manga.ReleaseStatusByte.Unreleased; MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
HtmlNode infoNode = document.DocumentNode.Descendants("div").First(d => d.HasClass("info")); HtmlNode infoNode = document.DocumentNode.Descendants("div").First(d => d.HasClass("info"));
@ -85,59 +81,59 @@ public class Mangaworld: MangaConnector
HtmlNode metadata = infoNode.Descendants().First(d => d.HasClass("meta-data")); HtmlNode metadata = infoNode.Descendants().First(d => d.HasClass("meta-data"));
HtmlNode altTitlesNode = metadata.SelectSingleNode("//span[text()='Titoli alternativi: ' or text()='Titolo alternativo: ']/..").ChildNodes[1]; HtmlNode altTitlesNode = metadata.SelectSingleNode("//span[text()='Titoli alternativi: ' or text()='Titolo alternativo: ']/..").ChildNodes[1];
string[] alts = altTitlesNode.InnerText.Split(", "); string[] alts = altTitlesNode.InnerText.Split(", ");
for(int i = 0; i < alts.Length; i++) for(int i = 0; i < alts.Length; i++)
altTitles.Add(i.ToString(), alts[i]); altTitlesDict.Add(i.ToString(), alts[i]);
List<MangaAltTitle> altTitles = altTitlesDict.Select(a => new MangaAltTitle(a.Key, a.Value)).ToList();
HtmlNode genresNode = HtmlNode genresNode =
metadata.SelectSingleNode("//span[text()='Generi: ' or text()='Genero: ']/.."); metadata.SelectSingleNode("//span[text()='Generi: ' or text()='Genero: ']/..");
HashSet<string> tags = genresNode.SelectNodes("a").Select(node => node.InnerText).ToHashSet(); HashSet<string> tags = genresNode.SelectNodes("a").Select(node => node.InnerText).ToHashSet();
List<MangaTag> mangaTags = tags.Select(t => new MangaTag(t)).ToList();
HtmlNode authorsNode = HtmlNode authorsNode =
metadata.SelectSingleNode("//span[text()='Autore: ' or text()='Autori: ']/.."); metadata.SelectSingleNode("//span[text()='Autore: ' or text()='Autori: ']/..");
string[] authors = authorsNode.SelectNodes("a").Select(node => node.InnerText).ToArray(); 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; string status = metadata.SelectSingleNode("//span[text()='Stato: ']/..").SelectNodes("a").First().InnerText;
// ReSharper disable 5 times StringLiteralTypo // ReSharper disable 5 times StringLiteralTypo
switch (status.ToLower()) switch (status.ToLower())
{ {
case "cancellato": releaseStatus = Manga.ReleaseStatusByte.Cancelled; break; case "cancellato": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "in pausa": releaseStatus = Manga.ReleaseStatusByte.OnHiatus; break; case "in pausa": releaseStatus = MangaReleaseStatus.OnHiatus; break;
case "droppato": releaseStatus = Manga.ReleaseStatusByte.Cancelled; break; case "droppato": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "finito": releaseStatus = Manga.ReleaseStatusByte.Completed; break; case "finito": releaseStatus = MangaReleaseStatus.Completed; break;
case "in corso": releaseStatus = Manga.ReleaseStatusByte.Continuing; break; case "in corso": releaseStatus = MangaReleaseStatus.Continuing; break;
} }
string posterUrl = document.DocumentNode.SelectSingleNode("//img[@class='rounded']").GetAttributeValue("src", ""); string coverUrl = document.DocumentNode.SelectSingleNode("//img[@class='rounded']").GetAttributeValue("src", "");
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, publicationId.Replace('/', '-'), RequestType.MangaCover);
string description = document.DocumentNode.SelectSingleNode("//div[@id='noidungm']").InnerText; string description = document.DocumentNode.SelectSingleNode("//div[@id='noidungm']").InnerText;
string yearString = metadata.SelectSingleNode("//span[text()='Anno di uscita: ']/..").SelectNodes("a").First().InnerText; string yearString = metadata.SelectSingleNode("//span[text()='Anno di uscita: ']/..").SelectNodes("a").First().InnerText;
int year = Convert.ToInt32(yearString); uint year = uint.Parse(yearString);
Manga manga = new (sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links, Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
year, originalLanguage, publicationId, releaseStatus, websiteUrl: websiteUrl); originalLanguage, releaseStatus, -1,
AddMangaToCache(manga); this,
return manga; authors,
mangaTags,
[],
altTitles);
return (manga, authors, mangaTags, [], altTitles);
} }
public override Chapter[] GetChapters(Manga manga, string language="en") public override Chapter[] GetChapters(Manga manga, string language="en")
{ {
Log($"Getting chapters {manga}"); string requestUrl = $"https://www.mangaworld.ac/manga/{manga.MangaId}";
string requestUrl = $"https://www.mangaworld.ac/manga/{manga.publicationId}";
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
return Array.Empty<Chapter>(); return [];
//Return Chapters ordered by Chapter-Number
if (requestResult.htmlDocument is null)
return Array.Empty<Chapter>();
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument); List<Chapter> chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument);
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.Order().ToArray(); return chapters.Order().ToArray();
} }
@ -156,20 +152,22 @@ public class Mangaworld: MangaConnector
{ {
foreach (HtmlNode volNode in document.DocumentNode.SelectNodes("//div[contains(concat(' ',normalize-space(@class),' '),'volume-element')]")) foreach (HtmlNode volNode in document.DocumentNode.SelectNodes("//div[contains(concat(' ',normalize-space(@class),' '),'volume-element')]"))
{ {
string volume = volumeRex.Match(volNode.SelectNodes("div").First(node => node.HasClass("volume")).SelectSingleNode("p").InnerText).Groups[1].Value; 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")) foreach (HtmlNode chNode in volNode.SelectNodes("div").First(node => node.HasClass("volume-chapters")).SelectNodes("div"))
{ {
string number = chapterRex.Match(chNode.SelectSingleNode("a").SelectSingleNode("span").InnerText).Groups[1].Value; 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 url = chNode.SelectSingleNode("a").GetAttributeValue("href", "");
string id = idRex.Match(chNode.SelectSingleNode("a").GetAttributeValue("href", "")).Groups[1].Value; string id = idRex.Match(chNode.SelectSingleNode("a").GetAttributeValue("href", "")).Groups[1].Value;
try try
{ {
ret.Add(new Chapter(manga, null, volume, number, url, id)); ret.Add(new Chapter(manga, url, chapterNumber, volume, null));
} }
catch (Exception e) catch (Exception e)
{ {
Log($"Failed to load chapter {number}: {e.Message}");
} }
} }
} }
@ -178,16 +176,17 @@ public class Mangaworld: MangaConnector
{ {
foreach (HtmlNode chNode in chaptersWrapper.SelectNodes("div").Where(node => node.HasClass("chapter"))) foreach (HtmlNode chNode in chaptersWrapper.SelectNodes("div").Where(node => node.HasClass("chapter")))
{ {
string number = chapterRex.Match(chNode.SelectSingleNode("a").SelectSingleNode("span").InnerText).Groups[1].Value; 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 url = chNode.SelectSingleNode("a").GetAttributeValue("href", "");
string id = idRex.Match(chNode.SelectSingleNode("a").GetAttributeValue("href", "")).Groups[1].Value; string id = idRex.Match(chNode.SelectSingleNode("a").GetAttributeValue("href", "")).Groups[1].Value;
try try
{ {
ret.Add(new Chapter(manga, null, null, number, url, id)); ret.Add(new Chapter(manga, url, chapterNumber, null, null));
} }
catch (Exception e) catch (Exception e)
{ {
Log($"Failed to load chapter {number}: {e.Message}");
} }
} }
} }
@ -196,34 +195,18 @@ public class Mangaworld: MangaConnector
return ret; return ret;
} }
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null) internal override string[] GetChapterImageUrls(Chapter chapter)
{ {
if (progressToken?.cancellationRequested ?? false) string requestUrl = $"{chapter.Url}?style=list";
{
progressToken.Cancel();
return HttpStatusCode.RequestTimeout;
}
Manga chapterParentManga = chapter.parentManga;
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
string requestUrl = $"{chapter.url}?style=list";
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
{ {
progressToken?.Cancel(); return [];
return requestResult.statusCode;
}
if (requestResult.htmlDocument is null)
{
progressToken?.Cancel();
return HttpStatusCode.InternalServerError;
} }
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument); string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
return imageUrls;
return DownloadChapterImages(imageUrls, chapter, RequestType.MangaImage,"https://www.mangaworld.bz/", progressToken:progressToken);
} }
private string[] ParseImageUrlsFromHtml(HtmlDocument document) private string[] ParseImageUrlsFromHtml(HtmlDocument document)

View File

@ -1,62 +1,56 @@
using System.Net; using System.Text.RegularExpressions;
using System.Text.RegularExpressions; using API.MangaDownloadClients;
using HtmlAgilityPack; using HtmlAgilityPack;
using Tranga.Jobs;
namespace Tranga.MangaConnectors; namespace API.Schema.MangaConnectors;
public class ManhuaPlus : MangaConnector public class ManhuaPlus : MangaConnector
{ {
public ManhuaPlus(GlobalBase clone) : base(clone, "ManhuaPlus", ["en"]) public ManhuaPlus() : base("ManhuaPlus", ["en"], ["manhuaplus.org"], "https://manhuaplus.org/uploads/images/favicon.png")
{ {
this.downloadClient = new ChromiumDownloadClient(clone); this.downloadClient = new ChromiumDownloadClient();
} }
public override Manga[] GetManga(string publicationTitle = "") public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] GetManga(string publicationTitle = "")
{ {
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower(); string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower();
string requestUrl = $"https://manhuaplus.org/search?keyword={sanitizedTitle}"; string requestUrl = $"https://manhuaplus.org/search?keyword={sanitizedTitle}";
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
return Array.Empty<Manga>(); return [];
if (requestResult.htmlDocument is null) (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
return Array.Empty<Manga>();
Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
return publications; return publications;
} }
private Manga[] ParsePublicationsFromHtml(HtmlDocument document) private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
{ {
if (document.DocumentNode.SelectSingleNode("//h1/../..").ChildNodes//I already want to not. if (document.DocumentNode.SelectSingleNode("//h1/../..").ChildNodes//I already want to not.
.Any(node => node.InnerText.Contains("No manga found"))) .Any(node => node.InnerText.Contains("No manga found")))
return Array.Empty<Manga>(); return [];
List<string> urls = document.DocumentNode 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'))]") .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(); .Select(mangaNode => mangaNode.GetAttributeValue("href", "")).ToList();
logger?.WriteLine($"Got {urls.Count} urls.");
HashSet<Manga> ret = new(); List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (string url in urls) foreach (string url in urls)
{ {
Manga? manga = GetMangaFromUrl(url); (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is not null) if (manga is { } x)
ret.Add((Manga)manga); ret.Add(x);
} }
return ret.ToArray(); return ret.ToArray();
} }
public override Manga? GetMangaFromId(string publicationId) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{ {
return GetMangaFromUrl($"https://manhuaplus.org/manga/{publicationId}"); return GetMangaFromUrl($"https://manhuaplus.org/manga/{publicationId}");
} }
public override Manga? GetMangaFromUrl(string url) public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromUrl(string url)
{ {
Regex publicationIdRex = new(@"https:\/\/manhuaplus.org\/manga\/(.*)(\/.*)*"); Regex publicationIdRex = new(@"https:\/\/manhuaplus.org\/manga\/(.*)(\/.*)*");
string publicationId = publicationIdRex.Match(url).Groups[1].Value; string publicationId = publicationIdRex.Match(url).Groups[1].Value;
@ -67,34 +61,33 @@ public class ManhuaPlus : MangaConnector
return null; return null;
} }
private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
{ {
string originalLanguage = "", status = ""; string originalLanguage = "", status = "";
Dictionary<string, string> altTitles = new(), links = new(); Dictionary<string, string> altTitles = new(), links = new();
HashSet<string> tags = new(); HashSet<string> tags = new();
Manga.ReleaseStatusByte releaseStatus = Manga.ReleaseStatusByte.Unreleased; MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
HtmlNode posterNode = document.DocumentNode.SelectSingleNode("/html/body/main/div/div/div[2]/div[1]/figure/a/img");//BRUH 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\-\._\~\!\$\&\'\(\)\*\+\,\;\=\:\@]+).*"); Regex posterRex = new(@".*(\/uploads/covers/[a-zA-Z0-9\-\._\~\!\$\&\'\(\)\*\+\,\;\=\:\@]+).*");
string posterUrl = $"https://manhuaplus.org/{posterRex.Match(posterNode.GetAttributeValue("src", "")).Groups[1].Value}"; string coverUrl = $"https://manhuaplus.org/{posterRex.Match(posterNode.GetAttributeValue("src", "")).Groups[1].Value}";
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, publicationId, RequestType.MangaCover);
HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//h1"); HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//h1");
string sortName = titleNode.InnerText.Replace("\n", ""); string sortName = titleNode.InnerText.Replace("\n", "");
List<string> authors = new(); List<string> authorNames = new();
try try
{ {
HtmlNode[] authorsNodes = document.DocumentNode HtmlNode[] authorsNodes = document.DocumentNode
.SelectNodes("//a[contains(@href, 'https://manhuaplus.org/authors/')]") .SelectNodes("//a[contains(@href, 'https://manhuaplus.org/authors/')]")
.ToArray(); .ToArray();
foreach (HtmlNode authorNode in authorsNodes) foreach (HtmlNode authorNode in authorsNodes)
authors.Add(authorNode.InnerText); authorNames.Add(authorNode.InnerText);
} }
catch (ArgumentNullException e) catch (ArgumentNullException e)
{ {
Log("No authors found.");
} }
List<Author> authors = authorNames.Select(a => new Author(a)).ToList();
try try
{ {
@ -105,39 +98,42 @@ public class ManhuaPlus : MangaConnector
} }
catch (ArgumentNullException e) catch (ArgumentNullException e)
{ {
Log("No genres found");
} }
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}"); 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"); HtmlNode yearNode = document.DocumentNode.SelectSingleNode("//aside//i[contains(concat(' ',normalize-space(@class),' '),' fa-clock ')]/../span");
Match match = yearRex.Match(yearNode.InnerText); Match match = yearRex.Match(yearNode.InnerText);
int year = match.Success && match.Groups[1].Success ? int.Parse(match.Groups[1].Value) : 1960; 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", ""); status = document.DocumentNode.SelectSingleNode("//aside//i[contains(concat(' ',normalize-space(@class),' '),' fa-rss ')]/../span").InnerText.Replace("\n", "");
switch (status.ToLower()) switch (status.ToLower())
{ {
case "cancelled": releaseStatus = Manga.ReleaseStatusByte.Cancelled; break; case "cancelled": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "hiatus": releaseStatus = Manga.ReleaseStatusByte.OnHiatus; break; case "hiatus": releaseStatus = MangaReleaseStatus.OnHiatus; break;
case "discontinued": releaseStatus = Manga.ReleaseStatusByte.Cancelled; break; case "discontinued": releaseStatus = MangaReleaseStatus.Cancelled; break;
case "complete": releaseStatus = Manga.ReleaseStatusByte.Completed; break; case "complete": releaseStatus = MangaReleaseStatus.Completed; break;
case "ongoing": releaseStatus = Manga.ReleaseStatusByte.Continuing; break; case "ongoing": releaseStatus = MangaReleaseStatus.Continuing; break;
} }
HtmlNode descriptionNode = document.DocumentNode HtmlNode descriptionNode = document.DocumentNode
.SelectSingleNode("//div[@id='syn-target']"); .SelectSingleNode("//div[@id='syn-target']");
string description = descriptionNode.InnerText; string description = descriptionNode.InnerText;
Manga manga = new(sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
coverFileNameInCache, links, originalLanguage, releaseStatus, -1,
year, originalLanguage, publicationId, releaseStatus, websiteUrl: websiteUrl); this,
AddMangaToCache(manga); authors,
return manga; mangaTags,
[],
[]);
return (manga, authors, mangaTags, [], []);
} }
public override Chapter[] GetChapters(Manga manga, string language="en") public override Chapter[] GetChapters(Manga manga, string language="en")
{ {
Log($"Getting chapters {manga}"); RequestResult result = downloadClient.MakeRequest($"https://manhuaplus.org/manga/{manga.MangaId}", RequestType.Default);
RequestResult result = downloadClient.MakeRequest($"https://manhuaplus.org/manga/{manga.publicationId}", RequestType.Default);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null) if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null)
{ {
return Array.Empty<Chapter>(); return Array.Empty<Chapter>();
@ -152,52 +148,32 @@ public class ManhuaPlus : MangaConnector
{ {
Match rexMatch = urlRex.Match(url); Match rexMatch = urlRex.Match(url);
string volumeNumber = "1"; string chapterNumber = new(rexMatch.Groups[1].Value);
string chapterNumber = rexMatch.Groups[1].Value;
string fullUrl = url; string fullUrl = url;
try try
{ {
chapters.Add(new Chapter(manga, "", volumeNumber, chapterNumber, fullUrl)); chapters.Add(new Chapter(manga, fullUrl, chapterNumber, null, null));
} }
catch (Exception e) catch (Exception e)
{ {
Log($"Failed to load chapter {chapterNumber}: {e.Message}");
} }
} }
//Return Chapters ordered by Chapter-Number //Return Chapters ordered by Chapter-Number
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.Order().ToArray(); return chapters.Order().ToArray();
} }
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null) internal override string[] GetChapterImageUrls(Chapter chapter)
{ {
if (progressToken?.cancellationRequested ?? false) RequestResult requestResult = this.downloadClient.MakeRequest(chapter.Url, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.htmlDocument is null)
{ {
progressToken.Cancel(); return [];
return HttpStatusCode.RequestTimeout;
}
Manga chapterParentManga = chapter.parentManga;
if (progressToken?.cancellationRequested ?? false)
{
progressToken.Cancel();
return HttpStatusCode.RequestTimeout;
}
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
RequestResult requestResult = this.downloadClient.MakeRequest(chapter.url, RequestType.Default);
if (requestResult.htmlDocument is null)
{
progressToken?.Cancel();
return HttpStatusCode.RequestTimeout;
} }
HtmlDocument document = requestResult.htmlDocument; HtmlDocument document = requestResult.htmlDocument;
HtmlNode[] images = document.DocumentNode.SelectNodes("//a[contains(concat(' ',normalize-space(@class),' '),' readImg ')]/img").ToArray(); HtmlNode[] images = document.DocumentNode.SelectNodes("//a[contains(concat(' ',normalize-space(@class),' '),' readImg ')]/img").ToArray();
List<string> urls = images.Select(node => node.GetAttributeValue("src", "")).ToList(); List<string> urls = images.Select(node => node.GetAttributeValue("src", "")).ToList();
return urls.ToArray();
return DownloadChapterImages(urls.ToArray(), chapter, RequestType.MangaImage, progressToken:progressToken);
} }
} }

View File

@ -1,51 +1,48 @@
using System.Net; using System.Net;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using HtmlAgilityPack; using HtmlAgilityPack;
using Tranga.Jobs;
namespace Tranga.MangaConnectors; namespace API.Schema.MangaConnectors;
public class Webtoons : MangaConnector public class Webtoons : MangaConnector
{ {
public Webtoons(GlobalBase clone) : base(clone, "Webtoons", ["en"]) public Webtoons() : base("Webtoons", ["en"], ["www.webtoons.com"], "https://webtoons-static.pstatic.net/image/favicon/favicon.ico")
{ {
this.downloadClient = new HttpDownloadClient(clone); this.downloadClient = new HttpDownloadClient();
} }
// Done // Done
public override Manga[] GetManga(string publicationTitle = "") 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 sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower();
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string requestUrl = $"https://www.webtoons.com/en/search?keyword={sanitizedTitle}&searchType=WEBTOON"; string requestUrl = $"https://www.webtoons.com/en/search?keyword={sanitizedTitle}&searchType=WEBTOON";
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) { if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) {
Log($"Failed to retrieve site"); return [];
return Array.Empty<Manga>();
} }
if (requestResult.htmlDocument is null) if (requestResult.htmlDocument is null)
{ {
Log($"Failed to retrieve site"); return [];
return Array.Empty<Manga>();
} }
Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument); (Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)[] publications =
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\""); ParsePublicationsFromHtml(requestResult.htmlDocument);
return publications; return publications;
} }
// Done // Done
public override Manga? GetMangaFromId(string publicationId) public override (Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)? GetMangaFromId(string publicationId)
{ {
PublicationManager pb = new PublicationManager(publicationId); PublicationManager pb = new PublicationManager(publicationId);
return GetMangaFromUrl($"https://www.webtoons.com/en/{pb.Category}/{pb.Title}/list?title_no={pb.Id}"); return GetMangaFromUrl($"https://www.webtoons.com/en/{pb.Category}/{pb.Title}/list?title_no={pb.Id}");
} }
// Done // Done
public override Manga? GetMangaFromUrl(string url) public override (Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)? GetMangaFromUrl(string url)
{ {
RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo); RequestResult requestResult = downloadClient.MakeRequest(url, RequestType.MangaInfo);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) { if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) {
@ -53,27 +50,24 @@ public class Webtoons : MangaConnector
} }
if (requestResult.htmlDocument is null) if (requestResult.htmlDocument is null)
{ {
Log($"Failed to retrieve site");
return null; return null;
} }
Regex regex = new Regex(@".*webtoons\.com\/en\/(?<category>[^\/]+)\/(?<title>[^\/]+)\/list\?title_no=(?<id>\d+).*"); Regex regex = new Regex(@".*webtoons\.com/en/(?<category>[^/]+)/(?<title>[^/]+)/list\?title_no=(?<id>\d+).*");
Match match = regex.Match(url); Match match = regex.Match(url);
if(match.Success) { if(match.Success) {
PublicationManager pm = new PublicationManager(match.Groups["title"].Value, match.Groups["category"].Value, match.Groups["id"].Value); PublicationManager pm = new PublicationManager(match.Groups["title"].Value, match.Groups["category"].Value, match.Groups["id"].Value);
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, pm.getPublicationId(), url); return ParseSinglePublicationFromHtml(requestResult.htmlDocument, pm.getPublicationId(), url);
} }
Log($"Failed match Regex ID");
return null; return null;
} }
// Done // Done
private Manga[] ParsePublicationsFromHtml(HtmlDocument document) private (Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)[] ParsePublicationsFromHtml(HtmlDocument document)
{ {
HtmlNode mangaList = document.DocumentNode.SelectSingleNode("//ul[contains(@class, 'card_lst')]"); HtmlNode mangaList = document.DocumentNode.SelectSingleNode("//ul[contains(@class, 'card_lst')]");
if (!mangaList.ChildNodes.Any(node => node.Name == "li")) { if (!mangaList.ChildNodes.Any(node => node.Name == "li")) {
Log($"Failed to parse publication"); return [];
return Array.Empty<Manga>();
} }
List<string> urls = document.DocumentNode List<string> urls = document.DocumentNode
@ -81,12 +75,12 @@ public class Webtoons : MangaConnector
.Select(node => node.GetAttributeValue("href", "https://www.webtoons.com")) .Select(node => node.GetAttributeValue("href", "https://www.webtoons.com"))
.ToList(); .ToList();
HashSet<Manga> ret = new(); List<(Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)> ret = new();
foreach (string url in urls) foreach (string url in urls)
{ {
Manga? manga = GetMangaFromUrl(url); (Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>)? manga = GetMangaFromUrl(url);
if (manga is not null) if(manga is { } m)
ret.Add((Manga)manga); ret.Add(m);
} }
return ret.ToArray(); return ret.ToArray();
@ -99,7 +93,7 @@ public class Webtoons : MangaConnector
} }
// Done // Done
private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl) private (Manga, List<Author>, List<MangaTag>, List<Link>, List<MangaAltTitle>) ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId, string websiteUrl)
{ {
HtmlNode infoNode1 = document.DocumentNode.SelectSingleNode("//*[@id='content']/div[2]/div[1]/div[1]"); HtmlNode infoNode1 = document.DocumentNode.SelectSingleNode("//*[@id='content']/div[2]/div[1]/div[1]");
HtmlNode infoNode2 = document.DocumentNode.SelectSingleNode("//*[@id='content']/div[2]/div[2]/div[2]"); HtmlNode infoNode2 = document.DocumentNode.SelectSingleNode("//*[@id='content']/div[2]/div[2]/div[2]");
@ -110,42 +104,46 @@ public class Webtoons : MangaConnector
HtmlNode posterNode = document.DocumentNode.SelectSingleNode("//div[contains(@class, 'detail_body') and contains(@class, 'banner')]"); HtmlNode posterNode = document.DocumentNode.SelectSingleNode("//div[contains(@class, 'detail_body') and contains(@class, 'banner')]");
Regex regex = new Regex(@"url\('(?<url>.*?)'\)"); Regex regex = new Regex(@"url\((?<url>.*?)\)");
Match match = regex.Match(posterNode.GetAttributeValue("style", "")); Match match = regex.Match(posterNode.GetAttributeValue("style", ""));
string posterUrl = match.Groups["url"].Value; string coverUrl = match.Groups["url"].Value;
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, publicationId, RequestType.MangaCover, websiteUrl);
string genre = infoNode1.SelectSingleNode(".//h2[contains(@class, 'genre')]") string genre = infoNode1.SelectSingleNode(".//h2[contains(@class, 'genre')]")
.InnerText.Trim(); .InnerText.Trim();
string[] tags = [ genre ]; List<MangaTag> mangaTags = [new MangaTag(genre)];
List<HtmlNode> authorsNodes = infoNode1.SelectSingleNode(".//div[contains(@class, 'author_area')]").Descendants("a").ToList(); List<HtmlNode> authorsNodes = infoNode1.SelectSingleNode(".//div[contains(@class, 'author_area')]").Descendants("a").ToList();
List<string> authors = authorsNodes.Select(node => node.InnerText.Trim()).ToList(); List<Author> authors = authorsNodes.Select(node => new Author(node.InnerText.Trim())).ToList();
string originalLanguage = ""; string originalLanguage = "";
int year = DateTime.Now.Year; uint year = 0;
string status1 = infoNode2.SelectSingleNode(".//p").InnerText; string status1 = infoNode2.SelectSingleNode(".//p").InnerText;
string status2 = infoNode2.SelectSingleNode(".//p/span").InnerText; string status2 = infoNode2.SelectSingleNode(".//p/span").InnerText;
Manga.ReleaseStatusByte releaseStatus = Manga.ReleaseStatusByte.Unreleased; MangaReleaseStatus releaseStatus = MangaReleaseStatus.Unreleased;
if(status2.Length == 0 || status1.ToLower() == "completed") { if(status2.Length == 0 || status1.ToLower() == "completed") {
releaseStatus = Manga.ReleaseStatusByte.Completed; releaseStatus = MangaReleaseStatus.Completed;
} else if(status2.ToLower() == "up") { } else if(status2.ToLower() == "up") {
releaseStatus = Manga.ReleaseStatusByte.Continuing; releaseStatus = MangaReleaseStatus.Continuing;
} }
Manga manga = new(sortName, authors, description, new Dictionary<string, string>(), tags, posterUrl, coverFileNameInCache, new Dictionary<string, string>(), Manga manga = new (publicationId, sortName, description, websiteUrl, coverUrl, null, year,
year, originalLanguage, publicationId, releaseStatus, websiteUrl: websiteUrl); originalLanguage, releaseStatus, -1,
AddMangaToCache(manga); this,
return manga; authors,
mangaTags,
[],
[]);
return (manga, authors, mangaTags, [], []);
} }
// Done // Done
public override Chapter[] GetChapters(Manga manga, string language = "en") public override Chapter[] GetChapters(Manga manga, string language = "en")
{ {
PublicationManager pm = new PublicationManager(manga.publicationId); PublicationManager pm = new(manga.MangaId);
string requestUrl = $"https://www.webtoons.com/en/{pm.Category}/{pm.Title}/list?title_no={pm.Id}"; string requestUrl = $"https://www.webtoons.com/en/{pm.Category}/{pm.Title}/list?title_no={pm.Id}";
// Leaving this in for verification if the page exists // Leaving this in for verification if the page exists
RequestResult requestResult = RequestResult requestResult =
@ -164,7 +162,7 @@ public class Webtoons : MangaConnector
string pageRequestUrl = $"{requestUrl}&page={page}"; string pageRequestUrl = $"{requestUrl}&page={page}";
chapters.AddRange(ParseChaptersFromHtml(manga, pageRequestUrl)); chapters.AddRange(ParseChaptersFromHtml(manga, pageRequestUrl));
} }
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.Order().ToArray(); return chapters.Order().ToArray();
} }
@ -174,7 +172,6 @@ public class Webtoons : MangaConnector
RequestResult result = downloadClient.MakeRequest(mangaUrl, RequestType.Default); RequestResult result = downloadClient.MakeRequest(mangaUrl, RequestType.Default);
if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null) if ((int)result.statusCode < 200 || (int)result.statusCode >= 300 || result.htmlDocument is null)
{ {
Log("Failed to load site");
return new List<Chapter>(); return new List<Chapter>();
} }
@ -187,38 +184,28 @@ public class Webtoons : MangaConnector
string id = chapterInfo.GetAttributeValue("id", ""); string id = chapterInfo.GetAttributeValue("id", "");
if(id == "") continue; if(id == "") continue;
string? volumeNumber = null;
string chapterNumber = chapterInfo.GetAttributeValue("data-episode-no", ""); string chapterNumber = chapterInfo.GetAttributeValue("data-episode-no", "");
if(chapterNumber == "") continue; if(chapterNumber == "") continue;
string chapterName = infoNode.SelectSingleNode(".//span[contains(@class, 'subj')]/span").InnerText.Trim(); string chapterName = infoNode.SelectSingleNode(".//span[contains(@class, 'subj')]/span").InnerText.Trim();
ret.Add(new Chapter(manga, chapterName, volumeNumber, chapterNumber, url)); ret.Add(new Chapter(manga, url, chapterNumber, null, chapterName));
} }
return ret; return ret;
} }
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null) internal override string[] GetChapterImageUrls(Chapter chapter)
{ {
if (progressToken?.cancellationRequested ?? false) string requestUrl = chapter.Url;
{
progressToken.Cancel();
return HttpStatusCode.RequestTimeout;
}
Manga chapterParentManga = chapter.parentManga;
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
string requestUrl = chapter.url;
// Leaving this in to check if the page exists // Leaving this in to check if the page exists
RequestResult requestResult = RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default); downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
{ {
progressToken?.Cancel(); return [];
return requestResult.statusCode;
} }
string[] imageUrls = ParseImageUrlsFromHtml(requestUrl); string[] imageUrls = ParseImageUrlsFromHtml(requestUrl);
return DownloadChapterImages(imageUrls, chapter, RequestType.MangaImage, progressToken:progressToken, referrer: requestUrl); return imageUrls;
} }
private string[] ParseImageUrlsFromHtml(string mangaUrl) private string[] ParseImageUrlsFromHtml(string mangaUrl)
@ -227,12 +214,11 @@ public class Webtoons : MangaConnector
downloadClient.MakeRequest(mangaUrl, RequestType.Default); downloadClient.MakeRequest(mangaUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
{ {
return Array.Empty<string>(); return [];
} }
if (requestResult.htmlDocument is null) if (requestResult.htmlDocument is null)
{ {
Log($"Failed to retrieve site"); return [];
return Array.Empty<string>();
} }
return requestResult.htmlDocument.DocumentNode return requestResult.htmlDocument.DocumentNode

View File

@ -0,0 +1,175 @@
using System.Net;
using System.Text.RegularExpressions;
using API.MangaDownloadClients;
using HtmlAgilityPack;
namespace API.Schema.MangaConnectors;
public class Weebcentral : MangaConnector
{
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"], ["weebcentral.com"], "https://weebcentral.com/favicon.ico")
{
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
var offset = 0; //"Page"
var requestUrl =
$"https://{BaseUris[0]}/search/data?limit={limit}&offset={offset}&text={publicationTitle}&sort=Best+Match&order=Ascending&official=Any&display_mode=Minimal%20Display";
var requestResult =
downloadClient.MakeRequest(requestUrl, RequestType.Default);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 ||
requestResult.htmlDocument == null)
{
return [];
}
var publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
return publications;
}
private (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)[] ParsePublicationsFromHtml(HtmlDocument document)
{
if (document.DocumentNode.SelectNodes("//article").Count < 1)
return [];
var urls = document.DocumentNode.SelectNodes("/html/body/article/a[contains(concat(' ',normalize-space(@class),' '),' link ')]")
.Select(elem => elem.GetAttributeValue("href", "")).ToList();
List<(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)> ret = new();
foreach (var url in urls)
{
(Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? manga = GetMangaFromUrl(url);
if (manga is { })
ret.Add(((Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?))manga);
}
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*)\/(.*)");
var publicationId = publicationIdRex.Match(url).Groups[1].Value;
var 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 posterUrl = 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<Author> authors = authorsNodes.Select(n => new Author(n.InnerText)).ToList();
HtmlNode[] genreNodes =
document.DocumentNode.SelectNodes("//ul/li[strong/text() = 'Tags(s): ']/span").ToArray();
List<MangaTag> tags = genreNodes.Select(n => new MangaTag(n.InnerText.EndsWith(',') ? n.InnerText.Substring(0,n.InnerText.Length-1) : n.InnerText)).ToList();
HtmlNode statusNode = document.DocumentNode.SelectSingleNode("//ul/li[strong/text() = 'Status: ']/a");
string statusText = statusNode?.InnerText ?? "";
MangaReleaseStatus releaseStatus = statusText.ToLower() switch
{
"cancelled" => MangaReleaseStatus.Cancelled,
"hiatus" => MangaReleaseStatus.OnHiatus,
"complete" => MangaReleaseStatus.Completed,
"ongoing" => MangaReleaseStatus.Continuing,
_ => MangaReleaseStatus.Unreleased
};
HtmlNode yearNode = document.DocumentNode.SelectSingleNode("//ul/li[strong/text() = 'Released: ']/span");
uint year = Convert.ToUInt32(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() ?? [];
List<MangaAltTitle> altTitles = altTitleNodes.Select(n => new MangaAltTitle("", n.InnerText)).ToList();
Manga m = new(publicationId, sortName, description, websiteUrl, posterUrl, null, year, null, releaseStatus, -1,
this, authors, tags, [], altTitles);
return (m, authors, tags, [], altTitles);
}
public override (Manga, List<Author>?, List<MangaTag>?, List<Link>?, List<MangaAltTitle>?)? GetMangaFromId(string publicationId)
{
return GetMangaFromUrl($"https://{BaseUris[0]}/series/{publicationId}");
}
public override Chapter[] GetChapters(Manga manga, string language = "en")
{
var requestUrl = $"https://{BaseUris[0]}/series/{manga.MangaConnectorId}/full-chapter-list";
var 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 [];
var chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument);
return chapters.Order().ToArray();
}
internal override string[] GetChapterImageUrls(Chapter chapter)
{
var requestResult = downloadClient.MakeRequest(chapter.Url, RequestType.Default);
if (requestResult.htmlDocument is null)
return [];
var document = requestResult.htmlDocument;
var imageNodes =
document.DocumentNode.SelectNodes($"//section[@hx-get='{chapter.Url}/images']/img")?.ToArray() ?? [];
var urls = imageNodes.Select(imgNode => imgNode.GetAttributeValue("src", "")).ToArray();
return urls;
}
private List<Chapter> ParseChaptersFromHtml(Manga manga, HtmlDocument document)
{
var chaptersWrapper = document.DocumentNode.SelectSingleNode("/html/body");
Regex chapterRex = new(@"(\d+(?:\.\d+)*)");
Regex idRex = new(@"https:\/\/weebcentral\.com\/chapters\/(\w*)");
var ret = chaptersWrapper.Descendants("a").Select(elem =>
{
var url = elem.GetAttributeValue("href", "") ?? "Undefined";
if (!url.StartsWith("https://") && !url.StartsWith("http://"))
return new Chapter(manga, "", "");
var idMatch = idRex.Match(url);
var id = idMatch.Success ? idMatch.Groups[1].Value : null;
var chapterNode = elem.SelectSingleNode("span[@class='grow flex items-center gap-2']/span")?.InnerText ??
"Undefined";
var chapterNumberMatch = chapterRex.Match(chapterNode);
var chapterNumber = chapterNumberMatch.Success ? chapterNumberMatch.Groups[1].Value : "-1";
return new Chapter(manga, url, chapterNumber);
}).Where(elem => elem.ChapterNumber != String.Empty && elem.Url != string.Empty).ToList();
ret.Reverse();
return ret;
}
}

View File

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

12
API/Schema/MangaTag.cs Normal file
View File

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

View File

@ -0,0 +1,28 @@
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)
{
[StringLength(64)]
[Required]
public string NotificationId { get; init; } = TokenGen.CreateToken("Notification");
[Required]
public NotificationUrgency Urgency { get; init; } = urgency;
[StringLength(128)]
[Required]
public string Title { get; init; } = title;
[StringLength(512)]
[Required]
public string Message { get; init; } = message;
[Required]
public DateTime Date { get; init; } = date ?? DateTime.UtcNow;
public Notification() : this("") { }
}

View File

@ -0,0 +1,82 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text;
using log4net;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace API.Schema.NotificationConnectors;
[PrimaryKey("Name")]
public class NotificationConnector(string name, string url, Dictionary<string, string> headers, string httpMethod, string body)
{
[StringLength(64)]
[Required]
public string Name { get; init; } = name;
[StringLength(2048)]
[Required]
[Url]
public string Url { get; internal set; } = url;
[Required]
public Dictionary<string, string> Headers { get; internal set; } = headers;
[StringLength(8)]
[Required]
public string HttpMethod { get; internal set; } = httpMethod;
[StringLength(4096)]
[Required]
public string Body { get; internal set; } = body;
[JsonIgnore]
[NotMapped]
private readonly HttpClient Client = new()
{
DefaultRequestHeaders = { { "User-Agent", TrangaSettings.userAgent } }
};
[JsonIgnore]
[NotMapped]
protected ILog Log = LogManager.GetLogger(name);
public void SendNotification(string title, string notificationText)
{
Log.Info($"Sending notification: {title} - {notificationText}");
CustomWebhookFormatProvider formatProvider = new (title, notificationText);
string formattedUrl = string.Format(formatProvider, Url);
string formattedBody = string.Format(formatProvider, Body, title, notificationText);
Dictionary<string, string> formattedHeaders = Headers.ToDictionary(h => h.Key,
h => string.Format(formatProvider, h.Value, title, notificationText));
HttpRequestMessage request = new(System.Net.Http.HttpMethod.Parse(HttpMethod), formattedUrl);
foreach (var (key, value) in formattedHeaders)
request.Headers.Add(key, value);
request.Content = new StringContent(formattedBody);
Log.Debug($"Request: {request}");
HttpResponseMessage response = Client.Send(request);
Log.Debug($"Response status code: {response.StatusCode}");
}
private class CustomWebhookFormatProvider(string title, string text) : IFormatProvider
{
public object? GetFormat(Type? formatType)
{
return this;
}
public string Format(string fmt, object arg, IFormatProvider provider)
{
if(arg.GetType() != typeof(string))
return arg.ToString() ?? string.Empty;
StringBuilder sb = new StringBuilder(fmt);
sb.Replace("%title", title);
sb.Replace("%text", text);
return sb.ToString();
}
}
}

View File

@ -0,0 +1,8 @@
namespace API.Schema;
public enum NotificationUrgency : byte
{
Low = 1,
Normal = 3,
High = 5
}

120
API/Schema/PgsqlContext.cs Normal file
View File

@ -0,0 +1,120 @@
using API.Schema.Jobs;
using API.Schema.LibraryConnectors;
using API.Schema.MangaConnectors;
using API.Schema.NotificationConnectors;
using Microsoft.EntityFrameworkCore;
namespace API.Schema;
public class PgsqlContext(DbContextOptions<PgsqlContext> options) : DbContext(options)
{
public DbSet<Job> Jobs { get; set; }
public DbSet<MangaConnector> MangaConnectors { get; set; }
public DbSet<Manga> Mangas { get; set; }
public DbSet<LocalLibrary> LocalLibraries { get; set; }
public DbSet<Chapter> Chapters { get; set; }
public DbSet<Author> Authors { get; set; }
public DbSet<Link> Links { get; set; }
public DbSet<MangaTag> Tags { get; set; }
public DbSet<MangaAltTitle> AltTitles { get; set; }
public DbSet<LibraryConnector> LibraryConnectors { get; set; }
public DbSet<NotificationConnector> NotificationConnectors { get; set; }
public DbSet<Notification> Notifications { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<MangaConnector>()
.HasDiscriminator(c => c.Name)
.HasValue<Global>("Global")
.HasValue<AsuraToon>("AsuraToon")
.HasValue<Bato>("Bato")
.HasValue<MangaHere>("MangaHere")
.HasValue<MangaKatana>("MangaKatana")
.HasValue<Mangaworld>("Mangaworld")
.HasValue<ManhuaPlus>("ManhuaPlus")
.HasValue<Weebcentral>("Weebcentral")
.HasValue<Manganato>("Manganato")
.HasValue<MangaDex>("MangaDex");
modelBuilder.Entity<LibraryConnector>()
.HasDiscriminator<LibraryType>(l => l.LibraryType)
.HasValue<Komga>(LibraryType.Komga)
.HasValue<Kavita>(LibraryType.Kavita);
modelBuilder.Entity<Job>()
.HasDiscriminator<JobType>(j => j.JobType)
.HasValue<MoveFileOrFolderJob>(JobType.MoveFileOrFolderJob)
.HasValue<DownloadAvailableChaptersJob>(JobType.DownloadAvailableChaptersJob)
.HasValue<DownloadSingleChapterJob>(JobType.DownloadSingleChapterJob)
.HasValue<DownloadMangaCoverJob>(JobType.DownloadMangaCoverJob)
.HasValue<UpdateMetadataJob>(JobType.UpdateMetaDataJob)
.HasValue<RetrieveChaptersJob>(JobType.RetrieveChaptersJob)
.HasValue<UpdateFilesDownloadedJob>(JobType.UpdateFilesDownloadedJob);
modelBuilder.Entity<Job>()
.HasOne<Job>(j => j.ParentJob)
.WithMany()
.HasForeignKey(j => j.ParentJobId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Job>()
.HasMany<Job>(j => j.DependsOnJobs)
.WithMany();
modelBuilder.Entity<DownloadAvailableChaptersJob>()
.Navigation(dncj => dncj.Manga)
.AutoInclude();
modelBuilder.Entity<DownloadSingleChapterJob>()
.Navigation(dscj => dscj.Chapter)
.AutoInclude();
modelBuilder.Entity<UpdateMetadataJob>()
.Navigation(umj => umj.Manga)
.AutoInclude();
modelBuilder.Entity<Manga>()
.HasOne<MangaConnector>(m => m.MangaConnector)
.WithMany()
.HasForeignKey(m => m.MangaConnectorId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Manga>()
.Navigation(m => m.MangaConnector)
.AutoInclude();
modelBuilder.Entity<Manga>()
.HasOne<LocalLibrary>(m => m.Library)
.WithMany()
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Manga>()
.Navigation(m => m.Library)
.AutoInclude();
modelBuilder.Entity<Manga>()
.HasMany<Author>(m => m.Authors)
.WithMany();
modelBuilder.Entity<Manga>()
.Navigation(m => m.Authors)
.AutoInclude();
modelBuilder.Entity<Manga>()
.HasMany<MangaTag>(m => m.MangaTags)
.WithMany();
modelBuilder.Entity<Manga>()
.Navigation(m => m.MangaTags)
.AutoInclude();
modelBuilder.Entity<Manga>()
.HasMany<Link>(m => m.Links)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Manga>()
.Navigation(m => m.Links)
.AutoInclude();
modelBuilder.Entity<Manga>()
.HasMany<MangaAltTitle>(m => m.AltTitles)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Manga>()
.Navigation(m => m.AltTitles)
.AutoInclude();
modelBuilder.Entity<Chapter>()
.HasOne<Manga>(c => c.ParentManga)
.WithMany()
.HasForeignKey(c => c.ParentMangaId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Chapter>()
.Navigation(c => c.ParentManga)
.AutoInclude();
}
}

37
API/TokenGen.cs Normal file
View File

@ -0,0 +1,37 @@
using System.Security.Cryptography;
using System.Text;
namespace API;
public static class TokenGen
{
private const int MinimumLength = 32;
private const int MaximumLength = 64;
private const string Chars = "abcdefghijklmnopqrstuvwxyz0123456789";
public static string CreateToken(Type t, params string[] identifiers) => CreateToken(t.Name, identifiers);
public static string CreateToken(string prefix, params string[] identifiers)
{
if (prefix.Length + 1 >= MaximumLength - MinimumLength)
throw new ArgumentException("Prefix to long to create Token of meaningful length.");
int tokenLength = MaximumLength - prefix.Length - 1;
if (identifiers.Length == 0)
{
// No identifier, just create a random token
byte[] rng = new byte[tokenLength];
RandomNumberGenerator.Create().GetBytes(rng);
string key = new(rng.Select(b => Chars[b % Chars.Length]).ToArray());
key = string.Join('-', prefix, key);
return key;
}
// Identifier provided, create a token based on the identifier hashed
byte[] hash = MD5.HashData(Encoding.UTF8.GetBytes(string.Join("", identifiers)));
string token = Convert.ToHexStringLower(hash);
return string.Join('-', prefix, token);
}
}

271
API/Tranga.cs Normal file
View File

@ -0,0 +1,271 @@
using API.Schema;
using API.Schema.Jobs;
using API.Schema.MangaConnectors;
using API.Schema.NotificationConnectors;
using log4net;
using log4net.Config;
using Microsoft.EntityFrameworkCore;
namespace API;
public static class Tranga
{
public static Thread NotificationSenderThread { get; } = new (NotificationSender);
public static Thread JobStarterThread { get; } = new (JobStarter);
private static readonly ILog Log = LogManager.GetLogger(typeof(Tranga));
internal static void StartLogger()
{
BasicConfigurator.Configure();
Log.Info("Logger Configured.");
}
private static void NotificationSender(object? serviceProviderObj)
{
if (serviceProviderObj is null)
{
Log.Error("serviceProviderObj is null");
return;
}
IServiceProvider serviceProvider = (IServiceProvider)serviceProviderObj!;
using IServiceScope scope = serviceProvider.CreateScope();
PgsqlContext? context = scope.ServiceProvider.GetService<PgsqlContext>();
if (context is null)
{
Log.Error("PgsqlContext is null");
return;
}
try
{
//Removing Notifications from previous runs
IQueryable<Notification> staleNotifications =
context.Notifications.Where(n => n.Urgency < NotificationUrgency.Normal);
context.Notifications.RemoveRange(staleNotifications);
context.SaveChanges();
}
catch (DbUpdateException e)
{
Log.Error("Error removing stale notifications.", e);
}
while (true)
{
SendNotifications(serviceProvider, NotificationUrgency.High);
SendNotifications(serviceProvider, NotificationUrgency.Normal);
SendNotifications(serviceProvider, NotificationUrgency.Low);
Thread.Sleep(2000);
}
}
private static void SendNotifications(IServiceProvider serviceProvider, NotificationUrgency urgency)
{
Log.Info($"Sending notifications for {urgency}");
using IServiceScope scope = serviceProvider.CreateScope();
PgsqlContext? context = scope.ServiceProvider.GetService<PgsqlContext>();
if (context is null)
{
Log.Error("PgsqlContext is null");
return;
}
List<Notification> notifications = context.Notifications.Where(n => n.Urgency == urgency).ToList();
if (!notifications.Any())
return;
try
{
foreach (NotificationConnector notificationConnector in context.NotificationConnectors)
{
foreach (Notification notification in notifications)
notificationConnector.SendNotification(notification.Title, notification.Message);
}
context.Notifications.RemoveRange(notifications);
context.SaveChangesAsync();
}
catch (DbUpdateException e)
{
Log.Error("Error sending notifications.", e);
}
}
private const string TRANGA =
"\n\n" +
" _______ \n" +
"|_ _|.----..---.-..-----..-----..---.-.\n" +
" | | | _|| _ || || _ || _ |\n" +
" |___| |__| |___._||__|__||___ ||___._|\n" +
" |_____| \n\n";
private static readonly Dictionary<Thread, Job> RunningJobs = new();
private static void JobStarter(object? serviceProviderObj)
{
if (serviceProviderObj is null)
{
Log.Error("serviceProviderObj is null");
return;
}
IServiceProvider serviceProvider = (IServiceProvider)serviceProviderObj;
using IServiceScope scope = serviceProvider.CreateScope();
PgsqlContext? context = scope.ServiceProvider.GetService<PgsqlContext>();
if (context is null)
{
Log.Error("PgsqlContext is null");
return;
}
Log.Info(TRANGA);
Log.Info("JobStarter Thread running.");
while (true)
{
List<Job> completedJobs = context.Jobs.Where(j => j.state >= JobState.Completed).ToList();
Log.Debug($"Completed jobs: {completedJobs.Count}");
foreach (Job job in completedJobs)
if (job.RecurrenceMs <= 0)
context.Jobs.Remove(job);
else
{
if (job.state >= JobState.Failed)
job.Enabled = false;
else
job.state = JobState.Waiting;
job.LastExecution = DateTime.UtcNow;
}
List<Job> runJobs = context.Jobs.Where(j => j.state <= JobState.Running && j.Enabled == true).ToList()
.Where(j => j.NextExecution < DateTime.UtcNow).ToList();
Log.Debug($"Due jobs: {runJobs.Count}");
Log.Debug($"Running jobs: {RunningJobs.Count}");
IEnumerable<Job> orderedJobs = OrderJobs(runJobs, context).ToList();
Log.Debug($"Ordered jobs: {orderedJobs.Count()}");
foreach (Job job in orderedJobs)
{
// If the job is already running, skip it
if (RunningJobs.Values.Any(j => j.JobId == job.JobId)) continue;
//If a Job for that connector is already running, skip it
if (job is DownloadAvailableChaptersJob dncj)
{
if (RunningJobs.Values.Any(j =>
j is DownloadAvailableChaptersJob rdncj &&
rdncj.Manga?.MangaConnector == dncj.Manga?.MangaConnector))
{
continue;
}
}
else if (job is DownloadSingleChapterJob dscj)
{
if (RunningJobs.Values.Any(j =>
j is DownloadSingleChapterJob rdscj && rdscj.Chapter?.ParentManga?.MangaConnector ==
dscj.Chapter?.ParentManga?.MangaConnector))
{
continue;
}
}
Thread t = new(() =>
{
job.Run(serviceProvider);
});
RunningJobs.Add(t, job);
t.Start();
}
(Thread, Job)[] removeFromThreadsList = RunningJobs.Where(t => !t.Key.IsAlive)
.Select(t => (t.Key, t.Value)).ToArray();
Log.Debug($"Remove from Threads List: {removeFromThreadsList.Length}");
foreach ((Thread thread, Job job) thread in removeFromThreadsList)
{
RunningJobs.Remove(thread.thread);
if(context.Jobs.Find(thread.job.JobId) is not null)
context.Jobs.Update(thread.job);
}
try
{
context.SaveChanges();
}
catch (DbUpdateException e)
{
Log.Error("Failed saving Job changes.", e);
}
Thread.Sleep(TrangaSettings.startNewJobTimeoutMs);
}
}
private static IEnumerable<Job> OrderJobs(List<Job> jobs, PgsqlContext context)
{
Dictionary<JobType, List<Job>> jobsByType = new();
foreach (Job job in jobs)
if(!jobsByType.TryAdd(job.JobType, [job]))
jobsByType[job.JobType].Add(job);
IEnumerable<Job> ret = new List<Job>();
if(jobsByType.ContainsKey(JobType.MoveMangaLibraryJob))
ret = ret.Concat(jobsByType[JobType.MoveMangaLibraryJob]);
if(jobsByType.ContainsKey(JobType.MoveFileOrFolderJob))
ret = ret.Concat(jobsByType[JobType.MoveFileOrFolderJob]);
if(jobsByType.ContainsKey(JobType.DownloadMangaCoverJob))
ret = ret.Concat(jobsByType[JobType.DownloadMangaCoverJob]);
if(jobsByType.ContainsKey(JobType.UpdateFilesDownloadedJob))
ret = ret.Concat(jobsByType[JobType.UpdateFilesDownloadedJob]);
Dictionary<MangaConnector, List<Job>> metadataJobsByConnector = new();
if (jobsByType.ContainsKey(JobType.DownloadAvailableChaptersJob))
{
foreach (DownloadAvailableChaptersJob job in jobsByType[JobType.DownloadAvailableChaptersJob])
{
Manga manga = job.Manga ?? context.Mangas.Find(job.MangaId)!;
MangaConnector connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId)!;
if(!metadataJobsByConnector.TryAdd(connector, [job]))
metadataJobsByConnector[connector].Add(job);
}
}
if (jobsByType.ContainsKey(JobType.UpdateMetaDataJob))
{
foreach (UpdateMetadataJob job in jobsByType[JobType.UpdateMetaDataJob])
{
Manga manga = job.Manga ?? context.Mangas.Find(job.MangaId)!;
MangaConnector connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId)!;
if(!metadataJobsByConnector.TryAdd(connector, [job]))
metadataJobsByConnector[connector].Add(job);
}
}
if (jobsByType.ContainsKey(JobType.RetrieveChaptersJob))
{
foreach (RetrieveChaptersJob job in jobsByType[JobType.RetrieveChaptersJob])
{
Manga manga = job.Manga ?? context.Mangas.Find(job.MangaId)!;
MangaConnector connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId)!;
if(!metadataJobsByConnector.TryAdd(connector, [job]))
metadataJobsByConnector[connector].Add(job);
}
}
foreach (List<Job> metadataJobs in metadataJobsByConnector.Values)
ret = ret.Append(metadataJobs.MinBy(j => j.NextExecution))!;
if (jobsByType.ContainsKey(JobType.DownloadSingleChapterJob))
{
Dictionary<MangaConnector, List<DownloadSingleChapterJob>> downloadJobsByConnector = new();
foreach (DownloadSingleChapterJob job in jobsByType[JobType.DownloadSingleChapterJob])
{
Chapter chapter = job.Chapter ?? context.Chapters.Find(job.ChapterId)!;
Manga manga = chapter.ParentManga ?? context.Mangas.Find(chapter.ParentMangaId)!;
MangaConnector connector = manga.MangaConnector ?? context.MangaConnectors.Find(manga.MangaConnectorId)!;
if(!downloadJobsByConnector.TryAdd(connector, [job]))
downloadJobsByConnector[connector].Add(job);
}
//From all jobs select those that are supposed to be executed soonest, then select the minimum chapternumber
foreach (List<DownloadSingleChapterJob> downloadJobs in downloadJobsByConnector.Values)
ret = ret.Append(
downloadJobs.Where(j => j.NextExecution == downloadJobs
.MinBy(mj => mj.NextExecution)!.NextExecution)
.MinBy(j => j.Chapter ?? context.Chapters.Find(j.ChapterId)!))!;
}
return ret;
}
}

178
API/TrangaSettings.cs Normal file
View File

@ -0,0 +1,178 @@
using System.Runtime.InteropServices;
using API.MangaDownloadClients;
using API.Schema;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace API;
public static class TrangaSettings
{
public static string downloadLocation { get; private set; } = (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/Manga" : Path.Join(Directory.GetCurrentDirectory(), "Downloads"));
public static string workingDirectory { get; private set; } = Path.Join(RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/usr/share" : Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "tranga-api");
[JsonIgnore]
internal static readonly string DefaultUserAgent = $"Tranga ({Enum.GetName(Environment.OSVersion.Platform)}; {(Environment.Is64BitOperatingSystem ? "x64" : "")}) / 1.0";
public static string userAgent { get; private set; } = DefaultUserAgent;
public static int compression{ get; private set; } = 40;
public static bool bwImages { get; private set; } = false;
/// <summary>
/// Placeholders:
/// %M Manga Name
/// %V Volume
/// %C Chapter
/// %T Title
/// %A Author (first in list)
/// %I Chapter Internal ID
/// %i Manga Internal ID
/// %Y Year (Manga)
///
/// ?_(...) replace _ with a value from above:
/// Everything inside the braces will only be added if the value of %_ is not null
/// </summary>
public static string chapterNamingScheme { get; private set; } = "%M - ?V(Vol.%V )Ch.%C?T( - %T)";
[JsonIgnore]
public static string settingsFilePath => Path.Join(workingDirectory, "settings.json");
[JsonIgnore]
public static string coverImageCache => Path.Join(workingDirectory, "imageCache");
public static bool aprilFoolsMode { get; private set; } = true;
public static int startNewJobTimeoutMs { get; private set; } = 1000;
[JsonIgnore]
internal static readonly Dictionary<RequestType, int> DefaultRequestLimits = new ()
{
{RequestType.MangaInfo, 250},
{RequestType.MangaDexFeed, 250},
{RequestType.MangaDexImage, 40},
{RequestType.MangaImage, 60},
{RequestType.MangaCover, 250},
{RequestType.Default, 60}
};
public static Dictionary<RequestType, int> requestLimits { get; private set; } = DefaultRequestLimits;
public static TimeSpan NotificationUrgencyDelay(NotificationUrgency urgency) => urgency switch
{
NotificationUrgency.High => TimeSpan.Zero,
NotificationUrgency.Normal => TimeSpan.FromMinutes(5),
NotificationUrgency.Low => TimeSpan.FromMinutes(10),
_ => TimeSpan.FromHours(1)
}; //TODO make this a setting?
public static void Load()
{
if(File.Exists(settingsFilePath))
Deserialize(File.ReadAllText(settingsFilePath));
else return;
Directory.CreateDirectory(downloadLocation);
ExportSettings();
}
public static void UpdateAprilFoolsMode(bool enabled)
{
aprilFoolsMode = enabled;
ExportSettings();
}
public static void UpdateCompressImages(int value)
{
compression = int.Clamp(value, 1, 100);
ExportSettings();
}
public static void UpdateBwImages(bool enabled)
{
bwImages = enabled;
ExportSettings();
}
public static void UpdateUserAgent(string? customUserAgent)
{
userAgent = customUserAgent ?? DefaultUserAgent;
ExportSettings();
}
public static void UpdateRequestLimit(RequestType requestType, int newLimit)
{
requestLimits[requestType] = newLimit;
ExportSettings();
}
public static void UpdateChapterNamingScheme(string namingScheme)
{
chapterNamingScheme = namingScheme;
ExportSettings();
}
public static void ResetRequestLimits()
{
requestLimits = DefaultRequestLimits;
ExportSettings();
}
public static void ExportSettings()
{
if (File.Exists(settingsFilePath))
{
while(IsFileInUse(settingsFilePath))
Thread.Sleep(100);
}
else
Directory.CreateDirectory(new FileInfo(settingsFilePath).DirectoryName!);
File.WriteAllText(settingsFilePath, Serialize());
}
internal static bool IsFileInUse(string filePath)
{
if (!File.Exists(filePath))
return false;
try
{
using FileStream stream = new (filePath, FileMode.Open, FileAccess.Read, FileShare.None);
stream.Close();
return false;
}
catch (IOException)
{
return true;
}
}
public static JObject AsJObject()
{
JObject jobj = new ();
jobj.Add("downloadLocation", JToken.FromObject(downloadLocation));
jobj.Add("workingDirectory", JToken.FromObject(workingDirectory));
jobj.Add("userAgent", JToken.FromObject(userAgent));
jobj.Add("aprilFoolsMode", JToken.FromObject(aprilFoolsMode));
jobj.Add("requestLimits", JToken.FromObject(requestLimits));
jobj.Add("compression", JToken.FromObject(compression));
jobj.Add("bwImages", JToken.FromObject(bwImages));
jobj.Add("startNewJobTimeoutMs", JToken.FromObject(startNewJobTimeoutMs));
jobj.Add("chapterNamingScheme", JToken.FromObject(chapterNamingScheme));
return jobj;
}
public static string Serialize() => AsJObject().ToString();
public static void Deserialize(string serialized)
{
JObject jobj = JObject.Parse(serialized);
if (jobj.TryGetValue("downloadLocation", out JToken? dl))
downloadLocation = dl.Value<string>()!;
if (jobj.TryGetValue("workingDirectory", out JToken? wd))
workingDirectory = wd.Value<string>()!;
if (jobj.TryGetValue("userAgent", out JToken? ua))
userAgent = ua.Value<string>()!;
if (jobj.TryGetValue("aprilFoolsMode", out JToken? afm))
aprilFoolsMode = afm.Value<bool>()!;
if (jobj.TryGetValue("requestLimits", out JToken? rl))
requestLimits = rl.ToObject<Dictionary<RequestType, int>>()!;
if (jobj.TryGetValue("compression", out JToken? ci))
compression = ci.Value<int>()!;
if (jobj.TryGetValue("bwImages", out JToken? bwi))
bwImages = bwi.Value<bool>()!;
if (jobj.TryGetValue("startNewJobTimeoutMs", out JToken? snjt))
startNewJobTimeoutMs = snjt.Value<int>()!;
if (jobj.TryGetValue("chapterNamingScheme", out JToken? cns))
chapterNamingScheme = cns.Value<string>()!;
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Error",
"Microsoft.AspNetCore": "Warning"
}
}
}

9
API/appsettings.json Normal file
View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Error",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console.Cli" Version="0.49.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tranga\Tranga.csproj" />
</ItemGroup>
</Project>

View File

@ -1,157 +0,0 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using Logging;
using Spectre.Console;
using Spectre.Console.Cli;
using Tranga;
var app = new CommandApp<TrangaCli>();
return app.Run(args);
internal sealed class TrangaCli : Command<TrangaCli.Settings>
{
public sealed class Settings : CommandSettings
{
[Description("Directory to which downloaded Manga are saved")]
[CommandOption("-d|--downloadLocation")]
[DefaultValue(null)]
public string? downloadLocation { get; init; }
[Description("Directory in which application-data is saved")]
[CommandOption("-w|--workingDirectory")]
[DefaultValue(null)]
public string? workingDirectory { get; init; }
[Description("Enables the file-logger")]
[CommandOption("-f")]
[DefaultValue(null)]
public bool? fileLogger { get; init; }
[Description("Path to save logfile to")]
[CommandOption("-l|--fPath")]
[DefaultValue(null)]
public string? fileLoggerPath { get; init; }
[Description("Port on which to run API on")]
[CommandOption("-p|--port")]
[DefaultValue(null)]
public int? apiPort { get; init; }
}
public override int Execute([NotNull] CommandContext context, [NotNull] Settings settings)
{
List<Logger.LoggerType> enabledLoggers = new();
if(settings.fileLogger is true)
enabledLoggers.Add(Logger.LoggerType.FileLogger);
string? logFolderPath = settings.fileLoggerPath ?? "";
Logger logger = new(enabledLoggers.ToArray(), Console.Out, Console.OutputEncoding, logFolderPath);
if(settings.workingDirectory is not null)
TrangaSettings.LoadFromWorkingDirectory(settings.workingDirectory);
else
TrangaSettings.CreateOrUpdate();
if(settings.downloadLocation is not null)
TrangaSettings.CreateOrUpdate(downloadDirectory: settings.downloadLocation);
Tranga.Tranga? api = null;
Thread trangaApi = new Thread(() =>
{
api = new(logger);
});
trangaApi.Start();
HttpClient client = new();
bool exit = false;
while (!exit)
{
string menuSelect = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Menu")
.PageSize(10)
.MoreChoicesText("Up/Down")
.AddChoices(new[]
{
"CustomRequest",
"Log",
"Exit"
}));
switch (menuSelect)
{
case "CustomRequest":
HttpMethod requestMethod = AnsiConsole.Prompt(
new SelectionPrompt<HttpMethod>()
.Title("Request Type")
.AddChoices(new[]
{
HttpMethod.Get,
HttpMethod.Delete,
HttpMethod.Post
}));
string requestPath = AnsiConsole.Prompt(
new TextPrompt<string>("Request Path:"));
List<ValueTuple<string, string>> parameters = new();
while (AnsiConsole.Confirm("Add Parameter?"))
{
string name = AnsiConsole.Ask<string>("Parameter Name:");
string value = AnsiConsole.Ask<string>("Parameter Value:");
parameters.Add(new ValueTuple<string, string>(name, value));
}
string requestString = $"http://localhost:{TrangaSettings.apiPortNumber}/{requestPath}";
if (parameters.Any())
{
requestString += "?";
foreach (ValueTuple<string, string> parameter in parameters)
requestString += $"{parameter.Item1}={parameter.Item2}&";
}
HttpRequestMessage request = new (requestMethod, requestString);
AnsiConsole.WriteLine($"Request: {request.Method} {request.RequestUri}");
HttpResponseMessage response;
if (AnsiConsole.Confirm("Send Request?"))
response = client.Send(request);
else break;
AnsiConsole.WriteLine($"Response: {(int)response.StatusCode} {response.StatusCode}");
AnsiConsole.WriteLine(response.Content.ReadAsStringAsync().Result);
break;
case "Log":
List<string> lines = logger.Tail(10).ToList();
Rows rows = new Rows(lines.Select(line => new Text(line)));
AnsiConsole.Live(rows).Start(context =>
{
bool running = true;
while (running)
{
string[] newLines = logger.GetNewLines();
if (newLines.Length > 0)
{
lines.AddRange(newLines);
rows = new Rows(lines.Select(line => new Text(line)));
context.UpdateTarget(rows);
}
Thread.Sleep(100);
if (AnsiConsole.Console.Input.IsKeyAvailable())
{
AnsiConsole.Console.Input.ReadKey(true); //Do not process input
running = false;
}
}
});
break;
case "Exit":
exit = true;
break;
}
}
if (api is not null)
api.keepRunning = false;
return 0;
}
}

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
ARG DOTNET=8.0 ARG DOTNET=9.0
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime:$DOTNET AS base FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:$DOTNET AS base
WORKDIR /publish WORKDIR /publish
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
@ -16,9 +16,7 @@ FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:$DOTNET AS build-env
WORKDIR /src WORKDIR /src
COPY Tranga.sln /src COPY Tranga.sln /src
COPY CLI/CLI.csproj /src/CLI/CLI.csproj COPY API/API.csproj /src/API/API.csproj
COPY Logging/Logging.csproj /src/Logging/Logging.csproj
COPY Tranga/Tranga.csproj /src/Tranga/Tranga.csproj
RUN dotnet restore /src/Tranga.sln RUN dotnet restore /src/Tranga.sln
COPY . /src/ COPY . /src/
@ -40,5 +38,5 @@ USER $UNAME
WORKDIR /publish WORKDIR /publish
COPY --chown=1000:1000 --from=build-env /publish . COPY --chown=1000:1000 --from=build-env /publish .
USER 0 USER 0
ENTRYPOINT ["dotnet", "/publish/Tranga.dll"] ENTRYPOINT ["dotnet", "/publish/API.dll"]
CMD ["-f", "-c", "-l", "/usr/share/tranga-api/logs"] CMD ["-f", "-c", "-l", "/usr/share/tranga-api/logs"]

View File

@ -1,32 +0,0 @@
using System.Text;
namespace Logging;
public class FileLogger : LoggerBase
{
internal string logFilePath { get; }
private const int MaxNumberOfLogFiles = 5;
public FileLogger(string logFilePath, Encoding? encoding = null) : base (encoding)
{
this.logFilePath = logFilePath;
DirectoryInfo dir = Directory.CreateDirectory(new FileInfo(logFilePath).DirectoryName!);
//Remove oldest logfile if more than MaxNumberOfLogFiles
for (int fileCount = dir.EnumerateFiles().Count(); fileCount > MaxNumberOfLogFiles - 1; fileCount--) //-1 because we create own logfile later
File.Delete(dir.EnumerateFiles().MinBy(file => file.LastWriteTime)!.FullName);
}
protected override void Write(LogMessage logMessage)
{
try
{
File.AppendAllText(logFilePath, logMessage.formattedMessage);
}
catch (Exception)
{
// ignored
}
}
}

View File

@ -1,17 +0,0 @@
using System.Text;
namespace Logging;
public class FormattedConsoleLogger : LoggerBase
{
private readonly TextWriter _stdOut;
public FormattedConsoleLogger(TextWriter stdOut, Encoding? encoding = null) : base(encoding)
{
this._stdOut = stdOut;
}
protected override void Write(LogMessage message)
{
this._stdOut.Write(message.formattedMessage);
}
}

View File

@ -1,23 +0,0 @@
namespace Logging;
public readonly struct LogMessage
{
public DateTime logTime { get; }
public string caller { get; }
public string value { get; }
public string formattedMessage => ToString();
public LogMessage(DateTime messageTime, string caller, string value)
{
this.logTime = messageTime;
this.caller = caller;
this.value = value;
}
public override string ToString()
{
string dateTimeString = $"{logTime.ToShortDateString()} {logTime.ToLongTimeString()}.{logTime.Millisecond,-3}";
string name = caller.Split(new char[] { '.', '+' }).Last();
return $"[{dateTimeString}] {name.Substring(0, name.Length >= 13 ? 13 : name.Length),13} | {value}";
}
}

View File

@ -1,80 +0,0 @@
using System.Runtime.InteropServices;
using System.Text;
namespace Logging;
public class Logger : TextWriter
{
private static readonly string LogDirectoryPath = RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
? "/var/log/tranga-api"
: Path.Join(Directory.GetCurrentDirectory(), "logs");
public string? logFilePath => _fileLogger?.logFilePath;
public override Encoding Encoding { get; }
public enum LoggerType
{
FileLogger,
ConsoleLogger
}
private readonly FileLogger? _fileLogger;
private readonly FormattedConsoleLogger? _formattedConsoleLogger;
private readonly MemoryLogger _memoryLogger;
public Logger(LoggerType[] enabledLoggers, TextWriter? stdOut, Encoding? encoding, string? logFolderPath)
{
this.Encoding = encoding ?? Encoding.UTF8;
DateTime now = DateTime.Now;
if(enabledLoggers.Contains(LoggerType.FileLogger) && (logFolderPath is null || logFolderPath == ""))
{
string filePath = Path.Join(LogDirectoryPath,
$"{now.ToShortDateString()}_{now.Hour}-{now.Minute}-{now.Second}.log");
_fileLogger = new FileLogger(filePath, encoding);
}else if (enabledLoggers.Contains(LoggerType.FileLogger) && logFolderPath is not null)
_fileLogger = new FileLogger(Path.Join(logFolderPath, $"{now.ToShortDateString()}_{now.Hour}-{now.Minute}-{now.Second}.log") , encoding);
if (enabledLoggers.Contains(LoggerType.ConsoleLogger) && stdOut is not null)
{
_formattedConsoleLogger = new FormattedConsoleLogger(stdOut, encoding);
}
else if (enabledLoggers.Contains(LoggerType.ConsoleLogger) && stdOut is null)
{
_formattedConsoleLogger = null;
throw new ArgumentException($"stdOut can not be null for LoggerType {LoggerType.ConsoleLogger}");
}
_memoryLogger = new MemoryLogger(encoding);
WriteLine(GetType().ToString(), $"Logfile: {logFilePath}");
}
public void WriteLine(string caller, string? value)
{
value = value is null ? Environment.NewLine : string.Concat(value, Environment.NewLine);
Write(caller, value);
}
public void Write(string caller, string? value)
{
if (value is null)
return;
_fileLogger?.Write(caller, value);
_formattedConsoleLogger?.Write(caller, value);
_memoryLogger.Write(caller, value);
}
public string[] Tail(uint? lines)
{
return _memoryLogger.Tail(lines);
}
public string[] GetNewLines()
{
return _memoryLogger.GetNewLines();
}
public string[] GetLog()
{
return _memoryLogger.GetLogMessages();
}
}

View File

@ -1,25 +0,0 @@
using System.Text;
namespace Logging;
public abstract class LoggerBase : TextWriter
{
public override Encoding Encoding { get; }
public LoggerBase(Encoding? encoding = null)
{
this.Encoding = encoding ?? Encoding.ASCII;
}
public void Write(string caller, string? value)
{
if (value is null)
return;
LogMessage message = new (DateTime.Now, caller, value);
Write(message);
}
protected abstract void Write(LogMessage message);
}

View File

@ -1,10 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12</LangVersion>
</PropertyGroup>
</Project>

View File

@ -1,74 +0,0 @@
using System.Text;
namespace Logging;
public class MemoryLogger : LoggerBase
{
private readonly SortedList<DateTime, LogMessage> _logMessages = new();
private int _lastLogMessageIndex = 0;
public MemoryLogger(Encoding? encoding = null) : base(encoding)
{
}
protected override void Write(LogMessage value)
{
lock (_logMessages)
{
_logMessages.Add(DateTime.Now, value);
}
}
public string[] GetLogMessages()
{
return Tail(Convert.ToUInt32(_logMessages.Count));
}
public string[] Tail(uint? length)
{
int retLength;
if (length is null || length > _logMessages.Count)
retLength = _logMessages.Count;
else
retLength = (int)length;
string[] ret = new string[retLength];
for (int retIndex = 0; retIndex < ret.Length; retIndex++)
{
lock (_logMessages)
{
ret[retIndex] = _logMessages.GetValueAtIndex(_logMessages.Count - retLength + retIndex).ToString();
}
}
_lastLogMessageIndex = _logMessages.Count - 1;
return ret;
}
public string[] GetNewLines()
{
int logMessageCount = _logMessages.Count;
List<string> ret = new();
int retIndex = 0;
for (; retIndex < logMessageCount - _lastLogMessageIndex; retIndex++)
{
try
{
lock(_logMessages)
{
ret.Add(_logMessages.GetValueAtIndex(_lastLogMessageIndex + retIndex).ToString());
}
}
catch (NullReferenceException)//Called when LogMessage has not finished writing
{
break;
}
}
_lastLogMessageIndex = _lastLogMessageIndex + retIndex;
return ret.ToArray();
}
}

189
README.md
View File

@ -1,82 +1,77 @@
# Testers for V2 wanted! <span id="readme-top"></span>
[Details](https://github.com/C9Glax/tranga/pull/355#issuecomment-2764217944)
<!-- PROJECT LOGO -->
<br />
<div align="center"> <div align="center">
<h3 align="center">Tranga</h3> <h1 align="center">Tranga v2</h1>
<p align="center"> <p align="center">
Automatic Manga and Metadata downloader Automatic Manga and Metadata downloader
</p> </p>
<p align="center">
This is the API for <a href="https://github.com/C9Glax/tranga-website">Tranga-Website</a> ![GitHub License](https://img.shields.io/github/license/C9glax/tranga)
</p>
<table>
<tr>
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga/master?label=master"></th>
<td><img alt="Last Run" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Fc9glax%2Ftranga%2Factions%2Fworkflows%2Fdocker-image-master.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td>
</tr>
<tr>
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga/cuttingedge?label=cuttingedge"></th>
<td><img alt="Last Run" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Fc9glax%2Ftranga%2Factions%2Fworkflows%2Fdocker-image-cuttingedge.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td>
</tr>
<tr>
<th><img alt="GitHub branch check runs" src="https://img.shields.io/github/check-runs/c9glax/tranga/postgres-Server-V2?label=postgres-Server-V2"></th>
<td><img alt="Last Run" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.github.com%2Frepos%2Fc9glax%2Ftranga%2Factions%2Fworkflows%2Fdocker-image-serverv2.yml%2Fruns%3Fper_page%3D1&query=workflow_runs%5B0%5D.created_at&label=Last%20Run"></td>
</tr>
</table>
</div> </div>
<!-- TABLE OF CONTENTS -->
<details>
<summary>Table of Contents</summary>
<ol>
<li>
<a href="#about-the-project">About The Project</a>
<ul>
<li><a href="#built-with">Built With</a></li>
</ul>
</li>
<li>
<a href="#getting-started">Getting Started</a>
<ul>
<li><a href="#prerequisites">Usage</a></li>
<li><a href="#prerequisites">Prerequisites</a></li>
</ul>
</li>
<li><a href="#roadmap">Roadmap</a></li>
<li><a href="#contributing">Contributing</a></li>
<li><a href="#license">License</a></li>
<li><a href="#acknowledgments">Acknowledgments</a></li>
</ol>
</details>
<!-- ABOUT THE PROJECT --> <!-- ABOUT THE PROJECT -->
## About The Project ## About The Project
Tranga can download Chapters and Metadata from "Scanlation" sites such as Tranga can download Chapters and Metadata from "Scanlation" sites such as
- [MangaDex.org](https://mangadex.org/) (Multilingual) - [MangaDex.org](https://mangadex.org/) (Multilingual)
- [Manganato.com](https://manganato.com/) (en) - [Manganato.gg](https://manganato.com/) (en) (or natomanga.com, mangakakalot, nelomanga, ...)
- [MangaKatana.com](https://mangakatana.com) (en) - [MangaKatana.com](https://mangakatana.com) (en)
- [Mangaworld.bz](https://www.mangaworld.bz/) (it) - [Mangaworld.bz](https://www.mangaworld.bz/) (it)
- [Bato.to](https://bato.to/v3x) (en) - [Bato.to](https://bato.to/v3x) (en)
- [ManhuaPlus](https://manhuaplus.org/) (en) - [ManhuaPlus](https://manhuaplus.org/) (en)
- [MangaHere](https://www.mangahere.cc/) (en) (Their covers aren't scrapeable.) - [MangaHere](https://www.mangahere.cc/) (en)
- [Weebcentral](https://weebcentral.com) (en) - [Weebcentral](https://weebcentral.com) (en)
- [Webtoons](https://www.webtoons.com/en/) - [Webtoons](https://www.webtoons.com/en/) (en)
- ❓ Open an [issue](https://github.com/C9Glax/tranga/issues/new?assignees=&labels=New+Connector&projects=&template=new_connector.yml&title=%5BNew+Connector%5D%3A+) - ❓ Open an [issue](https://github.com/C9Glax/tranga/issues/new?assignees=&labels=New+Connector&projects=&template=new_connector.yml&title=%5BNew+Connector%5D%3A+)
and trigger a library-scan with [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/). and trigger a library-scan with [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/).
Notifications can be sent to your devices using [Gotify](https://gotify.net/), [LunaSea](https://www.lunasea.app/) or [Ntfy](https://ntfy.sh/ Notifications can be sent to your devices using [Gotify](https://gotify.net/), [LunaSea](https://www.lunasea.app/) or [Ntfy](https://ntfy.sh/
), or any other service that can use REST Webhooks.
## What this program does and does *not* do
Tranga (the program in this repository) is a REST-API and worker in one. Meaning it will open a network-port
to listen for requests, and then work through these. Requests include searches for Manga, starting "Jobs" such
as downloading available chapters, creating a monitoring job (that will periodically do the aforementioned),
update metadata, and more.
This repository *does not* include a frontend. A frontend can take many forms, such as a website:
[tranga-website](https://github.com/C9Glax/tranga-website)
When downloading a chapter (meaning the images that make-up the manga) from a Scanlation-Website, Tranga will
additionally try and scrape Metadata from the same website ~~or enhance it from third-party sources~~
(tbd https://github.com/C9Glax/tranga/issues/280).
Downloaded images can be jpeg-compressed and/or made black and white to save on diskspace
(measured at least a 50% reduction in size, without a significant loss of quality).
Tranga will then package the contents of each chapter in a `.cbz`-archive and place it in a common folder per Manga.
If specified, Tranga will then notify library-Managers such as [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/) to trigger a scan for new
chapters. Tranga can also send notifications to your devices via third-party services such as [Gotify](https://gotify.net/), [LunaSea](https://www.lunasea.app/) or [Ntfy](https://ntfy.sh/
). ).
### What this does and doesn't do ## Screenshots
Tranga (this git-repo) will open a port (standard 6531) and listen for requests to add Jobs to Monitor and/or download specific Manga. This repository has no frontend, however checkout [tranga-website](https://github.com/C9Glax/tranga-website) for a default!
The configuration is all done through HTTP-Requests.
_**For a web-frontend use [tranga-website](https://github.com/C9Glax/tranga-website).**_
This project downloads the images for a Manga from the specified Scanlation-Website and packages them with some metadata - from that same website - in a .cbz-archive (per chapter). ## Inspiration:
It does this on an interval, and checks for any Chapters (.cbz-Archive) not already existing in your specified Download-Location. (If you rename or move files, it will download those again)
Tranga can (if configured) trigger a scan in Komga or Kavita, however the directory in which the Manga reside has to be available to both Tranga and Komga/Kavita.
The project doesn't manage metadata, and doesn't curate, change or enhance any information that isn't available on the selected Scanlation-Site.
It will blindly use whatever is scrapes (yes this is a glorified Web-scraper).
### Inspiration:
Because [Kaizoku](https://github.com/oae/kaizoku) was relying on [mangal](https://github.com/metafates/mangal) and mangal Because [Kaizoku](https://github.com/oae/kaizoku) was relying on [mangal](https://github.com/metafates/mangal) and mangal
hasn't received bugfixes for its issues with Titles not showing up, or throwing errors because of illegal characters, hasn't received bugfixes for its issues with Titles not showing up, or throwing errors because of illegal characters,
@ -86,13 +81,23 @@ That is why I wanted to create my own project, in a language I understand, and t
<p align="right">(<a href="#readme-top">back to top</a>)</p> <p align="right">(<a href="#readme-top">back to top</a>)</p>
### Built With ## Endpoint Documentation
- .NET-Core Endpoints are documented in Swagger. Just spin up an instance, and go to `http://<url>/swagger`.
- Newtonsoft.JSON
- [PuppeteerSharp](https://www.puppeteersharp.com/) ## Built With
- [Html Agility Pack (HAP)](https://html-agility-pack.net/)
- [Soenneker.Utils.String.NeedlemanWunsch](https://github.com/soenneker/soenneker.utils.string.needlemanwunsch) - .NET
- ASP.NET
- Entity Framework
- [PostgreSQL](https://www.postgresql.org/about/licence/)
- [Swagger](https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/LICENSE)
- [Ngpsql](https://github.com/npgsql/npgsql/blob/main/LICENSE)
- [Newtonsoft.Json](https://github.com/JamesNK/Newtonsoft.Json/blob/master/LICENSE.md)
- [PuppeteerSharp](https://github.com/hardkoded/puppeteer-sharp/blob/master/LICENSE)
- [Html Agility Pack (HAP)](https://github.com/zzzprojects/html-agility-pack/blob/master/LICENSE)
- [Soenneker.Utils.String.NeedlemanWunsch](https://github.com/soenneker/soenneker.utils.string.needlemanwunsch/blob/main/LICENSE)
- [Sixlabors.ImageSharp](https://docs-v2.sixlabors.com/articles/imagesharp/index.html#license)
- 💙 Blåhaj 🦈 - 💙 Blåhaj 🦈
<p align="right">(<a href="#readme-top">back to top</a>)</p> <p align="right">(<a href="#readme-top">back to top</a>)</p>
@ -112,60 +117,72 @@ That is why I wanted to create my own project, in a language I understand, and t
### Docker ### Docker
Download [docker-compose.yaml](https://git.bernloehr.eu/glax/Tranga/src/branch/master/docker-compose.yaml) and configure to your needs. An example `docker-compose.yaml` is provided. Mount `/Manga` to wherever you want your chapters (`.cbz`-Archives)
Mount `/Manga` to wherever you want your chapters (`.cbz`-Archives) downloaded (where Komga/Kavita can access them). downloaded (where Komga/Kavita can access them for example).
The `docker-compose` also includes [tranga-website](https://github.com/C9Glax/tranga-website) as frontend. For its configuration refer to the repo README. The file also includes [tranga-website](https://github.com/C9Glax/tranga-website) as frontend. For its configuration refer to the
[Tranga-Website Repository](https://github.com/C9Glax/tranga-website) README.
For compatibility do not execute the compose as root (which you should not do anyways...) but as user that can For compatibility do not execute the compose as root (which you should not do anyways...) but as user that can
access the folder. access the folder. Permission conflicts with Komga and Kavita should thus be limited.
### Prerequisites ### Bare-Metal
#### To Build While not supported/currently built, Tranga will also run Bare-Metal without issue.
[.NET-Core 8.0 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
#### To Run
[.NET-Core 8.0 Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) scroll down a bit, should be on the right the second item.
See the [open issues](https://github.com/C9Glax/tranga/issues) for a full list of proposed features (and known issues). Configuration-Files will be stored per OS:
- Linux `/usr/share/tranga-api`
- Windows `%appdata%/tranga-api`
<p align="right">(<a href="#readme-top">back to top</a>)</p> Downloads (default) are stored in - but this can be configured in `settings.json`:
- Linux `/Manga`
- Windows `%currentDirectory%/Downloads`
#### Prerequisits
[.NET-Core 9.0](https://dotnet.microsoft.com/en-us/download/dotnet/9.0)
<!-- CONTRIBUTING --> <!-- CONTRIBUTING -->
## Contributing ## Contributing
The following is copy & pasted: If you want to contribute, please feel free to fork and create a Pull-Request!
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. General rules:
- Strongly-type your variables. This improves readability.
```csharp
var xyz = Object.GetSomething(); //Do not do this. What type is xyz?
Manga[] zyx = Object.GetAnotherThing(); //I can now easily see that zyx is an Array.
```
If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". **A broad overview of where is what:**<br />
Don't forget to give the project a star! Thanks again!
1. Fork the Project
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the Branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
<p align="right">(<a href="#readme-top">back to top</a>)</p>
- `Program.cs` Configuration for ASP.NET, Swagger (also in `NamedSwaggerGenOptions.cs`, Npgsql
- `Tranga.cs` Job(worker)-Logic
- `Schema/` Entity-Framework
- `Schema/Jobs/` + Logic for Jobs
- `Schema/**/` + Logic for **
- `Schema/PgsqlContext.cs` EF configuration
- `MangaDownloadClients/` Networking-Clients for Scraping
- `Controllers/` ASP.NET Controllers (Endpoints)
- `APIEndpointRecords/` Records for API-Requests with specific Request-Types (Body)
If you want to add a new Scanlationsite-Connector: <br />
1. Copy one of the existing connectors, or start from scratch and inherit from `API.Schema.MangaConnectors.MangaConnector`.
2. Add the new Connector as Object-Instance in `Program.cs` to the MangaConnector-Array `connectors`.
3. In `Schema/PgsqlContext.cs` add the Discriminator for the Connector (the value is the name of the connector, as defined
in the constructor).
<!-- LICENSE --> <!-- LICENSE -->
## License ## License
Distributed under the GNU GPLv3 License. See `LICENSE.txt` for more information. Distributed under the GNU GPLv3 License. See [LICENSE.txt](https://github.com/C9Glax/tranga/blob/master/LICENSE.txt) for more information.
<p align="right">(<a href="#readme-top">back to top</a>)</p> <p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- ACKNOWLEDGMENTS --> <!-- ACKNOWLEDGMENTS -->
## Acknowledgments ## Acknowledgments
* [Choose an Open Source License](https://choosealicense.com) * [Choose an Open Source License](https://choosealicense.com)
* [Font Awesome](https://fontawesome.com)
* [Best-README-Template](https://github.com/othneildrew/Best-README-Template/tree/master) * [Best-README-Template](https://github.com/othneildrew/Best-README-Template/tree/master)
* [Shields.io](https://shields.io/)
<p align="right">(<a href="#readme-top">back to top</a>)</p> <p align="right">(<a href="#readme-top">back to top</a>)</p>

View File

@ -1,10 +1,6 @@
 
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tranga", ".\Tranga\Tranga.csproj", "{545E81B9-D96B-4C8F-A97F-2C02414DE566}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{EDB07E7B-351F-4FCC-9AEF-777838E5551E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logging", "Logging\Logging.csproj", "{415BE889-BB7D-426F-976F-8D977876A462}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CLI", "CLI\CLI.csproj", "{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -12,17 +8,9 @@ Global
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EDB07E7B-351F-4FCC-9AEF-777838E5551E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Debug|Any CPU.Build.0 = Debug|Any CPU {EDB07E7B-351F-4FCC-9AEF-777838E5551E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.ActiveCfg = Release|Any CPU {EDB07E7B-351F-4FCC-9AEF-777838E5551E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.Build.0 = Release|Any CPU {EDB07E7B-351F-4FCC-9AEF-777838E5551E}.Release|Any CPU.Build.0 = Release|Any CPU
{415BE889-BB7D-426F-976F-8D977876A462}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{415BE889-BB7D-426F-976F-8D977876A462}.Debug|Any CPU.Build.0 = Debug|Any CPU
{415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.ActiveCfg = Release|Any CPU
{415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.Build.0 = Release|Any CPU
{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@ -1,159 +0,0 @@
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using static System.IO.UnixFileMode;
namespace Tranga;
/// <summary>
/// Has to be Part of a publication
/// Includes the Chapter-Name, -VolumeNumber, -ChapterNumber, the location of the chapter on the internet and the saveName of the local file.
/// </summary>
public readonly struct Chapter : IComparable
{
// ReSharper disable once MemberCanBePrivate.Global
public Manga parentManga { get; }
public string? name { get; }
public float volumeNumber { get; }
public float chapterNumber { get; }
public string url { get; }
// ReSharper disable once MemberCanBePrivate.Global
public string fileName { get; }
public string? id { get; }
private static readonly Regex LegalCharacters = new (@"([A-z]*[0-9]* *\.*-*,*\]*\[*'*\'*\)*\(*~*!*)*");
private static readonly Regex IllegalStrings = new(@"(Vol(ume)?|Ch(apter)?)\.?", RegexOptions.IgnoreCase);
public Chapter(Manga parentManga, string? name, string? volumeNumber, string chapterNumber, string url, string? id = null)
: this(parentManga, name, float.Parse(volumeNumber??"0", GlobalBase.numberFormatDecimalPoint),
float.Parse(chapterNumber, GlobalBase.numberFormatDecimalPoint), url, id)
{
}
public Chapter(Manga parentManga, string? name, float? volumeNumber, float chapterNumber, string url, string? id = null)
{
this.parentManga = parentManga;
this.name = name;
this.volumeNumber = volumeNumber??0;
this.chapterNumber = chapterNumber;
this.url = url;
this.id = id;
string chapterVolNumStr = $"Vol.{this.volumeNumber} Ch.{chapterNumber}";
if (name is not null && name.Length > 0)
{
string chapterName = IllegalStrings.Replace(string.Concat(LegalCharacters.Matches(name)), "");
this.fileName = chapterName.Length > 0 ? $"{chapterVolNumStr} - {chapterName}" : chapterVolNumStr;
}
else
this.fileName = chapterVolNumStr;
}
public override string ToString()
{
return $"Chapter {parentManga.sortName} {parentManga.internalId} {chapterNumber} {name}";
}
public override bool Equals(object? obj)
{
if (obj is not Chapter)
return false;
return CompareTo(obj) == 0;
}
public int CompareTo(object? obj)
{
if(obj is not Chapter otherChapter)
throw new ArgumentException($"{obj} can not be compared to {this}");
return volumeNumber.CompareTo(otherChapter.volumeNumber) switch
{
<0 => -1,
>0 => 1,
_ => chapterNumber.CompareTo(otherChapter.chapterNumber)
};
}
/// <summary>
/// Checks if a chapter-archive is already present
/// </summary>
/// <returns>true if chapter is present</returns>
internal bool CheckChapterIsDownloaded()
{
string mangaDirectory = Path.Join(TrangaSettings.downloadLocation, parentManga.folderName);
if (!Directory.Exists(mangaDirectory))
return false;
FileInfo? mangaArchive = null;
string markerPath = Path.Join(mangaDirectory, $".{id}");
if (this.id is not null && File.Exists(markerPath))
{
if(File.Exists(File.ReadAllText(markerPath)))
mangaArchive = new FileInfo(File.ReadAllText(markerPath));
else
File.Delete(markerPath);
}
if(mangaArchive is null)
{
FileInfo[] archives = new DirectoryInfo(mangaDirectory).GetFiles("*.cbz");
Regex volChRex = new(@"(?:Vol(?:ume)?\.([0-9]+)\D*)?Ch(?:apter)?\.([0-9]+(?:\.[0-9]+)*)(?: - (.*))?.cbz");
Chapter t = this;
mangaArchive = archives.FirstOrDefault(archive =>
{
Match m = volChRex.Match(archive.Name);
/*
* 1. If the volumeNumber is not present in the filename, it is not checked.
* 2. Check the chapterNumber in the chapter against the one in the filename.
* 3. The chpaterName has to either be absent both in the chapter and the filename or match.
*/
return (!m.Groups[1].Success || m.Groups[1].Value == t.volumeNumber.ToString(GlobalBase.numberFormatDecimalPoint)) &&
m.Groups[2].Value == t.chapterNumber.ToString(GlobalBase.numberFormatDecimalPoint) &&
((!m.Groups[3].Success && string.IsNullOrEmpty(t.name)) || m.Groups[3].Value == t.name);
});
}
string correctPath = GetArchiveFilePath();
if(mangaArchive is not null && mangaArchive.FullName != correctPath)
mangaArchive.MoveTo(correctPath, true);
return (mangaArchive is not null);
}
public void CreateChapterMarker()
{
if (this.id is null)
return;
string path = Path.Join(TrangaSettings.downloadLocation, parentManga.folderName, $".{id}");
File.WriteAllText(path, GetArchiveFilePath());
File.SetAttributes(path, FileAttributes.Hidden);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(path, UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute | OtherRead | OtherExecute);
}
/// <summary>
/// Creates full file path of chapter-archive
/// </summary>
/// <returns>Filepath</returns>
internal string GetArchiveFilePath()
{
return Path.Join(TrangaSettings.downloadLocation, parentManga.folderName, $"{parentManga.folderName} - {this.fileName}.cbz");
}
/// <summary>
/// Creates a string containing XML of publication and chapter.
/// See ComicInfo.xml
/// </summary>
/// <returns>XML-string</returns>
internal string GetComicInfoXmlString()
{
XElement comicInfo = new XElement("ComicInfo",
new XElement("Tags", string.Join(',', parentManga.tags)),
new XElement("LanguageISO", parentManga.originalLanguage),
new XElement("Title", this.name),
new XElement("Writer", string.Join(',', parentManga.authors)),
new XElement("Volume", this.volumeNumber),
new XElement("Number", this.chapterNumber)
);
return comicInfo.ToString();
}
}

View File

@ -1,143 +0,0 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Logging;
using Newtonsoft.Json;
using Tranga.LibraryConnectors;
using Tranga.NotificationConnectors;
namespace Tranga;
public abstract class GlobalBase
{
[JsonIgnore]
public Logger? logger { get; init; }
protected HashSet<NotificationConnector> notificationConnectors { get; init; }
protected HashSet<LibraryConnector> libraryConnectors { get; init; }
private Dictionary<string, Manga> cachedPublications { get; init; }
public static readonly NumberFormatInfo numberFormatDecimalPoint = new (){ NumberDecimalSeparator = "." };
protected static readonly Regex baseUrlRex = new(@"https?:\/\/[0-9A-z\.-]+(:[0-9]+)?");
protected GlobalBase(GlobalBase clone)
{
this.logger = clone.logger;
this.notificationConnectors = clone.notificationConnectors;
this.libraryConnectors = clone.libraryConnectors;
this.cachedPublications = clone.cachedPublications;
}
protected GlobalBase(Logger? logger)
{
this.logger = logger;
this.notificationConnectors = TrangaSettings.LoadNotificationConnectors(this);
this.libraryConnectors = TrangaSettings.LoadLibraryConnectors(this);
this.cachedPublications = new();
}
protected void AddMangaToCache(Manga manga)
{
if (!this.cachedPublications.TryAdd(manga.internalId, manga))
{
Log($"Overwriting Manga {manga.internalId}");
this.cachedPublications[manga.internalId] = manga;
}
}
protected Manga? GetCachedManga(string internalId)
{
return cachedPublications.TryGetValue(internalId, out Manga manga) switch
{
true => manga,
_ => null
};
}
protected IEnumerable<Manga> GetAllCachedManga()
{
return cachedPublications.Values;
}
protected void Log(string message)
{
logger?.WriteLine(this.GetType().Name, message);
}
protected void Log(string fStr, params object?[] replace)
{
Log(string.Format(fStr, replace));
}
protected void SendNotifications(string title, string text, bool buffer = false)
{
foreach (NotificationConnector nc in notificationConnectors)
nc.SendNotification(title, text, buffer);
}
protected void AddNotificationConnector(NotificationConnector notificationConnector)
{
Log($"Adding {notificationConnector}");
notificationConnectors.RemoveWhere(nc => nc.notificationConnectorType == notificationConnector.notificationConnectorType);
notificationConnectors.Add(notificationConnector);
while(IsFileInUse(TrangaSettings.notificationConnectorsFilePath))
Thread.Sleep(100);
Log("Exporting notificationConnectors");
File.WriteAllText(TrangaSettings.notificationConnectorsFilePath, JsonConvert.SerializeObject(notificationConnectors));
}
protected void DeleteNotificationConnector(NotificationConnector.NotificationConnectorType notificationConnectorType)
{
Log($"Removing {notificationConnectorType}");
notificationConnectors.RemoveWhere(nc => nc.notificationConnectorType == notificationConnectorType);
while(IsFileInUse(TrangaSettings.notificationConnectorsFilePath))
Thread.Sleep(100);
Log("Exporting notificationConnectors");
File.WriteAllText(TrangaSettings.notificationConnectorsFilePath, JsonConvert.SerializeObject(notificationConnectors));
}
protected void UpdateLibraries()
{
foreach(LibraryConnector lc in libraryConnectors)
lc.UpdateLibrary();
}
protected void AddLibraryConnector(LibraryConnector libraryConnector)
{
Log($"Adding {libraryConnector}");
libraryConnectors.RemoveWhere(lc => lc.libraryType == libraryConnector.libraryType);
libraryConnectors.Add(libraryConnector);
while(IsFileInUse(TrangaSettings.libraryConnectorsFilePath))
Thread.Sleep(100);
Log("Exporting libraryConnectors");
File.WriteAllText(TrangaSettings.libraryConnectorsFilePath, JsonConvert.SerializeObject(libraryConnectors, Formatting.Indented));
}
protected void DeleteLibraryConnector(LibraryConnector.LibraryType libraryType)
{
Log($"Removing {libraryType}");
libraryConnectors.RemoveWhere(lc => lc.libraryType == libraryType);
while(IsFileInUse(TrangaSettings.libraryConnectorsFilePath))
Thread.Sleep(100);
Log("Exporting libraryConnectors");
File.WriteAllText(TrangaSettings.libraryConnectorsFilePath, JsonConvert.SerializeObject(libraryConnectors, Formatting.Indented));
}
protected bool IsFileInUse(string filePath) => IsFileInUse(filePath, this.logger);
public static bool IsFileInUse(string filePath, Logger? logger)
{
if (!File.Exists(filePath))
return false;
try
{
using FileStream stream = new (filePath, FileMode.Open, FileAccess.Read, FileShare.None);
stream.Close();
return false;
}
catch (IOException)
{
logger?.WriteLine($"File is in use {filePath}");
return true;
}
}
}

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