398 Commits

Author SHA1 Message Date
a8f0f1af15 More API Requests 2023-08-26 02:43:24 +02:00
0cf3a95f58 cachedPublications 2023-08-26 02:42:57 +02:00
a89a526fda Default language GetChapters: en 2023-08-26 02:42:31 +02:00
4d1e43e7b3 Job: add Id 2023-08-26 02:40:24 +02:00
7614f9aad3 Add User Agent to MangaConnectors 2023-08-26 01:50:31 +02:00
97c0e42512 Handle first requests, add parameter parser 2023-08-26 01:47:36 +02:00
565bc0775d Add Connectors to Tranga 2023-08-26 01:47:15 +02:00
e6a3fa2899 public GetPublications 2023-08-26 01:46:36 +02:00
2d82279d98 Added startup args, and first http-requesthandler 2023-08-24 13:35:07 +02:00
c5559a4ceb Save api-Portnumber in settings 2023-08-24 13:34:43 +02:00
2572a537ab Job Inherits from GlobalBase 2023-08-24 13:34:23 +02:00
58db049496 Merged MonitorJobs and CheckJobs in JobBoss 2023-08-24 13:34:09 +02:00
8f309fcfd7 Library- and NotificationConnectors in GlobalBase 2023-08-24 13:33:33 +02:00
11461051f3 Fixed missing filelogger crash 2023-08-24 12:13:34 +02:00
a4aa571870 Added Jobs and ProgressToken 2023-08-04 14:51:40 +02:00
e4086a8892 Rename TBaseObject -> GlobalBase
Remove Notification and Library Connectors from GlobalBase
2023-08-01 18:24:19 +02:00
c45e4ddf90 Rename Connectors -> MangaConnectors 2023-08-01 18:22:24 +02:00
675effd317 Trash everything and writing everything from scratch 2023-08-01 18:21:29 +02:00
a4f67c9ab4 Merge pull request 'Fixes for MangaKatana' (!53) from cuttingedge into master
Reviewed-on: #53
2023-07-31 23:09:24 +02:00
2538a29788 MangaKatana fix search result characters 2023-07-31 23:05:29 +02:00
81d5802092 MangaKatana fix bug where empty result in search would crash program 2023-07-31 23:03:46 +02:00
436edfde66 Fix issue where closed connection crashes api 2023-07-31 22:58:41 +02:00
00c1cd56b8 Merge pull request '#31 #40' (!52) from cuttingedge into master
Reviewed-on: #52
2023-07-31 22:50:22 +02:00
a63154b581 Fix new installation startup issue where version would be null on new installs 2023-07-31 22:47:35 +02:00
53fe7ee983 Possible fix for #31
chapter regex
2023-07-31 22:47:14 +02:00
6fb4098c16 Merge pull request 'Missing logger, breaking version in settings.json' (!51) from cuttingedge into master
Reviewed-on: #51
2023-07-31 02:14:06 +02:00
7a024e8733 Add logger to CommonObjects on deserialiazation 2023-07-31 02:11:53 +02:00
835e239be5 Cleanup 2023-07-31 02:07:39 +02:00
df8538c3b4 Merge pull request 'version' (!50) from cuttingedge into master
Reviewed-on: #50
2023-07-31 01:59:42 +02:00
f832fe0de3 version 2023-07-31 01:58:00 +02:00
ebdb38bd57 Merge pull request 'Moving away from API/CLI model, combined into single executable.' (!49) from cuttingedge into master
Reviewed-on: #49
2023-07-31 01:53:50 +02:00
e3201a9b99 Ignore Logger 2023-07-31 01:50:26 +02:00
eb50b84266 Converters 2023-07-31 01:48:40 +02:00
b3d778ff56 accessibility 2023-07-31 01:45:55 +02:00
00861c406a added logging 2023-07-31 01:42:15 +02:00
01c8784bab wrong array 2023-07-31 01:30:32 +02:00
3aa299e48a deserialization of enum 2023-07-31 01:28:32 +02:00
d1ce244135 New Migration to new commonObjects 2023-07-31 01:26:38 +02:00
c91754614b weird env 2023-07-31 00:58:22 +02:00
70b1ae4812 isLinux 2023-07-31 00:52:27 +02:00
336e08aebf If not running cli add back console output 2023-07-31 00:46:14 +02:00
18134cdf01 If not running cli add back console output 2023-07-31 00:43:57 +02:00
5b89cbd042 Only run TaskMode on Windows 2023-07-31 00:41:25 +02:00
74aca86b62 Wrong entrypoint 2023-07-31 00:36:56 +02:00
e5abaa4549 Wrong entrypoint 2023-07-31 00:35:11 +02:00
eb0eb71e86 wrong dockerfile 2023-07-31 00:33:57 +02:00
4e73b0a4cf wrong dockerfile 2023-07-31 00:32:42 +02:00
140074208f Merged API and CLI into one. 2023-07-31 00:31:19 +02:00
fa19d3da14 Fix missing file on loading settings/commonobjects 2023-07-31 00:01:18 +02:00
3d6657b483 Moved libraryManagers, notificationManagers and logger to commonObjects class. 2023-07-30 23:31:25 +02:00
f9b5e05974 Merge pull request #39 from C9Glax/cuttingedge
Move Namespaces, move logger to TrangaSettings, move downloadClient to seperate File, remove deprecated calls
2023-07-30 17:34:06 +02:00
ad4027779f Remove Deprecated CreateUpdateLibraryTask 2023-07-30 17:29:30 +02:00
98ec0b837f Remove Enter input from settings, instead update all settings on click of "Update" Button.
resolves #38
2023-07-30 17:27:47 +02:00
1afa3df316 Cleanup build warnings, ReShaper, Dictionary 2023-07-30 17:25:04 +02:00
d83aa1ef5b deprecated 2023-07-30 17:11:11 +02:00
b610ec734e Chapter readonly struct 2023-07-30 17:09:39 +02:00
abf587377c API: Changed uninstantiated class Program to static 2023-07-30 17:09:30 +02:00
437349bd27 TrangaSettings changed set directive 2023-07-30 17:09:10 +02:00
000539d6a6 Moved logger to Trangasettings 2023-07-30 17:08:43 +02:00
b4bef25a22 Moved downloadclient to separate file 2023-07-30 17:04:43 +02:00
579e400a5d Moved class to appropriate namespaces 2023-07-30 17:01:54 +02:00
8af2b12fc0 Moved class to appropriate namespaces 2023-07-30 16:26:29 +02:00
bad4330330 introduce branch cuttingedge 2023-07-30 16:21:04 +02:00
42596752d3 FIX: null Publications in tasks 2023-07-29 18:55:06 +02:00
16238c590b Remove UpdateLibrariesTask 2023-07-29 18:20:41 +02:00
9f38dc3b6a Revert "Remove UpdateLibrariesTask"
This reverts commit de14ff0b75.
2023-07-29 18:18:02 +02:00
485637d99a Added Min-Chapter-Number to API 2023-07-28 10:47:36 +02:00
de14ff0b75 Remove UpdateLibrariesTask 2023-07-28 10:41:20 +02:00
f947c37bd6 Change website context to revert location to / instead of /Website 2023-07-28 10:30:54 +02:00
77eec0f696 Fix wrong deserialization 2023-07-21 00:32:18 +02:00
18323f9f51 remove debug 2023-07-21 00:22:41 +02:00
2cd2b6842d arch armv7 fails to build 2023-07-21 00:20:13 +02:00
09f815903f arch arm64 fails to build 2023-07-21 00:18:08 +02:00
c108478039 context 2 2023-07-21 00:15:35 +02:00
74289e43b7 context 2023-07-21 00:14:07 +02:00
2779f9ba09 Merge remote-tracking branch 'origin/master' 2023-07-21 00:12:23 +02:00
59a8e556f0 wrong build path 2023-07-21 00:12:09 +02:00
074b137b5c Merge pull request 'dev' (!48) from dev into master
Reviewed-on: #48
2023-07-21 00:10:33 +02:00
3cb2540794 debugging 2023-07-21 00:09:59 +02:00
02c9934896 change context back to API 2023-07-21 00:09:51 +02:00
b2e1c95bca Merge remote-tracking branch 'origin/master' 2023-07-21 00:07:44 +02:00
8c9e3ea6b6 Merge pull request 'split into two actions, dont always build tranga-base' (!47) from dev into master
Reviewed-on: #47
2023-07-21 00:07:16 +02:00
db441607ad Merge branch 'master' into dev 2023-07-21 00:04:24 +02:00
91c56783dc restore absolute path 2023-07-21 00:03:32 +02:00
2c288eeeea Don't rebuild tranga-base every time. 2023-07-20 23:54:30 +02:00
57a1ea91fc Merge pull request 'dev' (!46) from dev into master
Reviewed-on: #46
2023-07-20 23:50:15 +02:00
06138a3927 Workflow change context 2023-07-20 23:49:33 +02:00
84b053e672 Merge remote-tracking branch 'origin/dev' 2023-07-20 23:44:17 +02:00
0fe0cbc4ad Merge pull request #34 from C9Glax/dev
Unsupported arch
2023-07-20 23:42:04 +02:00
62e6ce8363 remove unsupported platforms 2023-07-20 23:38:10 +02:00
a4f3ec6580 Merge pull request #33 from schklom/master
Automatic build of Docker images for many platforms (ARM too)
2023-07-20 23:25:10 +02:00
8b4e996b7e Create dependabot.yml 2023-07-20 23:10:46 +02:00
964540d30f Create docker-image.yml 2023-07-20 23:10:15 +02:00
fa69f4488f Removed UpdateLibraryTask (deprecated).
Libraries will be updated on new Chapters downloaded.
Added Migrator, for future file-changes
2023-07-20 18:15:14 +02:00
42c2876188 Mangakatana chapter num fix 2023-07-16 20:22:33 +02:00
715244ff1b Mangasee more logging 2023-07-16 18:15:28 +02:00
2333cd9095 Mangasee more bad words 2023-07-16 18:15:11 +02:00
c8225db4fe #30 #31 2023-07-16 17:47:00 +02:00
6741ca096b Startup Message 2023-07-16 17:38:42 +02:00
a897a7b3a2 Better Logger.
Includes a formatted Console-Log
2023-07-16 17:33:15 +02:00
0f8932e712 Fixed missing logger for notificationManagers on deserialization 2023-07-09 21:38:49 +02:00
78023ef0fd resolves #21 lunasea 2023-07-09 21:35:15 +02:00
d171f34e4e Update README.md 2023-07-07 14:23:33 +02:00
aa0dc4fa35 Fixes single result redirect 2023-07-06 02:09:56 +02:00
25f48592c0 Added more badwords to filter out when searching mangasee, resolves #26 2023-07-04 22:44:01 +02:00
398ac304d2 Update Komga/Kavita immediately after new chapter is Downloaded 2023-07-03 00:01:08 +02:00
58a62f8272 Mangasee search all title-fields. 2023-07-02 23:54:02 +02:00
86752c9a7e Order of task Execution by due-time 2023-07-02 23:10:16 +02:00
f9a7828d02 Moved notification back to DownloadChapterTask
temp: Dont wait for childTasks to finish to finish parent task
2023-07-02 23:06:24 +02:00
c97ff69148 Fix for new publications: Add to collection 2023-07-02 22:46:01 +02:00
1735bbcf8a Fix wrong query from allTasks to runningTasks 2023-06-30 00:23:00 +02:00
9ae8ca65df resolves #25 characters encoding mistake 2023-06-29 21:09:42 +02:00
00599cd24e Infinite loop on unavailable chapters 2023-06-28 23:00:24 +02:00
6d5618a1f7 Infinite loop on unavailable chapters 2023-06-28 22:46:22 +02:00
a1202a875d Moved sucessstate to taskmanager 2023-06-28 22:43:46 +02:00
98946b4aa3 Fixed null chapterNumber on mangadex 2023-06-28 22:43:24 +02:00
41b6bb77b6 Moved GetPublicationsFromConnector to connector.
Moved GetNewChaptersList to Connector.
Removed knownPublications file
Renamed chapterCollection to collection and only contains Publications
2023-06-28 22:43:03 +02:00
e70a14ca56 Only send notifications if more than 0 new chapters 2023-06-28 19:23:06 +02:00
b099da1156 Chapter fix RegexMatching on chapter number 2023-06-28 00:13:23 +02:00
01d1f922c2 MangaDex chapterNumber non.nullable 2023-06-28 00:13:09 +02:00
47a80d67a8 TrangaTask Success-State and child task deletion 2023-06-27 23:55:13 +02:00
16e3549455 Export Data after deleting task 2023-06-27 23:54:44 +02:00
be8c6b50ba Notification moved to TrangaTask 2023-06-27 23:37:13 +02:00
a38fcf50ca nullable types removed 2023-06-27 23:25:35 +02:00
82f6c7b3fe Moved GetArchiveFilePath, CheckChapterIsDownloaded and GetComicInfoXmlString to Chapter.cs 2023-06-27 23:22:23 +02:00
5586d2c104 Connector CheckChapterIsDownloaded more Regex 2023-06-27 23:14:22 +02:00
62dc9fee2a GetComicInfoXmlString: protected -> internal 2023-06-27 23:09:09 +02:00
ac96fca6dc Chapter illegalstring regex 2023-06-27 23:08:29 +02:00
25a6ceff10 Remove sortNumber-field from Chapter
API: Change Tasks/Progress chapterSortNumber to ChapterNumber
2023-06-27 23:06:37 +02:00
b3e1d39d0f Rename Connector.SearchChapters -> SelectChapters
Added "a(ll)"-option to SelectChapters
2023-06-27 23:02:55 +02:00
2833b7f22a Remove Legacy support for "DownloadNewChapters" 2023-06-27 22:59:33 +02:00
cbdd305b69 TaskManager AddTask make better use of GetTasksMatching and GetTasksMatching easier usage 2023-06-27 22:59:23 +02:00
b88890817e TaskManager _runningDownloadChapterTasks -> _runningTasks for all TrangaTasks 2023-06-27 22:58:40 +02:00
f66ab7d40b Connector use TrangaSettings instead of own values for imageCache and downloadLocation 2023-06-27 22:57:44 +02:00
4cb3694cd5 Re-add task timeout 2023-06-27 22:23:53 +02:00
a05d4c8bd9 Merge remote-tracking branch 'origin/master' 2023-06-27 22:23:23 +02:00
22f87a74b2 Re-add task timeout 2023-06-27 22:23:19 +02:00
ba57282879 Re-add task timeout 2023-06-27 22:19:06 +02:00
9ccba6fba6 Fix CheckChapterIsDownloaded Directory does not exists exception returning 0 chapters 2023-06-25 23:56:22 +02:00
4f01c1166f Fix taskIds being changed during requests, no workaround this time 2023-06-25 23:56:00 +02:00
0a51e7ad3d Fix taskIds being changed during requests 2023-06-25 23:26:36 +02:00
e541b922dc Merge pull request #24 from arxae/master
Added MangaKatana connector
2023-06-25 21:38:18 +02:00
604abd5f9a Fix bug where ChildTasks hung parentTasks 2023-06-24 21:00:26 +02:00
7b311eae75 Will break: CheckChapterIsDownloaded 2023-06-24 20:46:35 +02:00
d4eb72cd99 Required changes 2023-06-23 22:14:27 +02:00
b515215f4b Fix taskIds being changed during requests 2023-06-22 23:09:59 +02:00
a16686dfbf Fix wrong taskNames 2023-06-22 22:52:26 +02:00
4275703941 Added MangaKatana connector 2023-06-22 14:22:21 +02:00
c3342984ea Server fixed bug where ?& in request url caused variables to not parse 2023-06-21 18:04:41 +02:00
ed4bdb5b33 TrangaSettings export after change 2023-06-21 18:04:12 +02:00
0f0902c932 LunaSea changed to id device/id or user/id instead of full url 2023-06-21 18:03:48 +02:00
6508055b43 API Fix closed response socket 2023-06-21 17:42:56 +02:00
abc66511d8 Fixed progress tracking this time for realsies. resolves #5 2023-06-21 17:30:31 +02:00
9ed36c47b5 Fixed taskId on init deserialization 2023-06-21 17:29:48 +02:00
fd1b2a8470 API Fix closed response socket 2023-06-21 17:29:20 +02:00
8058749ab5 Website fix wrong task on deletion 2023-06-21 16:53:56 +02:00
8737617e5f Fix deletion of successful child tasks 2023-06-21 16:53:41 +02:00
7e4f43f1e2 API fix CORS preflight 2023-06-21 16:53:07 +02:00
12b1b2afd6 Server fix interfaces on windows 2023-06-21 16:52:57 +02:00
0f9ac60fcd closes #11 readme update 2023-06-21 16:17:40 +02:00
8c87f2948c README updated screenshots 2023-06-21 16:08:36 +02:00
e0fb817256 Changed glax/tranga-base to latest 2023-06-20 23:26:49 +02:00
cdd2d94ba1 Wrote my own Http-Server.
ASP-NET can **** my **** and *** :)
2023-06-20 23:15:56 +02:00
d5b7645cd2 "Thread-safe" message adding.. 2023-06-20 23:15:22 +02:00
9af5c1603e Using HttpStatusCode to signify Task-Success
When DownloadChapterTask returns notfound, do not retry.
2023-06-20 15:46:54 +02:00
1035939309 Fix overflow 2023-06-20 15:18:58 +02:00
3b542c04f6 ReShaper cleanup,
Remove unnecessary using directives
2023-06-20 14:59:08 +02:00
a809b7c285 Added timeout to Connector DownloadClient 2023-06-20 14:58:02 +02:00
e883277400 Renamed DownloadNewChaptersTask to MonitorPublicationTask
Added TrangaTask.Clone() method
Rewrote TrangaTask.progress for the billionth+1 time.
Removed Increment and DecrementProgress methods
Removed TrangaTask.ReplaceFailedChildTask method
Changed return type of TrangaTask.ExecuteTask to bool, signifying success.
Added Failed Execution state to TrangaTask
Replaced taskManager failed-task logic
Removed TaskManager bulky AddTask and DeleteTask methods
Removed TaskManager bulky Constructor
2023-06-20 14:57:44 +02:00
23dfdc0933 Connector DownloadChapter, DownloadImage, DownloadChapterImages returns successState.
RequestResult replace HttpStatusCode with success-status boolean.
DownloadChapterTask: Only send Notification when Chapter download successful
2023-06-19 22:45:33 +02:00
edc24fff5b Moved notification to DownloadChapterTask, sends when parentTask exists. 2023-06-19 22:34:34 +02:00
6cdccdf66b Fix infinite loop of DownloadNewChaptersTask 2023-06-19 22:32:32 +02:00
a4c9168551 Selector for task-sanitizer 2023-06-19 17:17:47 +02:00
821a1b7c3a Unique IDs for TrangaTask now based on Random-generator 2023-06-19 17:17:24 +02:00
b2b4256972 Startup message api 2023-06-19 16:46:12 +02:00
d2f46e4637 #21 Deserialization of LunaSea Object 2023-06-19 11:27:07 +02:00
303fc293ba Fixed Bug on AddTask where no new UpdateLibraryTask would be added 2023-06-15 22:32:55 +02:00
36c145da26 Gotify change to normal priority 2023-06-15 21:24:01 +02:00
c822c74f42 website fix taskSelectOutput overflow issue 2023-06-15 21:16:56 +02:00
dda4054d34 API: Fix nullable bug on Getchapters 2023-06-15 21:15:44 +02:00
5b2546fdbc removed unnecessary log 2023-06-15 19:07:25 +02:00
c11e3993ea Added successmessage to NotificationManager 2023-06-15 19:06:53 +02:00
02a382a99a Website: Added connector NotificationManager LunaSea
Added Update Method for TrangaSettings for LunaSea
#21
2023-06-15 18:57:50 +02:00
c6c8f5cdf6 TrangaSettings nullable library and notificationManagers will initialize a new Hashset 2023-06-15 18:50:50 +02:00
84842aed3c Added connector NotificationManager LunaSea 2023-06-15 18:50:19 +02:00
d9ced11cd1 Website: Added gotify config 2023-06-15 18:38:47 +02:00
25c90782dc Moved UpdateSettings to TrangaSettings
Added NotificaitonManager
Added Gotify
Added Notification on MonitorTask download new chapters
2023-06-15 18:25:32 +02:00
e789c429cd TaskManager when deleting task also remove from parent. 2023-06-15 18:24:19 +02:00
93de471836 Added TrangaTask.RemoveChildTask 2023-06-15 18:22:59 +02:00
8b58e7dd13 Website: On Download Chapters only show chapters that have not yet been downloaded
API: Added new variables to /Publications/GetChapters: onlyNew and onlyExisting. API will return only new, only existing or all chapters depending on variables.
#19
2023-06-15 17:14:20 +02:00
b571bfa43d Moved GetNewChaptersList to taskManager and added GetExistingChaptersList 2023-06-15 17:07:32 +02:00
088d1c4647 Derived Constructor 2023-06-15 17:06:41 +02:00
f280c01802 Browser Version for both windows and linux 2023-06-15 16:30:07 +02:00
1be10b310d Fix Regex Bug on downlaod volumes 2023-06-11 19:17:03 +02:00
a0469f3145 Cancel DownloadChapter-Task on removal 2023-06-11 19:16:05 +02:00
fcd81f03b3 resolves #17 no cover image 2023-06-11 19:05:08 +02:00
76604d84d8 Better way of handling progress, and childProgress. 2023-06-11 18:24:26 +02:00
af822febbe fixed nullable warning 2023-06-11 18:01:04 +02:00
8e207c3119 Better way of handling progress, and childProgress. 2023-06-11 17:27:33 +02:00
b6f8c8aab5 TaskType check 2023-06-11 17:05:24 +02:00
36f7cbd3e9 Better way of handling progress, and childProgress.
More reliable taskFinishTime
2023-06-11 17:04:33 +02:00
3b2643d949 Website show remaining time instead of percentage 2023-06-11 16:38:12 +02:00
9fd8bf1741 website uses taskId 2023-06-10 16:00:41 +02:00
d5c9c5ba96 Redid progress calcuation on DownloadNewChaptersTask and DownloadChapterTask 2023-06-10 16:00:16 +02:00
c8e27921ab Added taskId to trangaTask and parentTaskId to DownloadChapterTask as unique identifier to attach ChildTasks to ParentTask on deserialization. 2023-06-10 15:59:42 +02:00
6eaba07801 Changed progress type from float to double 2023-06-10 15:58:11 +02:00
41929e0c72 DownloadChapterTask sets execution of parentTask 2023-06-10 15:04:37 +02:00
4fcaca1a6e Multiple authors resolves #7 2023-06-10 14:45:04 +02:00
0e3c7f32d7 Added CancellationToken to TrangaTask #14 2023-06-10 14:34:30 +02:00
1c94625840 Added CancellationToken to TrangaTask #14 2023-06-10 14:27:09 +02:00
32f89f9dce Multiple authors resolves #7 2023-06-10 14:05:23 +02:00
234735a562 Order of tasks closes #15
Also API /Queue/Get orders in order of nextExecution
2023-06-10 00:45:55 +02:00
8b916eb854 invalid Ids 2023-06-10 00:23:23 +02:00
29e1790c93 website tasks-width now max 95vw 2023-06-10 00:10:16 +02:00
ac4c799a74 Better indication if tasks have started. 2023-06-10 00:07:41 +02:00
7c62883c37 invalid id 2023-06-10 00:02:51 +02:00
02018253bf wrong nesting ... 2023-06-10 00:01:38 +02:00
2aec884009 Moved update interval for task-progress to own interval, progress gets continually updated. 2023-06-09 23:58:04 +02:00
b3321ff030 unnecessary log 2023-06-09 23:48:39 +02:00
16c1094875 Replaced Task-Progress-Tracking Window with more fancy one 2023-06-09 23:46:10 +02:00
5763d50409 #14 temporary workaround for disposing tasks 2023-06-09 23:45:53 +02:00
ad43297358 API: Updated /Tasks/GetProgress to return progress of specific task (by sortNumber) 2023-06-09 23:43:57 +02:00
b17800e0ef Decrement progress of parenttask when childtask fails 2023-06-09 23:43:19 +02:00
89c80d2997 Fixed bug where tasks would instantly failed when launched #14 2023-06-09 23:42:54 +02:00
6485b8744f API: Updated /Tasks/GetProgress to return progress of specific task (by sortNumber) 2023-06-09 23:42:18 +02:00
a3a96b6b55 Added DecrementProgress function to TrangaTask 2023-06-09 23:38:28 +02:00
5bce3c6fdd Website: Monitor task creation styling 2023-06-09 22:15:29 +02:00
5fa0c98d05 Documentation how to create tasks #11 2023-06-09 11:26:51 +02:00
b166013770 resolves #13 Website: Clear previous results 2023-06-09 11:12:43 +02:00
02fe849046 Better downloadChapter selection 2023-06-09 11:06:18 +02:00
d42393c83a Website + API ability to download specific volumes 2023-06-08 19:53:05 +02:00
c685bd622f Website:
New task-Creation dialog
Redesigned Settings dialog
2023-06-08 19:25:28 +02:00
dc83cc2194 Fixed Range on CLI downloadchaptertask creation 2023-06-08 19:25:03 +02:00
7784f2024e API changes:
/Tranga/GetAvailableControllers => /Controllers/Get
/Tranga/GetKnownPublications =>/Publications/GetKnown
/Tranga/GetPublicationsFromConnector => /Publications/GetFromConnector
/Tasks/GetTaskTypes => /Tasks/GetTypes
/Tasks/GetTaskProgress => /Tasks/GetProgress
/Tasks/Create is now split in 3:
    /Tasks/CreateMonitorTask
    /Tasks/CreateUpdateLibraryTask
    /Tasks/CreateDownloadChaptersTask
2023-06-08 19:24:46 +02:00
4895079887 Remove DownloadChapterTask from _runningDownloadChapterTasks after completion 2023-06-07 15:01:24 +02:00
ab1ddc6dc8 Less cluttered log 2023-06-07 00:31:27 +02:00
87eade10cf #40 task timeout criteria 2023-06-07 00:27:53 +02:00
1f3ac41b30 removed unnecessary cast 2023-06-07 00:24:58 +02:00
6a304bb330 #40 task timeout 2023-06-07 00:24:27 +02:00
b0642d1251 removed unnecessary check 2023-06-06 22:11:57 +02:00
63b5139e93 Split error message for better logging 2023-06-06 22:11:38 +02:00
e938784388 Created own base image for tranga-api (to stop apt always updating) 2023-06-06 22:11:26 +02:00
c436389426 renamed wrong variable names publicationId -> internalId 2023-06-06 21:57:10 +02:00
5099e25f3f Fixed wrong comparison on add new task 2023-06-06 21:56:51 +02:00
cf6fc3b8f6 Update readme HtmlAgilityPack 2023-06-06 21:27:12 +02:00
f5141d0f8e removed unnecessary async 2023-06-06 21:27:02 +02:00
5c753e7a7d Added fields to TrangaTask:
executionStarted,
executionApproximatelyFinished,
executionApproximatelyRemaining
to track progress
2023-06-06 21:19:30 +02:00
17ce820cf3 Changed taskProgress increment to call method that updates progress accordingly (with parent-tasks being also updated) 2023-06-06 20:54:21 +02:00
5b4a3b9d7c README update 2023-06-06 15:41:26 +02:00
f73997e563 Fix browser startup 2023-06-05 21:38:32 +02:00
437136804d Also delete downloadChapterTask when delete download new chapter tasks 2023-06-05 21:16:04 +02:00
e14683d21a Merge remote-tracking branch 'origin/master'
# Conflicts:
#	Tranga/TaskManager.cs
2023-06-05 21:11:10 +02:00
5ae02ee0ed Fix Bug where all tasks would be deleted... 2023-06-05 21:10:42 +02:00
a2e9a3f34a Fix Bug where all tasks would be deleted... 2023-06-05 20:52:21 +02:00
bbf05e3dec name and volume number 2023-06-05 20:37:44 +02:00
d95839e5df Chromium Version and Dependencies 2023-06-05 20:36:01 +02:00
5a303598fe Download progress updates every 5 seconds 2023-06-05 19:53:00 +02:00
db2103963e Merge remote-tracking branch 'origin/master' 2023-06-05 19:47:08 +02:00
2c1105527a Add Connector Mangasee #34 2023-06-05 19:47:04 +02:00
ed19dcb5c3 Add Connector Mangasee 2023-06-05 19:46:22 +02:00
46f06c2685 Duplicate logs 2023-06-05 19:46:10 +02:00
d4f47e057c Fix sortNumber when volume null 2023-06-05 19:45:50 +02:00
61712d0537 variable name 2023-06-05 01:03:03 +02:00
1f8f8c09e3 Wrong comparison publicationId -> internalId 2023-06-05 00:50:51 +02:00
0522fa6215 Fix wrong cast 2023-06-05 00:40:19 +02:00
0383a7d686 Merge remote-tracking branch 'origin/master' 2023-06-05 00:38:11 +02:00
bd189984a9 Rewrote entire Task-Structure:
TrangaTask now only contains essentials, derived classes contain specific information such as connectorName, publication, chapter, etc.
Removed taskQueue system, instead all tasks are kept in _allTasks.
Progress is being tracked in TrangaTask resolves #36 resolves #32
Added new TrangaTask: DownloadChapter to download single chapters. #35
Fixed duplicate file-access when writing settings.
2023-06-05 00:38:07 +02:00
58c01b2174 Rewrote entire Task-Structure:
TrangaTask now only contains essentials, derived classes contain specific information such as connectorName, publication, chapter, etc.
Removed taskQueue system, instead all tasks are kept in _allTasks.
Progress is being tracked in TrangaTask
Added new TrangaTask: DownloadChapter to download single chapters.
Fixed duplicate file-access when writing settings.
2023-06-05 00:35:57 +02:00
459558a514 Merge remote-tracking branch 'origin/master' 2023-06-04 21:15:37 +02:00
721b316209 Legal Characters in folder names #38 2023-06-04 21:15:33 +02:00
b1befa2ecc Legal Characters in folder names 2023-06-04 21:14:45 +02:00
57a4cc4ab5 #38 Filenames 2023-06-03 23:44:58 +02:00
655e8db2b6 #38 Publication naming scheme 2023-06-03 23:42:59 +02:00
7cdf77cbb9 Less logging clutter 2023-06-03 22:55:53 +02:00
5a9aed4969 Kavita naming convention 2023-06-03 22:39:27 +02:00
5b41f687d0 Kavita naming convention 2023-06-03 22:34:02 +02:00
d6a62dc315 Manganato download order 2023-06-03 22:25:34 +02:00
6d91788655 Kavita naming convention 2023-06-03 22:25:24 +02:00
14785e5672 increased refresh time 2023-06-03 21:37:21 +02:00
496d502cd2 Kavita Auth is a pain. 2023-06-03 21:26:29 +02:00
7612411917 #33 Update Website 2023-06-03 16:25:04 +02:00
ed1402b5ec TrangaSettings Return libraryManageres on serialization 2023-06-03 16:24:54 +02:00
5adaee4821 redundant calls 2023-06-03 16:24:30 +02:00
2d82fe1489 libraryType in constructor 2023-06-03 16:24:14 +02:00
783fd8129e API: Kavita Auth #33 2023-06-03 15:40:26 +02:00
4f29eff48e Kavita authorization 2023-06-03 15:39:48 +02:00
e0e7abb62b #33 Added custom serializer for LibraryManager
Added Code for Kavita
2023-06-03 15:32:54 +02:00
ae63a216a5 unnecessary params 2023-06-03 15:27:35 +02:00
5d98295c59 #33 Preparation:
TrangaSettings now stores Hashset of LibraryManagers
2023-06-03 15:17:08 +02:00
0c580933f9 #33 Preparation:
Abstracted class Komga into LibraryManager
Fixed logger not attaching to LibraryManager
2023-06-03 15:02:15 +02:00
06f735aadd #32 API endpoint 2023-06-01 23:08:43 +02:00
439d69d8e0 Sanitization Manganto 2023-06-01 22:59:51 +02:00
933df58712 Moved publicationFolder creation to Publication with Permissions 2023-06-01 22:59:04 +02:00
165bbc412e Adjusted Manganato ratelimit 2023-06-01 22:49:20 +02:00
6158fa072b File permissions 2023-06-01 22:32:11 +02:00
0d3799e00d Fix Bug when strings where shorter than 25 characters on logger.writeline
Fixed CLI output
2023-06-01 22:27:37 +02:00
e977bed5a5 #32 formatting length 2023-06-01 22:14:00 +02:00
cacd5fede2 removed unnecessary todo 2023-06-01 22:06:10 +02:00
1bca99cb6a #32 Added progress tracking to task (internal and log use for now) 2023-06-01 22:05:48 +02:00
15fc367263 logging 2023-06-01 21:16:57 +02:00
8bb6fb902b File Permissions 2023-06-01 18:28:58 +02:00
a57903cd5a readme update 2023-06-01 16:04:21 +02:00
1cd819b21d update docker-compose 2023-06-01 15:40:14 +02:00
27afedc1b4 year in series.json 2023-06-01 15:25:26 +02:00
fac0a3f7eb resolves #2 2023-06-01 15:08:32 +02:00
03ca480fe8 remove empty lines at start of description 2023-06-01 14:59:09 +02:00
c2915468a5 status .tolower 2023-06-01 14:58:58 +02:00
8805c53cb8 wrong url for manga info page 2023-06-01 13:37:06 +02:00
adbbe3f6cc logs 2023-06-01 13:27:58 +02:00
14b694d3be Description value duplicate #2 2023-06-01 13:25:58 +02:00
72ce75c6e0 #2 Multiple alt titles 2023-06-01 13:18:26 +02:00
8381951168 #2 First Attempt 2023-06-01 13:13:53 +02:00
b24032d124 remove env var 2023-06-01 11:29:47 +02:00
8bc23f7c69 Instead of relying on concreate tasks to do chores, create method in abstract class that calls the BL in concrete class and does chores before and after execution 2023-06-01 10:35:23 +02:00
48b7371a18 Issue missing parameter 2023-05-31 22:34:13 +02:00
61ecefb615 Logging and Chores in abstract class 2023-05-31 22:32:37 +02:00
8ff65bf400 compatibility with older tasks.json 2023-05-31 22:26:53 +02:00
932057cca0 update execution time 2023-05-31 22:20:33 +02:00
67d06cd887 resolves #23 website filter 2023-05-31 22:16:14 +02:00
cbb012a659 alt and label 2023-05-31 22:16:01 +02:00
e4f33bcca9 Set ExecutionState to waiting after finished 2023-05-31 21:52:50 +02:00
fbba7c45b9 annotation 2023-05-31 21:44:16 +02:00
d9b6062767 Custom JSON Deserializer for concrete classes of abstract class TrangaTask 2023-05-31 21:43:07 +02:00
d477cd1ccd corrected check for tasktype on due 2023-05-31 21:42:23 +02:00
f892db7dda Switch Execution State to Running when executing 2023-05-31 21:40:00 +02:00
16c1b5c506 Create imageCacheFolder 2023-05-31 21:39:18 +02:00
d5ecc1c37d Spelling, redundant calls 2023-05-31 21:18:41 +02:00
1b9ebd096b Removed TaskExecutor
TrangaTask is now abstract
TrangaTask implements Execute Method, that is now called instead of TaskExecutor
Created inheriting classes of TrangaTask: UpdateKomgaLibraryTask, DownloadNewChaptersTask
2023-05-31 21:15:32 +02:00
8619630269 Renamed ExportData to ExportDataAndSettings 2023-05-31 21:14:11 +02:00
2bc92556e3 Method description and Name CopyCoverFromCacheToDownloadLocation 2023-05-31 20:39:57 +02:00
f1ab823e7f Method description 2023-05-31 20:39:23 +02:00
8261d02cc7 Renamed methods, made some methods non-static in Connector, some more logging 2023-05-31 20:29:30 +02:00
8d3b8be95c settings placeholders for komga if configured 2023-05-31 17:55:09 +02:00
60519910de cursors 2023-05-31 17:54:54 +02:00
0940afe41f publication viewer tags and styling 2023-05-31 17:54:09 +02:00
3dc376c19f favicon 2023-05-31 17:52:47 +02:00
3e56ef842b Update docker-compose.yaml with version 2023-05-30 19:37:09 +02:00
5a44e3b8b9 #25 only replace settings if parameter actually contains value. 2023-05-30 19:32:22 +02:00
732c2f119c More logging 2023-05-26 15:09:26 +02:00
81638f4b4a Path.join joins paths ya know 2023-05-26 14:51:11 +02:00
c547aa6422 favicon <3 2023-05-26 14:49:17 +02:00
d80980512e #28 is a pain in the buttcheeks 2023-05-26 14:47:13 +02:00
f9f802155d #28 why was there a '!' 2023-05-26 14:43:47 +02:00
eef0955009 #28 wrong filesnames 2023-05-26 14:36:02 +02:00
ec25900ac0 resolves #29 start manual execution 2023-05-26 14:32:08 +02:00
e5fe14a09e #28 2023-05-26 14:31:34 +02:00
5dc91095f8 #28 2023-05-26 14:30:52 +02:00
985ac8fc7a Fix #28 coverimages 2023-05-26 14:07:11 +02:00
c9537a9963 #24 2023-05-26 13:39:42 +02:00
4fd3c03804 Styling 2023-05-26 13:30:20 +02:00
a1e9dd0232 resolves #27 Foldernames ending in '.' 2023-05-25 22:22:57 +02:00
aa1f9b1b56 background fade 2023-05-25 22:17:47 +02:00
6069578b6e style choices 2023-05-25 22:15:06 +02:00
a84b768e24 design choices 2023-05-25 22:05:29 +02:00
d1a21af15d resolves #26 2023-05-25 21:58:45 +02:00
7423ae6ace Update README.md 2023-05-25 18:36:21 +02:00
3aa7ba9d96 screenshots 2023-05-25 18:28:43 +02:00
fdbb4570be Adjusted settings style 2023-05-25 18:18:31 +02:00
b643a0c2a9 Fix Wrong API uri for GetRunningTasks
Added GetQueue function

Added display for running, queued, total tasks
2023-05-25 18:09:18 +02:00
6fa6f897aa More legal characters 2023-05-25 17:34:24 +02:00
2bfab0298d border-radius 2023-05-25 17:33:55 +02:00
147a20385b illegal filenames 2023-05-25 16:55:58 +02:00
afa18d6a2c Illlegal characters on linux 2023-05-25 16:47:24 +02:00
66980eef23 Position publication viewer always withing display 2023-05-25 16:05:54 +02:00
65f468a30a popup position now "fixed"
changed publicationviewer width
2023-05-25 16:05:40 +02:00
a91c33ee4f image sizing 2023-05-25 15:50:00 +02:00
f39482fe4c Corrected image path in publication preview 2023-05-25 15:48:38 +02:00
41f47b4d6b styling 2023-05-25 15:47:59 +02:00
be40091102 Publication background fade 2023-05-25 15:41:24 +02:00
665092be6a image scaling 2023-05-25 15:40:03 +02:00
653cb699d0 Removed Sidebar
Moved settings tab to popup
Added footer
2023-05-25 15:34:10 +02:00
8dbc5446ad depends_on compose 2023-05-25 14:46:05 +02:00
750df4ed52 Wrong return value 2023-05-25 14:38:43 +02:00
4772ae0756 No unnecessary downloads of covers if they already exist 2023-05-25 14:35:33 +02:00
23f703d5a5 imageCache readonly for website 2023-05-25 14:30:33 +02:00
6aa0ea277b #22 2023-05-25 14:28:56 +02:00
780df1cd6e Created Image-Cache 2023-05-25 14:25:23 +02:00
0b7da2e9cb Merge remote-tracking branch 'origin/master'
# Conflicts:
#	Website/interaction.js
2023-05-25 13:59:06 +02:00
01a059d26b Base 64 images #22 2023-05-25 13:58:54 +02:00
a8dbece237 Base 64 images #22 2023-05-25 13:58:10 +02:00
5efa00e059 Added field posterBase64 to Publication #22 2023-05-25 13:50:48 +02:00
02075ed1b1 Renamed RequestType Cover to CoverUrl 2023-05-25 13:50:08 +02:00
fabd16ccea Remove unnecessary output from dockerfile 2023-05-25 13:49:29 +02:00
79928075b0 docker-compose.yaml 2023-05-25 10:49:24 +02:00
9b8eb6a197 add seconds field to addtask recurrence 2023-05-25 10:47:12 +02:00
1d263ef45a Configurable API-location 2023-05-25 10:42:19 +02:00
e0877add30 Paths for Linux 2023-05-25 10:25:24 +02:00
046cad8072 Dockerfile for Tranga 2023-05-25 10:25:11 +02:00
b2ce55be96 Port 2023-05-25 01:25:21 +02:00
a6e9013495 Latest alpine image 2023-05-25 00:05:31 +02:00
14c69631a6 Corrected port 2023-05-24 23:53:39 +02:00
ccc4e42a49 Komga update can now be configured in seconds 2023-05-24 23:53:32 +02:00
d6e75fda31 Fixed empty returns if some value were null 2023-05-24 23:52:40 +02:00
fc89537f63 Fixed Authorization on redirect 2023-05-24 23:52:25 +02:00
fd3423d03c Correct Port 2023-05-24 22:56:15 +02:00
878f77766f Fix CORS 2023-05-24 22:56:10 +02:00
08001fd684 SSL cert error 2023-05-24 22:55:32 +02:00
e2917d2f2e Changed CORS policy allow all origins
Added Dockerfile to website
Changed Ports
2023-05-24 22:30:11 +02:00
64 changed files with 2799 additions and 2840 deletions

7
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,7 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

43
.github/workflows/docker-base.yml vendored Normal file
View File

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

View File

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

View File

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

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
WORKDIR /src
COPY Tranga /src/Tranga
COPY Logging /src/Logging
COPY Tranga.sln /src
RUN dotnet restore /src/Tranga/Tranga.csproj
RUN dotnet publish -c Release -o /publish
FROM glax/tranga-base:latest as runtime
WORKDIR /publish
COPY --from=build-env /publish .
EXPOSE 6531
ENTRYPOINT ["dotnet", "/publish/Tranga.dll"]

8
Dockerfile-base Normal file
View File

@ -0,0 +1,8 @@
# syntax=docker/dockerfile:1
#FROM mcr.microsoft.com/dotnet/aspnet:7.0 as runtime
FROM mcr.microsoft.com/dotnet/runtime:7.0 as runtime
WORKDIR /publish
RUN apt-get update
RUN apt-get install -y libx11-6 libx11-xcb1 libatk1.0-0 libgtk-3-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 libxshmfence1 libnss3
RUN apt-get autopurge -y
RUN apt-get autoclean -y

View File

@ -1,5 +1,4 @@
using System.Text; using System.Text;
using System.Text.Json.Serialization;
namespace Logging; namespace Logging;
@ -8,7 +7,7 @@ public class FileLogger : LoggerBase
private string logFilePath { get; } private string logFilePath { get; }
private const int MaxNumberOfLogFiles = 5; private const int MaxNumberOfLogFiles = 5;
public FileLogger(string logFilePath, TextWriter? stdOut, Encoding? encoding = null) : base (stdOut, encoding) public FileLogger(string logFilePath, Encoding? encoding = null) : base (encoding)
{ {
this.logFilePath = logFilePath; this.logFilePath = logFilePath;
@ -22,11 +21,11 @@ public class FileLogger : LoggerBase
{ {
try try
{ {
File.AppendAllText(logFilePath, logMessage.ToString()); File.AppendAllText(logFilePath, logMessage.formattedMessage);
} }
catch (Exception e) catch (Exception)
{ {
stdOut?.WriteLine(e); // ignored
} }
} }
} }

View File

@ -4,14 +4,14 @@ namespace Logging;
public class FormattedConsoleLogger : LoggerBase public class FormattedConsoleLogger : LoggerBase
{ {
private readonly TextWriter _stdOut;
public FormattedConsoleLogger(TextWriter? stdOut, Encoding? encoding = null) : base(stdOut, encoding) public FormattedConsoleLogger(TextWriter stdOut, Encoding? encoding = null) : base(encoding)
{ {
this._stdOut = stdOut;
} }
protected override void Write(LogMessage message) protected override void Write(LogMessage message)
{ {
//Nothing to do yet this._stdOut.Write(message.formattedMessage);
} }
} }

23
Logging/LogMessage.cs Normal file
View File

@ -0,0 +1,23 @@
namespace Logging;
public class 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,5 +1,4 @@
using System.Net.Mime; using System.Text;
using System.Text;
namespace Logging; namespace Logging;
@ -12,24 +11,31 @@ public class Logger : TextWriter
ConsoleLogger ConsoleLogger
} }
private FileLogger? _fileLogger; private readonly FileLogger? _fileLogger;
private FormattedConsoleLogger? _formattedConsoleLogger; private readonly FormattedConsoleLogger? _formattedConsoleLogger;
private MemoryLogger _memoryLogger; private readonly MemoryLogger _memoryLogger;
private TextWriter? stdOut;
public Logger(LoggerType[] enabledLoggers, TextWriter? stdOut, Encoding? encoding, string? logFilePath) public Logger(LoggerType[] enabledLoggers, TextWriter? stdOut, Encoding? encoding, string? logFilePath)
{ {
this.Encoding = encoding ?? Encoding.ASCII; this.Encoding = encoding ?? Encoding.ASCII;
this.stdOut = stdOut ?? null;
if (enabledLoggers.Contains(LoggerType.FileLogger) && logFilePath is not null) if (enabledLoggers.Contains(LoggerType.FileLogger) && logFilePath is not null)
_fileLogger = new FileLogger(logFilePath, null, encoding); _fileLogger = new FileLogger(logFilePath, encoding);
else else if(enabledLoggers.Contains(LoggerType.FileLogger) && logFilePath is null)
{ {
_fileLogger = null; _fileLogger = null;
throw new ArgumentException($"logFilePath can not be null for LoggerType {LoggerType.FileLogger}"); throw new ArgumentException($"logFilePath can not be null for LoggerType {LoggerType.FileLogger}");
} }
_formattedConsoleLogger = enabledLoggers.Contains(LoggerType.ConsoleLogger) ? new FormattedConsoleLogger(null, encoding) : null;
_memoryLogger = new MemoryLogger(null, 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);
} }
public void WriteLine(string caller, string? value) public void WriteLine(string caller, string? value)
@ -46,9 +52,7 @@ public class Logger : TextWriter
_fileLogger?.Write(caller, value); _fileLogger?.Write(caller, value);
_formattedConsoleLogger?.Write(caller, value); _formattedConsoleLogger?.Write(caller, value);
_memoryLogger.Write(caller, value); _memoryLogger.Write(caller, value);
stdOut?.Write(value);
} }
public string[] Tail(uint? lines) public string[] Tail(uint? lines)

View File

@ -5,21 +5,10 @@ namespace Logging;
public abstract class LoggerBase : TextWriter public abstract class LoggerBase : TextWriter
{ {
public override Encoding Encoding { get; } public override Encoding Encoding { get; }
protected TextWriter? stdOut { get; }
public LoggerBase(TextWriter? stdOut, Encoding? encoding = null) public LoggerBase(Encoding? encoding = null)
{ {
this.Encoding = encoding ?? Encoding.ASCII; this.Encoding = encoding ?? Encoding.ASCII;
this.stdOut = stdOut;
}
public void WriteLine(string caller, string? value)
{
value = value is null ? Environment.NewLine : string.Join(value, Environment.NewLine);
LogMessage message = new LogMessage(DateTime.Now, caller, value);
Write(message);
} }
public void Write(string caller, string? value) public void Write(string caller, string? value)
@ -27,32 +16,10 @@ public abstract class LoggerBase : TextWriter
if (value is null) if (value is null)
return; return;
LogMessage message = new LogMessage(DateTime.Now, caller, value); LogMessage message = new (DateTime.Now, caller, value);
stdOut?.Write(message.ToString());
Write(message); Write(message);
} }
protected abstract void Write(LogMessage message); protected abstract void Write(LogMessage message);
public class LogMessage
{
public DateTime logTime { get; }
public string caller { get; }
public string value { get; }
public LogMessage(DateTime now, string caller, string value)
{
this.logTime = now;
this.caller = caller;
this.value = value;
}
public override string ToString()
{
string dateTimeString = $"{logTime.ToShortDateString()} {logTime.ToLongTimeString()}";
return $"[{dateTimeString}] {caller.Split(new char[]{'.','+'}).Last(),15} | {value}";
}
}
} }

View File

@ -7,14 +7,15 @@ public class MemoryLogger : LoggerBase
private readonly SortedList<DateTime, LogMessage> _logMessages = new(); private readonly SortedList<DateTime, LogMessage> _logMessages = new();
private int _lastLogMessageIndex = 0; private int _lastLogMessageIndex = 0;
public MemoryLogger(TextWriter? stdOut, Encoding? encoding = null) : base(stdOut, encoding) public MemoryLogger(Encoding? encoding = null) : base(encoding)
{ {
} }
protected override void Write(LogMessage value) protected override void Write(LogMessage value)
{ {
_logMessages.Add(value.logTime, value); while(!_logMessages.TryAdd(value.logTime, value))
Thread.Sleep(10);
} }
public string[] GetLogMessage() public string[] GetLogMessage()

View File

@ -30,9 +30,13 @@
<li><a href="#built-with">Built With</a></li> <li><a href="#built-with">Built With</a></li>
</ul> </ul>
</li> </li>
<li>
<a href="#screenshots">Screenshots</a>
</li>
<li> <li>
<a href="#getting-started">Getting Started</a> <a href="#getting-started">Getting Started</a>
<ul> <ul>
<li><a href="#prerequisites">Usage</a></li>
<li><a href="#prerequisites">Prerequisites</a></li> <li><a href="#prerequisites">Prerequisites</a></li>
</ul> </ul>
</li> </li>
@ -48,12 +52,15 @@
<!-- 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/) - [MangaDex.org](https://mangadex.org/)
- [Manganato.com](https://manganato.com/)
- [Mangasee](https://mangasee123.com/)
- [MangaKatana](https://mangakatana.com)
- ❓ Open an [issue](https://github.com/C9Glax/tranga/issues)
and automatically start updates in [Komga](https://komga.org/) to import them. and automatically import them with [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/). Also Notifications will be sent to your devices using [Gotify](https://gotify.net/) and [LunaSea](https://www.lunasea.app/).
### Inspiration: ### 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
@ -64,38 +71,81 @@ 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 ### Built With
- .NET-Core - .NET-Core
- Newtonsoft.JSON - Newtonsoft.JSON
- Love <3 - [PuppeteerSharp](https://www.puppeteersharp.com/)
- [Html Agility Pack (HAP)](https://html-agility-pack.net/)
- 💙 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>
## Screenshots
| ![image](screenshots/overview.png) | ![image](screenshots/addtask.png) |
|-----------------------------------:|:----------------------------------|
| ![image](screenshots/settings.png) | ![image](screenshots/publication-description.png) | ![image](screenshots/progress.png) |
|-----------------------------------:|:-------------------------------------------------:|:-----------------------------------|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- GETTING STARTED --> <!-- GETTING STARTED -->
## Getting Started ## Getting Started
To use head over to [releases](https://git.bernloehr.eu/glax/Tranga/releases) and download a release. There is two release types:
A CLI will guide you through setup. - CLI
- Docker
### CLI
Head over to [releases](https://git.bernloehr.eu/glax/Tranga/releases) and download. The CLI will guide you through setup.
### Docker
Download [docker-compose.yaml](https://git.bernloehr.eu/glax/Tranga/src/branch/master/docker-compose.yaml) and configure to your needs.
Wherever you are mounting `/usr/share/Tranga-API` you also need to mount that same path + `/imageCache` in the webserver container.
### Docker-Website usage
There is two ways to download Mangas:
- Downloading everything and monitor for new Chapters
- Selecting specific Volumes/Chapters
On the website you add new tasks, by selecting the blue '+' field. Next select the connector/site you want to use, and enter a search term.
After clicking 'Search' (or pressing Enter), the results will be presented below - this might, depending on the result-size, take a while because we are already preloading the cover-images.
Next select the publication (by selecting the cover) and a new popup will open with two options:
- "Monitor" - Download all chapters and monitor for new ones
- "Download Chapter" - Download specific chapters only
When selecting `Monitor` you will be presented with a new window and the selection of the interval you want to check for new chapters (Default: Every 3 hours).
When selecting `Download Chapter` a list will open with all available chapters - that have not yet been downloaded - from which you can then select a range (see below).
The syntax for selecting chapters is as follows:
- To download a single Chapter enter either the index number (the number at the very start of the line) or its absolute number like so: `c(h)(apter)[number]`, spaces are allowed.
- To download a range of chapters enter either a range of index numbers (`3-6`) or chapters (`ch 12-23`).
- For volumes the syntax is as follows: `v(ol)[number](-[number])`, again spaces allowed.
Examples: `2-12`, `c1`, `ch 2`, `chapter 3`, `v 2`, `vol3-4`, `v2c4` (note: you can only specify a single chapter with this last syntax).
### Prerequisites ### Prerequisites
[.NET-Core 7.0](https://dotnet.microsoft.com/en-us/download/dotnet/7.0) #### To Build
[.NET-Core 7.0 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/7.0)
#### To Run
[.NET-Core 7.0 Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/7.0) scroll down a bit, should be on the right the second item.
<!-- ROADMAP --> <!-- ROADMAP -->
## Roadmap ## Roadmap
- [ ] Web-UI #1 - [ ] Docker ARM support
- [ ] More Connectors - [ ]
- [ ] Manganato #2
- [ ] ?
See the [open issues](https://git.bernloehr.eu/glax/Tranga/issues) for a full list of proposed features (and known issues). See the [open issues](https://github.com/C9Glax/tranga/issues) for a full list of proposed features (and known issues).
<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,20 +0,0 @@
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /src
COPY ["Tranga-API/Tranga-API.csproj", "Tranga-API/"]
RUN dotnet restore "Tranga-API/Tranga-API.csproj"
COPY . .
WORKDIR "/src/Tranga-API"
RUN dotnet build "Tranga-API.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "Tranga-API.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Tranga-API.dll"]

View File

@ -1,134 +0,0 @@
using Logging;
using Tranga;
string applicationFolderPath =
Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Tranga-API");
string downloadFolderPath = Path.Join(applicationFolderPath, "Manga");
string logsFolderPath = Path.Join(applicationFolderPath, "logs");
string logFilePath = Path.Join(logsFolderPath, $"log-{DateTime.Now:dd-M-yyyy-HH-mm-ss}.txt");
string settingsFilePath = Path.Join(applicationFolderPath, "settings.json");
Directory.CreateDirectory(applicationFolderPath);
Directory.CreateDirectory(logsFolderPath);
Console.WriteLine($"Logfile-Path: {logFilePath}");
Console.WriteLine($"Settings-File-Path: {settingsFilePath}");
Logger logger = new(new[] { Logger.LoggerType.FileLogger }, null, null, logFilePath);
logger.WriteLine("Tranga_CLI", "Loading Taskmanager.");
TrangaSettings settings;
if (File.Exists(settingsFilePath))
settings = TrangaSettings.LoadSettings(settingsFilePath);
else
settings = new TrangaSettings(downloadFolderPath, applicationFolderPath, null);
TaskManager taskManager = new (settings, logger);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddControllers().AddNewtonsoftJson();
string corsHeader = "Tranga";
builder.Services.AddCors(options =>
{
options.AddPolicy(name: corsHeader,
policy =>
{
policy.WithOrigins("http://localhost", "http://127.0.0.1", "http://localhost:63342");
policy.WithMethods("GET", "POST", "DELETE");
});
});
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors(corsHeader);
app.MapGet("/Tranga/GetAvailableControllers", () => taskManager.GetAvailableConnectors().Keys.ToArray());
app.MapGet("/Tranga/GetKnownPublications", () => taskManager.GetAllPublications());
app.MapGet("/Tranga/GetPublicationsFromConnector", (string connectorName, string title) =>
{
Connector? connector = taskManager.GetAvailableConnectors().FirstOrDefault(con => con.Key == connectorName).Value;
if (connector is null)
return Array.Empty<Publication>();
if(title.Length < 4)
return Array.Empty<Publication>();
return taskManager.GetPublicationsFromConnector(connector, title);
});
app.MapGet("/Tasks/GetTaskTypes", () => Enum.GetNames(typeof(TrangaTask.Task)));
app.MapPost("/Tasks/Create", (string taskType, string? connectorName, string? publicationId, string reoccurrenceTime, string? language) =>
{
Publication? publication = taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == publicationId);
TrangaTask.Task task = Enum.Parse<TrangaTask.Task>(taskType);
taskManager.AddTask(task, connectorName, publication, TimeSpan.Parse(reoccurrenceTime), language??"");
});
app.MapDelete("/Tasks/Delete", (string taskType, string? connectorName, string? publicationId) =>
{
Publication? publication = taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == publicationId);
TrangaTask.Task task = Enum.Parse<TrangaTask.Task>(taskType);
taskManager.DeleteTask(task, connectorName, publication);
});
app.MapGet("/Tasks/Get", (string taskType, string? connectorName, string? searchString) =>
{
TrangaTask.Task task = Enum.Parse<TrangaTask.Task>(taskType);
if (searchString is null)
return taskManager.GetAllTasks().Where(tTask => tTask.task == task && tTask.connectorName == connectorName);
else
return taskManager.GetAllTasks().Where(tTask =>
tTask.task == task && tTask.connectorName == connectorName && tTask.ToString()
.Contains(searchString, StringComparison.InvariantCultureIgnoreCase));
});
app.MapPost("/Tasks/Start", (string taskType, string? connectorName, string? publicationId) =>
{
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType);
TrangaTask? task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
tTask.task == pTask && tTask.publication?.internalId == publicationId && tTask.connectorName == connectorName);
if (task is null)
return;
taskManager.ExecuteTaskNow(task);
});
app.MapGet("/Tasks/GetRunningTasks",
() => taskManager.GetAllTasks().Where(task => task.state is TrangaTask.ExecutionState.Running));
app.MapGet("/Queue/GetList",
() => taskManager.GetAllTasks().Where(task => task.state is TrangaTask.ExecutionState.Enqueued));
app.MapPost("/Queue/Enqueue", (string taskType, string? connectorName, string? publicationId) =>
{
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType);
TrangaTask? task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
tTask.task == pTask && tTask.publication?.internalId == publicationId && tTask.connectorName == connectorName);
if (task is null)
return;
taskManager.AddTaskToQueue(task);
});
app.MapDelete("/Queue/Dequeue", (string taskType, string? connectorName, string? publicationId) =>
{
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType);
TrangaTask? task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
tTask.task == pTask && tTask.publication?.internalId == publicationId && tTask.connectorName == connectorName);
if (task is null)
return;
taskManager.RemoveTaskFromQueue(task);
});
app.MapGet("/Settings/Get", () => taskManager.settings);
app.MapPost("/Settings/Update", (string? downloadLocation, string? komgaUrl, string? komgaAuth) => taskManager.UpdateSettings(downloadLocation, komgaUrl, komgaAuth) );
app.Run();

View File

@ -1,37 +0,0 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:1716",
"sslPort": 44391
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5177",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7036;http://localhost:5177",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Tranga_API</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Logging\Logging.csproj" />
<ProjectReference Include="..\Tranga\Tranga.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.5" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="7.0.6" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

@ -1,18 +0,0 @@
FROM mcr.microsoft.com/dotnet/runtime:7.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /src
COPY ["Tranga-CLI/Tranga-CLI.csproj", "Tranga-CLI/"]
RUN dotnet restore "Tranga-CLI/Tranga-CLI.csproj"
COPY . .
WORKDIR "/src/Tranga-CLI"
RUN dotnet build "Tranga-CLI.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "Tranga-CLI.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Tranga-CLI.dll"]

View File

@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<RootNamespace>Tranga_CLI</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tranga\Tranga.csproj" />
</ItemGroup>
</Project>

View File

@ -1,536 +0,0 @@
using System.Globalization;
using Logging;
using Tranga;
namespace Tranga_CLI;
/*
* This is written with pure hatred for readability.
* At some point do this properly.
* Read at own risk.
*/
public static class Tranga_Cli
{
public static void Main(string[] args)
{
string applicationFolderPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Tranga");
string logsFolderPath = Path.Join(applicationFolderPath, "logs");
string logFilePath = Path.Join(logsFolderPath, $"log-{DateTime.Now:dd-M-yyyy-HH-mm-ss}.txt");
string settingsFilePath = Path.Join(applicationFolderPath, "settings.json");
Directory.CreateDirectory(applicationFolderPath);
Directory.CreateDirectory(logsFolderPath);
Console.WriteLine($"Logfile-Path: {logFilePath}");
Console.WriteLine($"Settings-File-Path: {settingsFilePath}");
Logger logger = new(new[] { Logger.LoggerType.FileLogger }, null, null, logFilePath);
logger.WriteLine("Tranga_CLI", "Loading Taskmanager.");
TrangaSettings settings;
if (File.Exists(settingsFilePath))
settings = TrangaSettings.LoadSettings(settingsFilePath);
else
settings = new TrangaSettings(Directory.GetCurrentDirectory(), applicationFolderPath, null);
logger.WriteLine("Tranga_CLI", "User Input");
Console.WriteLine($"Output folder path [{settings.downloadLocation}]:");
string? tmpPath = Console.ReadLine();
while(tmpPath is null)
tmpPath = Console.ReadLine();
if (tmpPath.Length > 0)
settings.downloadLocation = tmpPath;
Console.WriteLine($"Komga BaseURL [{settings.komga?.baseUrl}]:");
string? tmpUrl = Console.ReadLine();
while (tmpUrl is null)
tmpUrl = Console.ReadLine();
if (tmpUrl.Length > 0)
{
Console.WriteLine("Username:");
string? tmpUser = Console.ReadLine();
while (tmpUser is null || tmpUser.Length < 1)
tmpUser = Console.ReadLine();
Console.WriteLine("Password:");
string tmpPass = string.Empty;
ConsoleKey key;
do
{
var keyInfo = Console.ReadKey(intercept: true);
key = keyInfo.Key;
if (key == ConsoleKey.Backspace && tmpPass.Length > 0)
{
Console.Write("\b \b");
tmpPass = tmpPass[0..^1];
}
else if (!char.IsControl(keyInfo.KeyChar))
{
Console.Write("*");
tmpPass += keyInfo.KeyChar;
}
} while (key != ConsoleKey.Enter);
settings.komga = new Komga(tmpUrl, tmpUser, tmpPass, logger);
}
logger.WriteLine("Tranga_CLI", "Loaded.");
TaskMode(settings, logger);
}
private static void TaskMode(TrangaSettings settings, Logger logger)
{
TaskManager taskManager = new (settings, logger);
ConsoleKey selection = ConsoleKey.EraseEndOfFile;
PrintMenu(taskManager, taskManager.settings.downloadLocation, logger);
while (selection != ConsoleKey.Q)
{
int taskCount = taskManager.GetAllTasks().Length;
int taskRunningCount = taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Running);
int taskEnqueuedCount =
taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Enqueued);
Console.SetCursorPosition(0,1);
Console.WriteLine($"Tasks (Running/Queue/Total)): {taskRunningCount}/{taskEnqueuedCount}/{taskCount}");
if (Console.KeyAvailable)
{
selection = Console.ReadKey().Key;
switch (selection)
{
case ConsoleKey.L:
PrintTasks(taskManager.GetAllTasks(), logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.C:
CreateTask(taskManager, taskManager.settings, logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.D:
DeleteTask(taskManager, logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.E:
ExecuteTaskNow(taskManager, logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.S:
SearchTasks(taskManager, logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.R:
PrintTasks(
taskManager.GetAllTasks().Where(eTask => eTask.state == TrangaTask.ExecutionState.Running)
.ToArray(), logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.K:
PrintTasks(
taskManager.GetAllTasks().Where(qTask => qTask.state is TrangaTask.ExecutionState.Enqueued)
.ToArray(), logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.F:
TailLog(logger);
Console.ReadKey();
break;
case ConsoleKey.G:
RemoveTaskFromQueue(taskManager, logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.B:
AddTaskToQueue(taskManager, logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.M:
AddMangaTaskToQueue(taskManager, logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
}
PrintMenu(taskManager, taskManager.settings.downloadLocation, logger);
}
Thread.Sleep(200);
}
logger.WriteLine("Tranga_CLI", "Exiting.");
Console.Clear();
Console.WriteLine("Exiting.");
if (taskManager.GetAllTasks().Any(task => task.state == TrangaTask.ExecutionState.Running))
{
Console.WriteLine("Force quit (Even with running tasks?) y/N");
selection = Console.ReadKey().Key;
while(selection != ConsoleKey.Y && selection != ConsoleKey.N)
selection = Console.ReadKey().Key;
taskManager.Shutdown(selection == ConsoleKey.Y);
}else
// ReSharper disable once RedundantArgumentDefaultValue Better readability
taskManager.Shutdown(false);
}
private static void PrintMenu(TaskManager taskManager, string folderPath, Logger logger)
{
int taskCount = taskManager.GetAllTasks().Length;
int taskRunningCount = taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Running);
int taskEnqueuedCount =
taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Enqueued);
Console.Clear();
Console.WriteLine($"Download Folder: {folderPath}");
Console.WriteLine($"Tasks (Running/Queue/Total)): {taskRunningCount}/{taskEnqueuedCount}/{taskCount}");
Console.WriteLine();
Console.WriteLine($"{"C: Create Task",-30}{"L: List tasks",-30}{"B: Enqueue Task", -30}");
Console.WriteLine($"{"D: Delete Task",-30}{"S: Search Tasks", -30}{"K: List Task Queue", -30}");
Console.WriteLine($"{"E: Execute Task now",-30}{"R: List Running Tasks", -30}{"G: Remove Task from Queue", -30}");
Console.WriteLine($"{"M: New Download Manga Task",-30}{"", -30}{"", -30}");
Console.WriteLine($"{"",-30}{"F: Show Log",-30}{"Q: Exit",-30}");
}
private static void PrintTasks(TrangaTask[] tasks, Logger logger)
{
logger.WriteLine("Tranga_CLI", "Printing Tasks");
int taskCount = tasks.Length;
int taskRunningCount = tasks.Count(task => task.state == TrangaTask.ExecutionState.Running);
int taskEnqueuedCount = tasks.Count(task => task.state == TrangaTask.ExecutionState.Enqueued);
Console.Clear();
int tIndex = 0;
Console.WriteLine($"Tasks (Running/Queue/Total): {taskRunningCount}/{taskEnqueuedCount}/{taskCount}");
string header =
$"{"",-5}{"Task",-20} | {"Last Executed",-20} | {"Reoccurrence",-12} | {"State",-10} | {"Connector",-15} | Publication/Manga";
Console.WriteLine(header);
Console.WriteLine(new string('-', header.Length));
foreach (TrangaTask trangaTask in tasks)
{
string[] taskSplit = trangaTask.ToString().Split(", ");
Console.WriteLine($"{tIndex++:000}: {taskSplit[0],-20} | {taskSplit[1],-20} | {taskSplit[2],-12} | {taskSplit[3],-10} | {(taskSplit.Length > 4 ? taskSplit[4] : ""),-15} | {(taskSplit.Length > 5 ? taskSplit[5] : "")}");
}
}
private static TrangaTask? SelectTask(TrangaTask[] tasks, Logger logger)
{
logger.WriteLine("Tranga_CLI", "Menu: Select task");
if (tasks.Length < 1)
{
Console.Clear();
Console.WriteLine("There are no available Tasks.");
logger.WriteLine("Tranga_CLI", "No available Tasks.");
return null;
}
PrintTasks(tasks, logger);
logger.WriteLine("Tranga_CLI", "Selecting Task to Remove (from queue)");
Console.WriteLine("Enter q to abort");
Console.WriteLine($"Select Task (0-{tasks.Length - 1}):");
string? selectedTask = Console.ReadLine();
while(selectedTask is null || selectedTask.Length < 1)
selectedTask = Console.ReadLine();
if (selectedTask.Length == 1 && selectedTask.ToLower() == "q")
{
Console.Clear();
Console.WriteLine("aborted.");
logger.WriteLine("Tranga_CLI", "aborted");
return null;
}
try
{
int selectedTaskIndex = Convert.ToInt32(selectedTask);
return tasks[selectedTaskIndex];
}
catch (Exception e)
{
Console.WriteLine($"Exception: {e.Message}");
logger.WriteLine("Tranga_CLI", e.Message);
}
return null;
}
private static void AddMangaTaskToQueue(TaskManager taskManager, Logger logger)
{
Console.Clear();
logger.WriteLine("Tranga_CLI", "Menu: Add Manga Download to queue");
Connector? connector = SelectConnector(taskManager.settings.downloadLocation, taskManager.GetAvailableConnectors().Values.ToArray(), logger);
if (connector is null)
return;
Publication? publication = SelectPublication(taskManager, connector!, logger);
if (publication is null)
return;
TimeSpan reoccurrence = SelectReoccurrence(logger);
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
TrangaTask newTask = taskManager.AddTask(TrangaTask.Task.DownloadNewChapters, connector?.name, publication, reoccurrence, "en");
Console.WriteLine(newTask);
}
private static void AddTaskToQueue(TaskManager taskManager, Logger logger)
{
Console.Clear();
logger.WriteLine("Tranga_CLI", "Menu: Add Task to queue");
TrangaTask[] tasks = taskManager.GetAllTasks().Where(rTask =>
rTask.state is not TrangaTask.ExecutionState.Enqueued and not TrangaTask.ExecutionState.Running).ToArray();
TrangaTask? selectedTask = SelectTask(tasks, logger);
if (selectedTask is null)
return;
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
taskManager.AddTaskToQueue(selectedTask);
}
private static void RemoveTaskFromQueue(TaskManager taskManager, Logger logger)
{
Console.Clear();
logger.WriteLine("Tranga_CLI", "Menu: Remove Task from queue");
TrangaTask[] tasks = taskManager.GetAllTasks().Where(rTask => rTask.state is TrangaTask.ExecutionState.Enqueued).ToArray();
TrangaTask? selectedTask = SelectTask(tasks, logger);
if (selectedTask is null)
return;
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
taskManager.RemoveTaskFromQueue(selectedTask);
}
private static void TailLog(Logger logger)
{
logger.WriteLine("Tranga_CLI", "Menu: Show Log-lines");
Console.Clear();
string[] lines = logger.Tail(20);
foreach (string message in lines)
Console.Write(message);
while (!Console.KeyAvailable)
{
string[] newLines = logger.GetNewLines();
foreach(string message in newLines)
Console.Write(message);
Thread.Sleep(40);
}
}
private static void CreateTask(TaskManager taskManager, TrangaSettings settings, Logger logger)
{
logger.WriteLine("Tranga_CLI", "Menu: Creating Task");
TrangaTask.Task? tmpTask = SelectTaskType(logger);
if (tmpTask is null)
return;
TrangaTask.Task task = (TrangaTask.Task)tmpTask!;
Connector? connector = null;
if (task != TrangaTask.Task.UpdateKomgaLibrary)
{
connector = SelectConnector(settings.downloadLocation, taskManager.GetAvailableConnectors().Values.ToArray(), logger);
if (connector is null)
return;
}
Publication? publication = null;
if (task != TrangaTask.Task.UpdatePublications && task != TrangaTask.Task.UpdateKomgaLibrary)
{
publication = SelectPublication(taskManager, connector!, logger);
if (publication is null)
return;
}
TimeSpan reoccurrence = SelectReoccurrence(logger);
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
TrangaTask newTask = taskManager.AddTask(task, connector?.name, publication, reoccurrence, "en");
Console.WriteLine(newTask);
}
private static void ExecuteTaskNow(TaskManager taskManager, Logger logger)
{
logger.WriteLine("Tranga_CLI", "Menu: Executing Task");
TrangaTask[] tasks = taskManager.GetAllTasks().Where(nTask => nTask.state is not TrangaTask.ExecutionState.Running).ToArray();
TrangaTask? selectedTask = SelectTask(tasks, logger);
if (selectedTask is null)
return;
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
taskManager.ExecuteTaskNow(selectedTask);
}
private static void DeleteTask(TaskManager taskManager, Logger logger)
{
logger.WriteLine("Tranga_CLI", "Menu: Delete Task");
TrangaTask[] tasks = taskManager.GetAllTasks();
TrangaTask? selectedTask = SelectTask(tasks, logger);
if (selectedTask is null)
return;
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
taskManager.DeleteTask(selectedTask.task, selectedTask.connectorName, selectedTask.publication);
}
private static TrangaTask.Task? SelectTaskType(Logger logger)
{
logger.WriteLine("Tranga_CLI", "Menu: Select TaskType");
Console.Clear();
string[] taskNames = Enum.GetNames<TrangaTask.Task>();
int tIndex = 0;
Console.WriteLine("Available Tasks:");
foreach (string taskName in taskNames)
Console.WriteLine($"{tIndex++}: {taskName}");
Console.WriteLine("Enter q to abort");
Console.WriteLine($"Select Task (0-{taskNames.Length - 1}):");
string? selectedTask = Console.ReadLine();
while(selectedTask is null || selectedTask.Length < 1)
selectedTask = Console.ReadLine();
if (selectedTask.Length == 1 && selectedTask.ToLower() == "q")
{
Console.Clear();
Console.WriteLine("aborted.");
logger.WriteLine("Tranga_CLI", "aborted.");
return null;
}
try
{
int selectedTaskIndex = Convert.ToInt32(selectedTask);
string selectedTaskName = taskNames[selectedTaskIndex];
return Enum.Parse<TrangaTask.Task>(selectedTaskName);
}
catch (Exception e)
{
Console.WriteLine($"Exception: {e.Message}");
logger.WriteLine("Tranga_CLI", e.Message);
}
return null;
}
private static TimeSpan SelectReoccurrence(Logger logger)
{
logger.WriteLine("Tranga_CLI", "Menu: Select Reoccurrence");
Console.WriteLine("Select reoccurrence Timer (Format hh:mm:ss):");
return TimeSpan.Parse(Console.ReadLine()!, new CultureInfo("en-US"));
}
private static Connector? SelectConnector(string folderPath, Connector[] connectors, Logger logger)
{
logger.WriteLine("Tranga_CLI", "Menu: Select Connector");
Console.Clear();
int cIndex = 0;
Console.WriteLine("Connectors:");
foreach (Connector connector in connectors)
Console.WriteLine($"{cIndex++}: {connector.name}");
Console.WriteLine("Enter q to abort");
Console.WriteLine($"Select Connector (0-{connectors.Length - 1}):");
string? selectedConnector = Console.ReadLine();
while(selectedConnector is null || selectedConnector.Length < 1)
selectedConnector = Console.ReadLine();
if (selectedConnector.Length == 1 && selectedConnector.ToLower() == "q")
{
Console.Clear();
Console.WriteLine("aborted.");
logger.WriteLine("Tranga_CLI", "aborted.");
return null;
}
try
{
int selectedConnectorIndex = Convert.ToInt32(selectedConnector);
return connectors[selectedConnectorIndex];
}
catch (Exception e)
{
Console.WriteLine($"Exception: {e.Message}");
logger.WriteLine("Tranga_CLI", e.Message);
}
return null;
}
private static Publication? SelectPublication(TaskManager taskManager, Connector connector, Logger logger)
{
logger.WriteLine("Tranga_CLI", "Menu: Select Publication");
Console.Clear();
Console.WriteLine($"Connector: {connector.name}");
Console.WriteLine("Publication search query (leave empty for all):");
string? query = Console.ReadLine();
Publication[] publications = taskManager.GetPublicationsFromConnector(connector, query ?? "");
if (publications.Length < 1)
{
logger.WriteLine("Tranga_CLI", "No publications returned");
Console.WriteLine($"No publications for query '{query}' returned;");
return null;
}
int pIndex = 0;
Console.WriteLine("Publications:");
foreach(Publication publication in publications)
Console.WriteLine($"{pIndex++}: {publication.sortName}");
Console.WriteLine("Enter q to abort");
Console.WriteLine($"Select publication to Download (0-{publications.Length - 1}):");
string? selectedPublication = Console.ReadLine();
while(selectedPublication is null || selectedPublication.Length < 1)
selectedPublication = Console.ReadLine();
if (selectedPublication.Length == 1 && selectedPublication.ToLower() == "q")
{
Console.Clear();
Console.WriteLine("aborted.");
logger.WriteLine("Tranga_CLI", "aborted.");
return null;
}
try
{
int selectedPublicationIndex = Convert.ToInt32(selectedPublication);
return publications[selectedPublicationIndex];
}
catch (Exception e)
{
Console.WriteLine($"Exception: {e.Message}");
logger.WriteLine("Tranga_CLI", e.Message);
}
return null;
}
private static void SearchTasks(TaskManager taskManager, Logger logger)
{
logger.WriteLine("Tranga_CLI", "Menu: Search task");
Console.Clear();
Console.WriteLine("Enter search query:");
string? query = Console.ReadLine();
while (query is null || query.Length < 4)
query = Console.ReadLine();
PrintTasks(taskManager.GetAllTasks().Where(qTask =>
qTask.ToString().ToLower().Contains(query, StringComparison.OrdinalIgnoreCase)).ToArray(), logger);
}
}

View File

@ -2,12 +2,8 @@
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}") = "Tranga", ".\Tranga\Tranga.csproj", "{545E81B9-D96B-4C8F-A97F-2C02414DE566}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tranga-CLI", "Tranga-CLI\Tranga-CLI.csproj", "{4899E3B2-B259-479A-B43E-042D043E9501}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logging", "Logging\Logging.csproj", "{415BE889-BB7D-426F-976F-8D977876A462}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logging", "Logging\Logging.csproj", "{415BE889-BB7D-426F-976F-8D977876A462}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tranga-API", "Tranga-API\Tranga-API.csproj", "{48F4E495-75BC-4402-8E03-DEC5B79D7E83}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -18,17 +14,9 @@ Global
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Debug|Any CPU.Build.0 = Debug|Any CPU {545E81B9-D96B-4C8F-A97F-2C02414DE566}.Debug|Any CPU.Build.0 = Debug|Any CPU
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.ActiveCfg = Release|Any CPU {545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.ActiveCfg = Release|Any CPU
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.Build.0 = Release|Any CPU {545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.Build.0 = Release|Any CPU
{4899E3B2-B259-479A-B43E-042D043E9501}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4899E3B2-B259-479A-B43E-042D043E9501}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4899E3B2-B259-479A-B43E-042D043E9501}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4899E3B2-B259-479A-B43E-042D043E9501}.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.ActiveCfg = Debug|Any CPU
{415BE889-BB7D-426F-976F-8D977876A462}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.Build.0 = Release|Any CPU {415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.Build.0 = Release|Any CPU
{48F4E495-75BC-4402-8E03-DEC5B79D7E83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{48F4E495-75BC-4402-8E03-DEC5B79D7E83}.Debug|Any CPU.Build.0 = Debug|Any CPU
{48F4E495-75BC-4402-8E03-DEC5B79D7E83}.Release|Any CPU.ActiveCfg = Release|Any CPU
{48F4E495-75BC-4402-8E03-DEC5B79D7E83}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@ -1,3 +1,10 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/UserDictionary/Words/=altnames/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=authorsartists/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Gotify/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Komga/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Komga/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=mangakatana/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Manganato/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Mangasee/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Taskmanager/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Tranga/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary> <s:Boolean x:Key="/Default/UserDictionary/Words/=Tranga/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@ -1,4 +1,5 @@
using System.Globalization; using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace Tranga; namespace Tranga;
@ -6,28 +7,84 @@ namespace Tranga;
/// Has to be Part of a publication /// 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. /// Includes the Chapter-Name, -VolumeNumber, -ChapterNumber, the location of the chapter on the internet and the saveName of the local file.
/// </summary> /// </summary>
public struct Chapter public readonly struct Chapter
{ {
// ReSharper disable once MemberCanBePrivate.Global
public Publication parentPublication { get; }
public string? name { get; } public string? name { get; }
public string? volumeNumber { get; } public string? volumeNumber { get; }
public string? chapterNumber { get; } public string chapterNumber { get; }
public string url { get; } public string url { get; }
// ReSharper disable once MemberCanBePrivate.Global
public string fileName { get; } public string fileName { get; }
public string sortNumber { get; }
public Chapter(string? name, string? volumeNumber, string? chapterNumber, string url) private static readonly Regex LegalCharacters = new (@"([A-z]*[0-9]* *\.*-*,*\]*\[*'*\'*\)*\(*~*!*)*");
private static readonly Regex IllegalStrings = new(@"Vol(ume)?.?", RegexOptions.IgnoreCase);
public Chapter(Publication parentPublication, string? name, string? volumeNumber, string chapterNumber, string url)
{ {
this.parentPublication = parentPublication;
this.name = name; this.name = name;
this.volumeNumber = volumeNumber is { Length: > 0 } ? volumeNumber : "1"; this.volumeNumber = volumeNumber;
this.chapterNumber = chapterNumber; this.chapterNumber = chapterNumber;
this.url = url; this.url = url;
string chapterName = string.Concat((name ?? "").Split(Path.GetInvalidFileNameChars()));
NumberFormatInfo nfi = new NumberFormatInfo() string chapterName = string.Concat(LegalCharacters.Matches(name ?? ""));
string volStr = this.volumeNumber is not null ? $"Vol.{this.volumeNumber} " : "";
string chNumberStr = $"Ch.{chapterNumber} ";
string chNameStr = chapterName.Length > 0 ? $"- {chapterName}" : "";
chNameStr = IllegalStrings.Replace(chNameStr, "");
this.fileName = $"{volStr}{chNumberStr}{chNameStr}";
}
/// <summary>
/// Checks if a chapter-archive is already present
/// </summary>
/// <returns>true if chapter is present</returns>
internal bool CheckChapterIsDownloaded(string downloadLocation)
{ {
NumberDecimalSeparator = "." string newFilePath = GetArchiveFilePath(downloadLocation);
}; if (!Directory.Exists(Path.Join(downloadLocation, parentPublication.folderName)))
sortNumber = decimal.Round(Convert.ToDecimal(this.volumeNumber) * Convert.ToDecimal(this.chapterNumber, nfi), 1) return false;
.ToString(nfi); FileInfo[] archives = new DirectoryInfo(Path.Join(downloadLocation, parentPublication.folderName)).GetFiles();
this.fileName = $"{chapterName} - V{volumeNumber}C{chapterNumber} - {sortNumber}"; Regex chapterInfoRex = new(@"Ch\.[0-9.]+");
Regex chapterRex = new(@"[0-9]+(\.[0-9]+)?");
if (File.Exists(newFilePath))
return true;
string cn = this.chapterNumber;
if (archives.FirstOrDefault(archive => chapterRex.Match(chapterInfoRex.Match(archive.Name).Value).Value == cn) is { } path)
{
File.Move(path.FullName, newFilePath);
return true;
}
return false;
}
/// <summary>
/// Creates full file path of chapter-archive
/// </summary>
/// <returns>Filepath</returns>
internal string GetArchiveFilePath(string downloadLocation)
{
return Path.Join(downloadLocation, parentPublication.folderName, $"{parentPublication.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(',', parentPublication.tags)),
new XElement("LanguageISO", parentPublication.originalLanguage),
new XElement("Title", this.name),
new XElement("Writer", string.Join(',', parentPublication.authors)),
new XElement("Volume", this.volumeNumber),
new XElement("Number", this.chapterNumber)
);
return comicInfo.ToString();
} }
} }

View File

@ -1,248 +0,0 @@
using System.IO.Compression;
using System.Net;
using System.Xml.Linq;
using Logging;
namespace Tranga;
/// <summary>
/// Base-Class for all Connectors
/// Provides some methods to be used by all Connectors, as well as a DownloadClient
/// </summary>
public abstract class Connector
{
internal string downloadLocation { get; } //Location of local files
protected DownloadClient downloadClient { get; init; }
protected Logger? logger;
protected Connector(string downloadLocation, Logger? logger)
{
this.downloadLocation = downloadLocation;
this.logger = logger;
this.downloadClient = new DownloadClient(new Dictionary<byte, int>()
{
//RequestTypes for RateLimits
}, logger);
}
public abstract string name { get; } //Name of the Connector (e.g. Website)
/// <summary>
/// Returns all Publications with the given string.
/// If the string is empty or null, returns all Publication of the Connector
/// </summary>
/// <param name="publicationTitle">Search-Query</param>
/// <returns>Publications matching the query</returns>
public abstract Publication[] GetPublications(string publicationTitle = "");
/// <summary>
/// Returns all Chapters of the publication in the provided language.
/// If the language is empty or null, returns all Chapters in all Languages.
/// </summary>
/// <param name="publication">Publication to get Chapters for</param>
/// <param name="language">Language of the Chapters</param>
/// <returns>Array of Chapters matching Publication and Language</returns>
public abstract Chapter[] GetChapters(Publication publication, string language = "");
/// <summary>
/// Retrieves the Chapter (+Images) from the website.
/// Should later call DownloadChapterImages to retrieve the individual Images of the Chapter.
/// </summary>
/// <param name="publication">Publication that contains Chapter</param>
/// <param name="chapter">Chapter with Images to retrieve</param>
public abstract void DownloadChapter(Publication publication, Chapter chapter);
/// <summary>
/// Retrieves the Cover from the Website
/// </summary>
/// <param name="publication">Publication to retrieve Cover for</param>
public abstract void DownloadCover(Publication publication);
/// <summary>
/// Saves the series-info to series.json in the Publication Folder
/// </summary>
/// <param name="publication">Publication to save series.json for</param>
public void SaveSeriesInfo(Publication publication)
{
logger?.WriteLine(this.GetType().ToString(), $"Saving series.json for {publication.sortName}");
//Check if Publication already has a Folder and a series.json
string publicationFolder = Path.Join(downloadLocation, publication.folderName);
if(!Directory.Exists(publicationFolder))
Directory.CreateDirectory(publicationFolder);
string seriesInfoPath = Path.Join(publicationFolder, "series.json");
if(!File.Exists(seriesInfoPath))
File.WriteAllText(seriesInfoPath,publication.GetSeriesInfoJson());
}
/// <summary>
/// Creates a string containing XML of publication and chapter.
/// See ComicInfo.xml
/// </summary>
/// <returns>XML-string</returns>
protected static string CreateComicInfo(Publication publication, Chapter chapter, Logger? logger)
{
logger?.WriteLine("Connector", $"Creating ComicInfo.Xml for {publication.sortName} Chapter {chapter.volumeNumber} {chapter.chapterNumber}");
XElement comicInfo = new XElement("ComicInfo",
new XElement("Tags", string.Join(',',publication.tags)),
new XElement("LanguageISO", publication.originalLanguage),
new XElement("Title", chapter.name),
new XElement("Writer", publication.author),
new XElement("Volume", chapter.volumeNumber),
new XElement("Number", chapter.chapterNumber) //TODO check if this is correct at some point
);
return comicInfo.ToString();
}
/// <summary>
/// Checks if a chapter-archive is already present
/// </summary>
/// <returns>true if chapter is present</returns>
public bool ChapterIsDownloaded(Publication publication, Chapter chapter)
{
return File.Exists(CreateFullFilepath(publication, chapter));
}
/// <summary>
/// Creates full file path of chapter-archive
/// </summary>
/// <returns>Filepath</returns>
protected string CreateFullFilepath(Publication publication, Chapter chapter)
{
return Path.Join(downloadLocation, publication.folderName, $"{chapter.fileName}.cbz");
}
/// <summary>
/// Downloads Image from URL and saves it to the given path(incl. fileName)
/// </summary>
/// <param name="imageUrl"></param>
/// <param name="fullPath"></param>
/// <param name="downloadClient">DownloadClient of the connector</param>
/// <param name="requestType">Requesttype for ratelimit</param>
protected static void DownloadImage(string imageUrl, string fullPath, DownloadClient downloadClient, byte requestType)
{
DownloadClient.RequestResult requestResult = downloadClient.MakeRequest(imageUrl, requestType);
byte[] buffer = new byte[requestResult.result.Length];
requestResult.result.ReadExactly(buffer, 0, buffer.Length);
File.WriteAllBytes(fullPath, buffer);
}
/// <summary>
/// Downloads all Images from URLs, Compresses to zip(cbz) and saves.
/// </summary>
/// <param name="imageUrls">List of URLs to download Images from</param>
/// <param name="saveArchiveFilePath">Full path to save archive to (without file ending .cbz)</param>
/// <param name="downloadClient">DownloadClient of the connector</param>
/// <param name="comicInfoPath">Path of the generate Chapter ComicInfo.xml, if it was generated</param>
/// <param name="requestType">RequestType for RateLimits</param>
protected static void DownloadChapterImages(string[] imageUrls, string saveArchiveFilePath, DownloadClient downloadClient, byte requestType, Logger? logger, string? comicInfoPath = null)
{
logger?.WriteLine("Connector", "Downloading Images");
//Check if Publication Directory already exists
string directoryPath = Path.GetDirectoryName(saveArchiveFilePath)!;
if (!Directory.Exists(directoryPath))
Directory.CreateDirectory(directoryPath);
if (File.Exists(saveArchiveFilePath)) //Don't download twice.
return;
//Create a temporary folder to store images
string tempFolder = Directory.CreateTempSubdirectory().FullName;
int chapter = 0;
//Download all Images to temporary Folder
foreach (string imageUrl in imageUrls)
{
string[] split = imageUrl.Split('.');
string extension = split[^1];
DownloadImage(imageUrl, Path.Join(tempFolder, $"{chapter++}.{extension}"), downloadClient, requestType);
}
if(comicInfoPath is not null)
File.Copy(comicInfoPath, Path.Join(tempFolder, "ComicInfo.xml"));
logger?.WriteLine("Connector", "Creating archive");
//ZIP-it and ship-it
ZipFile.CreateFromDirectory(tempFolder, saveArchiveFilePath);
Directory.Delete(tempFolder, true); //Cleanup
}
protected class DownloadClient
{
private static readonly HttpClient Client = new();
private readonly Dictionary<byte, DateTime> _lastExecutedRateLimit;
private readonly Dictionary<byte, TimeSpan> _rateLimit;
private Logger? logger;
/// <summary>
/// Creates a httpClient
/// </summary>
/// <param name="delay">minimum delay between requests (to avoid spam)</param>
/// <param name="rateLimitRequestsPerMinute">Rate limits for requests. byte is RequestType, int maximum requests per minute for RequestType</param>
public DownloadClient(Dictionary<byte, int> rateLimitRequestsPerMinute, Logger? logger)
{
this.logger = logger;
_lastExecutedRateLimit = new();
_rateLimit = new();
foreach(KeyValuePair<byte, int> limit in rateLimitRequestsPerMinute)
_rateLimit.Add(limit.Key, TimeSpan.FromMinutes(1).Divide(limit.Value));
}
/// <summary>
/// Request Webpage
/// </summary>
/// <param name="url"></param>
/// <param name="requestType">For RateLimits: Same Endpoints use same type</param>
/// <returns>RequestResult with StatusCode and Stream of received data</returns>
public RequestResult MakeRequest(string url, byte requestType)
{
if (_rateLimit.TryGetValue(requestType, out TimeSpan value))
_lastExecutedRateLimit.TryAdd(requestType, DateTime.Now.Subtract(value));
else
{
logger?.WriteLine(this.GetType().ToString(), "RequestType not configured for rate-limit.");
return new RequestResult(HttpStatusCode.NotAcceptable, Stream.Null);
}
TimeSpan rateLimitTimeout = _rateLimit[requestType]
.Subtract(DateTime.Now.Subtract(_lastExecutedRateLimit[requestType]));
if(rateLimitTimeout > TimeSpan.Zero)
Thread.Sleep(rateLimitTimeout);
HttpResponseMessage? response = null;
while (response is null)
{
try
{
HttpRequestMessage requestMessage = new(HttpMethod.Get, url);
_lastExecutedRateLimit[requestType] = DateTime.Now;
response = Client.Send(requestMessage);
}
catch (HttpRequestException e)
{
logger?.WriteLine(this.GetType().ToString(), e.Message);
Thread.Sleep(_rateLimit[requestType] * 2);
}
}
Stream resultString = response.IsSuccessStatusCode ? response.Content.ReadAsStream() : Stream.Null;
if (!response.IsSuccessStatusCode)
logger?.WriteLine(this.GetType().ToString(), $"Request-Error {response.StatusCode}: {response.ReasonPhrase}");
return new RequestResult(response.StatusCode, resultString);
}
public struct RequestResult
{
public HttpStatusCode statusCode { get; }
public Stream result { get; }
public RequestResult(HttpStatusCode statusCode, Stream result)
{
this.statusCode = statusCode;
this.result = result;
}
}
}
}

92
Tranga/GlobalBase.cs Normal file
View File

@ -0,0 +1,92 @@
using Logging;
using Newtonsoft.Json;
using Tranga.LibraryConnectors;
using Tranga.NotificationConnectors;
namespace Tranga;
public abstract class GlobalBase
{
protected Logger? logger { get; init; }
protected TrangaSettings settings { get; init; }
protected HashSet<NotificationConnector> notificationConnectors { get; init; }
protected HashSet<LibraryConnector> libraryConnectors { get; init; }
protected List<Publication> cachedPublications { get; init; }
protected GlobalBase(GlobalBase clone)
{
this.logger = clone.logger;
this.settings = clone.settings;
this.notificationConnectors = clone.notificationConnectors;
this.libraryConnectors = clone.libraryConnectors;
this.cachedPublications = clone.cachedPublications;
}
protected GlobalBase(Logger? logger, TrangaSettings settings)
{
this.logger = logger;
this.settings = settings;
this.notificationConnectors = settings.LoadNotificationConnectors();
this.libraryConnectors = settings.LoadLibraryConnectors();
this.cachedPublications = new();
}
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)
{
foreach (NotificationConnector nc in notificationConnectors)
nc.SendNotification(title, text);
}
protected void AddNotificationConnector(NotificationConnector notificationConnector)
{
notificationConnectors.RemoveWhere(nc => nc.GetType() == notificationConnector.GetType());
notificationConnectors.Add(notificationConnector);
while(IsFileInUse(settings.notificationConnectorsFilePath))
Thread.Sleep(100);
File.WriteAllText(settings.notificationConnectorsFilePath, JsonConvert.SerializeObject(notificationConnectors));
}
protected void UpdateLibraries()
{
foreach(LibraryConnector lc in libraryConnectors)
lc.UpdateLibrary();
}
protected void AddLibraryConnector(LibraryConnector libraryConnector)
{
libraryConnectors.RemoveWhere(lc => lc.GetType() == libraryConnector.GetType());
libraryConnectors.Add(libraryConnector);
while(IsFileInUse(settings.libraryConnectorsFilePath))
Thread.Sleep(100);
File.WriteAllText(settings.libraryConnectorsFilePath, JsonConvert.SerializeObject(libraryConnectors));
}
protected 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)
{
Log($"File is in use {filePath}");
return true;
}
}
}

View File

@ -0,0 +1,31 @@
using System.Text;
using Tranga.MangaConnectors;
namespace Tranga.Jobs;
public class DownloadChapter : Job
{
public Chapter chapter { get; init; }
public DownloadChapter(GlobalBase clone, MangaConnector connector, Chapter chapter) : base(clone, connector)
{
this.chapter = chapter;
}
protected override string GetId()
{
return Convert.ToBase64String(Encoding.ASCII.GetBytes(string.Concat(this.GetType().ToString(), chapter.parentPublication.internalId, chapter.chapterNumber)));
}
protected override IEnumerable<Job> ExecuteReturnSubTasksInternal()
{
Task downloadTask = new(delegate
{
mangaConnector.DownloadChapter(chapter, this.progressToken);
UpdateLibraries();
SendNotifications("Chapter downloaded", $"{chapter.parentPublication.sortName} - {chapter.chapterNumber}");
});
downloadTask.Start();
return Array.Empty<Job>();
}
}

View File

@ -0,0 +1,33 @@
using System.Text;
using Tranga.MangaConnectors;
namespace Tranga.Jobs;
public class DownloadNewChapters : Job
{
public Publication publication { get; init; }
public DownloadNewChapters(GlobalBase clone, MangaConnector connector, Publication publication, bool recurring = false) : base (clone, connector, recurring)
{
this.publication = publication;
}
protected override string GetId()
{
return Convert.ToBase64String(Encoding.ASCII.GetBytes(string.Concat(this.GetType().ToString(), publication.internalId)));
}
protected override IEnumerable<Job> ExecuteReturnSubTasksInternal()
{
Chapter[] chapters = mangaConnector.GetNewChapters(publication);
this.progressToken.increments = chapters.Length;
List<Job> subJobs = new();
foreach (Chapter chapter in chapters)
{
DownloadChapter downloadChapterJob = new(this, this.mangaConnector, chapter);
subJobs.Add(downloadChapterJob);
}
progressToken.Complete();
return subJobs;
}
}

76
Tranga/Jobs/Job.cs Normal file
View File

@ -0,0 +1,76 @@
using Tranga.MangaConnectors;
namespace Tranga.Jobs;
public abstract class Job : GlobalBase
{
public MangaConnector mangaConnector { get; init; }
public ProgressToken progressToken { get; private set; }
public bool recurring { get; init; }
public TimeSpan? recurrenceTime { get; set; }
public DateTime? lastExecution { get; private set; }
public DateTime nextExecution => NextExecution();
public string id => GetId();
public Job(GlobalBase clone, MangaConnector connector, bool recurring = false, TimeSpan? recurrenceTime = null) : base(clone)
{
this.mangaConnector = connector;
this.progressToken = new ProgressToken(0);
this.recurring = recurring;
if (recurring && recurrenceTime is null)
throw new ArgumentException("If recurrence is set to true, a recurrence time has to be provided.");
this.recurrenceTime = recurrenceTime;
}
protected abstract string GetId();
public Job(GlobalBase clone, MangaConnector connector, ProgressToken progressToken, bool recurring = false, TimeSpan? recurrenceTime = null) : base(clone)
{
this.mangaConnector = connector;
this.progressToken = progressToken;
this.recurring = recurring;
if (recurring && recurrenceTime is null)
throw new ArgumentException("If recurrence is set to true, a recurrence time has to be provided.");
this.recurrenceTime = recurrenceTime;
}
public Job(GlobalBase clone, MangaConnector connector, int taskIncrements, bool recurring = false, TimeSpan? recurrenceTime = null) : base(clone)
{
this.mangaConnector = connector;
this.progressToken = new ProgressToken(taskIncrements);
this.recurring = recurring;
if (recurring && recurrenceTime is null)
throw new ArgumentException("If recurrence is set to true, a recurrence time has to be provided.");
this.recurrenceTime = recurrenceTime;
}
private DateTime NextExecution()
{
if(recurring && recurrenceTime.HasValue && lastExecution.HasValue)
return lastExecution.Value.Add(recurrenceTime.Value);
if(recurring && recurrenceTime.HasValue && !lastExecution.HasValue)
return DateTime.Now;
return DateTime.MaxValue;
}
public void Reset()
{
this.progressToken = new ProgressToken(this.progressToken.increments);
}
public void Cancel()
{
this.progressToken.cancellationRequested = true;
this.progressToken.Complete();
}
public IEnumerable<Job> ExecuteReturnSubTasks()
{
progressToken.Start();
IEnumerable<Job> ret = ExecuteReturnSubTasksInternal();
lastExecution = DateTime.Now;
return ret;
}
protected abstract IEnumerable<Job> ExecuteReturnSubTasksInternal();
}

64
Tranga/Jobs/JobBoss.cs Normal file
View File

@ -0,0 +1,64 @@
using Tranga.MangaConnectors;
namespace Tranga.Jobs;
public class JobBoss : GlobalBase
{
public HashSet<Job> jobs { get; init; }
private Dictionary<MangaConnector, Queue<Job>> mangaConnectorJobQueue { get; init; }
public JobBoss(GlobalBase clone) : base(clone)
{
this.jobs = new();
this.mangaConnectorJobQueue = new();
}
public void AddJob(Job job)
{
this.jobs.Add(job);
}
public void RemoveJob(Job job)
{
job.Cancel();
this.jobs.Remove(job);
}
private bool QueueContainsJob(Job job)
{
mangaConnectorJobQueue.TryAdd(job.mangaConnector, new Queue<Job>());
return mangaConnectorJobQueue[job.mangaConnector].Contains(job);
}
private void AddJobToQueue(Job job)
{
Log($"Adding Job to Queue. {job}");
mangaConnectorJobQueue.TryAdd(job.mangaConnector, new Queue<Job>());
Queue<Job> connectorJobQueue = mangaConnectorJobQueue[job.mangaConnector];
if(!connectorJobQueue.Contains(job))
connectorJobQueue.Enqueue(job);
}
public void AddJobsToQueue(IEnumerable<Job> jobs)
{
foreach(Job job in jobs)
AddJobToQueue(job);
}
public void CheckJobs()
{
foreach (Job job in jobs.Where(job => job.nextExecution < DateTime.Now && !QueueContainsJob(job)).OrderBy(job => job.nextExecution))
AddJobToQueue(job);
foreach (Queue<Job> jobQueue in mangaConnectorJobQueue.Values)
{
Job queueHead = jobQueue.Peek();
if (queueHead.progressToken.state == ProgressToken.State.Complete)
{
if(queueHead.recurring)
queueHead.Reset();
jobQueue.Dequeue();
AddJobsToQueue(jobQueue.Peek().ExecuteReturnSubTasks());
}
}
}
}

View File

@ -0,0 +1,44 @@
namespace Tranga.Jobs;
public class ProgressToken
{
public bool cancellationRequested { get; set; }
public int increments { get; set; }
public int incrementsCompleted { get; set; }
public float progress => GetProgress();
public enum State { Running, Complete, Standby }
public State state { get; private set; }
public ProgressToken(int increments)
{
this.cancellationRequested = false;
this.increments = increments;
this.incrementsCompleted = 0;
this.state = State.Standby;
}
private float GetProgress()
{
if(increments > 0 && incrementsCompleted > 0)
return (float)incrementsCompleted / (float)increments;
return 0;
}
public void Increment()
{
this.incrementsCompleted++;
if (incrementsCompleted > increments)
state = State.Complete;
}
public void Start()
{
state = State.Running;
}
public void Complete()
{
state = State.Complete;
}
}

View File

@ -1,125 +0,0 @@
using System.Net.Http.Headers;
using System.Text.Json.Nodes;
using Logging;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace Tranga;
/// <summary>
/// Provides connectivity to Komga-API
/// Can fetch and update libraries
/// </summary>
public class Komga
{
public string baseUrl { get; }
public string auth { get; } //Base64 encoded, if you use your password everywhere, you have problems
private Logger? logger;
/// <param name="baseUrl">Base-URL of Komga instance, no trailing slashes(/)</param>
/// <param name="username">Komga Username</param>
/// <param name="password">Komga password, will be base64 encoded. yea</param>
public Komga(string baseUrl, string username, string password, Logger? logger)
{
this.baseUrl = baseUrl;
this.auth = Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($"{username}:{password}"));
this.logger = logger;
}
/// <param name="baseUrl">Base-URL of Komga instance, no trailing slashes(/)</param>
/// <param name="auth">Base64 string of username and password (username):(password)</param>
[JsonConstructor]
public Komga(string baseUrl, string auth, Logger? logger)
{
this.baseUrl = baseUrl;
this.auth = auth;
this.logger = logger;
}
/// <summary>
/// Fetches all libraries available to the user
/// </summary>
/// <returns>Array of KomgaLibraries</returns>
public KomgaLibrary[] GetLibraries()
{
logger?.WriteLine(this.GetType().ToString(), $"Getting Libraries");
Stream data = NetClient.MakeRequest($"{baseUrl}/api/v1/libraries", auth);
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
if (result is null)
return Array.Empty<KomgaLibrary>();
HashSet<KomgaLibrary> ret = new();
foreach (JsonNode? jsonNode in result)
{
var jObject = (JsonObject?)jsonNode;
string libraryId = jObject!["id"]!.GetValue<string>();
string libraryName = jObject!["name"]!.GetValue<string>();
ret.Add(new KomgaLibrary(libraryId, libraryName));
}
return ret.ToArray();
}
/// <summary>
/// Updates library with given id
/// </summary>
/// <param name="libraryId">Id of the Komga-Library</param>
/// <returns>true if successful</returns>
public bool UpdateLibrary(string libraryId)
{
logger?.WriteLine(this.GetType().ToString(), $"Updating Libraries");
return NetClient.MakePost($"{baseUrl}/api/v1/libraries/{libraryId}/scan", auth);
}
public struct KomgaLibrary
{
public string id { get; }
public string name { get; }
public KomgaLibrary(string id, string name)
{
this.id = id;
this.name = name;
}
}
private static class NetClient
{
public static Stream MakeRequest(string url, string auth)
{
HttpClient client = new();
HttpRequestMessage requestMessage = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = new Uri(url),
Headers =
{
{ "Accept", "application/json" },
{ "Authorization", new AuthenticationHeaderValue("Basic", auth).ToString() }
}
};
HttpResponseMessage response = client.Send(requestMessage);
Stream resultString = response.IsSuccessStatusCode ? response.Content.ReadAsStream() : Stream.Null;
return resultString;
}
public static bool MakePost(string url, string auth)
{
HttpClient client = new();
HttpRequestMessage requestMessage = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri(url),
Headers =
{
{ "Accept", "application/json" },
{ "Authorization", new AuthenticationHeaderValue("Basic", auth).ToString() }
}
};
HttpResponseMessage response = client.Send(requestMessage);
return response.IsSuccessStatusCode;
}
}
}

View File

@ -0,0 +1,95 @@
using System.Text.Json.Nodes;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace Tranga.LibraryConnectors;
public class Kavita : LibraryConnector
{
public Kavita(string baseUrl, string username, string password, GlobalBase clone) :
base(baseUrl, GetToken(baseUrl, username, password), LibraryType.Kavita, clone)
{
}
[JsonConstructor]
public Kavita(string baseUrl, string auth, GlobalBase clone) : base(baseUrl, auth, LibraryType.Kavita, clone)
{
}
private static string GetToken(string baseUrl, string username, string password)
{
HttpClient client = new()
{
DefaultRequestHeaders =
{
{ "Accept", "application/json" }
}
};
HttpRequestMessage requestMessage = new ()
{
Method = HttpMethod.Post,
RequestUri = new Uri($"{baseUrl}/api/Account/login"),
Content = new StringContent($"{{\"username\":\"{username}\",\"password\":\"{password}\"}}", System.Text.Encoding.UTF8, "application/json")
};
HttpResponseMessage response = client.Send(requestMessage);
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(response.Content.ReadAsStream());
if (result is not null)
return result["token"]!.GetValue<string>();
else return "";
}
public override void UpdateLibrary()
{
Log("Updating libraries.");
foreach (KavitaLibrary lib in GetLibraries())
NetClient.MakePost($"{baseUrl}/api/Library/scan?libraryId={lib.id}", "Bearer", auth, logger);
}
/// <summary>
/// Fetches all libraries available to the user
/// </summary>
/// <returns>Array of KavitaLibrary</returns>
private IEnumerable<KavitaLibrary> GetLibraries()
{
Log("Getting libraries.");
Stream data = NetClient.MakeRequest($"{baseUrl}/api/Library", "Bearer", auth, logger);
if (data == Stream.Null)
{
Log("No libraries returned");
return Array.Empty<KavitaLibrary>();
}
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
if (result is null)
{
Log("No libraries returned");
return Array.Empty<KavitaLibrary>();
}
HashSet<KavitaLibrary> ret = new();
foreach (JsonNode? jsonNode in result)
{
var jObject = (JsonObject?)jsonNode;
int libraryId = jObject!["id"]!.GetValue<int>();
string libraryName = jObject["name"]!.GetValue<string>();
ret.Add(new KavitaLibrary(libraryId, libraryName));
}
return ret;
}
private struct KavitaLibrary
{
public int id { get; }
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string name { get; }
public KavitaLibrary(int id, string name)
{
this.id = id;
this.name = name;
}
}
}

View File

@ -0,0 +1,75 @@
using System.Text.Json.Nodes;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace Tranga.LibraryConnectors;
/// <summary>
/// Provides connectivity to Komga-API
/// Can fetch and update libraries
/// </summary>
public class Komga : LibraryConnector
{
public Komga(string baseUrl, string username, string password, GlobalBase clone)
: base(baseUrl, Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($"{username}:{password}")), LibraryType.Komga, clone)
{
}
[JsonConstructor]
public Komga(string baseUrl, string auth, GlobalBase clone) : base(baseUrl, auth, LibraryType.Komga, clone)
{
}
public override void UpdateLibrary()
{
Log("Updating libraries.");
foreach (KomgaLibrary lib in GetLibraries())
NetClient.MakePost($"{baseUrl}/api/v1/libraries/{lib.id}/scan", "Basic", auth, logger);
}
/// <summary>
/// Fetches all libraries available to the user
/// </summary>
/// <returns>Array of KomgaLibraries</returns>
private IEnumerable<KomgaLibrary> GetLibraries()
{
Log("Getting Libraries");
Stream data = NetClient.MakeRequest($"{baseUrl}/api/v1/libraries", "Basic", auth, logger);
if (data == Stream.Null)
{
Log("No libraries returned");
return Array.Empty<KomgaLibrary>();
}
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
if (result is null)
{
Log("No libraries returned");
return Array.Empty<KomgaLibrary>();
}
HashSet<KomgaLibrary> ret = new();
foreach (JsonNode? jsonNode in result)
{
var jObject = (JsonObject?)jsonNode;
string libraryId = jObject!["id"]!.GetValue<string>();
string libraryName = jObject["name"]!.GetValue<string>();
ret.Add(new KomgaLibrary(libraryId, libraryName));
}
return ret;
}
private struct KomgaLibrary
{
public string id { get; }
// ReSharper disable once UnusedAutoPropertyAccessor.Local
public string name { get; }
public KomgaLibrary(string id, string name)
{
this.id = id;
this.name = name;
}
}
}

View File

@ -0,0 +1,78 @@
using System.Net;
using System.Net.Http.Headers;
using Logging;
namespace Tranga.LibraryConnectors;
public abstract class LibraryConnector : GlobalBase
{
public enum LibraryType : byte
{
Komga = 0,
Kavita = 1
}
// ReSharper disable once UnusedAutoPropertyAccessor.Global
public LibraryType libraryType { get; }
public string baseUrl { get; }
// ReSharper disable once MemberCanBeProtected.Global
public string auth { get; } //Base64 encoded, if you use your password everywhere, you have problems
protected LibraryConnector(string baseUrl, string auth, LibraryType libraryType, GlobalBase clone) : base(clone)
{
this.baseUrl = baseUrl;
this.auth = auth;
this.libraryType = libraryType;
}
public abstract void UpdateLibrary();
protected static class NetClient
{
public static Stream MakeRequest(string url, string authScheme, string auth, Logger? logger)
{
HttpClient client = new();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(authScheme, auth);
HttpRequestMessage requestMessage = new ()
{
Method = HttpMethod.Get,
RequestUri = new Uri(url)
};
HttpResponseMessage response = client.Send(requestMessage);
logger?.WriteLine("LibraryManager.NetClient", $"GET {url} -> {(int)response.StatusCode}: {response.ReasonPhrase}");
if(response.StatusCode is HttpStatusCode.Unauthorized && response.RequestMessage!.RequestUri!.AbsoluteUri != url)
return MakeRequest(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth, logger);
else if (response.IsSuccessStatusCode)
return response.Content.ReadAsStream();
else
return Stream.Null;
}
public static bool MakePost(string url, string authScheme, string auth, Logger? logger)
{
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);
logger?.WriteLine("LibraryManager.NetClient", $"POST {url} -> {(int)response.StatusCode}: {response.ReasonPhrase}");
if(response.StatusCode is HttpStatusCode.Unauthorized && response.RequestMessage!.RequestUri!.AbsoluteUri != url)
return MakePost(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth, logger);
else if (response.IsSuccessStatusCode)
return true;
else
return false;
}
}
}

View File

@ -0,0 +1,34 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Tranga.LibraryConnectors;
public class LibraryManagerJsonConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return (objectType == typeof(LibraryConnector));
}
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
JObject jo = JObject.Load(reader);
if (jo["libraryType"]!.Value<Int64>() == (Int64)LibraryConnector.LibraryType.Komga)
return jo.ToObject<Komga>(serializer)!;
if (jo["libraryType"]!.Value<Int64>() == (Int64)LibraryConnector.LibraryType.Kavita)
return jo.ToObject<Kavita>(serializer)!;
throw new Exception();
}
public override bool CanWrite => false;
/// <summary>
/// Don't call this
/// </summary>
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
throw new Exception("Dont call this");
}
}

View File

@ -0,0 +1,106 @@
using System.Net;
using System.Net.Http.Headers;
namespace Tranga.MangaConnectors;
internal class DownloadClient : GlobalBase
{
private static readonly HttpClient Client = new()
{
Timeout = TimeSpan.FromSeconds(60),
DefaultRequestHeaders =
{
UserAgent =
{
new ProductInfoHeaderValue("Tranga", "0.1")
}
}
};
private readonly Dictionary<byte, DateTime> _lastExecutedRateLimit;
private readonly Dictionary<byte, TimeSpan> _rateLimit;
public DownloadClient(Dictionary<byte, int> rateLimitRequestsPerMinute, GlobalBase clone) : base(clone)
{
_lastExecutedRateLimit = new();
_rateLimit = new();
foreach(KeyValuePair<byte, int> limit in rateLimitRequestsPerMinute)
_rateLimit.Add(limit.Key, TimeSpan.FromMinutes(1).Divide(limit.Value));
}
/// <summary>
/// Request Webpage
/// </summary>
/// <param name="url"></param>
/// <param name="requestType">For RateLimits: Same Endpoints use same type</param>
/// <param name="referrer">Used in http request header</param>
/// <returns>RequestResult with StatusCode and Stream of received data</returns>
public RequestResult MakeRequest(string url, byte requestType, string? referrer = null)
{
if (_rateLimit.TryGetValue(requestType, out TimeSpan value))
_lastExecutedRateLimit.TryAdd(requestType, DateTime.Now.Subtract(value));
else
{
Log("RequestType not configured for rate-limit.");
return new RequestResult(HttpStatusCode.NotAcceptable, Stream.Null);
}
TimeSpan rateLimitTimeout = _rateLimit[requestType]
.Subtract(DateTime.Now.Subtract(_lastExecutedRateLimit[requestType]));
if(rateLimitTimeout > TimeSpan.Zero)
Thread.Sleep(rateLimitTimeout);
HttpResponseMessage? response = null;
while (response is null)
{
try
{
HttpRequestMessage requestMessage = new(HttpMethod.Get, url);
if(referrer is not null)
requestMessage.Headers.Referrer = new Uri(referrer);
_lastExecutedRateLimit[requestType] = DateTime.Now;
response = Client.Send(requestMessage);
}
catch (HttpRequestException e)
{
Log("Exception:\n\t{0}\n\tWaiting {1} before retrying.", e.Message, _rateLimit[requestType] * 2);
Thread.Sleep(_rateLimit[requestType] * 2);
}
}
if (!response.IsSuccessStatusCode)
{
Log($"Request-Error {response.StatusCode}: {response.ReasonPhrase}");
return new RequestResult(response.StatusCode, Stream.Null);
}
// Request has been redirected to another page. For example, it redirects directly to the results when there is only 1 result
if(response.RequestMessage is not null && response.RequestMessage.RequestUri is not null)
{
return new RequestResult(response.StatusCode, response.Content.ReadAsStream(), true, response.RequestMessage.RequestUri.AbsoluteUri);
}
return new RequestResult(response.StatusCode, response.Content.ReadAsStream());
}
public struct RequestResult
{
public HttpStatusCode statusCode { get; }
public Stream result { get; }
public bool hasBeenRedirected { get; }
public string? redirectedToUrl { get; }
public RequestResult(HttpStatusCode statusCode, Stream result)
{
this.statusCode = statusCode;
this.result = result;
}
public RequestResult(HttpStatusCode statusCode, Stream result, bool hasBeenRedirected, string redirectedTo)
: this(statusCode, result)
{
this.hasBeenRedirected = hasBeenRedirected;
redirectedToUrl = redirectedTo;
}
}
}

View File

@ -0,0 +1,250 @@
using System.Globalization;
using System.IO.Compression;
using System.Net;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using Tranga.Jobs;
using static System.IO.UnixFileMode;
namespace Tranga.MangaConnectors;
/// <summary>
/// Base-Class for all Connectors
/// Provides some methods to be used by all Connectors, as well as a DownloadClient
/// </summary>
public abstract class MangaConnector : GlobalBase
{
internal DownloadClient downloadClient { get; init; } = null!;
protected MangaConnector(GlobalBase clone) : base(clone)
{
if (!Directory.Exists(settings.coverImageCache))
Directory.CreateDirectory(settings.coverImageCache);
}
public abstract string name { get; } //Name of the Connector (e.g. Website)
/// <summary>
/// Returns all Publications with the given string.
/// If the string is empty or null, returns all Publication of the Connector
/// </summary>
/// <param name="publicationTitle">Search-Query</param>
/// <returns>Publications matching the query</returns>
public abstract Publication[] GetPublications(string publicationTitle = "");
/// <summary>
/// Returns all Chapters of the publication in the provided language.
/// If the language is empty or null, returns all Chapters in all Languages.
/// </summary>
/// <param name="publication">Publication to get Chapters for</param>
/// <param name="language">Language of the Chapters</param>
/// <returns>Array of Chapters matching Publication and Language</returns>
public abstract Chapter[] GetChapters(Publication publication, string language="en");
/// <summary>
/// Updates the available Chapters of a Publication
/// </summary>
/// <param name="publication">Publication to check</param>
/// <param name="language">Language to receive chapters for</param>
/// <returns>List of Chapters that were previously not in collection</returns>
public Chapter[] GetNewChapters(Publication publication, string language = "en")
{
Log($"Getting new Chapters for {publication}");
Chapter[] newChapters = this.GetChapters(publication, language);
NumberFormatInfo decimalPoint = new (){ NumberDecimalSeparator = "." };
Log($"Checking for duplicates {publication}");
List<Chapter> newChaptersList = newChapters.Where(nChapter =>
float.Parse(nChapter.chapterNumber, decimalPoint) > publication.ignoreChaptersBelow &&
!nChapter.CheckChapterIsDownloaded(settings.downloadLocation)).ToList();
Log($"{newChaptersList.Count} new chapters. {publication}");
return newChaptersList.ToArray();
}
public Chapter[] SelectChapters(Publication publication, string searchTerm, string? language = null)
{
Chapter[] availableChapters = this.GetChapters(publication, language??"en");
Regex volumeRegex = new ("((v(ol)*(olume)*){1} *([0-9]+(-[0-9]+)?){1})", RegexOptions.IgnoreCase);
Regex chapterRegex = new ("((c(h)*(hapter)*){1} *([0-9]+(-[0-9]+)?){1})", RegexOptions.IgnoreCase);
Regex singleResultRegex = new("([0-9]+)", RegexOptions.IgnoreCase);
Regex rangeResultRegex = new("([0-9]+(-[0-9]+))", RegexOptions.IgnoreCase);
Regex allRegex = new("a(ll)?", RegexOptions.IgnoreCase);
if (volumeRegex.IsMatch(searchTerm) && chapterRegex.IsMatch(searchTerm))
{
string volume = singleResultRegex.Match(volumeRegex.Match(searchTerm).Value).Value;
string chapter = singleResultRegex.Match(chapterRegex.Match(searchTerm).Value).Value;
return availableChapters.Where(aCh => aCh.volumeNumber is not null &&
aCh.volumeNumber.Equals(volume, StringComparison.InvariantCultureIgnoreCase) &&
aCh.chapterNumber.Equals(chapter, StringComparison.InvariantCultureIgnoreCase))
.ToArray();
}
else if (volumeRegex.IsMatch(searchTerm))
{
string volume = volumeRegex.Match(searchTerm).Value;
if (rangeResultRegex.IsMatch(volume))
{
string range = rangeResultRegex.Match(volume).Value;
int start = Convert.ToInt32(range.Split('-')[0]);
int end = Convert.ToInt32(range.Split('-')[1]);
return availableChapters.Where(aCh => aCh.volumeNumber is not null &&
Convert.ToInt32(aCh.volumeNumber) >= start &&
Convert.ToInt32(aCh.volumeNumber) <= end).ToArray();
}
else if (singleResultRegex.IsMatch(volume))
{
string volumeNumber = singleResultRegex.Match(volume).Value;
return availableChapters.Where(aCh =>
aCh.volumeNumber is not null &&
aCh.volumeNumber.Equals(volumeNumber, StringComparison.InvariantCultureIgnoreCase)).ToArray();
}
}
else if (chapterRegex.IsMatch(searchTerm))
{
string chapter = chapterRegex.Match(searchTerm).Value;
if (rangeResultRegex.IsMatch(chapter))
{
string range = rangeResultRegex.Match(chapter).Value;
int start = Convert.ToInt32(range.Split('-')[0]);
int end = Convert.ToInt32(range.Split('-')[1]);
return availableChapters.Where(aCh => Convert.ToInt32(aCh.chapterNumber) >= start &&
Convert.ToInt32(aCh.chapterNumber) <= end).ToArray();
}
else if (singleResultRegex.IsMatch(chapter))
{
string chapterNumber = singleResultRegex.Match(chapter).Value;
return availableChapters.Where(aCh =>
aCh.chapterNumber.Equals(chapterNumber, StringComparison.InvariantCultureIgnoreCase)).ToArray();
}
}
else
{
if (rangeResultRegex.IsMatch(searchTerm))
{
int start = Convert.ToInt32(searchTerm.Split('-')[0]);
int end = Convert.ToInt32(searchTerm.Split('-')[1]);
return availableChapters[start..(end + 1)];
}
else if(singleResultRegex.IsMatch(searchTerm))
return new [] { availableChapters[Convert.ToInt32(searchTerm)] };
else if (allRegex.IsMatch(searchTerm))
return availableChapters;
}
return Array.Empty<Chapter>();
}
public abstract HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null);
/// <summary>
/// Copies the already downloaded cover from cache to downloadLocation
/// </summary>
/// <param name="publication">Publication to retrieve Cover for</param>
public void CopyCoverFromCacheToDownloadLocation(Publication publication)
{
Log($"Copy cover {publication}");
//Check if Publication already has a Folder and cover
string publicationFolder = publication.CreatePublicationFolder(settings.downloadLocation);
DirectoryInfo dirInfo = new (publicationFolder);
if (dirInfo.EnumerateFiles().Any(info => info.Name.Contains("cover", StringComparison.InvariantCultureIgnoreCase)))
{
Log($"Cover exists {publication}");
return;
}
string fileInCache = Path.Join(settings.coverImageCache, publication.coverFileNameInCache);
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" );
Log($"Cloning cover {fileInCache} -> {newFilePath}");
File.Copy(fileInCache, newFilePath, true);
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | OtherRead | OtherWrite | UserRead | UserWrite);
}
/// <summary>
/// Downloads Image from URL and saves it to the given path(incl. fileName)
/// </summary>
/// <param name="imageUrl"></param>
/// <param name="fullPath"></param>
/// <param name="requestType">RequestType for Rate-Limit</param>
/// <param name="referrer">referrer used in html request header</param>
private HttpStatusCode DownloadImage(string imageUrl, string fullPath, byte requestType, string? referrer = null)
{
DownloadClient.RequestResult requestResult = downloadClient.MakeRequest(imageUrl, requestType, referrer);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.result == Stream.Null)
return requestResult.statusCode;
byte[] buffer = new byte[requestResult.result.Length];
requestResult.result.ReadExactly(buffer, 0, buffer.Length);
File.WriteAllBytes(fullPath, buffer);
return requestResult.statusCode;
}
protected HttpStatusCode DownloadChapterImages(string[] imageUrls, string saveArchiveFilePath, byte requestType, string? comicInfoPath = null, string? referrer = null, ProgressToken? progressToken = null)
{
if (progressToken?.cancellationRequested ?? false)
return HttpStatusCode.RequestTimeout;
Log($"Downloading Images for {saveArchiveFilePath}");
if(progressToken is not null)
progressToken.increments = imageUrls.Length;
//Check if Publication Directory already exists
string directoryPath = Path.GetDirectoryName(saveArchiveFilePath)!;
if (!Directory.Exists(directoryPath))
Directory.CreateDirectory(directoryPath);
if (File.Exists(saveArchiveFilePath)) //Don't download twice.
return HttpStatusCode.OK;
//Create a temporary folder to store images
string tempFolder = Directory.CreateTempSubdirectory().FullName;
int chapter = 0;
//Download all Images to temporary Folder
foreach (string imageUrl in imageUrls)
{
string[] split = imageUrl.Split('.');
string extension = split[^1];
Log($"Downloading image {chapter + 1:000}/{imageUrls.Length:000}"); //TODO
HttpStatusCode status = DownloadImage(imageUrl, Path.Join(tempFolder, $"{chapter++}.{extension}"), requestType, referrer);
if ((int)status < 200 || (int)status >= 300)
{
progressToken?.Complete();
return status;
}
if (progressToken?.cancellationRequested ?? false)
{
progressToken?.Complete();
return HttpStatusCode.RequestTimeout;
}
progressToken?.Increment();
}
if(comicInfoPath is not null)
File.Copy(comicInfoPath, Path.Join(tempFolder, "ComicInfo.xml"));
Log($"Creating archive {saveArchiveFilePath}");
//ZIP-it and ship-it
ZipFile.CreateFromDirectory(tempFolder, saveArchiveFilePath);
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(saveArchiveFilePath, GroupRead | GroupWrite | OtherRead | OtherWrite | UserRead | UserWrite);
Directory.Delete(tempFolder, true); //Cleanup
progressToken?.Complete();
return HttpStatusCode.OK;
}
protected string SaveCoverImageToCache(string url, byte requestType)
{
string[] split = url.Split('/');
string filename = split[^1];
string saveImagePath = Path.Join(settings.coverImageCache, filename);
if (File.Exists(saveImagePath))
return filename;
DownloadClient.RequestResult coverResult = downloadClient.MakeRequest(url, requestType);
using MemoryStream ms = new();
coverResult.result.CopyTo(ms);
File.WriteAllBytes(saveImagePath, ms.ToArray());
Log($"Saving cover to {saveImagePath}");
return filename;
}
}

View File

@ -2,10 +2,10 @@
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Logging; using Tranga.Jobs;
namespace Tranga.Connectors; namespace Tranga.MangaConnectors;
public class MangaDex : Connector public class MangaDex : MangaConnector
{ {
public override string name { get; } public override string name { get; }
@ -14,11 +14,11 @@ public class MangaDex : Connector
Manga, Manga,
Feed, Feed,
AtHomeServer, AtHomeServer,
Cover, CoverUrl,
Author Author,
} }
public MangaDex(string downloadLocation, Logger? logger) : base(downloadLocation, logger) public MangaDex(GlobalBase clone) : base(clone)
{ {
name = "MangaDex"; name = "MangaDex";
this.downloadClient = new DownloadClient(new Dictionary<byte, int>() this.downloadClient = new DownloadClient(new Dictionary<byte, int>()
@ -26,25 +26,26 @@ public class MangaDex : Connector
{(byte)RequestType.Manga, 250}, {(byte)RequestType.Manga, 250},
{(byte)RequestType.Feed, 250}, {(byte)RequestType.Feed, 250},
{(byte)RequestType.AtHomeServer, 40}, {(byte)RequestType.AtHomeServer, 40},
{(byte)RequestType.Cover, 250}, {(byte)RequestType.CoverUrl, 250},
{(byte)RequestType.Author, 250} {(byte)RequestType.Author, 250}
}, logger); }, clone);
} }
public override Publication[] GetPublications(string publicationTitle = "") public override Publication[] GetPublications(string publicationTitle = "")
{ {
logger?.WriteLine(this.GetType().ToString(), $"Getting Publications (title={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<Publication> publications = new(); HashSet<Publication> publications = new();
int loadedPublicationData = 0;
while (offset < total) //As long as we haven't requested all "Pages" while (offset < total) //As long as we haven't requested all "Pages"
{ {
//Request next Page //Request next Page
DownloadClient.RequestResult requestResult = DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest( downloadClient.MakeRequest(
$"https://api.mangadex.org/manga?limit={limit}&title={publicationTitle}&offset={offset}", (byte)RequestType.Manga); $"https://api.mangadex.org/manga?limit={limit}&title={publicationTitle}&offset={offset}", (byte)RequestType.Manga);
if (requestResult.statusCode != HttpStatusCode.OK) 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);
@ -58,6 +59,7 @@ public class MangaDex : Connector
//Loop each Manga and extract information from JSON //Loop each Manga and extract information from JSON
foreach (JsonNode? mangeNode in mangaInResult) foreach (JsonNode? mangeNode in mangaInResult)
{ {
Log($"Getting publication data. {++loadedPublicationData}/{total}");
JsonObject manga = (JsonObject)mangeNode!; JsonObject manga = (JsonObject)mangeNode!;
JsonObject attributes = manga["attributes"]!.AsObject(); JsonObject attributes = manga["attributes"]!.AsObject();
@ -90,15 +92,21 @@ public class MangaDex : Connector
} }
string? posterId = null; string? posterId = null;
string? authorId = null; HashSet<string> authorIds = new();
if (manga.ContainsKey("relationships") && manga["relationships"] is not null) if (manga.ContainsKey("relationships") && manga["relationships"] is not null)
{ {
JsonArray relationships = manga["relationships"]!.AsArray(); JsonArray relationships = manga["relationships"]!.AsArray();
posterId = relationships.FirstOrDefault(relationship => relationship!["type"]!.GetValue<string>() == "cover_art")!["id"]!.GetValue<string>(); posterId = relationships.FirstOrDefault(relationship => relationship!["type"]!.GetValue<string>() == "cover_art")!["id"]!.GetValue<string>();
authorId = relationships.FirstOrDefault(relationship => relationship!["type"]!.GetValue<string>() == "author")!["id"]!.GetValue<string>(); foreach (JsonNode? node in relationships.Where(relationship =>
relationship!["type"]!.GetValue<string>() == "author"))
authorIds.Add(node!["id"]!.GetValue<string>());
} }
string? coverUrl = GetCoverUrl(publicationId, posterId); string? coverUrl = GetCoverUrl(publicationId, posterId);
string? author = GetAuthor(authorId); string? coverCacheName = null;
if (coverUrl is not null)
coverCacheName = SaveCoverImageToCache(coverUrl, (byte)RequestType.AtHomeServer);
List<string> authors = GetAuthors(authorIds);
Dictionary<string, string> linksDict = new(); Dictionary<string, string> linksDict = new();
if (attributes.ContainsKey("links") && attributes["links"] is not null) if (attributes.ContainsKey("links") && attributes["links"] is not null)
@ -122,11 +130,12 @@ public class MangaDex : Connector
Publication pub = new ( Publication pub = new (
title, title,
author, authors,
description, description,
altTitlesDict, altTitlesDict,
tags.ToArray(), tags.ToArray(),
coverUrl, coverUrl,
coverCacheName,
linksDict, linksDict,
year, year,
originalLanguage, originalLanguage,
@ -137,12 +146,14 @@ public class MangaDex : Connector
} }
} }
cachedPublications.AddRange(publications);
Log($"Retrieved {publications.Count} publications. Term=\"{publicationTitle}\"");
return publications.ToArray(); return publications.ToArray();
} }
public override Chapter[] GetChapters(Publication publication, string language = "") public override Chapter[] GetChapters(Publication publication, string language="en")
{ {
logger?.WriteLine(this.GetType().ToString(), $"Getting Chapters {publication.sortName} (language={language})"); Log($"Getting chapters {publication}");
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
@ -154,7 +165,7 @@ public class MangaDex : Connector
DownloadClient.RequestResult requestResult = DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest( downloadClient.MakeRequest(
$"https://api.mangadex.org/manga/{publication.publicationId}/feed?limit={limit}&offset={offset}&translatedLanguage%5B%5D={language}", (byte)RequestType.Feed); $"https://api.mangadex.org/manga/{publication.publicationId}/feed?limit={limit}&offset={offset}&translatedLanguage%5B%5D={language}", (byte)RequestType.Feed);
if (requestResult.statusCode != HttpStatusCode.OK) 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);
@ -179,33 +190,34 @@ public class MangaDex : Connector
? attributes["volume"]!.GetValue<string>() ? attributes["volume"]!.GetValue<string>()
: null; : null;
string? chapterNum = attributes.ContainsKey("chapter") && attributes["chapter"] is not null string chapterNum = attributes.ContainsKey("chapter") && attributes["chapter"] is not null
? attributes["chapter"]!.GetValue<string>() ? attributes["chapter"]!.GetValue<string>()
: null; : "null";
chapters.Add(new Chapter(title, volume, chapterNum, chapterId)); if(chapterNum is not "null")
chapters.Add(new Chapter(publication, title, volume, chapterNum, chapterId));
} }
} }
//Return Chapters ordered by Chapter-Number //Return Chapters ordered by Chapter-Number
NumberFormatInfo chapterNumberFormatInfo = new() NumberFormatInfo chapterNumberFormatInfo = new() { NumberDecimalSeparator = "." };
{ Log($"Got {chapters.Count} chapters. {publication}");
NumberDecimalSeparator = "."
};
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, chapterNumberFormatInfo)).ToArray(); return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, chapterNumberFormatInfo)).ToArray();
} }
public override void DownloadChapter(Publication publication, Chapter chapter) public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
{ {
logger?.WriteLine(this.GetType().ToString(), $"Download Chapter {publication.sortName} {chapter.volumeNumber}-{chapter.chapterNumber}"); if (progressToken?.cancellationRequested ?? false)
return HttpStatusCode.RequestTimeout;
Log($"Retrieving chapter-info {chapter} {chapter.parentPublication}");
//Request URLs for Chapter-Images //Request URLs for Chapter-Images
DownloadClient.RequestResult requestResult = DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest($"https://api.mangadex.org/at-home/server/{chapter.url}?forcePort443=false'", (byte)RequestType.AtHomeServer); downloadClient.MakeRequest($"https://api.mangadex.org/at-home/server/{chapter.url}?forcePort443=false'", (byte)RequestType.AtHomeServer);
if (requestResult.statusCode != HttpStatusCode.OK) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
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)
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>();
@ -216,24 +228,25 @@ public class MangaDex : Connector
imageUrls.Add($"{baseUrl}/data/{hash}/{image!.GetValue<string>()}"); imageUrls.Add($"{baseUrl}/data/{hash}/{image!.GetValue<string>()}");
string comicInfoPath = Path.GetTempFileName(); string comicInfoPath = Path.GetTempFileName();
File.WriteAllText(comicInfoPath, CreateComicInfo(publication, chapter, logger)); File.WriteAllText(comicInfoPath, chapter.GetComicInfoXmlString());
//Download Chapter-Images //Download Chapter-Images
DownloadChapterImages(imageUrls.ToArray(), CreateFullFilepath(publication, chapter), downloadClient, (byte)RequestType.AtHomeServer, logger, comicInfoPath); return DownloadChapterImages(imageUrls.ToArray(), chapter.GetArchiveFilePath(settings.downloadLocation), (byte)RequestType.AtHomeServer, comicInfoPath, progressToken:progressToken);
} }
private string? GetCoverUrl(string publicationId, string? posterId) private string? GetCoverUrl(string publicationId, string? posterId)
{ {
Log($"Getting CoverUrl for Publication {publicationId}");
if (posterId is null) if (posterId is null)
{ {
logger?.WriteLine(this.GetType().ToString(), $"No posterId"); Log("No cover.");
return null; return null;
} }
//Request information where to download Cover //Request information where to download Cover
DownloadClient.RequestResult requestResult = DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest($"https://api.mangadex.org/cover/{posterId}", (byte)RequestType.Cover); downloadClient.MakeRequest($"https://api.mangadex.org/cover/{posterId}", (byte)RequestType.CoverUrl);
if (requestResult.statusCode != HttpStatusCode.OK) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null; return null;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result); JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
if (result is null) if (result is null)
@ -242,54 +255,28 @@ public class MangaDex : Connector
string fileName = result["data"]!["attributes"]!["fileName"]!.GetValue<string>(); string fileName = result["data"]!["attributes"]!["fileName"]!.GetValue<string>();
string coverUrl = $"https://uploads.mangadex.org/covers/{publicationId}/{fileName}"; string coverUrl = $"https://uploads.mangadex.org/covers/{publicationId}/{fileName}";
Log($"Cover-Url {publicationId} -> {coverUrl}");
return coverUrl; return coverUrl;
} }
private string? GetAuthor(string? authorId) private List<string> GetAuthors(IEnumerable<string> authorIds)
{
Log("Retrieving authors.");
List<string> ret = new();
foreach (string authorId in authorIds)
{ {
if (authorId is null)
return null;
DownloadClient.RequestResult requestResult = DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest($"https://api.mangadex.org/author/{authorId}", (byte)RequestType.Author); downloadClient.MakeRequest($"https://api.mangadex.org/author/{authorId}", (byte)RequestType.Author);
if (requestResult.statusCode != HttpStatusCode.OK) if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null; return ret;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result); JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
if (result is null) if (result is null)
return null; return ret;
string author = result["data"]!["attributes"]!["name"]!.GetValue<string>(); string authorName = result["data"]!["attributes"]!["name"]!.GetValue<string>();
return author; ret.Add(authorName);
Log($"Got author {authorId} -> {authorName}");
} }
return ret;
public override void DownloadCover(Publication publication)
{
logger?.WriteLine(this.GetType().ToString(), $"Download cover {publication.sortName}");
//Check if Publication already has a Folder and cover
string publicationFolder = Path.Join(downloadLocation, publication.folderName);
if(!Directory.Exists(publicationFolder))
Directory.CreateDirectory(publicationFolder);
DirectoryInfo dirInfo = new (publicationFolder);
if (dirInfo.EnumerateFiles().Any(info => info.Name.Contains("cover.")))
{
logger?.WriteLine(this.GetType().ToString(), $"Cover exists {publication.sortName}");
return;
}
if (publication.posterUrl is null || publication.posterUrl!.Contains("http"))
{
logger?.WriteLine(this.GetType().ToString(), $"No Poster-URL in publication");
return;
}
//Get file-extension (jpg, png)
string[] split = publication.posterUrl.Split('.');
string extension = split[^1];
string outFolderPath = Path.Join(downloadLocation, publication.folderName);
Directory.CreateDirectory(outFolderPath);
//Download cover-Image
DownloadImage(publication.posterUrl, Path.Join(downloadLocation, publication.folderName, $"cover.{extension}"), this.downloadClient, (byte)RequestType.AtHomeServer);
} }
} }

View File

@ -0,0 +1,224 @@
using System.Globalization;
using System.Net;
using System.Text.RegularExpressions;
using HtmlAgilityPack;
using Tranga.Jobs;
namespace Tranga.MangaConnectors;
public class MangaKatana : MangaConnector
{
public override string name { get; }
public MangaKatana(GlobalBase clone) : base(clone)
{
this.name = "MangaKatana";
this.downloadClient = new DownloadClient(new Dictionary<byte, int>()
{
{1, 60}
}, clone);
}
public override Publication[] GetPublications(string publicationTitle = "")
{
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string sanitizedTitle = string.Join('_', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower();
string requestUrl = $"https://mangakatana.com/?search={sanitizedTitle}&search_by=book_name";
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Publication>();
// ReSharper disable once MergeIntoPattern
// If a single result is found, the user will be redirected to the results directly instead of a result page
if(requestResult.hasBeenRedirected
&& requestResult.redirectedToUrl is not null
&& requestResult.redirectedToUrl.Contains("mangakatana.com/manga"))
{
return new [] { ParseSinglePublicationFromHtml(requestResult.result, requestResult.redirectedToUrl.Split('/')[^1]) };
}
Publication[] publications = ParsePublicationsFromHtml(requestResult.result);
cachedPublications.AddRange(publications);
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
return publications;
}
private Publication[] ParsePublicationsFromHtml(Stream html)
{
StreamReader reader = new(html);
string htmlString = reader.ReadToEnd();
HtmlDocument document = new();
document.LoadHtml(htmlString);
IEnumerable<HtmlNode> searchResults = document.DocumentNode.SelectNodes("//*[@id='book_list']/div");
if (searchResults is null || !searchResults.Any())
return Array.Empty<Publication>();
List<string> urls = new();
foreach (HtmlNode mangaResult in searchResults)
{
urls.Add(mangaResult.Descendants("a").First().GetAttributes()
.First(a => a.Name == "href").Value);
}
HashSet<Publication> ret = new();
foreach (string url in urls)
{
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(url, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Publication>();
ret.Add(ParseSinglePublicationFromHtml(requestResult.result, url.Split('/')[^1]));
}
return ret.ToArray();
}
private Publication ParseSinglePublicationFromHtml(Stream html, string publicationId)
{
StreamReader reader = new(html);
string htmlString = reader.ReadToEnd();
HtmlDocument document = new();
document.LoadHtml(htmlString);
string status = "";
Dictionary<string, string> altTitles = new();
Dictionary<string, string>? links = null;
HashSet<string> tags = new();
string[] authors = Array.Empty<string>();
string originalLanguage = "";
HtmlNode infoNode = document.DocumentNode.SelectSingleNode("//*[@id='single_book']");
string sortName = infoNode.Descendants("h1").First(n => n.HasClass("heading")).InnerText;
HtmlNode infoTable = infoNode.SelectSingleNode("//*[@id='single_book']/div[2]/div/ul");
foreach (HtmlNode row in infoTable.Descendants("li"))
{
string key = row.SelectNodes("div").First().InnerText.ToLower();
string value = row.SelectNodes("div").Last().InnerText;
string keySanitized = string.Concat(Regex.Matches(key, "[a-z]"));
switch (keySanitized)
{
case "altnames":
string[] alts = value.Split(" ; ");
for (int i = 0; i < alts.Length; i++)
altTitles.Add(i.ToString(), alts[i]);
break;
case "authorsartists":
authors = value.Split(',');
break;
case "status":
status = value;
break;
case "genres":
tags = row.SelectNodes("div").Last().Descendants("a").Select(a => a.InnerText).ToHashSet();
break;
}
}
string posterUrl = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[1]/div").Descendants("img").First()
.GetAttributes().First(a => a.Name == "src").Value;
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, 1);
string description = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[3]/p").InnerText;
while (description.StartsWith('\n'))
description = description.Substring(1);
int year = DateTime.Now.Year;
string yearString = infoTable.Descendants("div").First(d => d.HasClass("updateAt"))
.InnerText.Split('-')[^1];
if(yearString.Contains("ago") == false)
{
year = Convert.ToInt32(yearString);
}
return new Publication(sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links,
year, originalLanguage, status, publicationId);
}
public override Chapter[] GetChapters(Publication publication, string language="en")
{
Log($"Getting chapters {publication}");
string requestUrl = $"https://mangakatana.com/manga/{publication.publicationId}";
// Leaving this in for verification if the page exists
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Chapter>();
//Return Chapters ordered by Chapter-Number
NumberFormatInfo chapterNumberFormatInfo = new()
{
NumberDecimalSeparator = "."
};
List<Chapter> chapters = ParseChaptersFromHtml(publication, requestUrl);
Log($"Got {chapters.Count} chapters. {publication}");
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, chapterNumberFormatInfo)).ToArray();
}
private List<Chapter> ParseChaptersFromHtml(Publication publication, string mangaUrl)
{
// Using HtmlWeb will include the chapters since they are loaded with js
HtmlWeb web = new();
HtmlDocument document = web.Load(mangaUrl);
List<Chapter> ret = new();
HtmlNode chapterList = document.DocumentNode.SelectSingleNode("//div[contains(@class, 'chapters')]/table/tbody");
foreach (HtmlNode chapterInfo in chapterList.Descendants("tr"))
{
string fullString = chapterInfo.Descendants("a").First().InnerText;
string? volumeNumber = fullString.Contains("Vol.") ? fullString.Replace("Vol.", "").Split(' ')[0] : null;
string chapterNumber = fullString.Split(':')[0].Split("Chapter ")[1].Split(" ")[0].Replace('-', '.');
string chapterName = string.Concat(fullString.Split(':')[1..]);
string url = chapterInfo.Descendants("a").First()
.GetAttributeValue("href", "");
ret.Add(new Chapter(publication, chapterName, volumeNumber, chapterNumber, url));
}
return ret;
}
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
{
if (progressToken?.cancellationRequested ?? false)
return HttpStatusCode.RequestTimeout;
Log($"Retrieving chapter-info {chapter} {chapter.parentPublication}");
string requestUrl = chapter.url;
// Leaving this in to check if the page exists
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return requestResult.statusCode;
string[] imageUrls = ParseImageUrlsFromHtml(requestUrl);
string comicInfoPath = Path.GetTempFileName();
File.WriteAllText(comicInfoPath, chapter.GetComicInfoXmlString());
return DownloadChapterImages(imageUrls, chapter.GetArchiveFilePath(settings.downloadLocation), 1, comicInfoPath, "https://mangakatana.com/", progressToken:progressToken);
}
private string[] ParseImageUrlsFromHtml(string mangaUrl)
{
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
string js = document.DocumentNode.SelectSingleNode("//script[contains(., 'data-src')]").InnerText
.Replace("\r", "")
.Replace("\n", "")
.Replace("\t", "");
// ReSharper disable once StringLiteralTypo
string regexPat = @"(var thzq=\[')(.*)(,];function)";
var group = Regex.Matches(js, regexPat).First().Groups[2].Value.Replace("'", "");
var urls = group.Split(',');
return urls;
}
}

View File

@ -0,0 +1,207 @@
using System.Globalization;
using System.Net;
using System.Text.RegularExpressions;
using HtmlAgilityPack;
using Tranga.Jobs;
namespace Tranga.MangaConnectors;
public class Manganato : MangaConnector
{
public override string name { get; }
public Manganato(GlobalBase clone) : base(clone)
{
this.name = "Manganato";
this.downloadClient = new DownloadClient(new Dictionary<byte, int>()
{
{1, 60}
}, clone);
}
public override Publication[] GetPublications(string publicationTitle = "")
{
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string sanitizedTitle = string.Join('_', Regex.Matches(publicationTitle, "[A-z]*")).ToLower();
string requestUrl = $"https://manganato.com/search/story/{sanitizedTitle}";
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Publication>();
Publication[] publications = ParsePublicationsFromHtml(requestResult.result);
cachedPublications.AddRange(publications);
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
return publications;
}
private Publication[] ParsePublicationsFromHtml(Stream html)
{
StreamReader reader = new (html);
string htmlString = reader.ReadToEnd();
HtmlDocument document = new ();
document.LoadHtml(htmlString);
IEnumerable<HtmlNode> searchResults = document.DocumentNode.Descendants("div").Where(n => n.HasClass("search-story-item"));
List<string> urls = new();
foreach (HtmlNode mangaResult in searchResults)
{
urls.Add(mangaResult.Descendants("a").First(n => n.HasClass("item-title")).GetAttributes()
.First(a => a.Name == "href").Value);
}
HashSet<Publication> ret = new();
foreach (string url in urls)
{
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(url, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Publication>();
ret.Add(ParseSinglePublicationFromHtml(requestResult.result, url.Split('/')[^1]));
}
return ret.ToArray();
}
private Publication ParseSinglePublicationFromHtml(Stream html, string publicationId)
{
StreamReader reader = new (html);
string htmlString = reader.ReadToEnd();
HtmlDocument document = new ();
document.LoadHtml(htmlString);
string status = "";
Dictionary<string, string> altTitles = new();
Dictionary<string, string>? links = null;
HashSet<string> tags = new();
string[] authors = Array.Empty<string>();
string originalLanguage = "";
HtmlNode infoNode = document.DocumentNode.Descendants("div").First(d => d.HasClass("story-info-right"));
string sortName = infoNode.Descendants("h1").First().InnerText;
HtmlNode infoTable = infoNode.Descendants().First(d => d.Name == "table");
foreach (HtmlNode row in infoTable.Descendants("tr"))
{
string key = row.SelectNodes("td").First().InnerText.ToLower();
string value = row.SelectNodes("td").Last().InnerText;
string keySanitized = string.Concat(Regex.Matches(key, "[a-z]"));
switch (keySanitized)
{
case "alternative":
string[] alts = value.Split(" ; ");
for(int i = 0; i < alts.Length; i++)
altTitles.Add(i.ToString(), alts[i]);
break;
case "authors":
authors = value.Split('-');
break;
case "status":
status = value;
break;
case "genres":
string[] genres = value.Split(" - ");
tags = genres.ToHashSet();
break;
}
}
string posterUrl = document.DocumentNode.Descendants("span").First(s => s.HasClass("info-image")).Descendants("img").First()
.GetAttributes().First(a => a.Name == "src").Value;
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, 1);
string description = document.DocumentNode.Descendants("div").First(d => d.HasClass("panel-story-info-description"))
.InnerText.Replace("Description :", "");
while (description.StartsWith('\n'))
description = description.Substring(1);
string yearString = document.DocumentNode.Descendants("li").Last(li => li.HasClass("a-h")).Descendants("span")
.First(s => s.HasClass("chapter-time")).InnerText;
int year = Convert.ToInt32(yearString.Split(',')[^1]) + 2000;
return new Publication(sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links,
year, originalLanguage, status, publicationId);
}
public override Chapter[] GetChapters(Publication publication, string language="en")
{
Log($"Getting chapters {publication}");
string requestUrl = $"https://chapmanganato.com/{publication.publicationId}";
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Chapter>();
//Return Chapters ordered by Chapter-Number
NumberFormatInfo chapterNumberFormatInfo = new()
{
NumberDecimalSeparator = "."
};
List<Chapter> chapters = ParseChaptersFromHtml(publication, requestResult.result);
Log($"Got {chapters.Count} chapters. {publication}");
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, chapterNumberFormatInfo)).ToArray();
}
private List<Chapter> ParseChaptersFromHtml(Publication publication, Stream html)
{
StreamReader reader = new (html);
string htmlString = reader.ReadToEnd();
HtmlDocument document = new ();
document.LoadHtml(htmlString);
List<Chapter> ret = new();
HtmlNode chapterList = document.DocumentNode.Descendants("ul").First(l => l.HasClass("row-content-chapter"));
foreach (HtmlNode chapterInfo in chapterList.Descendants("li"))
{
string fullString = chapterInfo.Descendants("a").First(d => d.HasClass("chapter-name")).InnerText;
string? volumeNumber = fullString.Contains("Vol.") ? fullString.Replace("Vol.", "").Split(' ')[0] : null;
string chapterNumber = fullString.Split(':')[0].Split("Chapter ")[1].Replace('-','.');
string chapterName = string.Concat(fullString.Split(':')[1..]);
string url = chapterInfo.Descendants("a").First(d => d.HasClass("chapter-name"))
.GetAttributeValue("href", "");
ret.Add(new Chapter(publication, chapterName, volumeNumber, chapterNumber, url));
}
ret.Reverse();
return ret;
}
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
{
if (progressToken?.cancellationRequested ?? false)
return HttpStatusCode.RequestTimeout;
Log($"Retrieving chapter-info {chapter} {chapter.parentPublication}");
string requestUrl = chapter.url;
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return requestResult.statusCode;
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.result);
string comicInfoPath = Path.GetTempFileName();
File.WriteAllText(comicInfoPath, chapter.GetComicInfoXmlString());
return DownloadChapterImages(imageUrls, chapter.GetArchiveFilePath(settings.downloadLocation), 1, comicInfoPath, "https://chapmanganato.com/", progressToken:progressToken);
}
private string[] ParseImageUrlsFromHtml(Stream html)
{
StreamReader reader = new (html);
string htmlString = reader.ReadToEnd();
HtmlDocument document = new ();
document.LoadHtml(htmlString);
List<string> ret = new();
HtmlNode imageContainer =
document.DocumentNode.Descendants("div").First(i => i.HasClass("container-chapter-reader"));
foreach(HtmlNode imageNode in imageContainer.Descendants("img"))
ret.Add(imageNode.GetAttributeValue("src", ""));
return ret.ToArray();
}
}

View File

@ -0,0 +1,277 @@
using System.Globalization;
using System.Net;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using HtmlAgilityPack;
using Newtonsoft.Json;
using PuppeteerSharp;
using Tranga.Jobs;
namespace Tranga.MangaConnectors;
public class Mangasee : MangaConnector
{
public override string name { get; }
private IBrowser? _browser;
private const string ChromiumVersion = "1154303";
public Mangasee(GlobalBase clone) : base(clone)
{
this.name = "Mangasee";
this.downloadClient = new DownloadClient(new Dictionary<byte, int>()
{
{ 1, 60 }
}, clone);
Task d = new Task(DownloadBrowser);
d.Start();
}
private async void DownloadBrowser()
{
BrowserFetcher browserFetcher = new BrowserFetcher();
foreach(string rev in browserFetcher.LocalRevisions().Where(rev => rev != ChromiumVersion))
browserFetcher.Remove(rev);
if (!browserFetcher.LocalRevisions().Contains(ChromiumVersion))
{
Log("Downloading headless browser");
DateTime last = DateTime.Now.Subtract(TimeSpan.FromSeconds(5));
browserFetcher.DownloadProgressChanged += (_, args) =>
{
double currentBytes = Convert.ToDouble(args.BytesReceived) / Convert.ToDouble(args.TotalBytesToReceive);
if (args.TotalBytesToReceive == args.BytesReceived)
Log("Browser downloaded.");
else if (DateTime.Now > last.AddSeconds(1))
{
Log($"Browser download progress: {currentBytes:P2}");
last = DateTime.Now;
}
};
if (!browserFetcher.CanDownloadAsync(ChromiumVersion).Result)
{
Log($"Can't download browser version {ChromiumVersion}");
throw new Exception();
}
await browserFetcher.DownloadAsync(ChromiumVersion);
}
Log("Starting Browser.");
this._browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
ExecutablePath = browserFetcher.GetExecutablePath(ChromiumVersion),
Args = new [] {
"--disable-gpu",
"--disable-dev-shm-usage",
"--disable-setuid-sandbox",
"--no-sandbox"}
});
}
public override Publication[] GetPublications(string publicationTitle = "")
{
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string requestUrl = $"https://mangasee123.com/_search.php";
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Publication>();
Publication[] publications = ParsePublicationsFromHtml(requestResult.result, publicationTitle);
cachedPublications.AddRange(publications);
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
return publications;
}
private Publication[] ParsePublicationsFromHtml(Stream html, string publicationTitle)
{
string jsonString = new StreamReader(html).ReadToEnd();
List<SearchResultItem> result = JsonConvert.DeserializeObject<List<SearchResultItem>>(jsonString)!;
Dictionary<SearchResultItem, int> queryFiltered = new();
foreach (SearchResultItem resultItem in result)
{
int matches = resultItem.GetMatches(publicationTitle);
if (matches > 0)
queryFiltered.TryAdd(resultItem, matches);
}
queryFiltered = queryFiltered.Where(item => item.Value >= publicationTitle.Split(' ').Length - 1)
.ToDictionary(item => item.Key, item => item.Value);
Log($"Retrieved {queryFiltered.Count} publications.");
HashSet<Publication> ret = new();
List<SearchResultItem> orderedFiltered =
queryFiltered.OrderBy(item => item.Value).ToDictionary(item => item.Key, item => item.Value).Keys.ToList();
uint index = 1;
foreach (SearchResultItem orderedItem in orderedFiltered)
{
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest($"https://mangasee123.com/manga/{orderedItem.i}", 1);
if ((int)requestResult.statusCode >= 200 || (int)requestResult.statusCode < 300)
{
Log($"Retrieving Publication info: {orderedItem.s} {index++}/{orderedFiltered.Count}");
ret.Add(ParseSinglePublicationFromHtml(requestResult.result, orderedItem.s, orderedItem.i, orderedItem.a));
}
}
return ret.ToArray();
}
private Publication ParseSinglePublicationFromHtml(Stream html, string sortName, string publicationId, string[] a)
{
StreamReader reader = new (html);
HtmlDocument document = new ();
document.LoadHtml(reader.ReadToEnd());
string originalLanguage = "", status = "";
Dictionary<string, string> altTitles = new(), links = new();
HashSet<string> tags = new();
HtmlNode posterNode =
document.DocumentNode.Descendants("img").First(img => img.HasClass("img-fluid") && img.HasClass("bottom-5"));
string posterUrl = posterNode.GetAttributeValue("src", "");
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, 1);
HtmlNode attributes = document.DocumentNode.Descendants("div")
.First(div => div.HasClass("col-md-9") && div.HasClass("col-sm-8") && div.HasClass("top-5"))
.Descendants("ul").First();
HtmlNode[] authorsNodes = attributes.Descendants("li")
.First(node => node.InnerText.Contains("author(s):", StringComparison.CurrentCultureIgnoreCase))
.Descendants("a").ToArray();
List<string> authors = new();
foreach(HtmlNode authorNode in authorsNodes)
authors.Add(authorNode.InnerText);
HtmlNode[] genreNodes = attributes.Descendants("li")
.First(node => node.InnerText.Contains("genre(s):", StringComparison.CurrentCultureIgnoreCase))
.Descendants("a").ToArray();
foreach (HtmlNode genreNode in genreNodes)
tags.Add(genreNode.InnerText);
HtmlNode yearNode = attributes.Descendants("li")
.First(node => node.InnerText.Contains("released:", StringComparison.CurrentCultureIgnoreCase))
.Descendants("a").First();
int year = Convert.ToInt32(yearNode.InnerText);
HtmlNode[] statusNodes = attributes.Descendants("li")
.First(node => node.InnerText.Contains("status:", StringComparison.CurrentCultureIgnoreCase))
.Descendants("a").ToArray();
foreach(HtmlNode statusNode in statusNodes)
if (statusNode.InnerText.Contains("publish", StringComparison.CurrentCultureIgnoreCase))
status = statusNode.InnerText.Split(' ')[0];
HtmlNode descriptionNode = attributes.Descendants("li").First(node => node.InnerText.Contains("description:", StringComparison.CurrentCultureIgnoreCase)).Descendants("div").First();
string description = descriptionNode.InnerText;
int i = 0;
foreach(string at in a)
altTitles.Add((i++).ToString(), at);
return new Publication(sortName, authors, description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links,
year, originalLanguage, status, publicationId);
}
// ReSharper disable once ClassNeverInstantiated.Local Will be instantiated during deserialization
private class SearchResultItem
{
public string i { get; init; }
public string s { get; init; }
public string[] a { get; init; }
[JsonConstructor]
public SearchResultItem(string i, string s, string[] a)
{
this.i = i;
this.s = s;
this.a = a;
}
public int GetMatches(string title)
{
int ret = 0;
Regex cleanRex = new("[A-z0-9]*");
string[] badWords = { "a", "an", "no", "ni", "so", "as", "and", "the", "of", "that", "in", "is", "for" };
string[] titleTerms = title.Split(new[] { ' ', '-' }).Where(str => !badWords.Contains(str)).ToArray();
foreach (Match matchTerm in cleanRex.Matches(this.i))
ret += titleTerms.Count(titleTerm =>
titleTerm.Equals(matchTerm.Value, StringComparison.OrdinalIgnoreCase));
foreach (Match matchTerm in cleanRex.Matches(this.s))
ret += titleTerms.Count(titleTerm =>
titleTerm.Equals(matchTerm.Value, StringComparison.OrdinalIgnoreCase));
foreach(string alt in this.a)
foreach (Match matchTerm in cleanRex.Matches(alt))
ret += titleTerms.Count(titleTerm =>
titleTerm.Equals(matchTerm.Value, StringComparison.OrdinalIgnoreCase));
return ret;
}
}
public override Chapter[] GetChapters(Publication publication, string language="en")
{
Log($"Getting chapters {publication}");
XDocument doc = XDocument.Load($"https://mangasee123.com/rss/{publication.publicationId}.xml");
XElement[] chapterItems = doc.Descendants("item").ToArray();
List<Chapter> chapters = new();
foreach (XElement chapter in chapterItems)
{
string volumeNumber = "1";
string chapterName = chapter.Descendants("title").First().Value;
string chapterNumber = Regex.Matches(chapterName, "[0-9]+")[^1].ToString();
string url = chapter.Descendants("link").First().Value;
url = url.Replace(Regex.Matches(url,"(-page-[0-9])")[0].ToString(),"");
chapters.Add(new Chapter(publication, "", volumeNumber, chapterNumber, url));
}
//Return Chapters ordered by Chapter-Number
NumberFormatInfo chapterNumberFormatInfo = new()
{
NumberDecimalSeparator = "."
};
Log($"Got {chapters.Count} chapters. {publication}");
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, chapterNumberFormatInfo)).ToArray();
}
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
{
if (progressToken?.cancellationRequested ?? false)
return HttpStatusCode.RequestTimeout;
while (this._browser is null && !(progressToken?.cancellationRequested??false))
{
Log("Waiting for headless browser to download...");
Thread.Sleep(1000);
}
if (progressToken?.cancellationRequested??false)
return HttpStatusCode.RequestTimeout;
Log($"Retrieving chapter-info {chapter} {chapter.parentPublication}");
IPage page = _browser!.NewPageAsync().Result;
IResponse response = page.GoToAsync(chapter.url).Result;
if (response.Ok)
{
HtmlDocument document = new ();
document.LoadHtml(page.GetContentAsync().Result);
HtmlNode gallery = document.DocumentNode.Descendants("div").First(div => div.HasClass("ImageGallery"));
HtmlNode[] images = gallery.Descendants("img").Where(img => img.HasClass("img-fluid")).ToArray();
List<string> urls = new();
foreach(HtmlNode galleryImage in images)
urls.Add(galleryImage.GetAttributeValue("src", ""));
string comicInfoPath = Path.GetTempFileName();
File.WriteAllText(comicInfoPath, chapter.GetComicInfoXmlString());
return DownloadChapterImages(urls.ToArray(), chapter.GetArchiveFilePath(settings.downloadLocation), 1, comicInfoPath, progressToken:progressToken);
}
return response.Status;
}
}

View File

@ -0,0 +1,51 @@
using System.Text;
using Newtonsoft.Json;
namespace Tranga.NotificationConnectors;
public class Gotify : NotificationConnector
{
public string endpoint { get; }
// ReSharper disable once MemberCanBePrivate.Global
public string appToken { get; }
private readonly HttpClient _client = new();
[JsonConstructor]
public Gotify(string endpoint, string appToken, GlobalBase clone) : base(NotificationManagerType.Gotify, clone)
{
this.endpoint = endpoint;
this.appToken = appToken;
}
public override void SendNotification(string title, string notificationText)
{
Log($"Sending notification: {title} - {notificationText}");
MessageData message = new(title, notificationText);
HttpRequestMessage request = new(HttpMethod.Post, $"{endpoint}/message");
request.Headers.Add("X-Gotify-Key", this.appToken);
request.Content = new StringContent(JsonConvert.SerializeObject(message, Formatting.None), Encoding.UTF8, "application/json");
HttpResponseMessage response = _client.Send(request);
if (!response.IsSuccessStatusCode)
{
StreamReader sr = new (response.Content.ReadAsStream());
logger?.WriteLine(this.GetType().ToString(), $"{response.StatusCode}: {sr.ReadToEnd()}");
}
}
private class MessageData
{
// ReSharper disable four times UnusedAutoPropertyAccessor.Local
public string message { get; }
public long priority { get; }
public string title { get; }
public Dictionary<string, object> extras { get; }
public MessageData(string title, string message)
{
this.title = title;
this.message = message;
this.extras = new();
this.priority = 4;
}
}
}

View File

@ -0,0 +1,44 @@
using System.Text;
using Newtonsoft.Json;
namespace Tranga.NotificationConnectors;
public class LunaSea : NotificationConnector
{
// ReSharper disable once MemberCanBePrivate.Global
public string id { get; init; }
private readonly HttpClient _client = new();
[JsonConstructor]
public LunaSea(string id, GlobalBase clone) : base(NotificationManagerType.LunaSea, clone)
{
this.id = id;
}
public override void SendNotification(string title, string notificationText)
{
Log($"Sending notification: {title} - {notificationText}");
MessageData message = new(title, notificationText);
HttpRequestMessage request = new(HttpMethod.Post, $"https://notify.lunasea.app/v1/custom/{id}");
request.Content = new StringContent(JsonConvert.SerializeObject(message, Formatting.None), Encoding.UTF8, "application/json");
HttpResponseMessage response = _client.Send(request);
if (!response.IsSuccessStatusCode)
{
StreamReader sr = new (response.Content.ReadAsStream());
Log($"{response.StatusCode}: {sr.ReadToEnd()}");
}
}
private class MessageData
{
// ReSharper disable twice UnusedAutoPropertyAccessor.Local
public string title { get; }
public string body { get; }
public MessageData(string title, string body)
{
this.title = title;
this.body = body;
}
}
}

View File

@ -0,0 +1,15 @@
namespace Tranga.NotificationConnectors;
public abstract class NotificationConnector : GlobalBase
{
public NotificationManagerType notificationManagerType;
protected NotificationConnector(NotificationManagerType notificationManagerType, GlobalBase clone) : base(clone)
{
this.notificationManagerType = notificationManagerType;
}
public enum NotificationManagerType : byte { Gotify = 0, LunaSea = 1 }
public abstract void SendNotification(string title, string notificationText);
}

View File

@ -0,0 +1,34 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Tranga.NotificationConnectors;
public class NotificationManagerJsonConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return (objectType == typeof(NotificationConnector));
}
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue,
JsonSerializer serializer)
{
JObject jo = JObject.Load(reader);
if (jo["notificationManagerType"]!.Value<byte>() == (byte)NotificationConnector.NotificationManagerType.Gotify)
return jo.ToObject<Gotify>(serializer)!;
else if (jo["notificationManagerType"]!.Value<byte>() == (byte)NotificationConnector.NotificationManagerType.LunaSea)
return jo.ToObject<LunaSea>(serializer)!;
throw new Exception();
}
public override bool CanWrite => false;
/// <summary>
/// Don't call this
/// </summary>
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
throw new Exception("Dont call this");
}
}

View File

@ -1,48 +1,85 @@
using System.Text; using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json; using Newtonsoft.Json;
using static System.IO.UnixFileMode;
namespace Tranga; namespace Tranga;
/// <summary> /// <summary>
/// Contains information on a Publication (Manga) /// Contains information on a Publication (Manga)
/// </summary> /// </summary>
public readonly struct Publication public struct Publication
{ {
public string sortName { get; } public string sortName { get; }
public string? author { get; } public List<string> authors { get; }
// ReSharper disable once UnusedAutoPropertyAccessor.Global
public Dictionary<string,string> altTitles { get; } public Dictionary<string,string> altTitles { get; }
// ReSharper disable trice MemberCanBePrivate.Global, trust // ReSharper disable once MemberCanBePrivate.Global
public string? description { get; } public string? description { get; }
public string[] tags { get; } public string[] tags { get; }
// ReSharper disable once UnusedAutoPropertyAccessor.Global
public string? posterUrl { get; } public string? posterUrl { get; }
public string? coverFileNameInCache { get; }
// ReSharper disable once UnusedAutoPropertyAccessor.Global
public Dictionary<string,string> links { get; } public Dictionary<string,string> links { get; }
// ReSharper disable once MemberCanBePrivate.Global
public int? year { get; } public int? year { get; }
public string? originalLanguage { get; } public string? originalLanguage { get; }
// ReSharper disable once MemberCanBePrivate.Global
public string status { get; } public string status { get; }
public string folderName { get; } public string folderName { get; }
public string publicationId { get; } public string publicationId { get; }
public string internalId { get; } public string internalId { get; }
public float ignoreChaptersBelow { get; set; }
public Publication(string sortName, string? author, string? description, Dictionary<string,string> altTitles, string[] tags, string? posterUrl, Dictionary<string,string>? links, int? year, string? originalLanguage, string status, string publicationId) private static readonly Regex LegalCharacters = new (@"[A-Z]*[a-z]*[0-9]* *\.*-*,*'*\'*\)*\(*~*!*");
[JsonConstructor]
public Publication(string sortName, List<string> authors, string? description, Dictionary<string,string> altTitles, string[] tags, string? posterUrl, string? coverFileNameInCache, Dictionary<string,string>? links, int? year, string? originalLanguage, string status, string publicationId, string? folderName = null, float? ignoreChaptersBelow = 0)
{ {
this.sortName = sortName; this.sortName = sortName;
this.author = author; this.authors = authors;
this.description = description; this.description = description;
this.altTitles = altTitles; this.altTitles = altTitles;
this.tags = tags; this.tags = tags;
this.coverFileNameInCache = coverFileNameInCache;
this.posterUrl = posterUrl; this.posterUrl = posterUrl;
this.links = links ?? new Dictionary<string, string>(); this.links = links ?? new Dictionary<string, string>();
this.year = year; this.year = year;
this.originalLanguage = originalLanguage; this.originalLanguage = originalLanguage;
this.status = status; this.status = status;
this.publicationId = publicationId; this.publicationId = publicationId;
this.folderName = string.Concat(sortName.Split(Path.GetInvalidPathChars().Concat(Path.GetInvalidFileNameChars()).ToArray())); this.folderName = folderName ?? string.Concat(LegalCharacters.Matches(sortName));
while (this.folderName.EndsWith('.'))
this.folderName = this.folderName.Substring(0, this.folderName.Length - 1);
string onlyLowerLetters = string.Concat(this.sortName.ToLower().Where(Char.IsLetter)); string onlyLowerLetters = string.Concat(this.sortName.ToLower().Where(Char.IsLetter));
this.internalId = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{onlyLowerLetters}{this.year}")); this.internalId = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{onlyLowerLetters}{this.year}"));
this.ignoreChaptersBelow = ignoreChaptersBelow ?? 0f;
}
public string CreatePublicationFolder(string downloadDirectory)
{
string publicationFolder = Path.Join(downloadDirectory, this.folderName);
if(!Directory.Exists(publicationFolder))
Directory.CreateDirectory(publicationFolder);
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(publicationFolder, GroupRead | GroupWrite | GroupExecute | OtherRead | OtherWrite | OtherExecute | UserRead | UserWrite | UserExecute);
return publicationFolder;
}
public void SaveSeriesInfoJson(string downloadDirectory)
{
string publicationFolder = CreatePublicationFolder(downloadDirectory);
string seriesInfoPath = Path.Join(publicationFolder, "series.json");
if(!File.Exists(seriesInfoPath))
File.WriteAllText(seriesInfoPath,this.GetSeriesInfoJson());
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(seriesInfoPath, GroupRead | GroupWrite | OtherRead | OtherWrite | UserRead | UserWrite);
} }
/// <returns>Serialized JSON String for series.json</returns> /// <returns>Serialized JSON String for series.json</returns>
public string GetSeriesInfoJson() private string GetSeriesInfoJson()
{ {
SeriesInfo si = new (new Metadata(this.sortName, this.year.ToString() ?? string.Empty, this.status, this.description ?? "")); SeriesInfo si = new (new Metadata(this.sortName, this.year.ToString() ?? string.Empty, this.status, this.description ?? ""));
return System.Text.Json.JsonSerializer.Serialize(si); return System.Text.Json.JsonSerializer.Serialize(si);
@ -78,9 +115,9 @@ public readonly struct Publication
{ {
this.name = name; this.name = name;
this.year = year; this.year = year;
if(status == "ongoing" || status == "hiatus") if(status.ToLower() == "ongoing" || status.ToLower() == "hiatus")
this.status = "Continuing"; this.status = "Continuing";
else if (status == "completed" || status == "cancelled") else if (status.ToLower() == "completed" || status.ToLower() == "cancelled" || status.ToLower() == "discontinued")
this.status = "Ended"; this.status = "Ended";
else else
this.status = status; this.status = status;

198
Tranga/Server.cs Normal file
View File

@ -0,0 +1,198 @@
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Tranga.Jobs;
using Tranga.MangaConnectors;
namespace Tranga;
public class Server : GlobalBase
{
private readonly HttpListener _listener = new ();
private readonly Tranga _parent;
public Server(Tranga parent) : base(parent)
{
this._parent = parent;
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
this._listener.Prefixes.Add($"http://*:{settings.apiPortNumber}/");
else
this._listener.Prefixes.Add($"http://localhost:{settings.apiPortNumber}/");
Thread t = new (Listen);
t.Start();
}
private void Listen()
{
this._listener.Start();
foreach(string prefix in this._listener.Prefixes)
Log($"Listening on {prefix}");
while (this._listener.IsListening && _parent.keepRunning)
{
HttpListenerContext context = this._listener.GetContextAsync().Result;
Log($"{context.Request.HttpMethod} {context.Request.Url} {context.Request.UserAgent}");
Task t = new(() =>
{
HandleRequest(context);
});
t.Start();
}
}
private void HandleRequest(HttpListenerContext context)
{
HttpListenerRequest request = context.Request;
HttpListenerResponse response = context.Response;
if(request.HttpMethod == "OPTIONS")
SendResponse(HttpStatusCode.OK, context.Response);
if(request.Url!.LocalPath.Contains("favicon"))
SendResponse(HttpStatusCode.NoContent, response);
switch (request.HttpMethod)
{
case "GET":
HandleGet(request, request.InputStream, response);
break;
case "POST":
HandlePost(request, request.InputStream, response);
break;
case "DELETE":
HandleDelete(request, request.InputStream, response);
break;
default:
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
}
private Dictionary<string, string> GetRequestVariables(string query)
{
Dictionary<string, string> ret = new();
Regex queryRex = new (@"\?{1}&?([A-z0-9-=]+=[A-z0-9-=]+)+(&[A-z0-9-=]+=[A-z0-9-=]+)*");
if (!queryRex.IsMatch(query))
return ret;
query = query.Substring(1);
foreach (string kvpair in query.Split('&').Where(str => str.Length >= 3))
{
string var = kvpair.Split('=')[0];
string val = Regex.Replace(kvpair.Substring(var.Length + 1), "%20", " ");
val = Regex.Replace(val, "%[0-9]{2}", "");
ret.Add(var, val);
}
return ret;
}
private void HandleGet(HttpListenerRequest request, Stream content, HttpListenerResponse response)
{
Dictionary<string, string> requestVariables = GetRequestVariables(request.Url!.Query);
string? connectorName, title, internalId, jobId;
MangaConnector connector;
Publication publication;
Job job;
string path = Regex.Match(request.Url!.LocalPath, @"[A-z0-9]+(\/[A-z0-9]+)*").Value;
switch (path)
{
case "Connectors":
SendResponse(HttpStatusCode.OK, response, _parent.GetConnectors().Select(connector => connector.name).ToArray());
break;
case "Publications/FromConnector":
if (!requestVariables.TryGetValue("connector", out connectorName) ||
!requestVariables.TryGetValue("title", out title) ||
_parent.GetConnector(connectorName) is null)
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
connector = _parent.GetConnector(connectorName)!;
SendResponse(HttpStatusCode.OK, response, connector.GetPublications(title));
break;
case "Publications/Chapters":
if(!requestVariables.TryGetValue("connector", out connectorName) ||
!requestVariables.TryGetValue("internalId", out internalId) ||
_parent.GetConnector(connectorName) is null ||
_parent.GetPublicationById(internalId) is null)
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
connector = _parent.GetConnector(connectorName)!;
publication = (Publication)_parent.GetPublicationById(internalId)!;
SendResponse(HttpStatusCode.OK, response, connector.GetChapters(publication));
break;
case "Tasks":
if (!requestVariables.TryGetValue("jobId", out jobId))
{
if(!_parent._jobBoss.jobs.Any(jjob => jjob.id == jobId))
SendResponse(HttpStatusCode.BadRequest, response);
else
SendResponse(HttpStatusCode.OK, response, _parent._jobBoss.jobs.First(jjob => jjob.id == jobId));
break;
}
SendResponse(HttpStatusCode.OK, response, _parent._jobBoss.jobs);
break;
case "Tasks/Progress":
if (!requestVariables.TryGetValue("jobId", out jobId))
{
if(!_parent._jobBoss.jobs.Any(jjob => jjob.id == jobId))
SendResponse(HttpStatusCode.BadRequest, response);
else
SendResponse(HttpStatusCode.OK, response, _parent._jobBoss.jobs.First(jjob => jjob.id == jobId).progressToken);
break;
}
SendResponse(HttpStatusCode.OK, response, _parent._jobBoss.jobs.Select(jjob => jjob.progressToken));
break;
case "Tasks/Running":
SendResponse(HttpStatusCode.OK, response, _parent._jobBoss.jobs.Where(jjob => jjob.progressToken.state is ProgressToken.State.Running));
break;
case "Tasks/Waiting":
SendResponse(HttpStatusCode.OK, response, _parent._jobBoss.jobs.Where(jjob => jjob.progressToken.state is ProgressToken.State.Standby));
break;
case "Settings":
SendResponse(HttpStatusCode.OK, response, settings);
break;
case "NotificationConnectors":
SendResponse(HttpStatusCode.OK, response, notificationConnectors);
break;
case "LibraryConnectors":
SendResponse(HttpStatusCode.OK, response, libraryConnectors);
break;
default:
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
}
private void HandlePost(HttpListenerRequest request, Stream content, HttpListenerResponse response)
{
}
private void HandleDelete(HttpListenerRequest request, Stream content, HttpListenerResponse response)
{
}
private void SendResponse(HttpStatusCode statusCode, HttpListenerResponse response, object? content = null)
{
Log($"Response: {statusCode} {content}");
response.StatusCode = (int)statusCode;
response.AddHeader("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With");
response.AddHeader("Access-Control-Allow-Methods", "GET, POST, DELETE");
response.AddHeader("Access-Control-Max-Age", "1728000");
response.AppendHeader("Access-Control-Allow-Origin", "*");
response.ContentType = "application/json";
try
{
response.OutputStream.Write(content is not null
? Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(content))
: Array.Empty<byte>());
response.OutputStream.Close();
}
catch (HttpListenerException)
{
}
}
}

View File

@ -1,129 +0,0 @@
using Logging;
namespace Tranga;
/// <summary>
/// Executes TrangaTasks
/// Based on the TrangaTask.Task a method is called.
/// The chapterCollection is updated with new Publications/Chapters.
/// </summary>
public static class TaskExecutor
{
/// <summary>
/// Executes TrangaTask.
/// </summary>
/// <param name="taskManager">Parent</param>
/// <param name="trangaTask">Task to execute</param>
/// <param name="chapterCollection">Current chapterCollection to update</param>
/// <param name="logger"></param>
/// <exception cref="ArgumentException">Is thrown when there is no Connector available with the name of the TrangaTask.connectorName</exception>
public static void Execute(TaskManager taskManager, TrangaTask trangaTask, Logger? logger)
{
//Only execute task if it is not already being executed.
if (trangaTask.state == TrangaTask.ExecutionState.Running)
{
logger?.WriteLine("TaskExecutor", $"Task already running {trangaTask}");
return;
}
trangaTask.state = TrangaTask.ExecutionState.Running;
logger?.WriteLine("TaskExecutor", $"Starting Task {trangaTask}");
//Connector is not needed for all tasks
Connector? connector = null;
if (trangaTask.task != TrangaTask.Task.UpdateKomgaLibrary)
connector = taskManager.GetConnector(trangaTask.connectorName!);
//Call appropriate Method based on TrangaTask.Task
switch (trangaTask.task)
{
case TrangaTask.Task.DownloadNewChapters:
DownloadNewChapters(connector!, (Publication)trangaTask.publication!, trangaTask.language, ref taskManager._chapterCollection);
break;
case TrangaTask.Task.UpdateChapters:
UpdateChapters(connector!, (Publication)trangaTask.publication!, trangaTask.language, ref taskManager._chapterCollection);
break;
case TrangaTask.Task.UpdatePublications:
UpdatePublications(connector!, ref taskManager._chapterCollection);
break;
case TrangaTask.Task.UpdateKomgaLibrary:
UpdateKomgaLibrary(taskManager);
break;
}
logger?.WriteLine("TaskExecutor", $"Task finished! {trangaTask}");
trangaTask.lastExecuted = DateTime.Now;
trangaTask.state = TrangaTask.ExecutionState.Waiting;
}
/// <summary>
/// Updates all Komga-Libraries
/// </summary>
/// <param name="taskManager">Parent</param>
private static void UpdateKomgaLibrary(TaskManager taskManager)
{
if (taskManager.komga is null)
return;
Komga komga = taskManager.komga;
Komga.KomgaLibrary[] allLibraries = komga.GetLibraries();
foreach (Komga.KomgaLibrary lib in allLibraries)
komga.UpdateLibrary(lib.id);
}
/// <summary>
/// Updates the available Publications from a Connector (all of them)
/// </summary>
/// <param name="connector">Connector to receive Publications from</param>
/// <param name="chapterCollection"></param>
private static void UpdatePublications(Connector connector, ref Dictionary<Publication, List<Chapter>> chapterCollection)
{
Publication[] publications = connector.GetPublications();
foreach (Publication publication in publications)
chapterCollection.TryAdd(publication, new List<Chapter>());
}
/// <summary>
/// Checks for new Chapters and Downloads new ones.
/// If no Chapters had been downloaded previously, download also cover and create series.json
/// </summary>
/// <param name="connector">Connector to use</param>
/// <param name="publication">Publication to check</param>
/// <param name="language">Language to receive chapters for</param>
/// <param name="chapterCollection"></param>
private static void DownloadNewChapters(Connector connector, Publication publication, string language, ref Dictionary<Publication, List<Chapter>> chapterCollection)
{
//Check if Publication already has a Folder
string publicationFolder = Path.Join(connector.downloadLocation, publication.folderName);
if(!Directory.Exists(publicationFolder))
Directory.CreateDirectory(publicationFolder);
List<Chapter> newChapters = UpdateChapters(connector, publication, language, ref chapterCollection);
connector.DownloadCover(publication);
string seriesInfoPath = Path.Join(publicationFolder, "series.json");
if(!File.Exists(seriesInfoPath))
File.WriteAllText(seriesInfoPath,publication.GetSeriesInfoJson());
foreach(Chapter newChapter in newChapters)
connector.DownloadChapter(publication, newChapter);
}
/// <summary>
/// Updates the available Chapters of a Publication
/// </summary>
/// <param name="connector">Connector to use</param>
/// <param name="publication">Publication to check</param>
/// <param name="language">Language to receive chapters for</param>
/// <param name="chapterCollection"></param>
/// <returns>List of Chapters that were previously not in collection</returns>
private static List<Chapter> UpdateChapters(Connector connector, Publication publication, string language, ref Dictionary<Publication, List<Chapter>> chapterCollection)
{
List<Chapter> newChaptersList = new();
chapterCollection.TryAdd(publication, newChaptersList); //To ensure publication is actually in collection
Chapter[] newChapters = connector.GetChapters(publication, language);
newChaptersList = newChapters.Where(nChapter => !connector.ChapterIsDownloaded(publication, nChapter)).ToList();
return newChaptersList;
}
}

View File

@ -1,341 +0,0 @@
using Logging;
using Newtonsoft.Json;
using Tranga.Connectors;
namespace Tranga;
/// <summary>
/// Manages all TrangaTasks.
/// Provides a Threaded environment to execute Tasks, and still manage the Task-Collection
/// </summary>
public class TaskManager
{
public Dictionary<Publication, List<Chapter>> _chapterCollection = new();
private HashSet<TrangaTask> _allTasks;
private bool _continueRunning = true;
private readonly Connector[] _connectors;
private readonly Dictionary<Connector, List<TrangaTask>> _taskQueue = new();
public TrangaSettings settings { get; }
private Logger? logger { get; }
public Komga? komga => settings.komga;
/// <param name="downloadFolderPath">Local path to save data (Manga) to</param>
/// <param name="workingDirectory">Path to the working directory</param>
/// <param name="komgaBaseUrl">The Url of the Komga-instance that you want to update</param>
/// <param name="komgaUsername">The Komga username</param>
/// <param name="komgaPassword">The Komga password</param>
/// <param name="logger"></param>
public TaskManager(string downloadFolderPath, string workingDirectory, string? komgaBaseUrl = null, string? komgaUsername = null, string? komgaPassword = null, Logger? logger = null)
{
this.logger = logger;
_allTasks = new HashSet<TrangaTask>();
Komga? newKomga = null;
if (komgaBaseUrl != null && komgaUsername != null && komgaPassword != null)
newKomga = new Komga(komgaBaseUrl, komgaUsername, komgaPassword, logger);
this.settings = new TrangaSettings(downloadFolderPath, workingDirectory, newKomga);
ExportData();
this._connectors = new Connector[]{ new MangaDex(downloadFolderPath, logger) };
foreach(Connector cConnector in this._connectors)
_taskQueue.Add(cConnector, new List<TrangaTask>());
Thread taskChecker = new(TaskCheckerThread);
taskChecker.Start();
}
public void UpdateSettings(string? downloadLocation, string? komgaUrl, string? komgaAuth)
{
Komga? komga = null;
if (komgaUrl is not null && komgaAuth is not null)
komga = new Komga(komgaUrl, komgaAuth, null);
settings.downloadLocation = downloadLocation ?? settings.downloadLocation;
settings.komga = komga ?? komga;
ExportData();
}
public TaskManager(TrangaSettings settings, Logger? logger = null)
{
this.logger = logger;
this._connectors = new Connector[]{ new MangaDex(settings.downloadLocation, logger) };
foreach(Connector cConnector in this._connectors)
_taskQueue.Add(cConnector, new List<TrangaTask>());
_allTasks = new HashSet<TrangaTask>();
this.settings = settings;
ImportData();
ExportData();
Thread taskChecker = new(TaskCheckerThread);
taskChecker.Start();
}
/// <summary>
/// Runs continuously until shutdown.
/// Checks if tasks have to be executed (time elapsed)
/// </summary>
private void TaskCheckerThread()
{
logger?.WriteLine(this.GetType().ToString(), "Starting TaskCheckerThread.");
while (_continueRunning)
{
//Check if previous tasks have finished and execute new tasks
foreach (KeyValuePair<Connector, List<TrangaTask>> connectorTaskQueue in _taskQueue)
{
if(connectorTaskQueue.Value.RemoveAll(task => task.state == TrangaTask.ExecutionState.Waiting) > 0)
ExportData();
if (connectorTaskQueue.Value.Count > 0 && connectorTaskQueue.Value.All(task => task.state is TrangaTask.ExecutionState.Enqueued))
ExecuteTaskNow(connectorTaskQueue.Value.First());
}
//Check if task should be executed
//Depending on type execute immediately or enqueue
foreach (TrangaTask task in _allTasks.Where(aTask => aTask.ShouldExecute()))
{
task.state = TrangaTask.ExecutionState.Enqueued;
if(task.connectorName is null)
ExecuteTaskNow(task);
else
{
logger?.WriteLine(this.GetType().ToString(), $"Task due: {task}");
_taskQueue[GetConnector(task.connectorName!)].Add(task);
}
}
Thread.Sleep(1000);
}
}
/// <summary>
/// Forces the execution of a given task
/// </summary>
/// <param name="task">Task to execute</param>
public void ExecuteTaskNow(TrangaTask task)
{
if (!this._allTasks.Contains(task))
return;
logger?.WriteLine(this.GetType().ToString(), $"Forcing Execution: {task}");
Task t = new Task(() =>
{
TaskExecutor.Execute(this, task, logger);
});
t.Start();
}
/// <summary>
/// Creates and adds a new Task to the task-Collection
/// </summary>
/// <param name="task">TrangaTask.Task to later execute</param>
/// <param name="connectorName">Name of the connector to use</param>
/// <param name="publication">Publication to execute Task on, can be null in case of unrelated Task</param>
/// <param name="reoccurrence">Time-Interval between Executions</param>
/// <param name="language">language, should Task require parameter. Can be empty</param>
/// <exception cref="ArgumentException">Is thrown when connectorName is not a available Connector</exception>
public TrangaTask AddTask(TrangaTask.Task task, string? connectorName, Publication? publication, TimeSpan reoccurrence,
string language = "")
{
logger?.WriteLine(this.GetType().ToString(), $"Adding new Task {task} {connectorName} {publication?.sortName}");
TrangaTask newTask;
if (task == TrangaTask.Task.UpdateKomgaLibrary)
{
newTask = new TrangaTask(task, null, null, reoccurrence, language);
//Check if same task already exists
// ReSharper disable once SimplifyLinqExpressionUseAll readabilty
if (!_allTasks.Any(trangaTask => trangaTask.task == task))
{
_allTasks.Add(newTask);
}
}
else
{
if(connectorName is null)
throw new ArgumentException($"connectorName can not be null for task {task}");
//Get appropriate Connector from available Connectors for TrangaTask
Connector? connector = _connectors.FirstOrDefault(c => c.name == connectorName);
if (connector is null)
throw new ArgumentException($"Connector {connectorName} is not a known connector.");
newTask = new TrangaTask(task, connector.name, publication, reoccurrence, language);
//Check if same task already exists
if (!_allTasks.Any(trangaTask => trangaTask.task == task && trangaTask.connectorName == connector.name &&
trangaTask.publication?.internalId == publication?.internalId))
{
if(task != TrangaTask.Task.UpdatePublications)
_chapterCollection.TryAdd((Publication)publication!, new List<Chapter>());
_allTasks.Add(newTask);
}
else
logger?.WriteLine(this.GetType().ToString(), $"Publication already exists {publication?.internalId}");
}
logger?.WriteLine(this.GetType().ToString(), $"Added new Task {newTask.ToString()}");
ExportData();
return newTask;
}
/// <summary>
/// Removes Task from task-collection
/// </summary>
/// <param name="task">TrangaTask.Task type</param>
/// <param name="connectorName">Name of Connector that was used</param>
/// <param name="publication">Publication that was used</param>
public void DeleteTask(TrangaTask.Task task, string? connectorName, Publication? publication)
{
logger?.WriteLine(this.GetType().ToString(), $"Removing Task {task} {publication?.sortName}");
if (task == TrangaTask.Task.UpdateKomgaLibrary)
{
_allTasks.RemoveWhere(uTask => uTask.task == TrangaTask.Task.UpdateKomgaLibrary);
logger?.WriteLine(this.GetType().ToString(), $"Removed Task {task} from all Tasks.");
}
else if (connectorName is null)
throw new ArgumentException($"connectorName can not be null for Task {task}");
else
{
foreach (List<TrangaTask> taskQueue in this._taskQueue.Values)
if(taskQueue.RemoveAll(trangaTask =>
trangaTask.task == task && trangaTask.connectorName == connectorName &&
trangaTask.publication?.internalId == publication?.internalId) > 0)
logger?.WriteLine(this.GetType().ToString(), $"Removed Task {task} {publication?.sortName} {publication?.internalId} from Queue.");
else
logger?.WriteLine(this.GetType().ToString(), $"Task {task} {publication?.sortName} {publication?.internalId} was not in Queue.");
if(_allTasks.RemoveWhere(trangaTask =>
trangaTask.task == task && trangaTask.connectorName == connectorName &&
trangaTask.publication?.internalId == publication?.internalId) > 0)
logger?.WriteLine(this.GetType().ToString(), $"Removed Task {task} {publication?.sortName} {publication?.internalId} from all Tasks.");
else
logger?.WriteLine(this.GetType().ToString(), $"No Task {task} {publication?.sortName} {publication?.internalId} could be found.");
}
ExportData();
}
/// <summary>
/// Removes a Task from the queue
/// </summary>
/// <param name="task"></param>
public void RemoveTaskFromQueue(TrangaTask task)
{
task.lastExecuted = DateTime.Now;
foreach (List<TrangaTask> taskList in this._taskQueue.Values)
taskList.Remove(task);
task.state = TrangaTask.ExecutionState.Waiting;
}
/// <summary>
/// Sets last execution time to start of time
/// Let taskManager handle enqueuing
/// </summary>
/// <param name="task"></param>
public void AddTaskToQueue(TrangaTask task)
{
task.lastExecuted = DateTime.UnixEpoch;
}
/// <returns>All available Connectors</returns>
public Dictionary<string, Connector> GetAvailableConnectors()
{
return this._connectors.ToDictionary(connector => connector.name, connector => connector);
}
/// <returns>All TrangaTasks in task-collection</returns>
public TrangaTask[] GetAllTasks()
{
TrangaTask[] ret = new TrangaTask[_allTasks.Count];
_allTasks.CopyTo(ret);
return ret;
}
public Publication[] GetPublicationsFromConnector(Connector connector, string? title = null)
{
Publication[] ret = connector.GetPublications(title ?? "");
foreach (Publication publication in ret)
{
if(!_chapterCollection.Any(pub => pub.Key.sortName == publication.sortName))
this._chapterCollection.TryAdd(publication, new List<Chapter>());
}
return ret;
}
/// <returns>All added Publications</returns>
public Publication[] GetAllPublications()
{
return this._chapterCollection.Keys.ToArray();
}
/// <summary>
/// Return Connector with given Name
/// </summary>
/// <param name="connectorName">Connector-name (exact)</param>
/// <exception cref="Exception">If Connector is not available</exception>
public Connector GetConnector(string? connectorName)
{
if(connectorName is null)
throw new Exception($"connectorName can not be null");
Connector? ret = this._connectors.FirstOrDefault(connector => connector.name == connectorName);
if (ret is null)
throw new Exception($"Connector {connectorName} is not an available Connector.");
return (Connector)ret!;
}
/// <summary>
/// Shuts down the taskManager.
/// </summary>
/// <param name="force">If force is true, tasks are aborted.</param>
public void Shutdown(bool force = false)
{
logger?.WriteLine(this.GetType().ToString(), $"Shutting down (forced={force})");
_continueRunning = false;
ExportData();
if(force)
Environment.Exit(_allTasks.Count(task => task.state is TrangaTask.ExecutionState.Enqueued or TrangaTask.ExecutionState.Running));
//Wait for tasks to finish
while(_allTasks.Any(task => task.state is TrangaTask.ExecutionState.Running or TrangaTask.ExecutionState.Enqueued))
Thread.Sleep(10);
logger?.WriteLine(this.GetType().ToString(), "Tasks finished. Bye!");
Environment.Exit(0);
}
private void ImportData()
{
logger?.WriteLine(this.GetType().ToString(), "Importing Data");
string buffer;
if (File.Exists(settings.tasksFilePath))
{
logger?.WriteLine(this.GetType().ToString(), $"Importing tasks from {settings.tasksFilePath}");
buffer = File.ReadAllText(settings.tasksFilePath);
this._allTasks = JsonConvert.DeserializeObject<HashSet<TrangaTask>>(buffer)!;
}
if (File.Exists(settings.knownPublicationsPath))
{
logger?.WriteLine(this.GetType().ToString(), $"Importing known publications from {settings.knownPublicationsPath}");
buffer = File.ReadAllText(settings.knownPublicationsPath);
Publication[] publications = JsonConvert.DeserializeObject<Publication[]>(buffer)!;
foreach (Publication publication in publications)
this._chapterCollection.TryAdd(publication, new List<Chapter>());
}
}
/// <summary>
/// Exports data (settings, tasks) to file
/// </summary>
private void ExportData()
{
logger?.WriteLine(this.GetType().ToString(), $"Exporting settings to {settings.settingsFilePath}");
File.WriteAllText(settings.settingsFilePath, JsonConvert.SerializeObject(settings));
logger?.WriteLine(this.GetType().ToString(), $"Exporting tasks to {settings.tasksFilePath}");
File.WriteAllText(settings.tasksFilePath, JsonConvert.SerializeObject(this._allTasks));
logger?.WriteLine(this.GetType().ToString(), $"Exporting known publications to {settings.knownPublicationsPath}");
File.WriteAllText(settings.knownPublicationsPath, JsonConvert.SerializeObject(this._chapterCollection.Keys.ToArray()));
}
}

61
Tranga/Tranga.cs Normal file
View File

@ -0,0 +1,61 @@
using Logging;
using Tranga.Jobs;
using Tranga.MangaConnectors;
namespace Tranga;
public partial class Tranga : GlobalBase
{
public bool keepRunning;
public JobBoss _jobBoss;
private Server server;
private HashSet<MangaConnector> connectors;
public Tranga(Logger? logger, TrangaSettings settings) : base(logger, settings)
{
keepRunning = true;
_jobBoss = new(this);
connectors = new HashSet<MangaConnector>()
{
new Manganato(this),
new Mangasee(this),
new MangaDex(this),
new MangaKatana(this)
};
StartJobBoss();
this.server = new Server(this);
}
public MangaConnector? GetConnector(string name)
{
foreach(MangaConnector mc in connectors)
if (mc.name.Equals(name, StringComparison.InvariantCultureIgnoreCase))
return mc;
return null;
}
public IEnumerable<MangaConnector> GetConnectors()
{
return connectors;
}
public Publication? GetPublicationById(string internalId)
{
if (cachedPublications.Exists(publication => publication.internalId == internalId))
return cachedPublications.First(publication => publication.internalId == internalId);
return null;
}
private void StartJobBoss()
{
Thread t = new (() =>
{
while (keepRunning)
{
_jobBoss.CheckJobs();
Thread.Sleep(1000);
}
});
t.Start();
}
}

View File

@ -4,14 +4,24 @@
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.11.46" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="PuppeteerSharp" Version="10.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Logging\Logging.csproj" /> <ProjectReference Include="..\Logging\Logging.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
<DependentUpon>Dockerfile</DependentUpon>
</Content>
</ItemGroup>
</Project> </Project>

136
Tranga/TrangaArgs.cs Normal file
View File

@ -0,0 +1,136 @@
using Logging;
namespace Tranga;
public partial class Tranga : GlobalBase
{
public static void Main(string[] args)
{
string[]? help = GetArg(args, ArgEnum.Help);
if (help is not null)
{
PrintHelp();
return;
}
string[]? consoleLogger = GetArg(args, ArgEnum.ConsoleLogger);
string[]? fileLogger = GetArg(args, ArgEnum.FileLogger);
string? filePath = fileLogger?[0];//TODO validate path
List<Logger.LoggerType> enabledLoggers = new();
if(consoleLogger is not null)
enabledLoggers.Add(Logger.LoggerType.ConsoleLogger);
if (fileLogger is not null)
enabledLoggers.Add(Logger.LoggerType.FileLogger);
Logger logger = new(enabledLoggers.ToArray(), Console.Out, Console.OutputEncoding, filePath);
TrangaSettings? settings = null;
string[]? downloadLocationPath = GetArg(args, ArgEnum.DownloadLocation);
string[]? workingDirectory = GetArg(args, ArgEnum.WorkingDirectory);
if (downloadLocationPath is not null && workingDirectory is not null)
{
settings = new TrangaSettings(downloadLocationPath[0], workingDirectory[0]);
}else if (downloadLocationPath is not null)
{
if (settings is null)
settings = new TrangaSettings(downloadLocation: downloadLocationPath[0]);
else
settings = new TrangaSettings(downloadLocation: downloadLocationPath[0], settings.workingDirectory);
}else if (workingDirectory is not null)
{
if (settings is null)
settings = new TrangaSettings(downloadLocation: workingDirectory[0]);
else
settings = new TrangaSettings(settings.downloadLocation, workingDirectory[0]);
}
else
{
settings = new TrangaSettings();
}
Directory.CreateDirectory(settings.downloadLocation);//TODO validate path
Directory.CreateDirectory(settings.workingDirectory);//TODO validate path
Tranga _ = new (logger, settings);
}
private static void PrintHelp()
{
Console.WriteLine("Tranga-Help:");
foreach (Argument argument in arguments.Values)
{
foreach(string name in argument.names)
Console.Write("{0} ", name);
if(argument.parameterCount > 0)
Console.Write($"<{argument.parameterCount}>");
Console.Write("\r\n {0}\r\n", argument.helpText);
}
}
/// <summary>
/// Returns an array containing the parameters for the argument.
/// </summary>
/// <param name="args">List of argument-strings</param>
/// <param name="arg">Requested parameter</param>
/// <returns>
/// If there are no parameters for an argument, returns an empty array.
/// If the argument is not found returns null.
/// </returns>
private static string[]? GetArg(string[] args, ArgEnum arg)
{
List<string> argsList = args.ToList();
List<string> ret = new();
foreach (string name in arguments[arg].names)
{
int argIndex = argsList.IndexOf(name);
if (argIndex != -1)
{
if (arguments[arg].parameterCount == 0)
return ret.ToArray();
for (int parameterIndex = 1; parameterIndex <= arguments[arg].parameterCount; parameterIndex++)
{
if(argIndex + parameterIndex >= argsList.Count || args[argIndex + parameterIndex].Contains('-'))//End of arguments, or no parameter provided, when one is required
Console.WriteLine($"No parameter provided for argument {name}. -h for help.");
ret.Add(args[argIndex + parameterIndex]);
}
}
}
return ret.Any() ? ret.ToArray() : null;
}
private static Dictionary<ArgEnum, Argument> arguments = new()
{
{ ArgEnum.DownloadLocation, new(new []{"-d", "--downloadLocation"}, 1, "Directory to which downloaded Manga are saved") },
{ ArgEnum.WorkingDirectory, new(new []{"-w", "--workingDirectory"}, 1, "Directory in which application-data is saved") },
{ ArgEnum.ConsoleLogger, new(new []{"-c", "--consoleLogger"}, 0, "Enables the consoleLogger") },
{ ArgEnum.FileLogger, new(new []{"-f", "--fileLogger"}, 1, "Enables the fileLogger, Directory where logfiles are saved") },
{ ArgEnum.Help, new(new []{"-h", "--help"}, 0, "Print this") }
//{ ArgEnum., new(new []{""}, 1, "") }
};
internal enum ArgEnum
{
TrangaSettings,
DownloadLocation,
WorkingDirectory,
ConsoleLogger,
FileLogger,
Help
}
private struct Argument
{
public string[] names { get; }
public byte parameterCount { get; }
public string helpText { get; }
public Argument(string[] names, byte parameterCount, string helpText)
{
this.names = names;
this.parameterCount = parameterCount;
this.helpText = helpText;
}
}
}

View File

@ -1,31 +1,88 @@
using Newtonsoft.Json; using Logging;
using Newtonsoft.Json;
using Tranga.LibraryConnectors;
using Tranga.NotificationConnectors;
namespace Tranga; namespace Tranga;
public class TrangaSettings public class TrangaSettings
{ {
public string downloadLocation { get; set; } public string downloadLocation { get; private set; }
public string workingDirectory { get; set; } public string workingDirectory { get; init; }
public int apiPortNumber { get; init; }
[JsonIgnore] public string settingsFilePath => Path.Join(workingDirectory, "settings.json"); [JsonIgnore] public string settingsFilePath => Path.Join(workingDirectory, "settings.json");
[JsonIgnore]public string tasksFilePath => Path.Join(workingDirectory, "tasks.json"); [JsonIgnore] public string libraryConnectorsFilePath => Path.Join(workingDirectory, "libraryConnectors.json");
[JsonIgnore]public string knownPublicationsPath => Path.Join(workingDirectory, "knownPublications.json");
public Komga? komga { get; set; }
public TrangaSettings(string downloadLocation, string workingDirectory, Komga? komga) [JsonIgnore] public string notificationConnectorsFilePath => Path.Join(workingDirectory, "notificationConnectors.json");
[JsonIgnore] public string tasksFilePath => Path.Join(workingDirectory, "tasks.json");
[JsonIgnore] public string coverImageCache => Path.Join(workingDirectory, "imageCache");
public ushort? version { get; set; }
public TrangaSettings(string? downloadLocation = null, string? workingDirectory = null, int apiPortNumber = 6531)
{ {
this.apiPortNumber = apiPortNumber;
downloadLocation ??= Path.Join(Directory.GetCurrentDirectory(), "Downloads");
workingDirectory ??= Directory.GetCurrentDirectory();
if (downloadLocation.Length < 1 || workingDirectory.Length < 1)
throw new ArgumentException("Download-location and working-directory paths can not be empty!");
this.workingDirectory = workingDirectory; this.workingDirectory = workingDirectory;
this.downloadLocation = downloadLocation; this.downloadLocation = downloadLocation;
this.komga = komga;
}
public static TrangaSettings LoadSettings(string importFilePath) if (File.Exists(settingsFilePath))
{ {
if (!File.Exists(importFilePath)) TrangaSettings settings = JsonConvert.DeserializeObject<TrangaSettings>(File.ReadAllText(settingsFilePath))!;
return new TrangaSettings(Path.Join(Directory.GetCurrentDirectory(), "Downloads"), Directory.GetCurrentDirectory(), null); this.downloadLocation = settings.downloadLocation;
this.workingDirectory = settings.workingDirectory;
string toRead = File.ReadAllText(importFilePath); }
TrangaSettings settings = JsonConvert.DeserializeObject<TrangaSettings>(toRead)!; }
return settings; public HashSet<LibraryConnector> LoadLibraryConnectors()
{
if (!File.Exists(libraryConnectorsFilePath))
return new HashSet<LibraryConnector>();
return JsonConvert.DeserializeObject<HashSet<LibraryConnector>>(File.ReadAllText(libraryConnectorsFilePath),
new JsonSerializerSettings()
{
Converters =
{
new LibraryManagerJsonConverter()
}
})!;
}
public HashSet<NotificationConnector> LoadNotificationConnectors()
{
if (!File.Exists(notificationConnectorsFilePath))
return new HashSet<NotificationConnector>();
return JsonConvert.DeserializeObject<HashSet<NotificationConnector>>(File.ReadAllText(libraryConnectorsFilePath),
new JsonSerializerSettings()
{
Converters =
{
new NotificationManagerJsonConverter()
}
})!;
}
public void ExportSettings()
{
while (File.Exists(settingsFilePath))
{
bool inUse = true;
while (inUse)
{
try
{
using FileStream stream = new (settingsFilePath, FileMode.Open, FileAccess.Read, FileShare.None);
stream.Close();
inUse = false;
}
catch (IOException)
{
Thread.Sleep(100);
}
}
}
File.WriteAllText(settingsFilePath, JsonConvert.SerializeObject(this));
} }
} }

View File

@ -1,61 +0,0 @@
using Newtonsoft.Json;
namespace Tranga;
/// <summary>
/// Stores information on Task
/// </summary>
public class TrangaTask
{
// ReSharper disable once CommentTypo ...Tell me why!
// ReSharper disable once MemberCanBePrivate.Global I want it thaaat way
public TimeSpan reoccurrence { get; }
public DateTime lastExecuted { get; set; }
public string? connectorName { get; }
public Task task { get; }
public Publication? publication { get; }
public string language { get; }
[JsonIgnore]public ExecutionState state { get; set; }
public enum ExecutionState
{
Waiting,
Enqueued,
Running
};
public TrangaTask(Task task, string? connectorName, Publication? publication, TimeSpan reoccurrence, string language = "")
{
if(task != Task.UpdateKomgaLibrary && connectorName is null)
throw new ArgumentException($"connectorName can not be null for task {task}");
if (publication is null && task != Task.UpdatePublications && task != Task.UpdateKomgaLibrary)
throw new ArgumentException($"Publication can not be null for task {task}");
this.publication = publication;
this.reoccurrence = reoccurrence;
this.lastExecuted = DateTime.Now.Subtract(reoccurrence);
this.connectorName = connectorName;
this.task = task;
this.language = language;
}
/// <returns>True if elapsed time since last execution is greater than set interval</returns>
public bool ShouldExecute()
{
return DateTime.Now.Subtract(this.lastExecuted) > reoccurrence && state is ExecutionState.Waiting;
}
public enum Task
{
UpdatePublications,
UpdateChapters,
DownloadNewChapters,
UpdateKomgaLibrary
}
public override string ToString()
{
return $"{task}, {lastExecuted}, {reoccurrence}, {state} {(connectorName is not null ? $", {connectorName}" : "" )} {(publication is not null ? $", {publication?.sortName}": "")}";
}
}

View File

@ -1,101 +0,0 @@
const apiUri = "http://localhost:5177";
async function GetData(uri){
let request = await fetch(uri, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
let json = await request.json();
return json;
}
function PostData(uri){
fetch(uri, {
method: 'POST'
});
}
function DeleteData(uri){
fetch(uri, {
method: 'DELETE'
});
}
async function GetAvailableControllers(){
var uri = apiUri + "/Tranga/GetAvailableControllers";
let json = await GetData(uri);
return json;
}
async function GetPublication(connectorName, title){
var uri = apiUri + `/Tranga/GetPublicationsFromConnector?connectorName=${connectorName}&title=${title}`;
let json = await GetData(uri);
return json;
}
async function GetKnownPublications(){
var uri = apiUri + "/Tranga/GetKnownPublications";
let json = await GetData(uri);
return json;
}
async function GetTaskTypes(){
var uri = apiUri + "/Tasks/GetTaskTypes";
let json = await GetData(uri);
return json;
}
async function GetRunningTasks(){
var uri = apiUri + "/Tranga/GetRunningTasks";
let json = await GetData(uri);
return json;
}
async function GetDownloadTasks(){
var uri = apiUri + "/Tasks/Get?taskType=DownloadNewChapters";
let json = await GetData(uri);
return json;
}
async function GetSettings(){
var uri = apiUri + "/Settings/Get";
let json = await GetData(uri);
return json;
}
async function GetKomgaTask(){
var uri = apiUri + "/Tasks/Get?taskType=UpdateKomgaLibrary";
let json = await GetData(uri);
return json;
}
function CreateTask(taskType, reoccurrence, connectorName, publicationId, language){
var uri = apiUri + `/Tasks/Create?taskType=${taskType}&connectorName=${connectorName}&publicationId=${publicationId}&reoccurrenceTime=${reoccurrence}&language=${language}`;
PostData(uri);
}
function StartTask(taskType, connectorName, publicationId){
var uri = apiUri + `/Tasks/Start?taskType=${taskType}&connectorName=${connectorName}&publicationId=${publicationId}`;
PostData(uri);
}
function EnqueueTask(taskType, connectorName, publicationId){
var uri = apiUri + `/Queue/Enqueue?taskType=${taskType}&connectorName=${connectorName}&publicationId=${publicationId}`;
PostData(uri);
}
function UpdateSettings(downloadLocation, komgaUrl, komgaAuth){
var uri = apiUri + `/Settings/Update?downloadLocation=${downloadLocation}&komgaUrl=${komgaUrl}&komgaAuth=${komgaAuth}`;
PostData(uri);
}
function DeleteTask(taskType, connectorName, publicationId){
var uri = apiUri + `/Tasks/Delete?taskType=${taskType}&connectorName=${connectorName}&publicationId=${publicationId}`;
DeleteData(uri);
}
function DequeueTask(taskType, connectorName, publicationId){
var uri = apiUri + `/Queue/Dequeue?taskType=${taskType}&connectorName=${connectorName}&publicationId=${publicationId}`;
DeleteData(uri);
}

View File

@ -1,99 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tranga</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<topbar>
<titlebox>
<img src="media/blahaj.png">
<span>Tranga</span>
</titlebox>
<spacer></spacer>
<searchdiv>
<input id="searchbox" placeholder="Filter" type="text">
</searchdiv>
<img id="settingscog" src="media/settings-cogwheel.svg" height="100%" alt="settingscog">
</topbar>
<viewport>
<sidebar>
<background-placeholder></background-placeholder>
<spacer></spacer>
<p style="text-align: center">Made with Blåhaj 🦈</p>
</sidebar>
<content>
<div id="addPublication">
<p>+</p>
</div>
<publication>
<img src="media/cover.jpg">
<publication-information>
<connector-name class="pill">MangaDex</connector-name>
<publication-name>Tensei Pandemic</publication-name>
</publication-information>
</publication>
</content>
<popup id="addTaskPopup">
<blur-background id="blurBackgroundTaskPopup"></blur-background>
<addtask-window>
<window-titlebar>
<p>Add Task</p>
<img id="closePopupImg" src="media/close-x.svg" alt="Close">
</window-titlebar>
<window-content>
<addtask-settings>
<addtask-setting><label for="selectReccurrence">Recurrence</label><input id="selectReccurrence" type="time" value="01:00" step="3600"></addtask-setting>
<addtask-setting><label for="connectors">Connector</label>
<select id="connectors">
<option value=""></option>
</select>
</addtask-setting>
<addtask-setting><label for="searchPublicationQuery">Search Title</label><input id="searchPublicationQuery" type="text"></addtask-setting>
<input type="submit" value="Search" onclick="NewSearch();">
</addtask-settings>
<div id="taskSelectOutput"></div>
</window-content>
</addtask-window>
</popup>
<popup id="publicationViewerPopup">
<blur-background id="blurBackgroundPublicationPopup"></blur-background>
<publication-viewer>
<img id="pubviewcover" src="media/cover.jpg" alt="cover">
<publication-information>
<publication-name id="publicationViewerName">Tensei Pandemic</publication-name>
<publication-author id="publicationViewerAuthor">Imamura Hinata</publication-author>
<publication-description id="publicationViewerDescription">Imamura Hinata is a high school boy with a cute appearance.
Since his trauma with the first love, he wanted to be more manly than anybody else. But one day he woke up to something different…
The total opposite of his ideal male body!
Pandemic love comedy!
</publication-description>
<publication-delete>Delete Task ❌</publication-delete>
<publication-add>Add Task </publication-add>
</publication-information>
</publication-viewer>
</popup>
</viewport>
<settingstab id="settingstab">
<span class="title">Download Location:</span>
<span id="downloadLocation"></span>
<komga-settings>
<span class="title">Komga</span>
<div>Configured: <span id="komgaConfigured">✅❌</span></div>
<label for="komgaUrl"></label><input placeholder="URL" id="komgaUrl" type="text">
<label for="komgaUsername"></label><input placeholder="Username" id="komgaUsername" type="text">
<label for="komgaPassword"></label><input placeholder="Password" id="komgaPassword" type="password">
<div><label for="komgaUpdateTime" style="margin-right: 5px;">Update Time</label><input id="komgaUpdateTime" type="time" value="00:01"></div>
<input type="submit" value="Update" onclick="UpdateSettingsClick()">
</komga-settings>
</settingstab>
<script src="apiConnector.js"></script>
<script src="interaction.js"></script>
</body>
</html>

View File

@ -1,277 +0,0 @@
const slideInRight = [
{ right: "-20rem" },
{ right: "0" }
];
const slideInRightTiming = {
duration: 200,
iterations: 1,
fill: "forwards",
easing: "ease-out"
}
const slideOutRightTiming = {
direction: "reverse",
duration: 200,
iterations: 1,
fill: "forwards",
easing: "ease-in"
}
let publications = [];
let tasks = [];
let toEditId;
const searchPublicationQuery = document.querySelector("#searchPublicationQuery");
const selectPublication = document.querySelector("#taskSelectOutput");
const connectorSelect = document.querySelector("#connectors");
const settingsTab = document.querySelector("#settingstab");
const settingsCog = document.querySelector("#settingscog");
const selectRecurrence = document.querySelector("#selectReccurrence");
const tasksContent = document.querySelector("content");
const addTaskPopup = document.querySelector("#addTaskPopup");
const publicationViewerPopup = document.querySelector("#publicationViewerPopup");
const publicationViewerWindow = document.querySelector("publication-viewer");
const publicationViewerDescription = document.querySelector("#publicationViewerDescription");
const publicationViewerName = document.querySelector("#publicationViewerName");
const publicationViewerAuthor = document.querySelector("#publicationViewerAuthor");
const pubviewcover = document.querySelector("#pubviewcover");
const publicationDelete = document.querySelector("publication-delete");
const publicationAdd = document.querySelector("publication-add");
const closetaskpopup = document.querySelector("#closePopupImg");
const settingDownloadLocation = document.querySelector("#downloadLocation");
const settingKomgaUrl = document.querySelector("#komgaUrl");
const settingKomgaUser = document.querySelector("#komgaUsername");
const settingKomgaPass = document.querySelector("#komgaPassword");
const settingKomgaTime = document.querySelector("#komgaUpdateTime");
const settingKomgaConfigured = document.querySelector("#komgaConfigured");
settingsCog.addEventListener("click", () => OpenSettings());
closetaskpopup.addEventListener("click", () => HideAddTaskPopup());
document.querySelector("#blurBackgroundTaskPopup").addEventListener("click", () => HideAddTaskPopup());
document.querySelector("#blurBackgroundPublicationPopup").addEventListener("click", () => HidePublicationPopup());
publicationDelete.addEventListener("click", () => DeleteTaskClick());
publicationAdd.addEventListener("click", () => AddTaskClick());
let availableConnectors;
GetAvailableControllers()
.then(json => availableConnectors = json)
.then(json =>
json.forEach(connector => {
var option = document.createElement('option');
option.value = connector;
option.innerText = connector;
connectorSelect.appendChild(option);
})
);
searchPublicationQuery.addEventListener("keypress", (event) => {
if(event.key === "Enter"){
NewSearch();
}
});
function NewSearch(){
//Disable inputs
selectRecurrence.disabled = true;
connectorSelect.disabled = true;
searchPublicationQuery.disabled = true;
//Empty previous results
selectPublication.replaceChildren();
GetPublication(connectorSelect.value, searchPublicationQuery.value)
.then(json =>
json.forEach(publication => {
var option = CreatePublication(publication, connectorSelect.value);
option.addEventListener("click", (mouseEvent) => {
ShowPublicationViewerWindow(publication.internalId, mouseEvent, true);
});
selectPublication.appendChild(option);
}
))
.then(() => {
//Re-enable inputs
selectRecurrence.disabled = false;
connectorSelect.disabled = false;
searchPublicationQuery.disabled = false;
});
}
//Returns a new "Publication" Item to display in the tasks section
function CreatePublication(publication, connector){
var publicationElement = document.createElement('publication');
publicationElement.setAttribute("id", publication.internalId);
var img = document.createElement('img');
img.src = publication.posterUrl;
publicationElement.appendChild(img);
var info = document.createElement('publication-information');
var connectorName = document.createElement('connector-name');
connectorName.innerText = connector;
connectorName.className = "pill";
info.appendChild(connectorName);
var publicationName = document.createElement('publication-name');
publicationName.innerText = publication.sortName;
info.appendChild(publicationName);
publicationElement.appendChild(info);
if(publications.filter(pub => pub.internalId === publication.internalId) < 1)
publications.push(publication);
return publicationElement;
}
function DeleteTaskClick(){
taskToDelete = tasks.filter(tTask => tTask.publication.internalId === toEditId)[0];
DeleteTask("DownloadNewChapters", taskToDelete.connectorName, toEditId);
HidePublicationPopup();
}
function AddTaskClick(){
CreateTask("DownloadNewChapters", selectRecurrence.value, connectorSelect.value, toEditId, "en")
HideAddTaskPopup();
HidePublicationPopup();
}
let slideIn = true;
function slide() {
if (slideIn)
settingsTab.animate(slideInRight, slideInRightTiming);
else
settingsTab.animate(slideInRight, slideOutRightTiming);
slideIn = !slideIn;
}
function ResetContent(){
//Delete everything
tasksContent.replaceChildren();
//Add "Add new Task" Button
var add = document.createElement("div");
add.setAttribute("id", "addPublication")
var plus = document.createElement("p");
plus.innerText = "+";
add.appendChild(plus);
add.addEventListener("click", () => ShowNewTaskWindow());
tasksContent.appendChild(add);
}
function ShowPublicationViewerWindow(publicationId, event, add){
//Set position to mouse-position
publicationViewerWindow.style.top = `${event.clientY - 60}px`;
publicationViewerWindow.style.left = `${event.clientX}px`;
//Edit information inside the window
var publication = publications.filter(pub => pub.internalId === publicationId)[0];
publicationViewerName.innerText = publication.sortName;
publicationViewerDescription.innerText = publication.description;
publicationViewerAuthor.innerText = publication.author;
pubviewcover.src = publication.posterUrl;
toEditId = publicationId;
//Check what action should be listed
if(add){
publicationAdd.style.display = "block";
publicationDelete.style.display = "none";
}
else{
publicationAdd.style.display = "none";
publicationDelete.style.display = "block";
}
//Show popup
publicationViewerPopup.style.display = "block";
}
function HidePublicationPopup(){
publicationViewerPopup.style.display = "none";
}
function ShowNewTaskWindow(){
selectPublication.replaceChildren();
addTaskPopup.style.display = "block";
}
function HideAddTaskPopup(){
addTaskPopup.style.display = "none";
}
const fadeIn = [
{ opacity: "0" },
{ opacity: "1" }
];
const fadeInTiming = {
duration: 50,
iterations: 1,
fill: "forwards"
}
function OpenSettings(){
GetSettingsClick();
slide();
}
function GetSettingsClick(){
settingKomgaUrl.value = "";
settingKomgaUser.value = "";
settingKomgaPass.value = "";
GetSettings().then(json => {
settingDownloadLocation.innerText = json.downloadLocation;
if(json.komga != null)
settingKomgaUrl.placeholder = json.komga.baseUrl;
});
GetKomgaTask().then(json => {
if(json.length > 0)
settingKomgaConfigured.innerText = "✅";
else
settingKomgaConfigured.innerText = "❌";
});
}
function UpdateSettingsClick(){
var auth = utf8_to_b64(`${settingKomgaUser.value}:${settingKomgaPass.value}`);
console.log(auth);
UpdateSettings("", settingKomgaUrl.value, auth);
CreateTask("UpdateKomgaLibrary", settingKomgaTime.value, "","","");
setTimeout(() => GetSettingsClick(), 500);
}
function utf8_to_b64( str ) {
return window.btoa(unescape(encodeURIComponent( str )));
}
//Resets the tasks shown
ResetContent();
//Get Tasks and show them
GetDownloadTasks()
.then(json => json.forEach(task => {
var publication = CreatePublication(task.publication, task.connectorName);
publication.addEventListener("click", (event) => ShowPublicationViewerWindow(task.publication.internalId, event, false));
tasksContent.appendChild(publication);
tasks.push(task);
}));
setInterval(() => {
//Tasks from API
var cTasks = [];
GetDownloadTasks()
.then(json => json.forEach(task => cTasks.push(task)))
.then(() => {
//Only update view if tasks-amount has changed
if(tasks.length != cTasks.length) {
//Resets the tasks shown
ResetContent();
//Add all currenttasks to view
cTasks.forEach(task => {
var publication = CreatePublication(task.publication, task.connectorName);
publication.addEventListener("click", (event) => ShowPublicationViewerWindow(task.publication.internalId, event, false));
tasksContent.appendChild(publication);
})
tasks = cTasks;
}
}
);
}, 1000);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.29289 5.29289C5.68342 4.90237 6.31658 4.90237 6.70711 5.29289L12 10.5858L17.2929 5.29289C17.6834 4.90237 18.3166 4.90237 18.7071 5.29289C19.0976 5.68342 19.0976 6.31658 18.7071 6.70711L13.4142 12L18.7071 17.2929C19.0976 17.6834 19.0976 18.3166 18.7071 18.7071C18.3166 19.0976 17.6834 19.0976 17.2929 18.7071L12 13.4142L6.70711 18.7071C6.31658 19.0976 5.68342 19.0976 5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929L10.5858 12L5.29289 6.70711C4.90237 6.31658 4.90237 5.68342 5.29289 5.29289Z" fill="#0F1729"/>
</svg>

Before

Width:  |  Height:  |  Size: 804 B

View File

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 93.5 93.5" xml:space="preserve">
<g>
<g>
<path d="M93.5,40.899c0-2.453-1.995-4.447-4.448-4.447H81.98c-0.74-2.545-1.756-5.001-3.035-7.331l4.998-5
c0.826-0.827,1.303-1.973,1.303-3.146c0-1.19-0.462-2.306-1.303-3.146L75.67,9.555c-1.613-1.615-4.673-1.618-6.29,0l-5,5
c-2.327-1.28-4.786-2.296-7.332-3.037v-7.07C57.048,1.995,55.053,0,52.602,0H40.899c-2.453,0-4.447,1.995-4.447,4.448v7.071
c-2.546,0.741-5.005,1.757-7.333,3.037l-5-5c-1.68-1.679-4.609-1.679-6.288,0L9.555,17.83c-1.734,1.734-1.734,4.555,0,6.289
l4.999,5c-1.279,2.33-2.295,4.788-3.036,7.333h-7.07C1.995,36.452,0,38.447,0,40.899V52.6c0,2.453,1.995,4.447,4.448,4.447h7.071
c0.74,2.545,1.757,5.003,3.036,7.332l-4.998,4.999c-0.827,0.827-1.303,1.974-1.303,3.146c0,1.189,0.462,2.307,1.302,3.146
l8.274,8.273c1.614,1.615,4.674,1.619,6.29,0l5-5c2.328,1.279,4.786,2.297,7.333,3.037v7.071c0,2.453,1.995,4.448,4.447,4.448
h11.702c2.453,0,4.446-1.995,4.446-4.448V81.98c2.546-0.74,5.005-1.756,7.332-3.037l5,5c1.681,1.68,4.608,1.68,6.288,0
l8.275-8.273c1.734-1.734,1.734-4.555,0-6.289l-4.998-5.001c1.279-2.329,2.295-4.787,3.035-7.332h7.071
c2.453,0,4.448-1.995,4.448-4.446V40.899z M62.947,46.75c0,8.932-7.266,16.197-16.197,16.197c-8.931,0-16.197-7.266-16.197-16.197
c0-8.931,7.266-16.197,16.197-16.197C55.682,30.553,62.947,37.819,62.947,46.75z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -1,421 +0,0 @@
:root{
--background-color: #eee;
--second-background-color: #fff;
--primary-color: #f5a9b8;
--secondary-color: #5bcefa;
--accent-color: #fff;
--topbar-height: 60px;
box-sizing: border-box;
}
body{
padding: 0;
margin: 0;
display: flex;
flex-flow: column;
flex-wrap: nowrap;
height: 100vh;
background-color: var(--background-color);
font-family: "Inter", sans-serif;
overflow-x: hidden;
}
background-placeholder{
background-color: var(--second-background-color);
opacity: 1;
position: absolute;
width: 100%;
height: 100%;
border-radius: 0 0 5px 0;
z-index: -1;
}
topbar {
display: flex;
align-items: center;
height: var(--topbar-height);
background-color: var(--secondary-color);
}
titlebox {
position: relative;
display: flex;
margin: 0 0 0 40px;
height: 100%;
align-items:center;
justify-content:center;
}
titlebox span{
font-size: 24pt;
font-weight: bold;
background: linear-gradient(150deg, var(--primary-color), var(--accent-color));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-left: 20px;
}
titlebox img {
height: 100%;
margin-right: 10px;
}
spacer{
flex-grow: 1;
}
searchdiv{
display: block;
margin: 0 10px 0 0;
}
#searchbox {
padding: 6px 8px;
border: 0;
border-radius: 3px;
font-size: 14pt;
width: 250px;
}
#settingscog {
cursor: pointer;
margin: 0px 30px;
height: calc(100% - 40px);
filter: invert(100%) sepia(0%) saturate(7465%) hue-rotate(115deg) brightness(116%) contrast(101%);
}
viewport {
position: relative;
display: flex;
flex-flow: row;
flex-wrap: nowrap;
flex-grow: 1;
}
sidebar{
position: relative;
width: 20rem;
margin-bottom: 20px;
border-radius: 0 0 5px 0;
display: flex;
flex-direction: column;
}
content {
position: relative;
flex-grow: 1;
margin: 0 10px 10px 10px;
border-radius: 5px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: start;
align-content: start;
}
settingstab{
position: absolute;
right: -20rem;
bottom: 0;
background-color: rgba(0,0,0,0.5);
width: 20rem;
height: calc(100% - var(--topbar-height) - 40px);
margin-bottom: 10px;
border-radius: 5px 0 0 5px;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
settingstab > * {
margin: 0 20px;
}
settingstab .title {
font-size: 14pt;
font-weight: bolder;
margin-top: 20px;
}
komga-settings {
margin-top: 20px;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
komga-settings > * {
margin: 2px 0;
}
komga-settings input {
padding: 3px;
border-radius: 3px;
border: 0;
}
#addPublication {
cursor: pointer;
background-color: var(--secondary-color);
width: 180px;
height: 300px;
border-radius: 5px;
margin: 10px 10px;
padding: 15px 20px;
position: relative;
}
#addPublication p{
width: 100%;
text-align: center;
font-size: 150pt;
vertical-align: middle;
line-height: 300px;
margin: 0;
color: var(--accent-color);
}
.pill {
flex-grow: 0;
height: 14pt;
font-size: 12pt;
border-radius: 9pt;
background-color: var(--primary-color);
padding: 2pt 20px;
}
publication{
cursor: pointer;
background-color: var(--secondary-color);
width: 180px;
height: 300px;
border-radius: 5px;
margin: 10px 10px;
padding: 15px 20px;
position: relative;
}
publication::after{
content: '';
position: absolute;
left: 0; top: 0;
border-radius: 5px;
width: 100%; height: 100%;
background: linear-gradient(rgba(0, 0, 0, 0.5),rgba(0, 0, 0, 0.1));
}
publication-information {
display: flex;
flex-direction: column;
justify-content: start;
}
publication-information * {
z-index: 1;
color: var(--accent-color);
}
connector-name{
width: fit-content;
margin: 10px 0;
}
publication-name{
width: fit-content;
font-size: 16pt;
font-weight: bold;
}
publication img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
popup{
display: none;
width: 100%;
min-height: 100%;
top: 0;
left: 0;
position: absolute;
z-index: 2;
}
blur-background {
width: 100%;
height: 100%;
position: absolute;
left: 0;
background-color: black;
opacity: 0.5;
}
addtask-window {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
position: absolute;
left: 12.5%;
top: 15%;
width: 75%;
min-height: 70%;
max-height: 80%;
padding: 0;
background-color: var(--accent-color);
z-index: 5;
border-radius: 5px;
}
window-titlebar {
width: 100%;
height: 60px;
background-color: var(--primary-color);
border-radius: 5px 5px 0 0;
color: var(--accent-color);
display: flex block;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
window-titlebar p {
margin: 0 30px;
font-size: 14pt;
font-weight: bolder;
letter-spacing: 1px;
}
window-titlebar #closePopupImg {
height: 70%;
cursor: pointer;
margin-right: 20px;
filter: invert(100%) sepia(0%) saturate(100%) hue-rotate(115deg) brightness(116%) contrast(101%);
}
window-content {
display: flex;
flex-direction: column;
padding: 20px 5%;
overflow-x: scroll;
}
addtask-settings{
display: flex;
justify-content: center;
align-items: center;
}
addtask-settings select, addtask-settings input{
padding: 5px;
font-size: 10pt;
border: 1px solid rgba(0,0,0,0.2);
border-radius: 3px;
background-color: transparent;
margin: 10px 0;
width: 150px;
}
addtask-settings label {
font-weight: bolder;
margin: 0 5px;
}
addtask-settings addtask-setting{
margin: 0 15px;
}
#taskSelectOutput{
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: start;
align-content: start;
}
#publicationViewerPopup{
z-index: 6;
}
publication-viewer{
display: block;
width: 500px;
height: 300px;
position: absolute;
top: 200px;
left: 400px;
background-color: var(--accent-color);
border-radius: 5px;
overflow: hidden;
}
publication-viewer{
padding: 30px;
}
publication-viewer::after{
content: '';
position: absolute;
left: 0; top: 0;
border-radius: 5px;
width: 100%; height: 100%;
background: rgba(0,0,0,0.8);
backdrop-filter: blur(3px);
}
publication-viewer img {
position: absolute;
left: 0;
top: 0;
min-height: 100%;
max-width: 100%;
border-radius: 5px;
z-index: 0;
}
publication-viewer publication-information publication-name{
margin: 5px 0;
}
publication-viewer publication-information publication-author {
margin: 5px 0;
}
publication-viewer publication-information publication-author::before {
content: "Author: ";
}
publication-viewer publication-information publication-description::before {
content: "Description";
display: block;
font-weight: bolder;
}
publication-viewer publication-information publication-description {
font-size: 12pt;
margin: 5px 0;
max-height: 200px;
overflow-x: scroll;
}
publication-viewer publication-information publication-delete {
position: absolute;
bottom: 0px;
right: 0px;
color: red;
margin: 20px;
font-size: 16pt;
}
publication-viewer publication-information publication-add {
position: absolute;
bottom: 0px;
right: 0px;
color: limegreen;
margin: 20px;
font-size: 16pt;
}

21
docker-compose.yaml Normal file
View File

@ -0,0 +1,21 @@
version: '3'
services:
tranga-api:
image: glax/tranga-api:latest
container_name: tranga-api
volumes:
- ./tranga:/usr/share/Tranga-API #1 when replacing ./tranga replace #2 with same value
- ./Manga:/Manga
ports:
- "6531:6531"
restart: unless-stopped
tranga-website:
image: glax/tranga-website:latest
container_name: tranga-website
volumes:
- ./tranga/imageCache:/usr/share/nginx/html/imageCache:ro #2 when replacing Point to same value as #1/imageCache
ports:
- "9555:80"
depends_on:
- tranga-api
restart: unless-stopped