132 Commits
0.6 ... 1.1.1

Author SHA1 Message Date
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
32dc58715e Merge branch 'Website' 2023-05-24 21:52:31 +02:00
add0583776 Changed default-download folder for API 2023-05-24 21:52:08 +02:00
6fed0e5473 removed console.log 2023-05-24 21:51:26 +02:00
a0636ac7a2 Finished Settings-Cart 2023-05-24 21:48:54 +02:00
7aeb78e2f6 Merge branch 'master' into Website 2023-05-24 21:04:52 +02:00
5cf512f2b2 API: /Tasks/GetList has become /Tasks/Get with options to search for specific tasks 2023-05-24 21:04:24 +02:00
7d96b0901f Search Button on AddTask 2023-05-24 20:57:41 +02:00
68e80bc066 Settings 2023-05-24 20:57:17 +02:00
ad971fb065 Code-Comments 2023-05-24 20:17:50 +02:00
86052472bc Merge pull request 'Website' (#21) from Website into master
Reviewed-on: #21
2023-05-23 18:53:40 +02:00
ec30bb40fa Merge pull request 'CORS, API-Path' (#20) from dev into master
Reviewed-on: #20
2023-05-23 18:53:03 +02:00
2fa96e9793 undo gitignore 2023-05-23 18:52:27 +02:00
78e44b7704 Fix Popup no closing bug
Fix wrong button (add) bug
2023-05-23 18:48:50 +02:00
8bf9df4419 Done Better Task-Adder 2023-05-23 18:46:06 +02:00
4bd54f096d WIP Better Task-Adder 2023-05-23 18:28:27 +02:00
877daf0a1e Fix bug with interval 2023-05-23 18:19:46 +02:00
6d0fcc13fb Only refresh items when tasks are added/removed #1 2023-05-23 18:17:39 +02:00
f0256494fd HidePopup after interaction 2023-05-23 18:12:45 +02:00
39fa905733 Access-Control-Allow-Methods 2023-05-23 18:11:18 +02:00
c557389967 Delete Task 2023-05-23 18:07:15 +02:00
201773af50 Craeted Publication Viewer 2023-05-23 17:57:48 +02:00
f85e02fb0a empty results when opening addtaskmenu add when searching. 2023-05-23 16:59:45 +02:00
73d98b9c0f Add Task Window styling 2023-05-23 16:54:39 +02:00
b0ee888c82 Exist popup by clicking outside of it 2023-05-23 16:29:09 +02:00
5c4431778e Task can now be added 2023-05-23 16:27:09 +02:00
ccfa213b77 some bugfixes 2023-05-23 15:19:09 +02:00
22d6389d38 Fix wrong API* Path create task 2023-05-23 15:17:47 +02:00
f53dfb0822 update task select window #1 2023-05-23 15:15:29 +02:00
a966bd788d Return array for GetAvailableControllers 2023-05-23 14:45:51 +02:00
dd651adc15 Add Task window 2023-05-23 14:44:59 +02:00
ba5ae67aa7 Fix wrong API path for GetTaskTypes 2023-05-23 14:44:45 +02:00
da4a5bed09 All API-calls #1 2023-05-23 13:52:35 +02:00
947b521163 Changed API: GetAvailableControllers, GetKnownPublications, GetPublicationsFromConnector to Tranga/* 2023-05-23 13:17:05 +02:00
5674adbd5e Added CORS for localhost 2023-05-23 13:16:37 +02:00
290819de09 Created first api-calls #1 2023-05-23 13:15:29 +02:00
0d0b68a8f9 add Website to .gitignore for dev-branch 2023-05-23 12:52:09 +02:00
87d2357b41 CORS Error 2023-05-23 12:51:21 +02:00
e3186aebb0 Merge branch 'master' into Website 2023-05-23 00:17:25 +02:00
1cd37e2b1b Update gitignore 2023-05-23 00:16:48 +02:00
9c267f395f But I like this! #1 2023-05-23 00:12:30 +02:00
e2b8888130 #1 Basic layout and colors 2023-05-22 23:52:35 +02:00
b6ac2682f6 #1 First commit
It do be uglyyyyy
2023-05-22 22:25:50 +02:00
eddf50483f Fixed some nullable types 2023-05-22 21:44:52 +02:00
a71d65e666 Fix negative sleep time 2023-05-22 21:41:11 +02:00
9a640aed27 Rewrote CoverDownload check if exists. 2023-05-22 21:38:44 +02:00
30b6c4680b Better Rate-Limits
Added Logger to DownloadClient
2023-05-22 21:38:23 +02:00
7b6253de0f Create Publication Folder at start of DownloadNewChapters 2023-05-22 21:37:30 +02:00
5aa3214ce5 TrangaTask.ToString() rewrite for logs-readability.
LogMessages only include class-name without path
2023-05-22 21:37:02 +02:00
9b70994f71 Adjusted RateLimit 2023-05-22 18:55:26 +02:00
93cf341f2d Fixed Publication.InternalId 2023-05-22 18:28:42 +02:00
01cb74c088 First attempt at #18 Rate Limits 2023-05-22 18:15:59 +02:00
ec480dffad Merge pull request 'closes #7' (#17) from Issue_7 into master
Reviewed-on: #17
2023-05-22 17:21:42 +02:00
b7014cbff5 Merge pull request 'fixes #14' (#16) from Issue_14_ChapterIsDownlaoded into master
Reviewed-on: #16
2023-05-22 17:21:19 +02:00
0cab921402 Merge pull request 'fixes #11' (#15) from Issue_11 into master
Reviewed-on: #15
2023-05-22 17:20:54 +02:00
0e0ba1796e closes #7 2023-05-22 17:20:07 +02:00
27d8565dc1 fixes #14 2023-05-22 17:09:47 +02:00
79dc44d707 fixes 11 2023-05-22 17:04:31 +02:00
bb6a0ad0d4 Merge pull request 'fixes #9' (#13) from Issue_9 into master
Reviewed-on: #13
2023-05-22 16:53:40 +02:00
43db463ba6 fixes #9 2023-05-22 16:52:52 +02:00
9eb8ddbc40 Changed Publication:
downloadUrl is now publicationId, internal to Connector
posterUrl is now a URL to the file, instead of an id
2023-05-22 16:45:55 +02:00
972cba69ec JsonIgnore
And better working directory stuff
2023-05-22 02:06:49 +02:00
962fe9529e Merge remote-tracking branch 'origin/master' 2023-05-22 01:53:36 +02:00
da1b0cb1cd Change to CommonApplicationFolder as applicationPath 2023-05-22 01:53:27 +02:00
7f88e57e47 Change to CommonApplicationFolder as applicationPath 2023-05-22 01:49:53 +02:00
8865bf284f Corrected applicationFolder in API 2023-05-22 01:42:53 +02:00
5fc2de5fcb logging 2023-05-22 01:20:32 +02:00
4bae223d95 Custom UniqueIdentifier. 2023-05-22 00:33:58 +02:00
0486168b43 AddMangaTaskToQueue Shortcut 2023-05-22 00:15:08 +02:00
b64ab5c6d4 Created TrangaSettings
Different files for settings, tasks, and known publications
Komga connector is stored in TrangaSettings
2023-05-22 00:13:24 +02:00
578fa5e6be JsonIgnore 2023-05-21 23:27:28 +02:00
4d33e78123 unused variable 2023-05-21 22:24:23 +02:00
52ac3e4e4e Proper Mapping for deleting and dequeueing 2023-05-21 22:24:12 +02:00
35 changed files with 1817 additions and 295 deletions

4
.gitignore vendored
View File

@ -16,4 +16,6 @@ riderModule.iml
# Datasource local storage ignored files # Datasource local storage ignored files
/dataSources/ /dataSources/
/dataSources.local.xml /dataSources.local.xml
/.idea /.idea
cover.jpg
cover.png

13
Dockerfile Normal file
View File

@ -0,0 +1,13 @@
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
WORKDIR /src
COPY . /src/
RUN dotnet restore Tranga-API/Tranga-API.csproj
RUN dotnet publish -c Release -o /publish
FROM mcr.microsoft.com/dotnet/aspnet:7.0 as runtime
WORKDIR /publish
COPY --from=build-env /publish .
EXPOSE 80
ENTRYPOINT ["dotnet", "/publish/Tranga-API.dll"]

View File

@ -52,7 +52,7 @@ public abstract class LoggerBase : TextWriter
public override string ToString() public override string ToString()
{ {
string dateTimeString = $"{logTime.ToShortDateString()} {logTime.ToLongTimeString()}"; string dateTimeString = $"{logTime.ToShortDateString()} {logTime.ToLongTimeString()}";
return $"[{dateTimeString}] {caller,30} | {value}"; return $"[{dateTimeString}] {caller.Split(new char[]{'.','+'}).Last(),15} | {value}";
} }
} }
} }

View File

@ -30,6 +30,9 @@
<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>
@ -64,24 +67,43 @@ 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 - Love <3 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) |
|-----------------------------------:|:-------------------------------------------------:|
<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.
### Prerequisites ### Prerequisites
@ -90,7 +112,7 @@ A CLI will guide you through setup.
<!-- ROADMAP --> <!-- ROADMAP -->
## Roadmap ## Roadmap
- [ ] Web-UI #1 - [x] Web-UI #1
- [ ] More Connectors - [ ] More Connectors
- [ ] Manganato #2 - [ ] Manganato #2
- [ ] ? - [ ] ?

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,44 +1,66 @@
using System.Runtime.InteropServices;
using Logging; using Logging;
using Tranga; using Tranga;
string applicationFolderPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Tranga-API"); string applicationFolderPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Tranga-API");
string logsFolderPath = Path.Join(applicationFolderPath, "logs"); string downloadFolderPath = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/Manga" : Path.Join(applicationFolderPath, "Manga");
string logsFolderPath = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/var/logs/Tranga" : Path.Join(applicationFolderPath, "logs");
string logFilePath = Path.Join(logsFolderPath, $"log-{DateTime.Now:dd-M-yyyy-HH-mm-ss}.txt"); string logFilePath = Path.Join(logsFolderPath, $"log-{DateTime.Now:dd-M-yyyy-HH-mm-ss}.txt");
string settingsFilePath = Path.Join(applicationFolderPath, "data.json"); string settingsFilePath = Path.Join(applicationFolderPath, "settings.json");
Directory.CreateDirectory(applicationFolderPath);
Directory.CreateDirectory(logsFolderPath); Directory.CreateDirectory(logsFolderPath);
Logger logger = new(new[] { Logger.LoggerType.FileLogger, Logger.LoggerType.ConsoleLogger }, Console.Out, Console.Out.Encoding, logFilePath);
Console.WriteLine($"Logfile-Path: {logFilePath}");
Console.WriteLine($"Settings-File-Path: {settingsFilePath}");
Logger logger = new(new[] { Logger.LoggerType.FileLogger }, null, null, logFilePath); logger.WriteLine("Tranga", "Loading settings.");
logger.WriteLine("Tranga_CLI", "Loading Taskmanager."); TrangaSettings settings;
TaskManager.SettingsData settings;
if (File.Exists(settingsFilePath)) if (File.Exists(settingsFilePath))
settings = TaskManager.LoadData(settingsFilePath); settings = TrangaSettings.LoadSettings(settingsFilePath);
else else
settings = new TaskManager.SettingsData(Directory.GetCurrentDirectory(), settingsFilePath, null, new HashSet<TrangaTask>()); settings = new TrangaSettings(downloadFolderPath, applicationFolderPath, null);
Directory.CreateDirectory(settings.workingDirectory);
Directory.CreateDirectory(settings.downloadLocation);
Directory.CreateDirectory(settings.coverImageCache);
logger.WriteLine("Tranga",$"Application-Folder: {settings.workingDirectory}");
logger.WriteLine("Tranga",$"Settings-File-Path: {settings.settingsFilePath}");
logger.WriteLine("Tranga",$"Download-Folder-Path: {settings.downloadLocation}");
logger.WriteLine("Tranga",$"Logfile-Path: {logFilePath}");
logger.WriteLine("Tranga",$"Image-Cache-Path: {settings.coverImageCache}");
logger.WriteLine("Tranga", "Loading Taskmanager.");
TaskManager taskManager = new (settings, logger); TaskManager taskManager = new (settings, logger);
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddControllers().AddNewtonsoftJson(); builder.Services.AddControllers().AddNewtonsoftJson();
string corsHeader = "Tranga";
builder.Services.AddCors(options =>
{
options.AddPolicy(name: corsHeader,
policy =>
{
policy.AllowAnyOrigin();
policy.WithMethods("GET", "POST", "DELETE");
});
});
var app = builder.Build(); var app = builder.Build();
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
app.MapGet("/GetAvailableControllers", () => taskManager.GetAvailableConnectors()); app.UseCors(corsHeader);
app.MapGet("/GetKnownPublications", () => taskManager.GetAllPublications()); app.MapGet("/Tranga/GetAvailableControllers", () => taskManager.GetAvailableConnectors().Keys.ToArray());
app.MapGet("/GetPublicationsFromConnector", (string connectorName, string title) => app.MapGet("/Tranga/GetKnownPublications", () => taskManager.GetAllPublications());
app.MapGet("/Tranga/GetPublicationsFromConnector", (string connectorName, string title) =>
{ {
Connector? connector = taskManager.GetAvailableConnectors().FirstOrDefault(con => con.Key == connectorName).Value; Connector? connector = taskManager.GetAvailableConnectors().FirstOrDefault(con => con.Key == connectorName).Value;
if (connector is null) if (connector is null)
@ -58,23 +80,54 @@ app.MapPost("/Tasks/Create", (string taskType, string? connectorName, string? pu
taskManager.AddTask(task, connectorName, publication, TimeSpan.Parse(reoccurrenceTime), language??""); taskManager.AddTask(task, connectorName, publication, TimeSpan.Parse(reoccurrenceTime), language??"");
}); });
app.MapPost("/Tasks/Delete", (string taskType, string? connectorName, string? publicationId) => app.MapDelete("/Tasks/Delete", (string taskType, string? connectorName, string? publicationId) =>
{ {
Publication? publication = taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == publicationId); Publication? publication = taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == publicationId);
TrangaTask.Task task = Enum.Parse<TrangaTask.Task>(taskType); TrangaTask.Task task = Enum.Parse<TrangaTask.Task>(taskType);
taskManager.DeleteTask(task, connectorName, publication); taskManager.DeleteTask(task, connectorName, publication);
}); });
app.MapGet("/Tasks/GetList", () => taskManager.GetAllTasks()); app.MapGet("/Tasks/Get", (string taskType, string? connectorName, string? searchString) =>
{
try
{
TrangaTask.Task task = Enum.Parse<TrangaTask.Task>(taskType);
if (searchString is null || connectorName is null)
return taskManager.GetAllTasks().Where(tTask => tTask.task == task);
else
return taskManager.GetAllTasks().Where(tTask =>
tTask.task == task && tTask.connectorName == connectorName && tTask.ToString()
.Contains(searchString, StringComparison.InvariantCultureIgnoreCase));
}
catch (ArgumentException)
{
return Array.Empty<TrangaTask>();
}
});
app.MapPost("/Tasks/Start", (string taskType, string? connectorName, string? publicationId) => app.MapPost("/Tasks/Start", (string taskType, string? connectorName, string? publicationId) =>
{ {
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType); try
TrangaTask? task = taskManager.GetAllTasks().FirstOrDefault(tTask => {
tTask.task == pTask && tTask.publication?.internalId == publicationId && tTask.connectorName == connectorName); TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType);
if (task is null) TrangaTask? task = null;
if (connectorName is null || publicationId is null)
task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
tTask.task == pTask);
else
task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
tTask.task == pTask && tTask.publication?.internalId == publicationId &&
tTask.connectorName == connectorName);
if (task is null)
return;
taskManager.ExecuteTaskNow(task);
}
catch (ArgumentException)
{
return; return;
taskManager.ExecuteTaskNow(task); }
}); });
app.MapGet("/Tasks/GetRunningTasks", app.MapGet("/Tasks/GetRunningTasks",
@ -85,38 +138,54 @@ app.MapGet("/Queue/GetList",
app.MapPost("/Queue/Enqueue", (string taskType, string? connectorName, string? publicationId) => app.MapPost("/Queue/Enqueue", (string taskType, string? connectorName, string? publicationId) =>
{ {
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType); try
TrangaTask? task = taskManager.GetAllTasks().FirstOrDefault(tTask => {
tTask.task == pTask && tTask.publication?.internalId == publicationId && tTask.connectorName == connectorName); TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType);
if (task is null) TrangaTask? task = null;
if (connectorName is null || publicationId is null)
task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
tTask.task == pTask);
else
task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
tTask.task == pTask && tTask.publication?.internalId == publicationId &&
tTask.connectorName == connectorName);
if (task is null)
return;
taskManager.AddTaskToQueue(task);
}
catch (ArgumentException)
{
return; return;
taskManager.AddTaskToQueue(task); }
}); });
app.MapPost("/Queue/Dequeue", (string taskType, string? connectorName, string? publicationId) => app.MapDelete("/Queue/Dequeue", (string taskType, string? connectorName, string? publicationId) =>
{ {
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType); try
TrangaTask? task = taskManager.GetAllTasks().FirstOrDefault(tTask => {
tTask.task == pTask && tTask.publication?.internalId == publicationId && tTask.connectorName == connectorName); TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType);
if (task is null) TrangaTask? task = null;
if (connectorName is null || publicationId is null)
task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
tTask.task == pTask);
else
task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
tTask.task == pTask && tTask.publication?.internalId == publicationId &&
tTask.connectorName == connectorName);
if (task is null)
return;
taskManager.RemoveTaskFromQueue(task);
}
catch (ArgumentException)
{
return; return;
taskManager.RemoveTaskFromQueue(task); }
}); });
app.MapGet("/Settings/Get", () => new Settings(taskManager.settings)); app.MapGet("/Settings/Get", () => taskManager.settings);
app.MapPost("/Settings/Update", (string? downloadLocation, string? komgaUrl, string? komgaAuth) => taskManager.UpdateSettings(downloadLocation, komgaUrl, komgaAuth) ); app.MapPost("/Settings/Update", (string? downloadLocation, string? komgaUrl, string? komgaAuth) => taskManager.UpdateSettings(downloadLocation, komgaUrl, komgaAuth) );
app.Run(); app.Run();
class Settings
{
public string downloadLocation { get; }
public Komga? komga { get; }
public Settings(TaskManager.SettingsData settings)
{
this.downloadLocation = settings.downloadLocation;
this.komga = settings.komga;
}
}

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

@ -14,10 +14,10 @@ public static class Tranga_Cli
{ {
public static void Main(string[] args) public static void Main(string[] args)
{ {
string applicationFolderPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Tranga"); string applicationFolderPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Tranga");
string logsFolderPath = Path.Join(applicationFolderPath, "logs"); string logsFolderPath = Path.Join(applicationFolderPath, "logs");
string logFilePath = Path.Join(logsFolderPath, $"log-{DateTime.Now:dd-M-yyyy-HH-mm-ss}.txt"); string logFilePath = Path.Join(logsFolderPath, $"log-{DateTime.Now:dd-M-yyyy-HH-mm-ss}.txt");
string settingsFilePath = Path.Join(applicationFolderPath, "data.json"); string settingsFilePath = Path.Join(applicationFolderPath, "settings.json");
Directory.CreateDirectory(applicationFolderPath); Directory.CreateDirectory(applicationFolderPath);
Directory.CreateDirectory(logsFolderPath); Directory.CreateDirectory(logsFolderPath);
@ -28,11 +28,11 @@ public static class Tranga_Cli
Logger logger = new(new[] { Logger.LoggerType.FileLogger }, null, null, logFilePath); Logger logger = new(new[] { Logger.LoggerType.FileLogger }, null, null, logFilePath);
logger.WriteLine("Tranga_CLI", "Loading Taskmanager."); logger.WriteLine("Tranga_CLI", "Loading Taskmanager.");
TaskManager.SettingsData settings; TrangaSettings settings;
if (File.Exists(settingsFilePath)) if (File.Exists(settingsFilePath))
settings = TaskManager.LoadData(settingsFilePath); settings = TrangaSettings.LoadSettings(settingsFilePath);
else else
settings = new TaskManager.SettingsData(Directory.GetCurrentDirectory(), settingsFilePath, null, new HashSet<TrangaTask>()); settings = new TrangaSettings(Directory.GetCurrentDirectory(), applicationFolderPath, null);
logger.WriteLine("Tranga_CLI", "User Input"); logger.WriteLine("Tranga_CLI", "User Input");
@ -40,8 +40,8 @@ public static class Tranga_Cli
string? tmpPath = Console.ReadLine(); string? tmpPath = Console.ReadLine();
while(tmpPath is null) while(tmpPath is null)
tmpPath = Console.ReadLine(); tmpPath = Console.ReadLine();
if(tmpPath.Length > 0) if (tmpPath.Length > 0)
settings.UpdateSettings(pDownloadLocation: tmpPath, null); settings.downloadLocation = tmpPath;
Console.WriteLine($"Komga BaseURL [{settings.komga?.baseUrl}]:"); Console.WriteLine($"Komga BaseURL [{settings.komga?.baseUrl}]:");
string? tmpUrl = Console.ReadLine(); string? tmpUrl = Console.ReadLine();
@ -74,14 +74,14 @@ public static class Tranga_Cli
} }
} while (key != ConsoleKey.Enter); } while (key != ConsoleKey.Enter);
settings.UpdateSettings(null, new Komga(tmpUrl, tmpUser, tmpPass, logger)); settings.komga = new Komga(tmpUrl, tmpUser, tmpPass, logger);
} }
logger.WriteLine("Tranga_CLI", "Loaded."); logger.WriteLine("Tranga_CLI", "Loaded.");
TaskMode(settings, logger); TaskMode(settings, logger);
} }
private static void TaskMode(TaskManager.SettingsData settings, Logger logger) private static void TaskMode(TrangaSettings settings, Logger logger)
{ {
TaskManager taskManager = new (settings, logger); TaskManager taskManager = new (settings, logger);
ConsoleKey selection = ConsoleKey.EraseEndOfFile; ConsoleKey selection = ConsoleKey.EraseEndOfFile;
@ -143,7 +143,7 @@ public static class Tranga_Cli
TailLog(logger); TailLog(logger);
Console.ReadKey(); Console.ReadKey();
break; break;
case ConsoleKey.M: case ConsoleKey.G:
RemoveTaskFromQueue(taskManager, logger); RemoveTaskFromQueue(taskManager, logger);
Console.WriteLine("Press any key."); Console.WriteLine("Press any key.");
Console.ReadKey(); Console.ReadKey();
@ -153,6 +153,11 @@ public static class Tranga_Cli
Console.WriteLine("Press any key."); Console.WriteLine("Press any key.");
Console.ReadKey(); Console.ReadKey();
break; break;
case ConsoleKey.M:
AddMangaTaskToQueue(taskManager, logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
} }
PrintMenu(taskManager, taskManager.settings.downloadLocation, logger); PrintMenu(taskManager, taskManager.settings.downloadLocation, logger);
} }
@ -186,8 +191,8 @@ public static class Tranga_Cli
Console.WriteLine(); Console.WriteLine();
Console.WriteLine($"{"C: Create Task",-30}{"L: List tasks",-30}{"B: Enqueue Task", -30}"); 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($"{"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}{"M: Remove Task from Queue", -30}"); Console.WriteLine($"{"E: Execute Task now",-30}{"R: List Running Tasks", -30}{"G: Remove Task from Queue", -30}");
Console.WriteLine(); Console.WriteLine($"{"M: New Download Manga Task",-30}{"", -30}{"", -30}");
Console.WriteLine($"{"",-30}{"F: Show Log",-30}{"Q: Exit",-30}"); Console.WriteLine($"{"",-30}{"F: Show Log",-30}{"Q: Exit",-30}");
} }
@ -204,8 +209,12 @@ public static class Tranga_Cli
$"{"",-5}{"Task",-20} | {"Last Executed",-20} | {"Reoccurrence",-12} | {"State",-10} | {"Connector",-15} | Publication/Manga"; $"{"",-5}{"Task",-20} | {"Last Executed",-20} | {"Reoccurrence",-12} | {"State",-10} | {"Connector",-15} | Publication/Manga";
Console.WriteLine(header); Console.WriteLine(header);
Console.WriteLine(new string('-', header.Length)); Console.WriteLine(new string('-', header.Length));
foreach(TrangaTask trangaTask in tasks) foreach (TrangaTask trangaTask in tasks)
Console.WriteLine($"{tIndex++:000}: {trangaTask}"); {
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) private static TrangaTask? SelectTask(TrangaTask[] tasks, Logger logger)
@ -239,7 +248,6 @@ public static class Tranga_Cli
try try
{ {
int selectedTaskIndex = Convert.ToInt32(selectedTask); int selectedTaskIndex = Convert.ToInt32(selectedTask);
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
return tasks[selectedTaskIndex]; return tasks[selectedTaskIndex];
} }
catch (Exception e) catch (Exception e)
@ -250,6 +258,25 @@ public static class Tranga_Cli
return null; 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) private static void AddTaskToQueue(TaskManager taskManager, Logger logger)
{ {
@ -300,7 +327,7 @@ public static class Tranga_Cli
} }
} }
private static void CreateTask(TaskManager taskManager, TaskManager.SettingsData settings, Logger logger) private static void CreateTask(TaskManager taskManager, TrangaSettings settings, Logger logger)
{ {
logger.WriteLine("Tranga_CLI", "Menu: Creating Task"); logger.WriteLine("Tranga_CLI", "Menu: Creating Task");
TrangaTask.Task? tmpTask = SelectTaskType(logger); TrangaTask.Task? tmpTask = SelectTaskType(logger);
@ -319,7 +346,7 @@ public static class Tranga_Cli
Publication? publication = null; Publication? publication = null;
if (task != TrangaTask.Task.UpdatePublications && task != TrangaTask.Task.UpdateKomgaLibrary) if (task != TrangaTask.Task.UpdatePublications && task != TrangaTask.Task.UpdateKomgaLibrary)
{ {
publication = SelectPublication(connector!, logger); publication = SelectPublication(taskManager, connector!, logger);
if (publication is null) if (publication is null)
return; return;
} }
@ -443,7 +470,7 @@ public static class Tranga_Cli
return null; return null;
} }
private static Publication? SelectPublication(Connector connector, Logger logger) private static Publication? SelectPublication(TaskManager taskManager, Connector connector, Logger logger)
{ {
logger.WriteLine("Tranga_CLI", "Menu: Select Publication"); logger.WriteLine("Tranga_CLI", "Menu: Select Publication");
@ -452,7 +479,14 @@ public static class Tranga_Cli
Console.WriteLine("Publication search query (leave empty for all):"); Console.WriteLine("Publication search query (leave empty for all):");
string? query = Console.ReadLine(); string? query = Console.ReadLine();
Publication[] publications = connector.GetPublications(query ?? ""); 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; int pIndex = 0;
Console.WriteLine("Publications:"); Console.WriteLine("Publications:");

View File

@ -1,3 +1,4 @@
<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/=Komga/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Komga/@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.Globalization;
using System.Text.RegularExpressions;
namespace Tranga; namespace Tranga;
@ -14,14 +15,15 @@ public struct Chapter
public string url { get; } public string url { get; }
public string fileName { get; } public string fileName { get; }
public string sortNumber { get; } public string sortNumber { get; }
private static readonly Regex LegalCharacters = new Regex(@"([A-z]*[0-9]* *\.*-*,*\]*\[*'*\'*\)*\(*~*!*)*");
public Chapter(string? name, string? volumeNumber, string? chapterNumber, string url) public Chapter(string? name, string? volumeNumber, string? chapterNumber, string url)
{ {
this.name = name; this.name = name;
this.volumeNumber = volumeNumber is { Length: > 0 } ? volumeNumber : "1"; this.volumeNumber = volumeNumber is { Length: > 0 } ? volumeNumber : "1";
this.chapterNumber = chapterNumber; this.chapterNumber = chapterNumber;
this.url = url; this.url = url;
string chapterName = string.Concat((name ?? "").Split(Path.GetInvalidFileNameChars())); string chapterName = string.Concat(LegalCharacters.Matches(name ?? ""));
NumberFormatInfo nfi = new NumberFormatInfo() NumberFormatInfo nfi = new NumberFormatInfo()
{ {
NumberDecimalSeparator = "." NumberDecimalSeparator = "."

View File

@ -12,15 +12,21 @@ namespace Tranga;
public abstract class Connector public abstract class Connector
{ {
internal string downloadLocation { get; } //Location of local files internal string downloadLocation { get; } //Location of local files
protected DownloadClient downloadClient { get; } protected DownloadClient downloadClient { get; init; }
protected Logger? logger; protected Logger? logger;
protected Connector(string downloadLocation, uint downloadDelay, Logger? logger) protected string imageCachePath;
protected Connector(string downloadLocation, string imageCachePath, Logger? logger)
{ {
this.downloadLocation = downloadLocation; this.downloadLocation = downloadLocation;
this.downloadClient = new DownloadClient(downloadDelay);
this.logger = logger; this.logger = logger;
this.downloadClient = new DownloadClient(new Dictionary<byte, int>()
{
//RequestTypes for RateLimits
}, logger);
this.imageCachePath = imageCachePath;
} }
public abstract string name { get; } //Name of the Connector (e.g. Website) public abstract string name { get; } //Name of the Connector (e.g. Website)
@ -49,12 +55,13 @@ public abstract class Connector
/// <param name="publication">Publication that contains Chapter</param> /// <param name="publication">Publication that contains Chapter</param>
/// <param name="chapter">Chapter with Images to retrieve</param> /// <param name="chapter">Chapter with Images to retrieve</param>
public abstract void DownloadChapter(Publication publication, Chapter chapter); public abstract void DownloadChapter(Publication publication, Chapter chapter);
/// <summary> /// <summary>
/// Retrieves the Cover from the Website /// Retrieves the Cover from the Website
/// </summary> /// </summary>
/// <param name="publication">Publication to retrieve Cover for</param> /// <param name="publication">Publication to retrieve Cover for</param>
public abstract void DownloadCover(Publication publication); /// <param name="settings">TrangaSettings</param>
public abstract void CloneCoverFromCache(Publication publication, TrangaSettings settings);
/// <summary> /// <summary>
/// Saves the series-info to series.json in the Publication Folder /// Saves the series-info to series.json in the Publication Folder
@ -80,11 +87,12 @@ public abstract class Connector
/// <returns>XML-string</returns> /// <returns>XML-string</returns>
protected static string CreateComicInfo(Publication publication, Chapter chapter, Logger? logger) protected static string CreateComicInfo(Publication publication, Chapter chapter, Logger? logger)
{ {
logger?.WriteLine("Connector", $"Creating ComicInfo.Xml for {publication.sortName} Chapter {chapter.volumeNumber} {chapter.chapterNumber}"); logger?.WriteLine("Connector", $"Creating ComicInfo.Xml for {publication.sortName} {publication.internalId} {chapter.volumeNumber}-{chapter.chapterNumber}");
XElement comicInfo = new XElement("ComicInfo", XElement comicInfo = new XElement("ComicInfo",
new XElement("Tags", string.Join(',',publication.tags)), new XElement("Tags", string.Join(',',publication.tags)),
new XElement("LanguageISO", publication.originalLanguage), new XElement("LanguageISO", publication.originalLanguage),
new XElement("Title", chapter.name), new XElement("Title", chapter.name),
new XElement("Writer", publication.author),
new XElement("Volume", chapter.volumeNumber), new XElement("Volume", chapter.volumeNumber),
new XElement("Number", chapter.chapterNumber) //TODO check if this is correct at some point new XElement("Number", chapter.chapterNumber) //TODO check if this is correct at some point
); );
@ -106,41 +114,42 @@ public abstract class Connector
/// <returns>Filepath</returns> /// <returns>Filepath</returns>
protected string CreateFullFilepath(Publication publication, Chapter chapter) protected string CreateFullFilepath(Publication publication, Chapter chapter)
{ {
return Path.Join(downloadLocation, publication.folderName, chapter.fileName); return Path.Join(downloadLocation, publication.folderName, $"{chapter.fileName}.cbz");
} }
/// <summary> /// <summary>
/// Downloads Image from URL and saves it to the given path(incl. fileName) /// Downloads Image from URL and saves it to the given path(incl. fileName)
/// </summary> /// </summary>
/// <param name="imageUrl"></param> /// <param name="imageUrl"></param>
/// <param name="fullPath"></param> /// <param name="fullPath"></param>
/// <param name="downloadClient">DownloadClient of the connector</param> /// <param name="downloadClient">DownloadClient of the connector</param>
protected static void DownloadImage(string imageUrl, string fullPath, DownloadClient downloadClient) /// <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); DownloadClient.RequestResult requestResult = downloadClient.MakeRequest(imageUrl, requestType);
byte[] buffer = new byte[requestResult.result.Length]; byte[] buffer = new byte[requestResult.result.Length];
requestResult.result.ReadExactly(buffer, 0, buffer.Length); requestResult.result.ReadExactly(buffer, 0, buffer.Length);
File.WriteAllBytes(fullPath, buffer); File.WriteAllBytes(fullPath, buffer);
} }
/// <summary> /// <summary>
/// Downloads all Images from URLs, Compresses to zip(cbz) and saves. /// Downloads all Images from URLs, Compresses to zip(cbz) and saves.
/// </summary> /// </summary>
/// <param name="imageUrls">List of URLs to download Images from</param> /// <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="saveArchiveFilePath">Full path to save archive to (without file ending .cbz)</param>
/// <param name="downloadClient">DownloadClient of the connector</param> /// <param name="downloadClient">DownloadClient of the connector</param>
/// <param name="logger"></param>
/// <param name="comicInfoPath">Path of the generate Chapter ComicInfo.xml, if it was generated</param> /// <param name="comicInfoPath">Path of the generate Chapter ComicInfo.xml, if it was generated</param>
protected static void DownloadChapterImages(string[] imageUrls, string saveArchiveFilePath, DownloadClient downloadClient, Logger? logger, string? comicInfoPath = null) /// <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"); logger?.WriteLine("Connector", $"Downloading Images for {saveArchiveFilePath}");
//Check if Publication Directory already exists //Check if Publication Directory already exists
string[] splitPath = saveArchiveFilePath.Split(Path.DirectorySeparatorChar); string directoryPath = Path.GetDirectoryName(saveArchiveFilePath)!;
string directoryPath = Path.Combine(splitPath.Take(splitPath.Length - 1).ToArray());
if (!Directory.Exists(directoryPath)) if (!Directory.Exists(directoryPath))
Directory.CreateDirectory(directoryPath); Directory.CreateDirectory(directoryPath);
string fullPath = $"{saveArchiveFilePath}.cbz"; if (File.Exists(saveArchiveFilePath)) //Don't download twice.
if (File.Exists(fullPath)) //Don't download twice.
return; return;
//Create a temporary folder to store images //Create a temporary folder to store images
@ -152,48 +161,82 @@ public abstract class Connector
{ {
string[] split = imageUrl.Split('.'); string[] split = imageUrl.Split('.');
string extension = split[^1]; string extension = split[^1];
DownloadImage(imageUrl, Path.Join(tempFolder, $"{chapter++}.{extension}"), downloadClient); logger?.WriteLine("Connector", $"Downloading Image {chapter + 1}/{imageUrls.Length}");
DownloadImage(imageUrl, Path.Join(tempFolder, $"{chapter++}.{extension}"), downloadClient, requestType);
} }
if(comicInfoPath is not null) if(comicInfoPath is not null)
File.Copy(comicInfoPath, Path.Join(tempFolder, "ComicInfo.xml")); File.Copy(comicInfoPath, Path.Join(tempFolder, "ComicInfo.xml"));
logger?.WriteLine("Connector", "Creating archive"); logger?.WriteLine("Connector", $"Creating archive {saveArchiveFilePath}");
//ZIP-it and ship-it //ZIP-it and ship-it
ZipFile.CreateFromDirectory(tempFolder, fullPath); ZipFile.CreateFromDirectory(tempFolder, saveArchiveFilePath);
Directory.Delete(tempFolder, true); //Cleanup Directory.Delete(tempFolder, true); //Cleanup
} }
protected class DownloadClient protected class DownloadClient
{ {
private readonly TimeSpan _requestSpeed;
private DateTime _lastRequest;
private static readonly HttpClient Client = new(); private static readonly HttpClient Client = new();
private readonly Dictionary<byte, DateTime> _lastExecutedRateLimit;
private readonly Dictionary<byte, TimeSpan> _rateLimit;
private Logger? logger;
/// <summary> /// <summary>
/// Creates a httpClient /// Creates a httpClient
/// </summary> /// </summary>
/// <param name="delay">minimum delay between requests (to avoid spam)</param> /// <param name="delay">minimum delay between requests (to avoid spam)</param>
public DownloadClient(uint delay) /// <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)
{ {
_requestSpeed = TimeSpan.FromMilliseconds(delay); this.logger = logger;
_lastRequest = DateTime.Now.Subtract(_requestSpeed); _lastExecutedRateLimit = new();
_rateLimit = new();
foreach(KeyValuePair<byte, int> limit in rateLimitRequestsPerMinute)
_rateLimit.Add(limit.Key, TimeSpan.FromMinutes(1).Divide(limit.Value));
} }
/// <summary> /// <summary>
/// Request Webpage /// Request Webpage
/// </summary> /// </summary>
/// <param name="url"></param> /// <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> /// <returns>RequestResult with StatusCode and Stream of received data</returns>
public RequestResult MakeRequest(string url) public RequestResult MakeRequest(string url, byte requestType)
{ {
while((DateTime.Now - _lastRequest) < _requestSpeed) if (_rateLimit.TryGetValue(requestType, out TimeSpan value))
Thread.Sleep(10); _lastExecutedRateLimit.TryAdd(requestType, DateTime.Now.Subtract(value));
_lastRequest = DateTime.Now; else
{
logger?.WriteLine(this.GetType().ToString(), "RequestType not configured for rate-limit.");
return new RequestResult(HttpStatusCode.NotAcceptable, Stream.Null);
}
HttpRequestMessage requestMessage = new(HttpMethod.Get, url); TimeSpan rateLimitTimeout = _rateLimit[requestType]
HttpResponseMessage response = Client.Send(requestMessage); .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);
logger?.WriteLine(this.GetType().ToString(), $"Waiting {_rateLimit[requestType] * 2}");
Thread.Sleep(_rateLimit[requestType] * 2);
}
}
Stream resultString = response.IsSuccessStatusCode ? response.Content.ReadAsStream() : Stream.Null; 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); return new RequestResult(response.StatusCode, resultString);
} }

View File

@ -9,14 +9,26 @@ public class MangaDex : Connector
{ {
public override string name { get; } public override string name { get; }
public MangaDex(string downloadLocation, uint downloadDelay, Logger? logger) : base(downloadLocation, downloadDelay, logger) private enum RequestType : byte
{ {
name = "MangaDex"; Manga,
Feed,
AtHomeServer,
CoverUrl,
Author,
} }
public MangaDex(string downloadLocation, Logger? logger) : base(downloadLocation, 750, logger) public MangaDex(string downloadLocation, string imageCachePath, Logger? logger) : base(downloadLocation, imageCachePath, logger)
{ {
name = "MangaDex"; name = "MangaDex";
this.downloadClient = new DownloadClient(new Dictionary<byte, int>()
{
{(byte)RequestType.Manga, 250},
{(byte)RequestType.Feed, 250},
{(byte)RequestType.AtHomeServer, 40},
{(byte)RequestType.CoverUrl, 250},
{(byte)RequestType.Author, 250}
}, logger);
} }
public override Publication[] GetPublications(string publicationTitle = "") public override Publication[] GetPublications(string publicationTitle = "")
@ -31,7 +43,7 @@ public class MangaDex : Connector
//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}"); $"https://api.mangadex.org/manga?limit={limit}&title={publicationTitle}&offset={offset}", (byte)RequestType.Manga);
if (requestResult.statusCode != HttpStatusCode.OK) if (requestResult.statusCode != HttpStatusCode.OK)
break; break;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result); JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
@ -43,12 +55,15 @@ public class MangaDex : Connector
total = result["total"]!.GetValue<int>(); //Update the total number of Publications total = result["total"]!.GetValue<int>(); //Update the total number of Publications
JsonArray mangaInResult = result["data"]!.AsArray(); //Manga-data-Array JsonArray mangaInResult = result["data"]!.AsArray(); //Manga-data-Array
logger?.WriteLine(this.GetType().ToString(), $"Getting publication data.");
//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)
{ {
JsonObject manga = (JsonObject)mangeNode!; JsonObject manga = (JsonObject)mangeNode!;
JsonObject attributes = manga["attributes"]!.AsObject(); JsonObject attributes = manga["attributes"]!.AsObject();
string publicationId = manga["id"]!.GetValue<string>();
string title = attributes["title"]!.AsObject().ContainsKey("en") && attributes["title"]!["en"] is not null string title = attributes["title"]!.AsObject().ContainsKey("en") && attributes["title"]!["en"] is not null
? attributes["title"]!["en"]!.GetValue<string>() ? attributes["title"]!["en"]!.GetValue<string>()
: attributes["title"]![((IDictionary<string, JsonNode?>)attributes["title"]!.AsObject()).Keys.First()]!.GetValue<string>(); : attributes["title"]![((IDictionary<string, JsonNode?>)attributes["title"]!.AsObject()).Keys.First()]!.GetValue<string>();
@ -75,15 +90,22 @@ public class MangaDex : Connector
tags.Add(tagObject["attributes"]!["name"]!["en"]!.GetValue<string>()); tags.Add(tagObject["attributes"]!["name"]!["en"]!.GetValue<string>());
} }
string? poster = null; string? posterId = null;
string? authorId = null;
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();
poster = 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>();
} }
string? coverUrl = GetCoverUrl(publicationId, posterId);
string? coverCacheName = null;
if (coverUrl is not null)
coverCacheName = SaveImage(coverUrl);
string? author = GetAuthor(authorId);
Dictionary<string, string> linksDict = new(); Dictionary<string, string> linksDict = new();
string[,]? links = null;
if (attributes.ContainsKey("links") && attributes["links"] is not null) if (attributes.ContainsKey("links") && attributes["links"] is not null)
{ {
JsonObject linksObject = attributes["links"]!.AsObject(); JsonObject linksObject = attributes["links"]!.AsObject();
@ -103,28 +125,31 @@ public class MangaDex : Connector
string status = attributes["status"]!.GetValue<string>(); string status = attributes["status"]!.GetValue<string>();
Publication pub = new Publication( Publication pub = new (
title, title,
author,
description, description,
altTitlesDict, altTitlesDict,
tags.ToArray(), tags.ToArray(),
poster, coverUrl,
coverCacheName,
linksDict, linksDict,
year, year,
originalLanguage, originalLanguage,
status, status,
manga["id"]!.GetValue<string>() publicationId
); );
publications.Add(pub); //Add Publication (Manga) to result publications.Add(pub); //Add Publication (Manga) to result
} }
} }
logger?.WriteLine(this.GetType().ToString(), $"Done getting publications (title={publicationTitle})");
return publications.ToArray(); return publications.ToArray();
} }
public override Chapter[] GetChapters(Publication publication, string language = "") public override Chapter[] GetChapters(Publication publication, string language = "")
{ {
logger?.WriteLine(this.GetType().ToString(), $"Getting Chapters {publication.sortName} (language={language})"); logger?.WriteLine(this.GetType().ToString(), $"Getting Chapters for {publication.sortName} {publication.internalId} (language={language})");
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
@ -135,7 +160,7 @@ public class MangaDex : Connector
//Request next "Page" //Request next "Page"
DownloadClient.RequestResult requestResult = DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest( downloadClient.MakeRequest(
$"https://api.mangadex.org/manga/{publication.downloadUrl}/feed?limit={limit}&offset={offset}&translatedLanguage%5B%5D={language}"); $"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 (requestResult.statusCode != HttpStatusCode.OK)
break; break;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result); JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
@ -174,15 +199,16 @@ public class MangaDex : Connector
{ {
NumberDecimalSeparator = "." NumberDecimalSeparator = "."
}; };
logger?.WriteLine(this.GetType().ToString(), $"Done getting Chapters for {publication.internalId}");
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 void DownloadChapter(Publication publication, Chapter chapter)
{ {
logger?.WriteLine(this.GetType().ToString(), $"Download Chapter {publication.sortName} {chapter.volumeNumber}-{chapter.chapterNumber}"); logger?.WriteLine(this.GetType().ToString(), $"Downloading Chapter-Info {publication.sortName} {publication.internalId} {chapter.volumeNumber}-{chapter.chapterNumber}");
//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'"); downloadClient.MakeRequest($"https://api.mangadex.org/at-home/server/{chapter.url}?forcePort443=false'", (byte)RequestType.AtHomeServer);
if (requestResult.statusCode != HttpStatusCode.OK) if (requestResult.statusCode != HttpStatusCode.OK)
return; return;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result); JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
@ -201,42 +227,87 @@ public class MangaDex : Connector
File.WriteAllText(comicInfoPath, CreateComicInfo(publication, chapter, logger)); File.WriteAllText(comicInfoPath, CreateComicInfo(publication, chapter, logger));
//Download Chapter-Images //Download Chapter-Images
DownloadChapterImages(imageUrls.ToArray(), CreateFullFilepath(publication, chapter), downloadClient, logger, comicInfoPath); DownloadChapterImages(imageUrls.ToArray(), CreateFullFilepath(publication, chapter), downloadClient, (byte)RequestType.AtHomeServer, logger, comicInfoPath);
} }
public override void DownloadCover(Publication publication) private string? GetCoverUrl(string publicationId, string? posterId)
{ {
logger?.WriteLine(this.GetType().ToString(), $"Download cover {publication.sortName}"); logger?.WriteLine(this.GetType().ToString(), $"Getting CoverUrl for {publicationId}");
//Check if Publication already has a Folder and cover if (posterId is null)
string publicationFolder = Path.Join(downloadLocation, publication.folderName); {
if(!Directory.Exists(publicationFolder)) logger?.WriteLine(this.GetType().ToString(), $"No posterId, aborting");
Directory.CreateDirectory(publicationFolder); return null;
DirectoryInfo dirInfo = new (publicationFolder); }
foreach(FileInfo fileInfo in dirInfo.EnumerateFiles())
if (fileInfo.Name.Contains("cover."))
return;
//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/{publication.posterUrl}"); downloadClient.MakeRequest($"https://api.mangadex.org/cover/{posterId}", (byte)RequestType.CoverUrl);
if (requestResult.statusCode != HttpStatusCode.OK) if (requestResult.statusCode != HttpStatusCode.OK)
return; return null;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result); JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
if (result is null) if (result is null)
return; return null;
string fileName = result["data"]!["attributes"]!["fileName"]!.GetValue<string>(); string fileName = result["data"]!["attributes"]!["fileName"]!.GetValue<string>();
string coverUrl = $"https://uploads.mangadex.org/covers/{publication.downloadUrl}/{fileName}"; string coverUrl = $"https://uploads.mangadex.org/covers/{publicationId}/{fileName}";
logger?.WriteLine(this.GetType().ToString(), $"Got Cover-Url for {publicationId} -> {coverUrl}");
//Get file-extension (jpg, png) return coverUrl;
string[] split = coverUrl.Split('.'); }
string extension = split[^1];
string outFolderPath = Path.Join(downloadLocation, publication.folderName); private string? GetAuthor(string? authorId)
Directory.CreateDirectory(outFolderPath); {
if (authorId is null)
return null;
//Download cover-Image DownloadClient.RequestResult requestResult =
DownloadImage(coverUrl, Path.Join(downloadLocation, publication.folderName, $"cover.{extension}"), this.downloadClient); downloadClient.MakeRequest($"https://api.mangadex.org/author/{authorId}", (byte)RequestType.Author);
if (requestResult.statusCode != HttpStatusCode.OK)
return null;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
if (result is null)
return null;
string author = result["data"]!["attributes"]!["name"]!.GetValue<string>();
logger?.WriteLine(this.GetType().ToString(), $"Got author {authorId} -> {author}");
return author;
}
public override void CloneCoverFromCache(Publication publication, TrangaSettings settings)
{
logger?.WriteLine(this.GetType().ToString(), $"Cloning 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;
}
string fileInCache = Path.Join(settings.coverImageCache, publication.coverFileNameInCache);
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" );
logger?.WriteLine(this.GetType().ToString(), $"Cloning cover {fileInCache} -> {newFilePath}");
File.Copy(fileInCache, newFilePath, true);
}
private string SaveImage(string url)
{
string[] split = url.Split('/');
string filename = split[^1];
string saveImagePath = Path.Join(imageCachePath, filename);
if (File.Exists(saveImagePath))
return filename;
DownloadClient.RequestResult coverResult = downloadClient.MakeRequest(url, (byte)RequestType.AtHomeServer);
using MemoryStream ms = new();
coverResult.result.CopyTo(ms);
File.WriteAllBytes(saveImagePath, ms.ToArray());
logger?.WriteLine(this.GetType().ToString(), $"Saving image to {saveImagePath}");
return filename;
} }
} }

View File

@ -1,4 +1,5 @@
using System.Net.Http.Headers; using System.Net;
using System.Net.Http.Headers;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using Logging; using Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -45,9 +46,17 @@ public class Komga
{ {
logger?.WriteLine(this.GetType().ToString(), $"Getting Libraries"); logger?.WriteLine(this.GetType().ToString(), $"Getting Libraries");
Stream data = NetClient.MakeRequest($"{baseUrl}/api/v1/libraries", auth); Stream data = NetClient.MakeRequest($"{baseUrl}/api/v1/libraries", auth);
if (data == Stream.Null)
{
logger?.WriteLine(this.GetType().ToString(), $"No libraries returned");
return Array.Empty<KomgaLibrary>();
}
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data); JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
if (result is null) if (result is null)
{
logger?.WriteLine(this.GetType().ToString(), $"No libraries returned");
return Array.Empty<KomgaLibrary>(); return Array.Empty<KomgaLibrary>();
}
HashSet<KomgaLibrary> ret = new(); HashSet<KomgaLibrary> ret = new();
@ -89,37 +98,51 @@ public class Komga
{ {
public static Stream MakeRequest(string url, string auth) public static Stream MakeRequest(string url, string auth)
{ {
HttpClient client = new(); HttpClientHandler clientHandler = new ();
HttpRequestMessage requestMessage = new HttpRequestMessage clientHandler.ServerCertificateCustomValidationCallback = (message, cert, chain, sslPolicyErrors) => true;
HttpClient client = new(clientHandler);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", auth);
HttpRequestMessage requestMessage = new ()
{ {
Method = HttpMethod.Get, Method = HttpMethod.Get,
RequestUri = new Uri(url), RequestUri = new Uri(url)
Headers =
{
{ "Accept", "application/json" },
{ "Authorization", new AuthenticationHeaderValue("Basic", auth).ToString() }
}
}; };
HttpResponseMessage response = client.Send(requestMessage); HttpResponseMessage response = client.Send(requestMessage);
Stream resultString = response.IsSuccessStatusCode ? response.Content.ReadAsStream() : Stream.Null; Stream ret;
return resultString; if (response.StatusCode is HttpStatusCode.Unauthorized)
{
ret = MakeRequest(response.RequestMessage!.RequestUri!.AbsoluteUri, auth);
}else
return response.IsSuccessStatusCode ? response.Content.ReadAsStream() : Stream.Null;
return ret;
} }
public static bool MakePost(string url, string auth) public static bool MakePost(string url, string auth)
{ {
HttpClient client = new(); HttpClientHandler clientHandler = new HttpClientHandler();
HttpRequestMessage requestMessage = new HttpRequestMessage clientHandler.ServerCertificateCustomValidationCallback = (message, cert, chain, sslPolicyErrors) => true;
HttpClient client = new(clientHandler)
{ {
Method = HttpMethod.Post, DefaultRequestHeaders =
RequestUri = new Uri(url),
Headers =
{ {
{ "Accept", "application/json" }, { "Accept", "application/json" },
{ "Authorization", new AuthenticationHeaderValue("Basic", auth).ToString() } { "Authorization", new AuthenticationHeaderValue("Basic", auth).ToString() }
} }
}; };
HttpRequestMessage requestMessage = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri(url)
};
HttpResponseMessage response = client.Send(requestMessage); HttpResponseMessage response = client.Send(requestMessage);
return response.IsSuccessStatusCode; bool ret;
if (response.StatusCode is HttpStatusCode.Unauthorized)
{
ret = MakePost(response.RequestMessage!.RequestUri!.AbsoluteUri, auth);
}else
return response.IsSuccessStatusCode;
return ret;
} }
} }
} }

View File

@ -1,4 +1,6 @@
using Newtonsoft.Json; using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
namespace Tranga; namespace Tranga;
@ -8,39 +10,42 @@ namespace Tranga;
public readonly struct Publication public readonly struct Publication
{ {
public string sortName { get; } public string sortName { get; }
// ReSharper disable UnusedAutoPropertyAccessor.Global we need it, trust public string? author { get; }
[JsonIgnore]public Dictionary<string,string> altTitles { get; } public Dictionary<string,string> altTitles { get; }
// ReSharper disable trice MemberCanBePrivate.Global, trust // ReSharper disable trice MemberCanBePrivate.Global, trust
public string? description { get; } public string? description { get; }
public string[] tags { get; } public string[] tags { get; }
public string? posterUrl { get; } public string? posterUrl { get; }
[JsonIgnore]public Dictionary<string,string> links { get; } public string? coverFileNameInCache { get; }
public Dictionary<string,string> links { get; }
public int? year { get; } public int? year { get; }
public string? originalLanguage { get; } public string? originalLanguage { get; }
public string status { get; } public string status { get; }
public string folderName { get; } public string folderName { get; }
public string downloadUrl { get; } public string publicationId { get; }
public string internalId { get; } public string internalId { get; }
public readonly struct ValueTuple private static readonly Regex LegalCharacters = new Regex(@"([A-z]*[0-9]* *\.*-*,*\]*\[*'*\'*\)*\(*~*!*)*");
{
}
public Publication(string sortName, string? description, Dictionary<string,string> altTitles, string[] tags, string? posterUrl, Dictionary<string,string>? links, int? year, string? originalLanguage, string status, string downloadUrl) public Publication(string sortName, string? author, string? description, Dictionary<string,string> altTitles, string[] tags, string? posterUrl, string? coverFileNameInCache, Dictionary<string,string>? links, int? year, string? originalLanguage, string status, string publicationId)
{ {
this.sortName = sortName; this.sortName = sortName;
this.author = author;
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.downloadUrl = downloadUrl; this.publicationId = publicationId;
this.folderName = string.Concat(sortName.Split(Path.GetInvalidPathChars().Concat(Path.GetInvalidFileNameChars()).ToArray())); this.folderName = string.Concat(LegalCharacters.Matches(sortName));
this.internalId = Guid.NewGuid().ToString(); while (this.folderName.EndsWith('.'))
this.folderName = this.folderName.Substring(0, this.folderName.Length - 1);
string onlyLowerLetters = string.Concat(this.sortName.ToLower().Where(Char.IsLetter));
this.internalId = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{onlyLowerLetters}{this.year}"));
} }
/// <returns>Serialized JSON String for series.json</returns> /// <returns>Serialized JSON String for series.json</returns>

View File

@ -37,7 +37,7 @@ public static class TaskExecutor
switch (trangaTask.task) switch (trangaTask.task)
{ {
case TrangaTask.Task.DownloadNewChapters: case TrangaTask.Task.DownloadNewChapters:
DownloadNewChapters(connector!, (Publication)trangaTask.publication!, trangaTask.language, ref taskManager._chapterCollection); DownloadNewChapters(connector!, (Publication)trangaTask.publication!, trangaTask.language, ref taskManager._chapterCollection, taskManager.settings);
break; break;
case TrangaTask.Task.UpdateChapters: case TrangaTask.Task.UpdateChapters:
UpdateChapters(connector!, (Publication)trangaTask.publication!, trangaTask.language, ref taskManager._chapterCollection); UpdateChapters(connector!, (Publication)trangaTask.publication!, trangaTask.language, ref taskManager._chapterCollection);
@ -90,15 +90,15 @@ public static class TaskExecutor
/// <param name="publication">Publication to check</param> /// <param name="publication">Publication to check</param>
/// <param name="language">Language to receive chapters for</param> /// <param name="language">Language to receive chapters for</param>
/// <param name="chapterCollection"></param> /// <param name="chapterCollection"></param>
private static void DownloadNewChapters(Connector connector, Publication publication, string language, ref Dictionary<Publication, List<Chapter>> chapterCollection) private static void DownloadNewChapters(Connector connector, Publication publication, string language, ref Dictionary<Publication, List<Chapter>> chapterCollection, TrangaSettings settings)
{ {
List<Chapter> newChapters = UpdateChapters(connector, publication, language, ref chapterCollection); //Check if Publication already has a Folder
connector.DownloadCover(publication);
//Check if Publication already has a Folder and a series.json
string publicationFolder = Path.Join(connector.downloadLocation, publication.folderName); string publicationFolder = Path.Join(connector.downloadLocation, publication.folderName);
if(!Directory.Exists(publicationFolder)) if(!Directory.Exists(publicationFolder))
Directory.CreateDirectory(publicationFolder); Directory.CreateDirectory(publicationFolder);
List<Chapter> newChapters = UpdateChapters(connector, publication, language, ref chapterCollection);
connector.CloneCoverFromCache(publication, settings);
string seriesInfoPath = Path.Join(publicationFolder, "series.json"); string seriesInfoPath = Path.Join(publicationFolder, "series.json");
if(!File.Exists(seriesInfoPath)) if(!File.Exists(seriesInfoPath))

View File

@ -11,31 +11,34 @@ namespace Tranga;
public class TaskManager public class TaskManager
{ {
public Dictionary<Publication, List<Chapter>> _chapterCollection = new(); public Dictionary<Publication, List<Chapter>> _chapterCollection = new();
private readonly HashSet<TrangaTask> _allTasks; private HashSet<TrangaTask> _allTasks;
private bool _continueRunning = true; private bool _continueRunning = true;
private readonly Connector[] _connectors; private readonly Connector[] _connectors;
private readonly Dictionary<Connector, List<TrangaTask>> _taskQueue = new(); private readonly Dictionary<Connector, List<TrangaTask>> _taskQueue = new();
public SettingsData settings { get; } public TrangaSettings settings { get; }
private Logger? logger { get; } private Logger? logger { get; }
public Komga? komga { get; } public Komga? komga => settings.komga;
/// <param name="downloadFolderPath">Local path to save data (Manga) to</param> /// <param name="downloadFolderPath">Local path to save data (Manga) to</param>
/// <param name="settingsFilePath">Path to the settings file (data.json)</param> /// <param name="workingDirectory">Path to the working directory</param>
/// <param name="imageCachePath">Path to the cover-image cache</param>
/// <param name="komgaBaseUrl">The Url of the Komga-instance that you want to update</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="komgaUsername">The Komga username</param>
/// <param name="komgaPassword">The Komga password</param> /// <param name="komgaPassword">The Komga password</param>
/// <param name="logger"></param> /// <param name="logger"></param>
public TaskManager(string downloadFolderPath, string? settingsFilePath = null, string? komgaBaseUrl = null, string? komgaUsername = null, string? komgaPassword = null, Logger? logger = null) public TaskManager(string downloadFolderPath, string workingDirectory, string imageCachePath, string? komgaBaseUrl = null, string? komgaUsername = null, string? komgaPassword = null, Logger? logger = null)
{ {
this.logger = logger; this.logger = logger;
_allTasks = new HashSet<TrangaTask>(); _allTasks = new HashSet<TrangaTask>();
Komga? newKomga = null;
if (komgaBaseUrl != null && komgaUsername != null && komgaPassword != null) if (komgaBaseUrl != null && komgaUsername != null && komgaPassword != null)
this.komga = new Komga(komgaBaseUrl, komgaUsername, komgaPassword, logger); newKomga = new Komga(komgaBaseUrl, komgaUsername, komgaPassword, logger);
this.settings = new SettingsData(downloadFolderPath, settingsFilePath, this.komga, this._allTasks); this.settings = new TrangaSettings(downloadFolderPath, workingDirectory, newKomga);
ExportData(); ExportData();
this._connectors = new Connector[]{ new MangaDex(downloadFolderPath, logger) }; this._connectors = new Connector[]{ new MangaDex(downloadFolderPath, imageCachePath, logger) };
foreach(Connector cConnector in this._connectors) foreach(Connector cConnector in this._connectors)
_taskQueue.Add(cConnector, new List<TrangaTask>()); _taskQueue.Add(cConnector, new List<TrangaTask>());
@ -48,21 +51,22 @@ public class TaskManager
Komga? komga = null; Komga? komga = null;
if (komgaUrl is not null && komgaAuth is not null) if (komgaUrl is not null && komgaAuth is not null)
komga = new Komga(komgaUrl, komgaAuth, null); komga = new Komga(komgaUrl, komgaAuth, null);
settings.UpdateSettings(downloadLocation, komga); settings.downloadLocation = downloadLocation ?? settings.downloadLocation;
settings.komga = komga ?? komga;
ExportData(); ExportData();
} }
public TaskManager(SettingsData settings, Logger? logger = null) public TaskManager(TrangaSettings settings, Logger? logger = null)
{ {
this.logger = logger; this.logger = logger;
this._connectors = new Connector[]{ new MangaDex(settings.downloadLocation, logger) }; this._connectors = new Connector[]{ new MangaDex(settings.downloadLocation, settings.coverImageCache, logger) };
this.settings = settings;
ExportData();
foreach(Connector cConnector in this._connectors) foreach(Connector cConnector in this._connectors)
_taskQueue.Add(cConnector, new List<TrangaTask>()); _taskQueue.Add(cConnector, new List<TrangaTask>());
this.komga = settings.komga; _allTasks = new HashSet<TrangaTask>();
_allTasks = settings.allTasks;
this.settings = settings;
ImportData();
ExportData();
Thread taskChecker = new(TaskCheckerThread); Thread taskChecker = new(TaskCheckerThread);
taskChecker.Start(); taskChecker.Start();
} }
@ -137,14 +141,11 @@ public class TaskManager
TrangaTask newTask; TrangaTask newTask;
if (task == TrangaTask.Task.UpdateKomgaLibrary) if (task == TrangaTask.Task.UpdateKomgaLibrary)
{ {
newTask = new TrangaTask(task, null, null, reoccurrence, language); newTask = new TrangaTask(task, null, null, reoccurrence);
logger?.WriteLine(this.GetType().ToString(), $"Removing old {task}-Task.");
//Check if same task already exists //Only one UpdateKomgaLibrary Task
// ReSharper disable once SimplifyLinqExpressionUseAll readabilty _allTasks.RemoveWhere(trangaTask => trangaTask.task is TrangaTask.Task.UpdateKomgaLibrary);
if (!_allTasks.Any(trangaTask => trangaTask.task == task)) _allTasks.Add(newTask);
{
_allTasks.Add(newTask);
}
} }
else else
{ {
@ -160,12 +161,14 @@ public class TaskManager
//Check if same task already exists //Check if same task already exists
if (!_allTasks.Any(trangaTask => trangaTask.task == task && trangaTask.connectorName == connector.name && if (!_allTasks.Any(trangaTask => trangaTask.task == task && trangaTask.connectorName == connector.name &&
trangaTask.publication?.downloadUrl == publication?.downloadUrl)) trangaTask.publication?.internalId == publication?.internalId))
{ {
if(task != TrangaTask.Task.UpdatePublications) if(task != TrangaTask.Task.UpdatePublications)
_chapterCollection.TryAdd((Publication)publication!, new List<Chapter>()); _chapterCollection.TryAdd((Publication)publication!, new List<Chapter>());
_allTasks.Add(newTask); _allTasks.Add(newTask);
} }
else
logger?.WriteLine(this.GetType().ToString(), $"Publication already exists {publication?.internalId}");
} }
logger?.WriteLine(this.GetType().ToString(), $"Added new Task {newTask.ToString()}"); logger?.WriteLine(this.GetType().ToString(), $"Added new Task {newTask.ToString()}");
ExportData(); ExportData();
@ -185,18 +188,25 @@ public class TaskManager
if (task == TrangaTask.Task.UpdateKomgaLibrary) if (task == TrangaTask.Task.UpdateKomgaLibrary)
{ {
_allTasks.RemoveWhere(uTask => uTask.task == TrangaTask.Task.UpdateKomgaLibrary); _allTasks.RemoveWhere(uTask => uTask.task == TrangaTask.Task.UpdateKomgaLibrary);
logger?.WriteLine(this.GetType().ToString(), $"Removed Task {task}"); logger?.WriteLine(this.GetType().ToString(), $"Removed Task {task} from all Tasks.");
} }
else if (connectorName is null) else if (connectorName is null)
throw new ArgumentException($"connectorName can not be null for Task {task}"); throw new ArgumentException($"connectorName can not be null for Task {task}");
else 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 => if(_allTasks.RemoveWhere(trangaTask =>
trangaTask.task == task && trangaTask.connectorName == connectorName && trangaTask.task == task && trangaTask.connectorName == connectorName &&
trangaTask.publication?.downloadUrl == publication?.downloadUrl) > 0) trangaTask.publication?.internalId == publication?.internalId) > 0)
logger?.WriteLine(this.GetType().ToString(), $"Removed Task {task} {publication?.sortName} {publication?.downloadUrl}."); logger?.WriteLine(this.GetType().ToString(), $"Removed Task {task} {publication?.sortName} {publication?.internalId} from all Tasks.");
else else
logger?.WriteLine(this.GetType().ToString(), $"No Task {task} {publication?.sortName} {publication?.downloadUrl} could be found."); logger?.WriteLine(this.GetType().ToString(), $"No Task {task} {publication?.sortName} {publication?.internalId} could be found.");
} }
ExportData(); ExportData();
} }
@ -289,19 +299,25 @@ public class TaskManager
Environment.Exit(0); Environment.Exit(0);
} }
/// <summary> private void ImportData()
/// Loads stored data (settings, tasks) from file
/// </summary>
/// <param name="importFilePath">working directory, filename has to be data.json</param>
public static SettingsData LoadData(string importFilePath)
{ {
if (!File.Exists(importFilePath)) logger?.WriteLine(this.GetType().ToString(), "Importing Data");
return new SettingsData(Directory.GetCurrentDirectory(), null, null, new HashSet<TrangaTask>()); 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)!;
}
string toRead = File.ReadAllText(importFilePath); if (File.Exists(settings.knownPublicationsPath))
SettingsData data = JsonConvert.DeserializeObject<SettingsData>(toRead)!; {
logger?.WriteLine(this.GetType().ToString(), $"Importing known publications from {settings.knownPublicationsPath}");
return data; buffer = File.ReadAllText(settings.knownPublicationsPath);
Publication[] publications = JsonConvert.DeserializeObject<Publication[]>(buffer)!;
foreach (Publication publication in publications)
this._chapterCollection.TryAdd(publication, new List<Chapter>());
}
} }
/// <summary> /// <summary>
@ -309,38 +325,15 @@ public class TaskManager
/// </summary> /// </summary>
private void ExportData() private void ExportData()
{ {
logger?.WriteLine(this.GetType().ToString(), $"Exporting data to {settings.settingsFilePath}"); 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}");
string serializedData = JsonConvert.SerializeObject(settings); File.WriteAllText(settings.knownPublicationsPath, JsonConvert.SerializeObject(this._chapterCollection.Keys.ToArray()));
File.WriteAllText(settings.settingsFilePath, serializedData);
} }
public class SettingsData
{
public string downloadLocation { get; private set; }
public string settingsFilePath { get; }
public Komga? komga { get; private set; }
public HashSet<TrangaTask> allTasks { get; }
public SettingsData(string downloadLocation, string? settingsFilePath, Komga? komga, HashSet<TrangaTask> allTasks)
{
this.settingsFilePath = settingsFilePath ??
Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Tranga", "data.json");
this.downloadLocation = downloadLocation;
this.komga = komga;
this.allTasks = allTasks;
}
public void UpdateSettings(string? pDownloadLocation, Komga? pKomga)
{
if(pDownloadLocation is not null)
this.downloadLocation = pDownloadLocation;
if(pKomga is not null)
this.komga = pKomga;
}
}
} }

32
Tranga/TrangaSettings.cs Normal file
View File

@ -0,0 +1,32 @@
using Newtonsoft.Json;
namespace Tranga;
public class TrangaSettings
{
public string downloadLocation { get; set; }
public string workingDirectory { get; set; }
[JsonIgnore]public string settingsFilePath => Path.Join(workingDirectory, "settings.json");
[JsonIgnore]public string tasksFilePath => Path.Join(workingDirectory, "tasks.json");
[JsonIgnore]public string knownPublicationsPath => Path.Join(workingDirectory, "knownPublications.json");
[JsonIgnore] public string coverImageCache => Path.Join(workingDirectory, "imageCache");
public Komga? komga { get; set; }
public TrangaSettings(string downloadLocation, string workingDirectory, Komga? komga)
{
this.workingDirectory = workingDirectory;
this.downloadLocation = downloadLocation;
this.komga = komga;
}
public static TrangaSettings LoadSettings(string importFilePath)
{
if (!File.Exists(importFilePath))
return new TrangaSettings(Path.Join(Directory.GetCurrentDirectory(), "Downloads"), Directory.GetCurrentDirectory(), null);
string toRead = File.ReadAllText(importFilePath);
TrangaSettings settings = JsonConvert.DeserializeObject<TrangaSettings>(toRead)!;
return settings;
}
}

View File

@ -56,6 +56,6 @@ public class TrangaTask
public override string ToString() public override string ToString()
{ {
return $"{task,-20} | {lastExecuted,-20} | {reoccurrence,-12} | {state,-10} | {connectorName,-15} | {publication?.sortName}"; return $"{task}, {lastExecuted}, {reoccurrence}, {state} {(connectorName is not null ? $", {connectorName}" : "" )} {(publication is not null ? $", {publication?.sortName}": "")}";
} }
} }

4
Website/Dockerfile Normal file
View File

@ -0,0 +1,4 @@
FROM nginx:alpine3.17-slim
COPY . /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

126
Website/apiConnector.js Normal file
View File

@ -0,0 +1,126 @@
let apiUri = `http://${window.location.host.split(':')[0]}:6531`
if(getCookie("apiUri") != ""){
apiUri = getCookie("apiUri");
}
function getCookie(cname) {
let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';');
for(let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == ' ') {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
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 + "/Tasks/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);
}
async function GetQueue(){
var uri = apiUri + "/Queue/GetList";
let json = await GetData(uri);
return json;
}

BIN
Website/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

123
Website/index.html Normal file
View File

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tranga</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<wrapper>
<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>
<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: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-interactions>
<publication-starttask>Start Task ▶️</publication-starttask>
<publication-delete>Delete Task ❌</publication-delete>
<publication-add>Add Task </publication-add>
</publication-interactions>
</publication-information>
</publication-viewer>
</popup>
<popup id="settingsPopup">
<blur-background id="blurBackgroundSettingsPopup"></blur-background>
<settings>
<span style="font-weight: bold; text-align: center; font-size: 16pt;">Settings</span>
<div>
<p class="title">Download Location:</p>
<span id="downloadLocation"></span>
</div>
<div>
<p class="title">API-URI</p>
<label for="settingApiUri"></label><input placeholder="https://" type="text" id="settingApiUri">
</div>
<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">
<label for="komgaUpdateTime" style="margin-right: 5px;">Update Time</label><input id="komgaUpdateTime" type="time" value="00:01:00" step="10">
<input type="submit" value="Update" onclick="UpdateKomgaSettings()">
</komga-settings>
</settings>
</popup>
</viewport>
<footer>
<div>
<img src="media/running.svg" alt="running"><div id="tasksRunningTag">0</div>
</div>
<div>
<img src="media/queue.svg" alt="queue"><div id="tasksQueuedTag">0</div>
</div>
<div>
<img src="media/tasks.svg" alt="queue"><div id="totalTasksTag">0</div>
</div>
<p id="madeWith">Made with Blåhaj 🦈</p>
</footer>
</wrapper>
<footer-tag-popup>
<footer-tag-content>
<footer-tag-task-name>Test</footer-tag-task-name>
</footer-tag-content>
</footer-tag-popup>
<script src="apiConnector.js"></script>
<script src="interaction.js"></script>
</body>
</html>

383
Website/interaction.js Normal file
View File

@ -0,0 +1,383 @@
let publications = [];
let tasks = [];
let toEditId;
const searchPublicationQuery = document.querySelector("#searchPublicationQuery");
const selectPublication = document.querySelector("#taskSelectOutput");
const connectorSelect = document.querySelector("#connectors");
const settingsPopup = document.querySelector("#settingsPopup");
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 publicationTaskStart = document.querySelector("publication-starttask");
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");
const settingApiUri = document.querySelector("#settingApiUri");
const tagTasksRunning = document.querySelector("#tasksRunningTag");
const tagTasksQueued = document.querySelector("#tasksQueuedTag");
const tagTasksTotal = document.querySelector("#totalTasksTag");
const tagTaskPopup = document.querySelector("footer-tag-popup");
const tagTasksPopupContent = document.querySelector("footer-tag-content");
settingsCog.addEventListener("click", () => OpenSettings());
document.querySelector("#blurBackgroundSettingsPopup").addEventListener("click", () => HideSettings());
closetaskpopup.addEventListener("click", () => HideAddTaskPopup());
document.querySelector("#blurBackgroundTaskPopup").addEventListener("click", () => HideAddTaskPopup());
document.querySelector("#blurBackgroundPublicationPopup").addEventListener("click", () => HidePublicationPopup());
publicationDelete.addEventListener("click", () => DeleteTaskClick());
publicationAdd.addEventListener("click", () => AddTaskClick());
publicationTaskStart.addEventListener("click", () => StartTaskClick());
settingApiUri.addEventListener("keypress", (event) => {
if(event.key === "Enter"){
apiUri = settingApiUri.value;
setTimeout(() => GetSettingsClick(), 100);
document.cookie = `apiUri=${apiUri};`;
}
});
searchPublicationQuery.addEventListener("keypress", (event) => {
if(event.key === "Enter"){
NewSearch();
}
});
tagTasksRunning.addEventListener("mouseover", (event) => ShowRunningTasks(event));
tagTasksRunning.addEventListener("mouseout", () => CloseTasksPopup());
tagTasksQueued.addEventListener("mouseover", (event) => ShowQueuedTasks(event));
tagTasksQueued.addEventListener("mouseout", () => CloseTasksPopup());
tagTasksTotal.addEventListener("mouseover", (event) => ShowAllTasks(event));
tagTasksTotal.addEventListener("mouseout", () => CloseTasksPopup());
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);
})
);
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 = `imageCache/${publication.coverFileNameInCache}`;
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();
}
function StartTaskClick(){
var toEditTask = tasks.filter(task => task.publication.internalId == toEditId)[0];
StartTask("DownloadNewChapters", toEditTask.connectorName, toEditId);
HidePublicationPopup();
}
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){
//Show popup
publicationViewerPopup.style.display = "block";
//Set position to mouse-position
if(event.clientY < window.innerHeight - publicationViewerWindow.offsetHeight)
publicationViewerWindow.style.top = `${event.clientY}px`;
else
publicationViewerWindow.style.top = `${event.clientY - publicationViewerWindow.offsetHeight}px`;
if(event.clientX < window.innerWidth - publicationViewerWindow.offsetWidth)
publicationViewerWindow.style.left = `${event.clientX}px`;
else
publicationViewerWindow.style.left = `${event.clientX - publicationViewerWindow.offsetWidth}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 = `imageCache/${publication.coverFileNameInCache}`;
toEditId = publicationId;
//Check what action should be listed
if(add){
publicationAdd.style.display = "initial";
publicationDelete.style.display = "none";
publicationTaskStart.style.display = "none";
}
else{
publicationAdd.style.display = "none";
publicationDelete.style.display = "initial";
publicationTaskStart.style.display = "initial";
}
}
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();
settingsPopup.style.display = "flex";
}
function HideSettings(){
settingsPopup.style.display = "none";
}
function GetSettingsClick(){
settingApiUri.value = "";
settingKomgaUrl.value = "";
settingKomgaUser.value = "";
settingKomgaPass.value = "";
settingApiUri.placeholder = apiUri;
GetSettings().then(json => {
settingDownloadLocation.innerText = json.downloadLocation;
if(json.komga != null)
settingKomgaUrl.placeholder = json.komga.baseUrl;
});
GetKomgaTask().then(json => {
settingKomgaTime.value = json[0].reoccurrence;
if(json.length > 0)
settingKomgaConfigured.innerText = "✅";
else
settingKomgaConfigured.innerText = "❌";
});
}
function UpdateKomgaSettings(){
if(settingKomgaUser.value != "" && settingKomgaPass != ""){
var auth = utf8_to_b64(`${settingKomgaUser.value}:${settingKomgaPass.value}`);
console.log(auth);
if(settingKomgaUrl.value != "")
UpdateSettings("", settingKomgaUrl.value, auth);
else
UpdateSettings("", settingKomgaUrl.placeholder, auth);
}
CreateTask("UpdateKomgaLibrary", settingKomgaTime.value, "","","");
setTimeout(() => GetSettingsClick(), 100);
}
function utf8_to_b64( str ) {
return window.btoa(unescape(encodeURIComponent( str )));
}
function ShowRunningTasks(event){
GetRunningTasks()
.then(json => {
tagTasksPopupContent.replaceChildren();
json.forEach(task => {
console.log(task);
if(task.publication != null){
var taskname = document.createElement("footer-tag-task-name");
taskname.innerText = task.publication.sortName;
tagTasksPopupContent.appendChild(taskname);
}
});
if(tagTasksPopupContent.children.length > 0){
tagTaskPopup.style.display = "block";
tagTaskPopup.style.left = `${tagTasksRunning.offsetLeft - 20}px`;
}
});
}
function ShowQueuedTasks(event){
GetQueue()
.then(json => {
tagTasksPopupContent.replaceChildren();
json.forEach(task => {
var taskname = document.createElement("footer-tag-task-name");
taskname.innerText = task.publication.sortName;
tagTasksPopupContent.appendChild(taskname);
});
if(json.length > 0){
tagTaskPopup.style.display = "block";
tagTaskPopup.style.left = `${tagTasksQueued.offsetLeft- 20}px`;
}
});
}
function ShowAllTasks(event){
GetDownloadTasks()
.then(json => {
tagTasksPopupContent.replaceChildren();
json.forEach(task => {
var taskname = document.createElement("footer-tag-task-name");
taskname.innerText = task.publication.sortName;
tagTasksPopupContent.appendChild(taskname);
});
if(json.length > 0){
tagTaskPopup.style.display = "block";
tagTaskPopup.style.left = `${tagTasksTotal.offsetLeft - 20}px`;
}
});
}
function CloseTasksPopup(){
tagTaskPopup.style.display = "none";
}
//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);
}));
GetRunningTasks()
.then(json => {
tagTasksRunning.innerText = json.length;
});
GetDownloadTasks()
.then(json => {
tagTasksTotal.innerText = json.length;
});
GetQueue()
.then(json => {
tagTasksQueued.innerText = json.length;
})
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;
}
}
);
GetRunningTasks()
.then(json => {
tagTasksRunning.innerText = json.length;
});
GetDownloadTasks()
.then(json => {
tagTasksTotal.innerText = json.length;
});
GetQueue()
.then(json => {
tagTasksQueued.innerText = json.length;
})
}, 1000);

BIN
Website/media/blahaj.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

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

After

Width:  |  Height:  |  Size: 804 B

7
Website/media/queue.svg Normal file
View File

@ -0,0 +1,7 @@
<?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 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
<g fill="#000000">

After

Width:  |  Height:  |  Size: 545 B

53
Website/media/running.svg Normal file
View File

@ -0,0 +1,53 @@
<?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 235.504 235.504"
xml:space="preserve">
<g>
<g>
<path d="M195.209,81.456l-49.227-0.15c0.737-0.886,1.351-1.868,2.284-2.583c3.282-2.497,3.911-7.166,1.427-10.438
c-2.501-3.266-7.161-3.919-10.443-1.423c-4.873,3.715-8.388,8.704-10.255,14.389l-22.191-0.064
c-9.508,0-19.588,7.398-22.938,16.851l-16.877,47.479c-1.775,5.013-1.338,9.966,1.207,13.568
c2.412,3.427,6.384,5.318,11.187,5.358l45.126,0.136c-1.509,5.186-4.701,9.622-9.352,12.424
c-4.891,2.957-10.636,3.814-16.172,2.444c-3.994-0.998-8.031,1.442-9.027,5.418c-0.99,4.012,1.445,8.035,5.432,9.032
c2.927,0.738,5.879,1.091,8.808,1.091c6.516,0,12.93-1.788,18.645-5.23c8.312-5.013,14.172-12.979,16.484-22.409
c0.232-0.905,0.232-1.823,0.124-2.713l28.296,0.092h0.049c2.925,0,5.854-0.89,8.684-2.147c0.2,0.493,0.32,1.014,0.661,1.471
c3.335,4.677,4.629,10.343,3.688,15.993c-0.95,5.627-4.028,10.536-8.688,13.862c-3.351,2.376-4.14,7.037-1.755,10.379
c1.466,2.04,3.751,3.122,6.062,3.122c1.491,0,3.006-0.429,4.312-1.367c7.919-5.61,13.16-13.966,14.771-23.52
c1.603-9.565-0.613-19.203-6.28-27.122c-0.48-0.693-1.134-1.19-1.779-1.659c1.318-1.831,2.501-3.763,3.238-5.854l16.863-47.464
c1.795-5.018,1.351-9.969-1.194-13.58C203.954,83.387,200.015,81.47,195.209,81.456z M201.979,98.405l-16.868,47.464
c-0.981,2.757-2.941,5.214-5.213,7.329c-0.337,0.16-0.706,0.229-1.026,0.465c-0.673,0.485-1.182,1.122-1.639,1.747
c-2.962,1.996-6.288,3.339-9.434,3.339v2.989l-0.044-2.989l-33.194-0.101c-0.232-0.076-0.424-0.261-0.661-0.324
c-1.435-0.353-2.805-0.145-4.095,0.309l-29.768-0.101l1.192-3.358c0.549-1.547-0.269-3.25-1.813-3.795
c-1.521-0.553-3.25,0.24-3.799,1.804l-1.899,5.334l-14.318-0.044c-2.805,0-5.063-0.998-6.336-2.813
c-1.437-2.032-1.603-4.921-0.463-8.144l16.877-47.478c2.48-6.979,10.417-12.868,17.356-12.868l12.217,0.038l-1.963,5.536
c-0.555,1.549,0.262,3.25,1.805,3.797c0.331,0.12,0.661,0.174,0.998,0.174c1.227,0,2.372-0.768,2.793-1.986l2.497-7.019
c0.064-0.164-0.048-0.322-0.016-0.487h2.512c-0.905,7.758,1.163,15.42,5.947,21.638c5.903,7.687,14.852,11.726,23.873,11.726
c6.371,0,12.771-2.001,18.186-6.129c3.266-2.488,3.911-7.167,1.426-10.441c-2.508-3.267-7.161-3.901-10.455-1.415
c-6.612,5.056-16.146,3.775-21.223-2.809c-2.445-3.194-3.487-7.133-2.958-11.117c0.061-0.503,0.353-0.916,0.481-1.402
l52.216,0.156c2.806,0,5.054,1.004,6.324,2.811C202.928,92.241,203.105,95.223,201.979,98.405z"/>
<path d="M107.997,127.194c-1.531-0.553-3.248,0.244-3.799,1.791l-4.302,12.099c-0.551,1.543,0.265,3.242,1.813,3.795
c0.331,0.116,0.659,0.16,0.998,0.16c1.214,0,2.372-0.765,2.801-1.976l4.294-12.099
C110.369,129.446,109.551,127.728,107.997,127.194z"/>
<path d="M116.6,103.014c-1.529-0.541-3.25,0.252-3.805,1.805l-4.298,12.088c-0.547,1.547,0.261,3.252,1.799,3.799
c0.329,0.12,0.659,0.172,1,0.172c1.222,0,2.368-0.769,2.809-1.983l4.294-12.09C118.955,105.268,118.139,103.555,116.6,103.014z"/>
<path d="M232.527,90.428l-14.896-0.038l0,0c-1.639,0-2.974,1.327-2.997,2.976c0,1.639,1.342,2.981,2.981,2.989l14.896,0.042l0,0
c1.643,0,2.978-1.331,2.993-2.979C235.504,91.763,234.17,90.436,232.527,90.428z"/>
<path d="M220.333,80.436c0.629,0,1.242-0.188,1.771-0.583l11.994-8.83c1.326-0.974,1.611-2.842,0.645-4.168
c-0.965-1.327-2.845-1.611-4.163-0.637l-11.998,8.833c-1.323,0.974-1.607,2.841-0.642,4.167
C218.513,80.003,219.418,80.436,220.333,80.436z"/>
<path d="M209.152,56.279c-1.547-0.549-3.25,0.269-3.787,1.805l-4.997,14.036c-0.537,1.547,0.26,3.252,1.803,3.807
c0.337,0.12,0.674,0.172,0.994,0.172c1.242,0,2.385-0.757,2.821-1.986l4.985-14.036C211.516,58.541,210.695,56.846,209.152,56.279
z"/>
<path d="M17.587,100.894h55.208c1.641,0,2.976-1.343,2.976-2.981c0-1.641-1.334-2.988-2.976-2.988H17.587
c-1.641,0-2.988,1.338-2.988,2.988C14.599,99.559,15.946,100.894,17.587,100.894z"/>
<path d="M68.471,119.328c0-1.641-1.345-2.987-2.986-2.987H10.283c-1.639,0-2.981,1.338-2.981,2.987
c0,1.639,1.342,2.974,2.981,2.974h55.202C67.119,122.301,68.471,120.967,68.471,119.328z"/>
<path d="M58.188,137.758H2.974c-1.641,0-2.974,1.335-2.974,2.989c0,1.64,1.333,2.974,2.974,2.974h55.214
c1.639,0,2.981-1.334,2.981-2.974C61.162,139.093,59.827,137.758,58.188,137.758z"/>
<path d="M169.611,28.097c11.821,0,21.403,9.584,21.403,21.41c0,11.82-9.582,21.408-21.403,21.408
c-11.822,0-21.412-9.588-21.412-21.408C148.199,37.681,157.789,28.097,169.611,28.097z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.7 KiB

10
Website/media/tasks.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" height="800px" width="800px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<g id="task">
<path d="M4,23.4l-3.7-3.7l1.4-1.4L4,20.6l4.3-4.3l1.4,1.4L4,23.4z M24,21H12v-2h12V21z M4,15.4l-3.7-3.7l1.4-1.4L4,12.6l4.3-4.3
l1.4,1.4L4,15.4z M24,13H12v-2h12V13z M4,7.4L0.3,3.7l1.4-1.4L4,4.6l4.3-4.3l1.4,1.4L4,7.4z M24,5H12V3h12V5z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 603 B

500
Website/style.css Normal file
View File

@ -0,0 +1,500 @@
:root{
--background-color: #030304;
--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;
height: 100vh;
background-color: var(--background-color);
font-family: "Inter", sans-serif;
overflow-x: hidden;
}
wrapper {
display: flex;
flex-flow: column;
flex-wrap: nowrap;
height: 100vh;
}
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);
z-index: 100;
box-shadow: 0 0 20px black;
}
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: 3px 10px;
border: 0;
border-radius: 4px;
font-size: 14pt;
width: 250px;
}
#settingscog {
cursor: pointer;
margin: 0px 30px;
height: 50%;
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;
height: 100%;
overflow-y: scroll;
}
footer {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
width: 100%;
height: 40px;
align-items: center;
justify-content: center;
background-color: var(--primary-color);
align-content: center;
}
footer > div {
height: 100%;
margin: 0 30px;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
}
footer > div > *{
height: 40%;
margin: 0 5px;
}
#madeWith {
flex-grow: 1;
text-align: right;
margin-right: 20px;
}
content {
position: relative;
flex-grow: 1;
border-radius: 5px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: start;
align-content: start;
}
settings {
width: 50%;
background-color: var(--accent-color);
display: flex;
flex-direction: column;
z-index: 10;
position: absolute;
left: 25%;
top: 25%;
border-radius: 5px;
padding: 10px 0;
}
#settingsPopup{
z-index: 10;
}
settings > * {
margin: 0 20%;
}
settings input {
margin: 3px 0;
padding: 3px;
border-radius: 3px;
border: 1px solid rgba(0,0,0,0.2);
width: 100%;
}
settings .title {
font-weight: bolder;
font-size: 14pt;
margin: 15px 0 2px 0;
}
komga-settings {
margin-top: 20px;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
#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 17px;
color: black;
}
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.8), rgba(0, 0, 0, 0.7),rgba(0, 0, 0, 0.2));
}
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%;
object-fit: cover;
z-index: 0;
border-radius: 5px;
}
popup{
display: none;
width: 100%;
min-height: 100%;
top: 0;
left: 0;
position: fixed;
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);
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: 5;
}
publication-viewer{
display: block;
width: 450px;
height: 300px;
position: absolute;
top: 200px;
left: 400px;
background-color: var(--accent-color);
border-radius: 5px;
overflow: hidden;
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;
height: 100%;
width: 100%;
object-fit: cover;
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-interactions {
display: flex;
flex-direction: row;
position: absolute;
justify-content: end;
align-items: start;
bottom: 0;
left: 0;
width: 100%;
}
publication-viewer publication-information publication-interactions > * {
margin: 0 10px 10px 10px;
font-size: 16pt;
}
publication-viewer publication-information publication-interactions publication-starttask {
color: var(--secondary-color);
}
publication-viewer publication-information publication-interactions publication-delete {
color: red;
}
publication-viewer publication-information publication-interactions publication-add {
color: limegreen;
}
footer-tag-popup {
display: none;
padding: 2px 4px;
position: fixed;
bottom: 58px;
left: 20px;
background-color: var(--second-background-color);
z-index: 8;
border-radius: 5px;
max-height: 400px;
}
footer-tag-content{
position: relative;
max-height: 400px;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
overflow-y: scroll;
}
footer-tag-content > * {
margin: 2px 0;
}
footer-tag-popup::before{
content: "";
width: 0;
height: 0;
position: absolute;
border-right: 10px solid var(--second-background-color);
border-left: 10px solid transparent;
border-top: 10px solid var(--second-background-color);
border-bottom: 10px solid transparent;
left: 0px;
bottom: -17px;
border-radius: 0 0 0 5px;
}

19
docker-compose.yaml Normal file
View File

@ -0,0 +1,19 @@
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:80
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

BIN
screenshots/addtask.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
screenshots/overview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
screenshots/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB