2
0

Merge branch 'cuttingedge'

# Conflicts:
#	Tranga/Connectors/Mangasee.cs
This commit is contained in:
glax 2023-09-19 16:59:58 +02:00
commit 2960a9b8f0
73 changed files with 2956 additions and 4490 deletions

View File

@ -43,16 +43,3 @@ jobs:
push: true
tags: |
glax/tranga-api:cuttingedge
# https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push Website
uses: docker/build-push-action@v4.1.1
with:
context: ./Website
file: ./Website/Dockerfile
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
platforms: linux/amd64
pull: true
push: true
tags: |
glax/tranga-website:cuttingedge

View File

@ -45,16 +45,3 @@ jobs:
push: true
tags: |
glax/tranga-api:latest
# https://github.com/docker/build-push-action#multi-platform-image
- name: Build and push Website
uses: docker/build-push-action@v4.1.1
with:
context: ./Website
file: ./Website/Dockerfile
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
platforms: linux/amd64
pull: true
push: true
tags: |
glax/tranga-website:latest

18
CLI/CLI.csproj Normal file
View File

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console.Cli" Version="0.47.1-preview.0.11" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tranga\Tranga.csproj" />
</ItemGroup>
</Project>

155
CLI/Program.cs Normal file
View File

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

View File

@ -2,6 +2,7 @@
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
WORKDIR /src
COPY CLI /src/CLI
COPY Tranga /src/Tranga
COPY Logging /src/Logging
COPY Tranga.sln /src
@ -12,4 +13,4 @@ FROM glax/tranga-base:latest as runtime
WORKDIR /publish
COPY --from=build-env /publish .
EXPOSE 6531
ENTRYPOINT ["dotnet", "/publish/Tranga.dll"]
ENTRYPOINT ["dotnet", "/publish/Tranga.dll", "-c"]

View File

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

View File

@ -1,6 +1,6 @@
namespace Logging;
public class LogMessage
public readonly struct LogMessage
{
public DateTime logTime { get; }
public string caller { get; }

View File

@ -1,9 +1,14 @@
using System.Text;
using System.Runtime.InteropServices;
using System.Text;
namespace Logging;
public class Logger : TextWriter
{
private static readonly string LogDirectoryPath = RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
? "/var/log/tranga-api"
: Path.Join(Directory.GetCurrentDirectory(), "logs");
public string? logFilePath => _fileLogger?.logFilePath;
public override Encoding Encoding { get; }
public enum LoggerType
{
@ -17,14 +22,16 @@ public class Logger : TextWriter
public Logger(LoggerType[] enabledLoggers, TextWriter? stdOut, Encoding? encoding, string? logFilePath)
{
this.Encoding = encoding ?? Encoding.ASCII;
if (enabledLoggers.Contains(LoggerType.FileLogger) && logFilePath is not null)
_fileLogger = new FileLogger(logFilePath, encoding);
else
this.Encoding = encoding ?? Encoding.UTF8;
if(enabledLoggers.Contains(LoggerType.FileLogger) && (logFilePath is null || logFilePath == ""))
{
_fileLogger = null;
throw new ArgumentException($"logFilePath can not be null for LoggerType {LoggerType.FileLogger}");
}
DateTime now = DateTime.Now;
logFilePath = Path.Join(LogDirectoryPath,
$"{now.ToShortDateString()}_{now.Hour}-{now.Minute}-{now.Second}.log");
_fileLogger = new FileLogger(logFilePath, encoding);
}else if (enabledLoggers.Contains(LoggerType.FileLogger) && logFilePath is not null)
_fileLogger = new FileLogger(logFilePath, encoding);
if (enabledLoggers.Contains(LoggerType.ConsoleLogger) && stdOut is not null)
{
@ -64,4 +71,9 @@ public class Logger : TextWriter
{
return _memoryLogger.GetNewLines();
}
public string[] GetLog()
{
return _memoryLogger.GetLogMessages();
}
}

View File

@ -14,11 +14,13 @@ public class MemoryLogger : LoggerBase
protected override void Write(LogMessage value)
{
while(!_logMessages.TryAdd(value.logTime, value))
Thread.Sleep(10);
lock (_logMessages)
{
_logMessages.Add(DateTime.Now, value);
}
}
public string[] GetLogMessage()
public string[] GetLogMessages()
{
return Tail(Convert.ToUInt32(_logMessages.Count));
}
@ -35,7 +37,10 @@ public class MemoryLogger : LoggerBase
for (int retIndex = 0; retIndex < ret.Length; retIndex++)
{
ret[retIndex] = _logMessages.GetValueAtIndex(_logMessages.Count - retLength + retIndex).ToString();
lock (_logMessages)
{
ret[retIndex] = _logMessages.GetValueAtIndex(_logMessages.Count - retLength + retIndex).ToString();
}
}
_lastLogMessageIndex = _logMessages.Count - 1;
@ -45,14 +50,25 @@ public class MemoryLogger : LoggerBase
public string[] GetNewLines()
{
int logMessageCount = _logMessages.Count;
string[] ret = new string[logMessageCount - _lastLogMessageIndex];
List<string> ret = new();
for (int retIndex = 0; retIndex < ret.Length; retIndex++)
int retIndex = 0;
for (; retIndex < logMessageCount - _lastLogMessageIndex; retIndex++)
{
ret[retIndex] = _logMessages.GetValueAtIndex(_lastLogMessageIndex + retIndex).ToString();
try
{
lock(_logMessages)
{
ret.Add(_logMessages.GetValueAtIndex(_lastLogMessageIndex + retIndex).ToString());
}
}
catch (NullReferenceException e)//Called when LogMessage has not finished writing
{
break;
}
}
_lastLogMessageIndex = logMessageCount;
return ret;
_lastLogMessageIndex = _lastLogMessageIndex + retIndex;
return ret.ToArray();
}
}

View File

@ -16,9 +16,15 @@
<p align="center">
Automatic Manga and Metadata downloader
</p>
<p align="center">
This is the API for <a href="https://github.com/C9Glax/tranga-website">Tranga-Website</a>
</p>
</div>
# Important for existing users:
Tranga just had a complete rewrite. Old settings, tasks, etc. will not work.
For the time being the docker-tag `latest` will be the old, discontinued branch. `cuttingedge` is the active branch and
will soon be moved to the `latest` branch. There is no migration-tool. Make a backup of old files.
<!-- TABLE OF CONTENTS -->
<details>
@ -30,9 +36,6 @@
<li><a href="#built-with">Built With</a></li>
</ul>
</li>
<li>
<a href="#screenshots">Screenshots</a>
</li>
<li>
<a href="#getting-started">Getting Started</a>
<ul>
@ -60,7 +63,24 @@ Tranga can download Chapters and Metadata from "Scanlation" sites such as
- [MangaKatana](https://mangakatana.com)
- ❓ Open an [issue](https://github.com/C9Glax/tranga/issues)
and automatically import them with [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/). Also Notifications will be sent to your devices using [Gotify](https://gotify.net/) and [LunaSea](https://www.lunasea.app/).
and trigger an scan with [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/).
Notifications will can sent to your devices using [Gotify](https://gotify.net/) and [LunaSea](https://www.lunasea.app/).
### What this does and doesn't do
Tranga (this git-repo) will open a port (standard 6531) and listen for requests to add Jobs to Monitor and/or download specific Manga.
The configuration is all done through HTTP-Requests.
The frontend in this repo is **CLI**-based.
_**For a web-frontend use [tranga-website](https://github.com/C9Glax/tranga-website).**_
This project downloads the images for a Manga from the specified Scanlation-Website and packages them with some metadata - from that same website - in a .cbz-archive (per chapter).
It does this on an interval, and checks for any Chapters (.cbz-Archive) not already existing in your specified Download-Location. (If you rename or move files, it will download those again)
Tranga can (if configured) trigger a scan in Komga or Kavita, however the directory in which the Manga reside has to be available to both Tranga and Komga/Kavita.
The project doesn't manage metadata, doesn't curate, change or enhance any information that isn't available on the selected Scanlation-Site.
It will blindly use whatever is scrapes (yes this is a glorified Web-scraper).
### Inspiration:
Because [Kaizoku](https://github.com/oae/kaizoku) was relying on [mangal](https://github.com/metafates/mangal) and mangal
@ -81,17 +101,6 @@ 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>
## Screenshots
| ![image](screenshots/overview.png) | ![image](screenshots/addtask.png) |
|-----------------------------------:|:----------------------------------|
| ![image](screenshots/settings.png) | ![image](screenshots/publication-description.png) | ![image](screenshots/progress.png) |
|-----------------------------------:|:-------------------------------------------------:|:-----------------------------------|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- GETTING STARTED -->
## Getting Started
@ -102,35 +111,17 @@ There is two release types:
### CLI
Head over to [releases](https://git.bernloehr.eu/glax/Tranga/releases) and download. The CLI will guide you through setup.
Head over to [releases](https://git.bernloehr.eu/glax/Tranga/releases) and download.
~~The CLI will guide you through setup.~~ Not in the current version.
Right now it is barebones with options to view logs and make HTTP-Requests
### Docker
Download [docker-compose.yaml](https://git.bernloehr.eu/glax/Tranga/src/branch/master/docker-compose.yaml) and configure to your needs.
Wherever you are mounting `/usr/share/Tranga-API` you also need to mount that same path + `/imageCache` in the webserver container.
### Docker-Website usage
There is two ways to download Mangas:
- Downloading everything and monitor for new Chapters
- Selecting specific Volumes/Chapters
On the website you add new tasks, by selecting the blue '+' field. Next select the connector/site you want to use, and enter a search term.
After clicking 'Search' (or pressing Enter), the results will be presented below - this might, depending on the result-size, take a while because we are already preloading the cover-images.
Next select the publication (by selecting the cover) and a new popup will open with two options:
- "Monitor" - Download all chapters and monitor for new ones
- "Download Chapter" - Download specific chapters only
When selecting `Monitor` you will be presented with a new window and the selection of the interval you want to check for new chapters (Default: Every 3 hours).
When selecting `Download Chapter` a list will open with all available chapters - that have not yet been downloaded - from which you can then select a range (see below).
The syntax for selecting chapters is as follows:
- To download a single Chapter enter either the index number (the number at the very start of the line) or its absolute number like so: `c(h)(apter)[number]`, spaces are allowed.
- To download a range of chapters enter either a range of index numbers (`3-6`) or chapters (`ch 12-23`).
- For volumes the syntax is as follows: `v(ol)[number](-[number])`, again spaces allowed.
Examples: `2-12`, `c1`, `ch 2`, `chapter 3`, `v 2`, `vol3-4`, `v2c4` (note: you can only specify a single chapter with this last syntax).
Mount `/Manga` to wherever you want your chapters (`.cbz`-Archives) downloaded (for exampled where Komga/Kavita can access them).
The `docker-compose` also includes [tranga-website](https://github.com/C9Glax/tranga-website) as frontend. For its configuration refer to the repo README.
### Prerequisites

View File

@ -4,6 +4,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tranga", ".\Tranga\Tranga.c
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logging", "Logging\Logging.csproj", "{415BE889-BB7D-426F-976F-8D977876A462}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CLI", "CLI\CLI.csproj", "{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -18,5 +20,9 @@ Global
{415BE889-BB7D-426F-976F-8D977876A462}.Debug|Any CPU.Build.0 = Debug|Any CPU
{415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.ActiveCfg = Release|Any CPU
{415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.Build.0 = Release|Any CPU
{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -2,7 +2,9 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=altnames/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=authorsartists/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Gotify/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=jjob/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Komga/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=lunasea/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=mangakatana/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Manganato/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Mangasee/@EntryIndexedValue">True</s:Boolean>

View File

@ -1,365 +0,0 @@
using System.Globalization;
using System.Net;
using System.Text.RegularExpressions;
using Tranga;
using Tranga.Connectors;
using Tranga.TrangaTasks;
namespace Tranga.API;
public class RequestHandler
{
private TaskManager _taskManager;
private Server _parent;
private List<ValueTuple<HttpMethod, string, string[]>> _validRequestPaths = new()
{
new(HttpMethod.Get, "/", Array.Empty<string>()),
new(HttpMethod.Get, "/Connectors", Array.Empty<string>()),
new(HttpMethod.Get, "/Publications/Known", new[] { "internalId?" }),
new(HttpMethod.Get, "/Publications/FromConnector", new[] { "connectorName", "title" }),
new(HttpMethod.Get, "/Publications/Chapters",
new[] { "connectorName", "internalId", "onlyNew?", "onlyExisting?", "language?" }),
new(HttpMethod.Get, "/Tasks/Types", Array.Empty<string>()),
new(HttpMethod.Post, "/Tasks/CreateMonitorTask",
new[] { "connectorName", "internalId", "reoccurrenceTime", "language?", "ignoreChaptersBelow?" }),
new(HttpMethod.Post, "/Tasks/CreateDownloadChaptersTask",
new[] { "connectorName", "internalId", "chapters", "language?" }),
new(HttpMethod.Get, "/Tasks", new[] { "taskType", "connectorName?", "publicationId?" }),
new(HttpMethod.Delete, "/Tasks", new[] { "taskType", "connectorName?", "searchString?" }),
new(HttpMethod.Get, "/Tasks/Progress",
new[] { "taskType", "connectorName", "publicationId", "chapterSortNumber?" }),
new(HttpMethod.Post, "/Tasks/Start", new[] { "taskType", "connectorName?", "internalId?" }),
new(HttpMethod.Get, "/Tasks/RunningTasks", Array.Empty<string>()),
new(HttpMethod.Get, "/Queue/List", Array.Empty<string>()),
new(HttpMethod.Post, "/Queue/Enqueue", new[] { "taskType", "connectorName?", "publicationId?" }),
new(HttpMethod.Delete, "/Queue/Dequeue", new[] { "taskType", "connectorName?", "publicationId?" }),
new(HttpMethod.Get, "/Settings", Array.Empty<string>()),
new(HttpMethod.Post, "/Settings/Update", new[]
{
"downloadLocation?", "komgaUrl?", "komgaAuth?", "kavitaUrl?", "kavitaUsername?",
"kavitaPassword?", "gotifyUrl?", "gotifyAppToken?", "lunaseaWebhook?"
})
};
public RequestHandler(TaskManager taskManager, Server parent)
{
this._taskManager = taskManager;
this._parent = parent;
}
internal void HandleRequest(HttpListenerRequest request, HttpListenerResponse response)
{
string requestPath = request.Url!.LocalPath;
if (requestPath.Contains("favicon"))
{
_parent.SendResponse(HttpStatusCode.NoContent, response);
return;
}
if (!this._validRequestPaths.Any(path => path.Item1.Method == request.HttpMethod && path.Item2 == requestPath))
{
_parent.SendResponse(HttpStatusCode.BadRequest, response);
return;
}
Dictionary<string, string> variables = GetRequestVariables(request.Url!.Query);
object? responseObject = null;
switch (request.HttpMethod)
{
case "GET":
responseObject = this.HandleGet(requestPath, variables);
break;
case "POST":
this.HandlePost(requestPath, variables);
break;
case "DELETE":
this.HandleDelete(requestPath, variables);
break;
}
_parent.SendResponse(HttpStatusCode.OK, response, responseObject);
}
private Dictionary<string, string> GetRequestVariables(string query)
{
Dictionary<string, string> ret = new();
Regex queryRex = new (@"\?{1}&?([A-z0-9-=]+=[A-z0-9-=]+)+(&[A-z0-9-=]+=[A-z0-9-=]+)*");
if (!queryRex.IsMatch(query))
return ret;
query = query.Substring(1);
foreach (string kvpair in query.Split('&').Where(str => str.Length >= 3))
{
string var = kvpair.Split('=')[0];
string val = Regex.Replace(kvpair.Substring(var.Length + 1), "%20", " ");
val = Regex.Replace(val, "%[0-9]{2}", "");
ret.Add(var, val);
}
return ret;
}
private void HandleDelete(string requestPath, Dictionary<string, string> variables)
{
switch (requestPath)
{
case "/Tasks":
variables.TryGetValue("taskType", out string? taskType1);
variables.TryGetValue("connectorName", out string? connectorName1);
variables.TryGetValue("publicationId", out string? publicationId1);
if(taskType1 is null)
return;
try
{
TrangaTask.Task task = Enum.Parse<TrangaTask.Task>(taskType1);
foreach(TrangaTask tTask in _taskManager.GetTasksMatching(task, connectorName1, internalId: publicationId1))
_taskManager.DeleteTask(tTask);
}
catch (ArgumentException)
{
return;
}
break;
case "/Queue/Dequeue":
variables.TryGetValue("taskType", out string? taskType2);
variables.TryGetValue("connectorName", out string? connectorName2);
variables.TryGetValue("publicationId", out string? publicationId2);
if(taskType2 is null)
return;
try
{
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType2);
TrangaTask? task = _taskManager
.GetTasksMatching(pTask, connectorName: connectorName2, internalId: publicationId2).FirstOrDefault();
if (task is null)
return;
_taskManager.RemoveTaskFromQueue(task);
}
catch (ArgumentException)
{
return;
}
break;
}
}
private void HandlePost(string requestPath, Dictionary<string, string> variables)
{
switch (requestPath)
{
case "/Tasks/CreateMonitorTask":
variables.TryGetValue("connectorName", out string? connectorName1);
variables.TryGetValue("internalId", out string? internalId1);
variables.TryGetValue("reoccurrenceTime", out string? reoccurrenceTime1);
variables.TryGetValue("language", out string? language1);
variables.TryGetValue("ignoreChaptersBelow", out string? minChapter);
if (connectorName1 is null || internalId1 is null || reoccurrenceTime1 is null)
return;
Connector? connector1 =
_taskManager.GetAvailableConnectors().FirstOrDefault(con => con.Key == connectorName1).Value;
if (connector1 is null)
return;
Publication? publication1 = _taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == internalId1);
if (!publication1.HasValue)
return;
Publication pPublication1 = (Publication)publication1;
if (minChapter is not null)
pPublication1.ignoreChaptersBelow = float.Parse(minChapter,new NumberFormatInfo() { NumberDecimalSeparator = "." });
_taskManager.AddTask(new MonitorPublicationTask(connectorName1, pPublication1, TimeSpan.Parse(reoccurrenceTime1), language1 ?? "en"));
break;
case "/Tasks/CreateDownloadChaptersTask":
variables.TryGetValue("connectorName", out string? connectorName2);
variables.TryGetValue("internalId", out string? internalId2);
variables.TryGetValue("chapters", out string? chapters);
variables.TryGetValue("language", out string? language2);
if (connectorName2 is null || internalId2 is null || chapters is null)
return;
Connector? connector2 =
_taskManager.GetAvailableConnectors().FirstOrDefault(con => con.Key == connectorName2).Value;
if (connector2 is null)
return;
Publication? publication2 = _taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == internalId2);
if (publication2 is null)
return;
IEnumerable<Chapter> toDownload = connector2.SelectChapters((Publication)publication2, chapters, language2 ?? "en");
foreach(Chapter chapter in toDownload)
_taskManager.AddTask(new DownloadChapterTask(connectorName2, (Publication)publication2, chapter, "en"));
break;
case "/Tasks/Start":
variables.TryGetValue("taskType", out string? taskType1);
variables.TryGetValue("connectorName", out string? connectorName3);
variables.TryGetValue("internalId", out string? internalId3);
if (taskType1 is null)
return;
try
{
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType1);
TrangaTask? task = _taskManager
.GetTasksMatching(pTask, connectorName: connectorName3, internalId: internalId3).FirstOrDefault();
if (task is null)
return;
_taskManager.ExecuteTaskNow(task);
}
catch (ArgumentException)
{
return;
}
break;
case "/Queue/Enqueue":
variables.TryGetValue("taskType", out string? taskType2);
variables.TryGetValue("connectorName", out string? connectorName4);
variables.TryGetValue("publicationId", out string? publicationId);
if (taskType2 is null)
return;
try
{
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType2);
TrangaTask? task = _taskManager
.GetTasksMatching(pTask, connectorName: connectorName4, internalId: publicationId).FirstOrDefault();
if (task is null)
return;
_taskManager.AddTaskToQueue(task);
}
catch (ArgumentException)
{
return;
}
break;
case "/Settings/Update":
variables.TryGetValue("downloadLocation", out string? downloadLocation);
variables.TryGetValue("komgaUrl", out string? komgaUrl);
variables.TryGetValue("komgaAuth", out string? komgaAuth);
variables.TryGetValue("kavitaUrl", out string? kavitaUrl);
variables.TryGetValue("kavitaUsername", out string? kavitaUsername);
variables.TryGetValue("kavitaPassword", out string? kavitaPassword);
variables.TryGetValue("gotifyUrl", out string? gotifyUrl);
variables.TryGetValue("gotifyAppToken", out string? gotifyAppToken);
variables.TryGetValue("lunaseaWebhook", out string? lunaseaWebhook);
if (downloadLocation is not null && downloadLocation.Length > 0)
_taskManager.settings.UpdateSettings(TrangaSettings.UpdateField.DownloadLocation, downloadLocation);
if (komgaUrl is not null && komgaAuth is not null && komgaUrl.Length > 5 && komgaAuth.Length > 0)
_taskManager.settings.UpdateSettings(TrangaSettings.UpdateField.Komga, komgaUrl, komgaAuth);
if (kavitaUrl is not null && kavitaPassword is not null && kavitaUsername is not null && kavitaUrl.Length > 5 &&
kavitaUsername.Length > 0 && kavitaPassword.Length > 0)
_taskManager.settings.UpdateSettings(TrangaSettings.UpdateField.Kavita, kavitaUrl, kavitaUsername,
kavitaPassword);
if (gotifyUrl is not null && gotifyAppToken is not null && gotifyUrl.Length > 5 && gotifyAppToken.Length > 0)
_taskManager.settings.UpdateSettings(TrangaSettings.UpdateField.Gotify, gotifyUrl, gotifyAppToken);
if(lunaseaWebhook is not null && lunaseaWebhook.Length > 5)
_taskManager.settings.UpdateSettings(TrangaSettings.UpdateField.LunaSea, lunaseaWebhook);
break;
}
}
private object? HandleGet(string requestPath, Dictionary<string, string> variables)
{
switch (requestPath)
{
case "/Connectors":
return this._taskManager.GetAvailableConnectors().Keys.ToArray();
case "/Publications/Known":
variables.TryGetValue("internalId", out string? internalId1);
if(internalId1 is null)
return _taskManager.GetAllPublications();
return new [] { _taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == internalId1) };
case "/Publications/FromConnector":
variables.TryGetValue("connectorName", out string? connectorName1);
variables.TryGetValue("title", out string? title);
if (connectorName1 is null || title is null)
return null;
Connector? connector1 = _taskManager.GetAvailableConnectors().FirstOrDefault(con => con.Key == connectorName1).Value;
if (connector1 is null)
return null;
if(title.Length < 4)
return null;
return connector1.GetPublications(ref _taskManager.collection, title);
case "/Publications/Chapters":
string[] yes = { "true", "yes", "1", "y" };
variables.TryGetValue("connectorName", out string? connectorName2);
variables.TryGetValue("internalId", out string? internalId2);
variables.TryGetValue("onlyNew", out string? onlyNew);
variables.TryGetValue("onlyExisting", out string? onlyExisting);
variables.TryGetValue("language", out string? language);
if (connectorName2 is null || internalId2 is null)
return null;
bool newOnly = onlyNew is not null && yes.Contains(onlyNew);
bool existingOnly = onlyExisting is not null && yes.Contains(onlyExisting);
Connector? connector2 = _taskManager.GetAvailableConnectors().FirstOrDefault(con => con.Key == connectorName2).Value;
if (connector2 is null)
return null;
Publication? publication = _taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == internalId2);
if (publication is null)
return null;
if(newOnly)
return connector2.GetNewChaptersList((Publication)publication, language??"en", ref _taskManager.collection).ToArray();
else if (existingOnly)
return _taskManager.GetExistingChaptersList(connector2, (Publication)publication, language ?? "en").ToArray();
else
return connector2.GetChapters((Publication)publication, language??"en");
case "/Tasks/Types":
return Enum.GetNames(typeof(TrangaTask.Task));
case "/Tasks":
variables.TryGetValue("taskType", out string? taskType1);
variables.TryGetValue("connectorName", out string? connectorName3);
variables.TryGetValue("searchString", out string? searchString);
if (taskType1 is null)
return null;
try
{
TrangaTask.Task task = Enum.Parse<TrangaTask.Task>(taskType1);
return _taskManager.GetTasksMatching(task, connectorName:connectorName3, searchString:searchString);
}
catch (ArgumentException)
{
return null;
}
case "/Tasks/Progress":
variables.TryGetValue("taskType", out string? taskType2);
variables.TryGetValue("connectorName", out string? connectorName4);
variables.TryGetValue("publicationId", out string? publicationId);
variables.TryGetValue("chapterNumber", out string? chapterNumber);
if (taskType2 is null || connectorName4 is null || publicationId is null)
return null;
Connector? connector =
_taskManager.GetAvailableConnectors().FirstOrDefault(con => con.Key == connectorName4).Value;
if (connector is null)
return null;
try
{
TrangaTask? task = null;
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType2);
if (pTask is TrangaTask.Task.MonitorPublication)
{
task = _taskManager.GetTasksMatching(pTask, connectorName: connectorName4, internalId: publicationId).FirstOrDefault();
}else if (pTask is TrangaTask.Task.DownloadChapter && chapterNumber is not null)
{
task = _taskManager.GetTasksMatching(pTask, connectorName: connectorName4, internalId: publicationId,
chapterNumber: chapterNumber).FirstOrDefault();
}
if (task is null)
return null;
return task.progress;
}
catch (ArgumentException)
{
return null;
}
case "/Tasks/RunningTasks":
return _taskManager.GetAllTasks().Where(task => task.state is TrangaTask.ExecutionState.Running);
case "/Queue/List":
return _taskManager.GetAllTasks().Where(task => task.state is TrangaTask.ExecutionState.Enqueued).OrderBy(task => task.nextExecution);
case "/Settings":
return _taskManager.settings;
case "/":
default:
return this._validRequestPaths;
}
}
}

View File

@ -1,92 +0,0 @@
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using Logging;
using Newtonsoft.Json;
using Tranga;
namespace Tranga.API;
public class Server
{
private readonly HttpListener _listener = new ();
private readonly RequestHandler _requestHandler;
private readonly TaskManager _taskManager;
internal readonly Logger? logger;
private readonly Regex _validUrl =
new (@"https?:\/\/(www\.)?[-A-z0-9]{1,256}(\.[-a-zA-Z0-9]{1,6})?(:[0-9]{1,5})?(\/{1}[A-z0-9()@:%_\+.~#?&=]+)*\/?");
public Server(int port, TaskManager taskManager, Logger? logger = null)
{
this.logger = logger;
this._taskManager = taskManager;
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
this._listener.Prefixes.Add($"http://*:{port}/");
else
this._listener.Prefixes.Add($"http://localhost:{port}/");
this._requestHandler = new RequestHandler(taskManager, this);
Thread listenThread = new Thread(Listen);
listenThread.Start();
}
private void Listen()
{
this._listener.Start();
foreach (string prefix in this._listener.Prefixes)
this.logger?.WriteLine(this.GetType().ToString(), $"Listening on {prefix}");
while (this._listener.IsListening && _taskManager._continueRunning)
{
HttpListenerContext context = this._listener.GetContextAsync().Result;
Task t = new (() =>
{
HandleContext(context);
});
t.Start();
}
}
private void HandleContext(HttpListenerContext context)
{
HttpListenerRequest request = context.Request;
HttpListenerResponse response = context.Response;
//logger?.WriteLine(this.GetType().ToString(), $"New request: {request.HttpMethod} {request.Url}");
if (!_validUrl.IsMatch(request.Url!.ToString()))
{
SendResponse(HttpStatusCode.BadRequest, response);
return;
}
if (request.HttpMethod == "OPTIONS")
{
SendResponse(HttpStatusCode.OK, response);
}
else
{
_requestHandler.HandleRequest(request, response);
}
}
internal void SendResponse(HttpStatusCode statusCode, HttpListenerResponse response, object? content = null)
{
//logger?.WriteLine(this.GetType().ToString(), $"Sending response: {statusCode}");
response.StatusCode = (int)statusCode;
response.AddHeader("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With");
response.AddHeader("Access-Control-Allow-Methods", "GET, POST, DELETE");
response.AddHeader("Access-Control-Max-Age", "1728000");
response.AppendHeader("Access-Control-Allow-Origin", "*");
response.ContentType = "application/json";
try
{
response.OutputStream.Write(content is not null
? Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(content))
: Array.Empty<byte>());
response.OutputStream.Close();
}
catch (HttpListenerException)
{
}
}
}

View File

@ -10,7 +10,7 @@ namespace Tranga;
public readonly struct Chapter
{
// ReSharper disable once MemberCanBePrivate.Global
public Publication parentPublication { get; }
public Manga parentManga { get; }
public string? name { get; }
public string? volumeNumber { get; }
public string chapterNumber { get; }
@ -20,9 +20,9 @@ public readonly struct Chapter
private static readonly Regex LegalCharacters = new (@"([A-z]*[0-9]* *\.*-*,*\]*\[*'*\'*\)*\(*~*!*)*");
private static readonly Regex IllegalStrings = new(@"Vol(ume)?.?", RegexOptions.IgnoreCase);
public Chapter(Publication parentPublication, string? name, string? volumeNumber, string chapterNumber, string url)
public Chapter(Manga parentManga, string? name, string? volumeNumber, string chapterNumber, string url)
{
this.parentPublication = parentPublication;
this.parentManga = parentManga;
this.name = name;
this.volumeNumber = volumeNumber;
this.chapterNumber = chapterNumber;
@ -36,6 +36,10 @@ public readonly struct Chapter
this.fileName = $"{volStr}{chNumberStr}{chNameStr}";
}
public override string ToString()
{
return $"Chapter {parentManga.sortName} {parentManga.internalId} {chapterNumber} {name}";
}
/// <summary>
/// Checks if a chapter-archive is already present
@ -44,9 +48,9 @@ public readonly struct Chapter
internal bool CheckChapterIsDownloaded(string downloadLocation)
{
string newFilePath = GetArchiveFilePath(downloadLocation);
if (!Directory.Exists(Path.Join(downloadLocation, parentPublication.folderName)))
if (!Directory.Exists(Path.Join(downloadLocation, parentManga.folderName)))
return false;
FileInfo[] archives = new DirectoryInfo(Path.Join(downloadLocation, parentPublication.folderName)).GetFiles();
FileInfo[] archives = new DirectoryInfo(Path.Join(downloadLocation, parentManga.folderName)).GetFiles();
Regex chapterInfoRex = new(@"Ch\.[0-9.]+");
Regex chapterRex = new(@"[0-9]+(\.[0-9]+)?");
@ -67,7 +71,7 @@ public readonly struct Chapter
/// <returns>Filepath</returns>
internal string GetArchiveFilePath(string downloadLocation)
{
return Path.Join(downloadLocation, parentPublication.folderName, $"{parentPublication.folderName} - {this.fileName}.cbz");
return Path.Join(downloadLocation, parentManga.folderName, $"{parentManga.folderName} - {this.fileName}.cbz");
}
/// <summary>
@ -78,10 +82,10 @@ public readonly struct Chapter
internal string GetComicInfoXmlString()
{
XElement comicInfo = new XElement("ComicInfo",
new XElement("Tags", string.Join(',', parentPublication.tags)),
new XElement("LanguageISO", parentPublication.originalLanguage),
new XElement("Tags", string.Join(',', parentManga.tags)),
new XElement("LanguageISO", parentManga.originalLanguage),
new XElement("Title", this.name),
new XElement("Writer", string.Join(',', parentPublication.authors)),
new XElement("Writer", string.Join(',', parentManga.authors)),
new XElement("Volume", this.volumeNumber),
new XElement("Number", this.chapterNumber)
);

View File

@ -1,125 +0,0 @@
using Logging;
using Newtonsoft.Json;
using Tranga.LibraryManagers;
using Tranga.NotificationManagers;
namespace Tranga;
public class CommonObjects
{
public HashSet<LibraryManager> libraryManagers { get; init; }
public HashSet<NotificationManager> notificationManagers { get; init; }
[JsonIgnore]public Logger? logger { get; set; }
[JsonIgnore]private string settingsFilePath { get; init; }
public CommonObjects(HashSet<LibraryManager>? libraryManagers, HashSet<NotificationManager>? notificationManagers, Logger? logger, string settingsFilePath)
{
this.libraryManagers = libraryManagers??new();
this.notificationManagers = notificationManagers??new();
this.logger = logger;
this.settingsFilePath = settingsFilePath;
}
public static CommonObjects LoadSettings(string settingsFilePath, Logger? logger)
{
if (!File.Exists(settingsFilePath))
return new CommonObjects(null, null, logger, settingsFilePath);
string toRead = File.ReadAllText(settingsFilePath);
TrangaSettings.SettingsJsonObject settings = JsonConvert.DeserializeObject<TrangaSettings.SettingsJsonObject>(
toRead,
new JsonSerializerSettings
{
Converters =
{
new NotificationManager.NotificationManagerJsonConverter(),
new LibraryManager.LibraryManagerJsonConverter()
}
})!;
if(settings.co is null)
return new CommonObjects(null, null, logger, settingsFilePath);
if (logger is not null)
{
settings.co.logger = logger;
foreach (LibraryManager lm in settings.co.libraryManagers)
lm.AddLogger(logger);
foreach(NotificationManager nm in settings.co.notificationManagers)
nm.AddLogger(logger);
}
return settings.co;
}
public void ExportSettings()
{
TrangaSettings.SettingsJsonObject? settings = null;
if (File.Exists(settingsFilePath))
{
bool inUse = true;
while (inUse)
{
try
{
using FileStream stream = new (settingsFilePath, FileMode.Open, FileAccess.Read, FileShare.None);
stream.Close();
inUse = false;
}
catch (IOException)
{
inUse = true;
Thread.Sleep(50);
}
}
string toRead = File.ReadAllText(settingsFilePath);
settings = JsonConvert.DeserializeObject<TrangaSettings.SettingsJsonObject>(toRead,
new JsonSerializerSettings
{
Converters =
{
new NotificationManager.NotificationManagerJsonConverter(),
new LibraryManager.LibraryManagerJsonConverter()
}
});
}
settings = new TrangaSettings.SettingsJsonObject(settings?.ts, this);
File.WriteAllText(settingsFilePath, JsonConvert.SerializeObject(settings));
}
public void UpdateSettings(TrangaSettings.UpdateField field, params string[] values)
{
switch (field)
{
case TrangaSettings.UpdateField.Komga:
if (values.Length != 2)
return;
libraryManagers.RemoveWhere(lm => lm.GetType() == typeof(Komga));
libraryManagers.Add(new Komga(values[0], values[1], this.logger));
break;
case TrangaSettings.UpdateField.Kavita:
if (values.Length != 3)
return;
libraryManagers.RemoveWhere(lm => lm.GetType() == typeof(Kavita));
libraryManagers.Add(new Kavita(values[0], values[1], values[2], this.logger));
break;
case TrangaSettings.UpdateField.Gotify:
if (values.Length != 2)
return;
notificationManagers.RemoveWhere(nm => nm.GetType() == typeof(Gotify));
Gotify newGotify = new(values[0], values[1], this.logger);
notificationManagers.Add(newGotify);
newGotify.SendNotification("Success!", "Gotify was added to Tranga!");
break;
case TrangaSettings.UpdateField.LunaSea:
if(values.Length != 1)
return;
notificationManagers.RemoveWhere(nm => nm.GetType() == typeof(LunaSea));
LunaSea newLunaSea = new(values[0], this.logger);
notificationManagers.Add(newLunaSea);
newLunaSea.SendNotification("Success!", "LunaSea was added to Tranga!");
break;
}
ExportSettings();
}
}

View File

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

View File

@ -1,116 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using Logging;
namespace Tranga;
internal class DownloadClient
{
private static readonly HttpClient Client = new()
{
Timeout = TimeSpan.FromSeconds(60),
DefaultRequestHeaders =
{
UserAgent =
{
new ProductInfoHeaderValue("Tranga", "0.1")
}
}
};
private readonly Dictionary<byte, DateTime> _lastExecutedRateLimit;
private readonly Dictionary<byte, TimeSpan> _rateLimit;
// ReSharper disable once InconsistentNaming
private readonly Logger? logger;
/// <summary>
/// Creates a httpClient
/// </summary>
/// <param name="rateLimitRequestsPerMinute">Rate limits for requests. byte is RequestType, int maximum requests per minute for RequestType</param>
/// <param name="logger"></param>
public DownloadClient(Dictionary<byte, int> rateLimitRequestsPerMinute, Logger? logger)
{
this.logger = logger;
_lastExecutedRateLimit = new();
_rateLimit = new();
foreach(KeyValuePair<byte, int> limit in rateLimitRequestsPerMinute)
_rateLimit.Add(limit.Key, TimeSpan.FromMinutes(1).Divide(limit.Value));
}
/// <summary>
/// Request Webpage
/// </summary>
/// <param name="url"></param>
/// <param name="requestType">For RateLimits: Same Endpoints use same type</param>
/// <param name="referrer">Used in http request header</param>
/// <returns>RequestResult with StatusCode and Stream of received data</returns>
public RequestResult MakeRequest(string url, byte requestType, string? referrer = null)
{
if (_rateLimit.TryGetValue(requestType, out TimeSpan value))
_lastExecutedRateLimit.TryAdd(requestType, DateTime.Now.Subtract(value));
else
{
logger?.WriteLine(this.GetType().ToString(), "RequestType not configured for rate-limit.");
return new RequestResult(HttpStatusCode.NotAcceptable, Stream.Null);
}
TimeSpan rateLimitTimeout = _rateLimit[requestType]
.Subtract(DateTime.Now.Subtract(_lastExecutedRateLimit[requestType]));
if(rateLimitTimeout > TimeSpan.Zero)
Thread.Sleep(rateLimitTimeout);
HttpResponseMessage? response = null;
while (response is null)
{
try
{
HttpRequestMessage requestMessage = new(HttpMethod.Get, url);
if(referrer is not null)
requestMessage.Headers.Referrer = new Uri(referrer);
_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}... Retrying.");
Thread.Sleep(_rateLimit[requestType] * 2);
}
}
if (!response.IsSuccessStatusCode)
{
logger?.WriteLine(this.GetType().ToString(), $"Request-Error {response.StatusCode}: {response.ReasonPhrase}");
return new RequestResult(response.StatusCode, Stream.Null);
}
// Request has been redirected to another page. For example, it redirects directly to the results when there is only 1 result
if(response.RequestMessage is not null && response.RequestMessage.RequestUri is not null)
{
return new RequestResult(response.StatusCode, response.Content.ReadAsStream(), true, response.RequestMessage.RequestUri.AbsoluteUri);
}
return new RequestResult(response.StatusCode, response.Content.ReadAsStream());
}
public struct RequestResult
{
public HttpStatusCode statusCode { get; }
public Stream result { get; }
public bool hasBeenRedirected { get; }
public string? redirectedToUrl { get; }
public RequestResult(HttpStatusCode statusCode, Stream result)
{
this.statusCode = statusCode;
this.result = result;
}
public RequestResult(HttpStatusCode statusCode, Stream result, bool hasBeenRedirected, string redirectedTo)
: this(statusCode, result)
{
this.hasBeenRedirected = hasBeenRedirected;
redirectedToUrl = redirectedTo;
}
}
}

108
Tranga/GlobalBase.cs Normal file
View File

@ -0,0 +1,108 @@
using System.Globalization;
using Logging;
using Newtonsoft.Json;
using Tranga.LibraryConnectors;
using Tranga.NotificationConnectors;
namespace Tranga;
public abstract class GlobalBase
{
protected Logger? logger { get; init; }
protected TrangaSettings settings { get; init; }
protected HashSet<NotificationConnector> notificationConnectors { get; init; }
protected HashSet<LibraryConnector> libraryConnectors { get; init; }
protected List<Manga> cachedPublications { get; init; }
protected static readonly NumberFormatInfo numberFormatDecimalPoint = new (){ NumberDecimalSeparator = "." };
protected GlobalBase(GlobalBase clone)
{
this.logger = clone.logger;
this.settings = clone.settings;
this.notificationConnectors = clone.notificationConnectors;
this.libraryConnectors = clone.libraryConnectors;
this.cachedPublications = clone.cachedPublications;
}
protected GlobalBase(Logger? logger, TrangaSettings settings)
{
this.logger = logger;
this.settings = settings;
this.notificationConnectors = settings.LoadNotificationConnectors(this);
this.libraryConnectors = settings.LoadLibraryConnectors(this);
this.cachedPublications = new();
}
protected void Log(string message)
{
logger?.WriteLine(this.GetType().Name, message);
}
protected void Log(string fStr, params object?[] replace)
{
Log(string.Format(fStr, replace));
}
protected void SendNotifications(string title, string text)
{
foreach (NotificationConnector nc in notificationConnectors)
nc.SendNotification(title, text);
}
protected void AddNotificationConnector(NotificationConnector notificationConnector)
{
Log($"Adding {notificationConnector}");
notificationConnectors.RemoveWhere(nc => nc.GetType() == notificationConnector.GetType());
notificationConnectors.Add(notificationConnector);
while(IsFileInUse(settings.notificationConnectorsFilePath))
Thread.Sleep(100);
File.WriteAllText(settings.notificationConnectorsFilePath, JsonConvert.SerializeObject(notificationConnectors));
}
protected void DeleteNotificationConnector(NotificationConnector.NotificationConnectorType notificationConnectorType)
{
Log($"Removing {notificationConnectorType}");
notificationConnectors.RemoveWhere(nc => nc.notificationConnectorType == notificationConnectorType);
}
protected void UpdateLibraries()
{
foreach(LibraryConnector lc in libraryConnectors)
lc.UpdateLibrary();
}
protected void AddLibraryConnector(LibraryConnector libraryConnector)
{
Log($"Adding {libraryConnector}");
libraryConnectors.RemoveWhere(lc => lc.GetType() == libraryConnector.GetType());
libraryConnectors.Add(libraryConnector);
while(IsFileInUse(settings.libraryConnectorsFilePath))
Thread.Sleep(100);
File.WriteAllText(settings.libraryConnectorsFilePath, JsonConvert.SerializeObject(libraryConnectors));
}
protected void DeleteLibraryConnector(LibraryConnector.LibraryType libraryType)
{
Log($"Removing {libraryType}");
libraryConnectors.RemoveWhere(lc => lc.libraryType == libraryType);
}
protected bool IsFileInUse(string filePath)
{
if (!File.Exists(filePath))
return false;
try
{
using FileStream stream = new (filePath, FileMode.Open, FileAccess.Read, FileShare.None);
stream.Close();
return false;
}
catch (IOException)
{
Log($"File is in use {filePath}");
return true;
}
}
}

View File

@ -0,0 +1,41 @@
using System.Text;
using Tranga.MangaConnectors;
namespace Tranga.Jobs;
public class DownloadChapter : Job
{
public Chapter chapter { get; init; }
public DownloadChapter(GlobalBase clone, MangaConnector connector, Chapter chapter, DateTime lastExecution, string? parentJobId = null) : base(clone, connector, lastExecution, parentJobId: parentJobId)
{
this.chapter = chapter;
}
public DownloadChapter(GlobalBase clone, MangaConnector connector, Chapter chapter, string? parentJobId = null) : base(clone, connector, parentJobId: parentJobId)
{
this.chapter = chapter;
}
protected override string GetId()
{
return $"{GetType()}-{chapter.parentManga.internalId}-{chapter.chapterNumber}";
}
public override string ToString()
{
return $"{id} Chapter: {chapter}";
}
protected override IEnumerable<Job> ExecuteReturnSubTasksInternal()
{
Task downloadTask = new(delegate
{
mangaConnector.DownloadChapter(chapter, this.progressToken);
UpdateLibraries();
SendNotifications("Chapter downloaded", $"{chapter.parentManga.sortName} - {chapter.chapterNumber}");
});
downloadTask.Start();
return Array.Empty<Job>();
}
}

View File

@ -0,0 +1,47 @@
using Tranga.MangaConnectors;
namespace Tranga.Jobs;
public class DownloadNewChapters : Job
{
public Manga manga { get; init; }
public string translatedLanguage { get; init; }
public DownloadNewChapters(GlobalBase clone, MangaConnector connector, Manga manga, DateTime lastExecution,
bool recurring = false, TimeSpan? recurrence = null, string? parentJobId = null, string translatedLanguage = "en") : base(clone, connector, lastExecution, recurring,
recurrence, parentJobId)
{
this.manga = manga;
this.translatedLanguage = translatedLanguage;
}
public DownloadNewChapters(GlobalBase clone, MangaConnector connector, Manga manga, bool recurring = false, TimeSpan? recurrence = null, string? parentJobId = null, string translatedLanguage = "en") : base (clone, connector, recurring, recurrence, parentJobId)
{
this.manga = manga;
this.translatedLanguage = translatedLanguage;
}
protected override string GetId()
{
return $"{GetType()}-{manga.internalId}";
}
public override string ToString()
{
return $"{id} Manga: {manga}";
}
protected override IEnumerable<Job> ExecuteReturnSubTasksInternal()
{
Chapter[] chapters = mangaConnector.GetNewChapters(manga, this.translatedLanguage);
this.progressToken.increments = chapters.Length;
List<Job> jobs = new();
foreach (Chapter chapter in chapters)
{
DownloadChapter downloadChapterJob = new(this, this.mangaConnector, chapter, parentJobId: this.id);
jobs.Add(downloadChapterJob);
}
progressToken.Complete();
return jobs;
}
}

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

@ -0,0 +1,93 @@
using Tranga.MangaConnectors;
namespace Tranga.Jobs;
public abstract class Job : GlobalBase
{
public MangaConnector mangaConnector { get; init; }
public ProgressToken progressToken { get; private set; }
public bool recurring { get; init; }
public TimeSpan? recurrenceTime { get; set; }
public DateTime? lastExecution { get; private set; }
public DateTime nextExecution => NextExecution();
public string id => GetId();
internal IEnumerable<Job>? subJobs { get; private set; }
public string? parentJobId { get; init; }
internal Job(GlobalBase clone, MangaConnector connector, bool recurring = false, TimeSpan? recurrenceTime = null, string? parentJobId = null) : base(clone)
{
this.mangaConnector = connector;
this.progressToken = new ProgressToken(0);
this.recurring = recurring;
if (recurring && recurrenceTime is null)
throw new ArgumentException("If recurrence is set to true, a recurrence time has to be provided.");
else if(recurring && recurrenceTime is not null)
this.lastExecution = DateTime.Now.Subtract((TimeSpan)recurrenceTime);
this.recurrenceTime = recurrenceTime ?? TimeSpan.Zero;
this.parentJobId = parentJobId;
}
internal Job(GlobalBase clone, MangaConnector connector, DateTime lastExecution, bool recurring = false,
TimeSpan? recurrenceTime = null, string? parentJobId = null) : base(clone)
{
this.mangaConnector = connector;
this.progressToken = new ProgressToken(0);
this.recurring = recurring;
if (recurring && recurrenceTime is null)
throw new ArgumentException("If recurrence is set to true, a recurrence time has to be provided.");
this.lastExecution = lastExecution;
this.recurrenceTime = recurrenceTime ?? TimeSpan.Zero;
this.parentJobId = parentJobId;
}
protected abstract string GetId();
public void AddSubJob(Job job)
{
subJobs ??= new List<Job>();
subJobs = subJobs.Append(job);
}
private DateTime NextExecution()
{
if(recurrenceTime.HasValue && lastExecution.HasValue)
return lastExecution.Value.Add(recurrenceTime.Value);
if(recurrenceTime.HasValue && !lastExecution.HasValue)
return DateTime.Now;
return DateTime.MaxValue;
}
public void ResetProgress()
{
this.progressToken.increments = this.progressToken.increments - this.progressToken.incrementsCompleted;
this.lastExecution = DateTime.Now;
}
public void ExecutionEnqueue()
{
this.progressToken.increments = this.progressToken.increments - this.progressToken.incrementsCompleted;
this.lastExecution = recurrenceTime is not null ? DateTime.Now.Subtract((TimeSpan)recurrenceTime) : DateTime.UnixEpoch;
this.progressToken.Standby();
}
public void Cancel()
{
Log($"Cancelling {this}");
this.progressToken.cancellationRequested = true;
this.progressToken.Cancel();
this.lastExecution = DateTime.Now;
if(subJobs is not null)
foreach(Job subJob in subJobs)
subJob.Cancel();
}
public IEnumerable<Job> ExecuteReturnSubTasks()
{
progressToken.Start();
subJobs = ExecuteReturnSubTasksInternal();
lastExecution = DateTime.Now;
return subJobs;
}
protected abstract IEnumerable<Job> ExecuteReturnSubTasksInternal();
}

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

@ -0,0 +1,232 @@
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Tranga.MangaConnectors;
namespace Tranga.Jobs;
public class JobBoss : GlobalBase
{
public HashSet<Job> jobs { get; init; }
private Dictionary<MangaConnector, Queue<Job>> mangaConnectorJobQueue { get; init; }
public JobBoss(GlobalBase clone, HashSet<MangaConnector> connectors) : base(clone)
{
this.jobs = new();
LoadJobsList(connectors);
this.mangaConnectorJobQueue = new();
}
public void AddJob(Job job)
{
if (ContainsJobLike(job))
{
Log($"Already Contains Job {job}");
}
else
{
Log($"Added {job}");
this.jobs.Add(job);
ExportJobsList();
}
}
public void AddJobs(IEnumerable<Job> jobsToAdd)
{
foreach (Job job in jobsToAdd)
AddJob(job);
}
public bool ContainsJobLike(Job job)
{
if (job is DownloadChapter dcJob)
{
return this.GetJobsLike(dcJob.mangaConnector, chapter: dcJob.chapter).Any();
}else if (job is DownloadNewChapters ncJob)
{
return this.GetJobsLike(ncJob.mangaConnector, ncJob.manga).Any();
}
return false;
}
public void RemoveJob(Job job)
{
Log($"Removing {job}");
job.Cancel();
this.jobs.Remove(job);
if(job.subJobs is not null)
RemoveJobs(job.subJobs);
ExportJobsList();
}
public void RemoveJobs(IEnumerable<Job?> jobsToRemove)
{
Log($"Removing {jobsToRemove.Count()} jobs.");
foreach (Job? job in jobsToRemove)
if(job is not null)
RemoveJob(job);
}
public IEnumerable<Job> GetJobsLike(string? connectorName = null, string? internalId = null, string? chapterNumber = null)
{
IEnumerable<Job> ret = this.jobs;
if (connectorName is not null)
ret = ret.Where(job => job.mangaConnector.name == connectorName);
if (internalId is not null && chapterNumber is not null)
ret = ret.Where(jjob =>
{
if (jjob is not DownloadChapter job)
return false;
return job.chapter.parentManga.internalId == internalId &&
job.chapter.chapterNumber == chapterNumber;
});
else if (internalId is not null)
ret = ret.Where(jjob =>
{
if (jjob is not DownloadNewChapters job)
return false;
return job.manga.internalId == internalId;
});
return ret;
}
public IEnumerable<Job> GetJobsLike(MangaConnector? mangaConnector = null, Manga? publication = null,
Chapter? chapter = null)
{
if (chapter is not null)
return GetJobsLike(mangaConnector?.name, chapter.Value.parentManga.internalId, chapter?.chapterNumber);
else
return GetJobsLike(mangaConnector?.name, publication?.internalId);
}
public Job? GetJobById(string jobId)
{
if (this.jobs.FirstOrDefault(jjob => jjob.id == jobId) is { } job)
return job;
return null;
}
public bool TryGetJobById(string jobId, out Job? job)
{
if (this.jobs.FirstOrDefault(jjob => jjob.id == jobId) is { } ret)
{
job = ret;
return true;
}
job = null;
return false;
}
private bool QueueContainsJob(Job job)
{
mangaConnectorJobQueue.TryAdd(job.mangaConnector, new Queue<Job>());
return mangaConnectorJobQueue[job.mangaConnector].Contains(job);
}
public void AddJobToQueue(Job job)
{
Log($"Adding Job to Queue. {job}");
mangaConnectorJobQueue.TryAdd(job.mangaConnector, new Queue<Job>());
Queue<Job> connectorJobQueue = mangaConnectorJobQueue[job.mangaConnector];
if(!connectorJobQueue.Contains(job))
connectorJobQueue.Enqueue(job);
job.ExecutionEnqueue();
}
public void AddJobsToQueue(IEnumerable<Job> jobs)
{
foreach(Job job in jobs)
AddJobToQueue(job);
}
public void LoadJobsList(HashSet<MangaConnector> connectors)
{
Directory.CreateDirectory(settings.jobsFolderPath);
Regex idRex = new (@"(.*)\.json");
foreach (FileInfo file in new DirectoryInfo(settings.jobsFolderPath).EnumerateFiles())
if (idRex.IsMatch(file.Name))
{
Job job = JsonConvert.DeserializeObject<Job>(File.ReadAllText(file.FullName),
new JobJsonConverter(this, new MangaConnectorJsonConverter(this, connectors)))!;
this.jobs.Add(job);
}
foreach (Job job in this.jobs)
this.jobs.FirstOrDefault(jjob => jjob.id == job.parentJobId)?.AddSubJob(job);
foreach (DownloadNewChapters ncJob in this.jobs.Where(job => job is DownloadNewChapters))
cachedPublications.Add(ncJob.manga);
}
public void ExportJobsList()
{
Log("Exporting Jobs");
foreach (Job job in this.jobs)
{
string jobFilePath = Path.Join(settings.jobsFolderPath, $"{job.id}.json");
if (!File.Exists(jobFilePath))
{
string jobStr = JsonConvert.SerializeObject(job);
while(IsFileInUse(jobFilePath))
Thread.Sleep(10);
Log($"Exporting Job {jobFilePath}");
File.WriteAllText(jobFilePath, jobStr);
}
}
//Remove files with jobs not in this.jobs-list
Regex idRex = new (@"(.*)\.json");
foreach (FileInfo file in new DirectoryInfo(settings.jobsFolderPath).EnumerateFiles())
{
if (idRex.IsMatch(file.Name))
{
string id = idRex.Match(file.Name).Groups[1].Value;
if (!this.jobs.Any(job => job.id == id))
{
try
{
file.Delete();
}
catch (Exception e)
{
Log(e.ToString());
}
}
}
}
}
public void CheckJobs()
{
foreach (Job job in jobs.Where(job => job.nextExecution < DateTime.Now && !QueueContainsJob(job)).OrderBy(job => job.nextExecution))
AddJobToQueue(job);
foreach (Queue<Job> jobQueue in mangaConnectorJobQueue.Values)
{
if(jobQueue.Count < 1)
continue;
Job queueHead = jobQueue.Peek();
if (queueHead.progressToken.state is ProgressToken.State.Complete or ProgressToken.State.Cancelled)
{
switch (queueHead)
{
case DownloadChapter:
RemoveJob(queueHead);
break;
case DownloadNewChapters:
if(queueHead.recurring)
queueHead.progressToken.Complete();
break;
}
jobQueue.Dequeue();
}else if (queueHead.progressToken.state is ProgressToken.State.Standby)
{
Job[] subJobs = jobQueue.Peek().ExecuteReturnSubTasks().ToArray();
AddJobs(subJobs);
AddJobsToQueue(subJobs);
}
}
}
}

View File

@ -0,0 +1,70 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Tranga.MangaConnectors;
namespace Tranga.Jobs;
public class JobJsonConverter : JsonConverter
{
private GlobalBase _clone;
private MangaConnectorJsonConverter _mangaConnectorJsonConverter;
internal JobJsonConverter(GlobalBase clone, MangaConnectorJsonConverter mangaConnectorJsonConverter)
{
this._clone = clone;
this._mangaConnectorJsonConverter = mangaConnectorJsonConverter;
}
public override bool CanConvert(Type objectType)
{
return (objectType == typeof(Job));
}
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
JObject jo = JObject.Load(reader);
if (jo.ContainsKey("manga"))//DownloadNewChapters
{
return new DownloadNewChapters(this._clone,
jo.GetValue("mangaConnector")!.ToObject<MangaConnector>(JsonSerializer.Create(new JsonSerializerSettings()
{
Converters =
{
this._mangaConnectorJsonConverter
}
}))!,
jo.GetValue("manga")!.ToObject<Manga>(),
jo.GetValue("lastExecution")!.ToObject<DateTime>(),
jo.GetValue("recurring")!.Value<bool>(),
jo.GetValue("recurrenceTime")!.ToObject<TimeSpan?>(),
jo.GetValue("parentJobId")!.Value<string?>());
}
if (jo.ContainsKey("chapter"))//DownloadChapter
{
return new DownloadChapter(this._clone,
jo.GetValue("mangaConnector")!.ToObject<MangaConnector>(JsonSerializer.Create(new JsonSerializerSettings()
{
Converters =
{
this._mangaConnectorJsonConverter
}
}))!,
jo.GetValue("chapter")!.ToObject<Chapter>(),
DateTime.UnixEpoch,
jo.GetValue("parentJobId")!.Value<string?>());
}
throw new Exception();
}
public override bool CanWrite => false;
/// <summary>
/// Don't call this
/// </summary>
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
throw new Exception("Dont call this");
}
}

View File

@ -0,0 +1,66 @@
namespace Tranga.Jobs;
public class ProgressToken
{
public bool cancellationRequested { get; set; }
public int increments { get; set; }
public int incrementsCompleted { get; set; }
public float progress => GetProgress();
public DateTime executionStarted { get; private set; }
public TimeSpan timeRemaining => GetTimeRemaining();
public enum State { Running, Complete, Standby, Cancelled }
public State state { get; private set; }
public ProgressToken(int increments)
{
this.cancellationRequested = false;
this.increments = increments;
this.incrementsCompleted = 0;
this.state = State.Complete;
this.executionStarted = DateTime.UnixEpoch;
}
private float GetProgress()
{
if(increments > 0 && incrementsCompleted > 0)
return (float)incrementsCompleted / (float)increments;
return 0;
}
private TimeSpan GetTimeRemaining()
{
if (increments > 0 && incrementsCompleted > 0)
return DateTime.Now.Subtract(this.executionStarted).Divide(incrementsCompleted).Multiply(increments - incrementsCompleted);
return TimeSpan.MaxValue;
}
public void Increment()
{
this.incrementsCompleted++;
if (incrementsCompleted > increments)
state = State.Complete;
}
public void Standby()
{
state = State.Standby;
}
public void Start()
{
state = State.Running;
this.executionStarted = DateTime.Now;
}
public void Complete()
{
state = State.Complete;
}
public void Cancel()
{
state = State.Cancelled;
}
}

View File

@ -1,22 +1,27 @@
using System.Text.Json.Nodes;
using Logging;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace Tranga.LibraryManagers;
namespace Tranga.LibraryConnectors;
public class Kavita : LibraryManager
public class Kavita : LibraryConnector
{
public Kavita(string baseUrl, string username, string password, Logger? logger) : base(baseUrl, GetToken(baseUrl, username, password), logger, LibraryType.Kavita)
public Kavita(GlobalBase clone, string baseUrl, string username, string password) :
base(clone, baseUrl, GetToken(baseUrl, username, password), LibraryType.Kavita)
{
}
[JsonConstructor]
public Kavita(string baseUrl, string auth, Logger? logger) : base(baseUrl, auth, logger, LibraryType.Kavita)
public Kavita(GlobalBase clone, string baseUrl, string auth) : base(clone, baseUrl, auth, LibraryType.Kavita)
{
}
public override string ToString()
{
return $"Kavita {baseUrl}";
}
private static string GetToken(string baseUrl, string username, string password)
{
HttpClient client = new()
@ -37,12 +42,12 @@ public class Kavita : LibraryManager
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(response.Content.ReadAsStream());
if (result is not null)
return result["token"]!.GetValue<string>();
else return "";
else throw new Exception("Did not receive token.");
}
public override void UpdateLibrary()
{
logger?.WriteLine(this.GetType().ToString(), $"Updating Libraries");
Log("Updating libraries.");
foreach (KavitaLibrary lib in GetLibraries())
NetClient.MakePost($"{baseUrl}/api/Library/scan?libraryId={lib.id}", "Bearer", auth, logger);
}
@ -53,17 +58,17 @@ public class Kavita : LibraryManager
/// <returns>Array of KavitaLibrary</returns>
private IEnumerable<KavitaLibrary> GetLibraries()
{
logger?.WriteLine(this.GetType().ToString(), $"Getting Libraries");
Log("Getting libraries.");
Stream data = NetClient.MakeRequest($"{baseUrl}/api/Library", "Bearer", auth, logger);
if (data == Stream.Null)
{
logger?.WriteLine(this.GetType().ToString(), $"No libraries returned");
Log("No libraries returned");
return Array.Empty<KavitaLibrary>();
}
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
if (result is null)
{
logger?.WriteLine(this.GetType().ToString(), $"No libraries returned");
Log("No libraries returned");
return Array.Empty<KavitaLibrary>();
}

View File

@ -1,29 +1,33 @@
using System.Text.Json.Nodes;
using Logging;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace Tranga.LibraryManagers;
namespace Tranga.LibraryConnectors;
/// <summary>
/// Provides connectivity to Komga-API
/// Can fetch and update libraries
/// </summary>
public class Komga : LibraryManager
public class Komga : LibraryConnector
{
public Komga(string baseUrl, string username, string password, Logger? logger)
: base(baseUrl, Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($"{username}:{password}")), logger, LibraryType.Komga)
public Komga(GlobalBase clone, string baseUrl, string username, string password)
: base(clone, baseUrl, Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($"{username}:{password}")), LibraryType.Komga)
{
}
[JsonConstructor]
public Komga(string baseUrl, string auth, Logger? logger) : base(baseUrl, auth, logger, LibraryType.Komga)
public Komga(GlobalBase clone, string baseUrl, string auth) : base(clone, baseUrl, auth, LibraryType.Komga)
{
}
public override string ToString()
{
return $"Komga {baseUrl}";
}
public override void UpdateLibrary()
{
logger?.WriteLine(this.GetType().ToString(), $"Updating Libraries");
Log("Updating libraries.");
foreach (KomgaLibrary lib in GetLibraries())
NetClient.MakePost($"{baseUrl}/api/v1/libraries/{lib.id}/scan", "Basic", auth, logger);
}
@ -34,17 +38,17 @@ public class Komga : LibraryManager
/// <returns>Array of KomgaLibraries</returns>
private IEnumerable<KomgaLibrary> GetLibraries()
{
logger?.WriteLine(this.GetType().ToString(), $"Getting Libraries");
Log("Getting Libraries");
Stream data = NetClient.MakeRequest($"{baseUrl}/api/v1/libraries", "Basic", auth, logger);
if (data == Stream.Null)
{
logger?.WriteLine(this.GetType().ToString(), $"No libraries returned");
Log("No libraries returned");
return Array.Empty<KomgaLibrary>();
}
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
if (result is null)
{
logger?.WriteLine(this.GetType().ToString(), $"No libraries returned");
Log("No libraries returned");
return Array.Empty<KomgaLibrary>();
}

View File

@ -1,12 +1,10 @@
using System.Net;
using System.Net.Http.Headers;
using Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Tranga.LibraryManagers;
namespace Tranga.LibraryConnectors;
public abstract class LibraryManager
public abstract class LibraryConnector : GlobalBase
{
public enum LibraryType : byte
{
@ -19,26 +17,15 @@ public abstract class LibraryManager
public string baseUrl { get; }
// ReSharper disable once MemberCanBeProtected.Global
public string auth { get; } //Base64 encoded, if you use your password everywhere, you have problems
protected Logger? logger;
/// <param name="baseUrl">Base-URL of Komga instance, no trailing slashes(/)</param>
/// <param name="auth">Base64 string of username and password (username):(password)</param>
/// <param name="logger"></param>
/// <param name="libraryType"></param>
protected LibraryManager(string baseUrl, string auth, Logger? logger, LibraryType libraryType)
protected LibraryConnector(GlobalBase clone, string baseUrl, string auth, LibraryType libraryType) : base(clone)
{
this.baseUrl = baseUrl;
this.auth = auth;
this.logger = logger;
this.libraryType = libraryType;
}
public abstract void UpdateLibrary();
public void AddLogger(Logger newLogger)
{
this.logger = newLogger;
}
protected static class NetClient
{
public static Stream MakeRequest(string url, string authScheme, string auth, Logger? logger)
@ -52,7 +39,7 @@ public abstract class LibraryManager
RequestUri = new Uri(url)
};
HttpResponseMessage response = client.Send(requestMessage);
logger?.WriteLine("LibraryManager", $"GET {url} -> {(int)response.StatusCode}: {response.ReasonPhrase}");
logger?.WriteLine("LibraryManager.NetClient", $"GET {url} -> {(int)response.StatusCode}: {response.ReasonPhrase}");
if(response.StatusCode is HttpStatusCode.Unauthorized && response.RequestMessage!.RequestUri!.AbsoluteUri != url)
return MakeRequest(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth, logger);
@ -78,7 +65,7 @@ public abstract class LibraryManager
RequestUri = new Uri(url)
};
HttpResponseMessage response = client.Send(requestMessage);
logger?.WriteLine("LibraryManager", $"POST {url} -> {(int)response.StatusCode}: {response.ReasonPhrase}");
logger?.WriteLine("LibraryManager.NetClient", $"POST {url} -> {(int)response.StatusCode}: {response.ReasonPhrase}");
if(response.StatusCode is HttpStatusCode.Unauthorized && response.RequestMessage!.RequestUri!.AbsoluteUri != url)
return MakePost(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth, logger);
@ -88,34 +75,4 @@ public abstract class LibraryManager
return false;
}
}
public class LibraryManagerJsonConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return (objectType == typeof(LibraryManager));
}
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
JObject jo = JObject.Load(reader);
if (jo["libraryType"]!.Value<Int64>() == (Int64)LibraryType.Komga)
return jo.ToObject<Komga>(serializer)!;
if (jo["libraryType"]!.Value<Int64>() == (Int64)LibraryType.Kavita)
return jo.ToObject<Kavita>(serializer)!;
throw new Exception();
}
public override bool CanWrite => false;
/// <summary>
/// Don't call this
/// </summary>
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
throw new Exception("Dont call this");
}
}
}

View File

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

View File

@ -9,7 +9,7 @@ namespace Tranga;
/// <summary>
/// Contains information on a Publication (Manga)
/// </summary>
public struct Publication
public struct Manga
{
public string sortName { get; }
public List<string> authors { get; }
@ -19,8 +19,8 @@ public struct Publication
public string? description { get; }
public string[] tags { get; }
// ReSharper disable once UnusedAutoPropertyAccessor.Global
public string? posterUrl { get; }
public string? coverFileNameInCache { get; }
public string? coverUrl { get; }
public string? coverFileNameInCache { get; set; }
// ReSharper disable once UnusedAutoPropertyAccessor.Global
public Dictionary<string,string> links { get; }
// ReSharper disable once MemberCanBePrivate.Global
@ -28,15 +28,15 @@ public struct Publication
public string? originalLanguage { get; }
// ReSharper disable once MemberCanBePrivate.Global
public string status { get; }
public string folderName { get; }
public string folderName { get; private set; }
public string publicationId { get; }
public string internalId { get; }
public float ignoreChaptersBelow { get; set; }
private static readonly Regex LegalCharacters = new Regex(@"[A-Z]*[a-z]*[0-9]* *\.*-*,*'*\'*\)*\(*~*!*");
private static readonly Regex LegalCharacters = new (@"[A-Z]*[a-z]*[0-9]* *\.*-*,*'*\'*\)*\(*~*!*");
[JsonConstructor]
public Publication(string sortName, List<string> authors, string? description, Dictionary<string,string> altTitles, string[] tags, string? posterUrl, string? coverFileNameInCache, Dictionary<string,string>? links, int? year, string? originalLanguage, string status, string publicationId, string? folderName = null, float? ignoreChaptersBelow = 0)
public Manga(string sortName, List<string> authors, string? description, Dictionary<string,string> altTitles, string[] tags, string? coverUrl, string? coverFileNameInCache, Dictionary<string,string>? links, int? year, string? originalLanguage, string status, string publicationId, string? folderName = null, float? ignoreChaptersBelow = 0)
{
this.sortName = sortName;
this.authors = authors;
@ -44,7 +44,7 @@ public struct Publication
this.altTitles = altTitles;
this.tags = tags;
this.coverFileNameInCache = coverFileNameInCache;
this.posterUrl = posterUrl;
this.coverUrl = coverUrl;
this.links = links ?? new Dictionary<string, string>();
this.year = year;
this.originalLanguage = originalLanguage;
@ -58,6 +58,11 @@ public struct Publication
this.ignoreChaptersBelow = ignoreChaptersBelow ?? 0f;
}
public override string ToString()
{
return $"Publication {sortName} {internalId}";
}
public string CreatePublicationFolder(string downloadDirectory)
{
string publicationFolder = Path.Join(downloadDirectory, this.folderName);
@ -68,6 +73,15 @@ public struct Publication
return publicationFolder;
}
public void MovePublicationFolder(string downloadDirectory, string newFolderName)
{
string oldPath = Path.Join(downloadDirectory, this.folderName);
this.folderName = newFolderName;
string newPath = CreatePublicationFolder(downloadDirectory);
if(Directory.Exists(oldPath))
Directory.Move(oldPath, newPath);
}
public void SaveSeriesInfoJson(string downloadDirectory)
{
string publicationFolder = CreatePublicationFolder(downloadDirectory);
@ -110,14 +124,30 @@ public struct Publication
[JsonRequired]public string year { get; }
[JsonRequired]public string status { get; }
[JsonRequired]public string description_text { get; }
[JsonIgnore] public static string[] continuing = new[]
{
"ongoing",
"hiatus",
"in corso",
"in pausa"
};
[JsonIgnore] public static string[] ended = new[]
{
"completed",
"cancelled",
"discontinued",
"finito",
"cancellato",
"droppato"
};
public Metadata(string name, string year, string status, string description_text)
{
this.name = name;
this.year = year;
if(status.ToLower() == "ongoing" || status.ToLower() == "hiatus")
if(continuing.Contains(status.ToLower()))
this.status = "Continuing";
else if (status.ToLower() == "completed" || status.ToLower() == "cancelled" || status.ToLower() == "discontinued")
else if(ended.Contains(status.ToLower()))
this.status = "Ended";
else
this.status = status;

View File

@ -0,0 +1,95 @@
using System.Net;
using System.Text;
using HtmlAgilityPack;
using PuppeteerSharp;
namespace Tranga.MangaConnectors;
internal class ChromiumDownloadClient : DownloadClient
{
private IBrowser browser { get; set; }
private const string ChromiumVersion = "1154303";
private async Task<IBrowser> DownloadBrowser()
{
BrowserFetcher browserFetcher = new BrowserFetcher();
foreach(string rev in browserFetcher.LocalRevisions().Where(rev => rev != ChromiumVersion))
browserFetcher.Remove(rev);
if (!browserFetcher.LocalRevisions().Contains(ChromiumVersion))
{
Log("Downloading headless browser");
DateTime last = DateTime.Now.Subtract(TimeSpan.FromSeconds(5));
browserFetcher.DownloadProgressChanged += (_, args) =>
{
double currentBytes = Convert.ToDouble(args.BytesReceived) / Convert.ToDouble(args.TotalBytesToReceive);
if (args.TotalBytesToReceive == args.BytesReceived)
Log("Browser downloaded.");
else if (DateTime.Now > last.AddSeconds(1))
{
Log($"Browser download progress: {currentBytes:P2}");
last = DateTime.Now;
}
};
if (!browserFetcher.CanDownloadAsync(ChromiumVersion).Result)
{
Log($"Can't download browser version {ChromiumVersion}");
throw new Exception();
}
await browserFetcher.DownloadAsync(ChromiumVersion);
}
Log("Starting Browser.");
return await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
ExecutablePath = browserFetcher.GetExecutablePath(ChromiumVersion),
Args = new [] {
"--disable-gpu",
"--disable-dev-shm-usage",
"--disable-setuid-sandbox",
"--no-sandbox"}
});
}
public ChromiumDownloadClient(GlobalBase clone, Dictionary<byte, int> rateLimitRequestsPerMinute) : base(clone, rateLimitRequestsPerMinute)
{
this.browser = DownloadBrowser().Result;
}
protected override RequestResult MakeRequestInternal(string url, string? referrer = null)
{
IPage page = this.browser!.NewPageAsync().Result;
IResponse response = page.GoToAsync(url, WaitUntilNavigation.DOMContentLoaded).Result;
Stream stream = Stream.Null;
HtmlDocument? document = null;
if (response.Headers.TryGetValue("Content-Type", out string? content))
{
if (content.Contains("text/html"))
{
string htmlString = page.GetContentAsync().Result;
stream = new MemoryStream(Encoding.Default.GetBytes(htmlString));
document = new ();
document.LoadHtml(htmlString);
}else if (content.Contains("image"))
{
stream = new MemoryStream(response.BufferAsync().Result);
}
}
else
{
page.CloseAsync();
return new RequestResult(HttpStatusCode.InternalServerError, null, Stream.Null);
}
page.CloseAsync();
return new RequestResult(response.Status, document, stream, false, "");
}
public override void Close()
{
this.browser.CloseAsync();
}
}

View File

@ -0,0 +1,66 @@
using System.Net;
using HtmlAgilityPack;
namespace Tranga.MangaConnectors;
internal abstract class DownloadClient : GlobalBase
{
private readonly Dictionary<byte, DateTime> _lastExecutedRateLimit;
private readonly Dictionary<byte, TimeSpan> _rateLimit;
protected DownloadClient(GlobalBase clone, Dictionary<byte, int> rateLimitRequestsPerMinute) : base(clone)
{
this._lastExecutedRateLimit = new();
_rateLimit = new();
foreach (KeyValuePair<byte, int> limit in rateLimitRequestsPerMinute)
_rateLimit.Add(limit.Key, TimeSpan.FromMinutes(1).Divide(limit.Value));
}
public RequestResult MakeRequest(string url, byte requestType, string? referrer = null)
{
if (_rateLimit.TryGetValue(requestType, out TimeSpan value))
_lastExecutedRateLimit.TryAdd(requestType, DateTime.Now.Subtract(value));
else
{
Log("RequestType not configured for rate-limit.");
return new RequestResult(HttpStatusCode.NotAcceptable, null, Stream.Null);
}
TimeSpan rateLimitTimeout = _rateLimit[requestType]
.Subtract(DateTime.Now.Subtract(_lastExecutedRateLimit[requestType]));
if (rateLimitTimeout > TimeSpan.Zero)
Thread.Sleep(rateLimitTimeout);
RequestResult result = MakeRequestInternal(url, referrer);
_lastExecutedRateLimit[requestType] = DateTime.Now;
return result;
}
protected abstract RequestResult MakeRequestInternal(string url, string? referrer = null);
public abstract void Close();
public struct RequestResult
{
public HttpStatusCode statusCode { get; }
public Stream result { get; }
public bool hasBeenRedirected { get; }
public string? redirectedToUrl { get; }
public HtmlDocument? htmlDocument { get; }
public RequestResult(HttpStatusCode statusCode, HtmlDocument? htmlDocument, Stream result)
{
this.statusCode = statusCode;
this.htmlDocument = htmlDocument;
this.result = result;
}
public RequestResult(HttpStatusCode statusCode, HtmlDocument? htmlDocument, Stream result, bool hasBeenRedirected, string redirectedTo)
: this(statusCode, htmlDocument, result)
{
this.hasBeenRedirected = hasBeenRedirected;
redirectedToUrl = redirectedTo;
}
}
}

View File

@ -0,0 +1,70 @@
using System.Net.Http.Headers;
using HtmlAgilityPack;
namespace Tranga.MangaConnectors;
internal class HttpDownloadClient : DownloadClient
{
private static readonly HttpClient Client = new()
{
Timeout = TimeSpan.FromSeconds(60),
DefaultRequestHeaders =
{
UserAgent =
{
new ProductInfoHeaderValue("Tranga", "0.1")
}
}
};
public HttpDownloadClient(GlobalBase clone, Dictionary<byte, int> rateLimitRequestsPerMinute) : base(clone, rateLimitRequestsPerMinute)
{
}
protected override RequestResult MakeRequestInternal(string url, string? referrer = null)
{
HttpResponseMessage? response = null;
while (response is null)
{
HttpRequestMessage requestMessage = new(HttpMethod.Get, url);
if (referrer is not null)
requestMessage.Headers.Referrer = new Uri(referrer);
//Log($"Requesting {requestType} {url}");
response = Client.Send(requestMessage);
}
if (!response.IsSuccessStatusCode)
{
Log($"Request-Error {response.StatusCode}: {response.ReasonPhrase}");
return new RequestResult(response.StatusCode, null, Stream.Null);
}
Stream stream = response.Content.ReadAsStream();
HtmlDocument? document = null;
if (response.Content.Headers.ContentType?.MediaType == "text/html")
{
StreamReader reader = new (stream);
document = new ();
document.LoadHtml(reader.ReadToEnd());
stream.Position = 0;
}
// Request has been redirected to another page. For example, it redirects directly to the results when there is only 1 result
if (response.RequestMessage is not null && response.RequestMessage.RequestUri is not null)
{
return new RequestResult(response.StatusCode, document, stream, true,
response.RequestMessage.RequestUri.AbsoluteUri);
}
return new RequestResult(response.StatusCode, document, stream);
}
public override void Close()
{
Log("Closing.");
}
}

View File

@ -1,82 +1,74 @@
using System.Globalization;
using System.IO.Compression;
using System.IO.Compression;
using System.Net;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using Tranga.TrangaTasks;
using Tranga.Jobs;
using static System.IO.UnixFileMode;
namespace Tranga.Connectors;
namespace Tranga.MangaConnectors;
/// <summary>
/// Base-Class for all Connectors
/// Provides some methods to be used by all Connectors, as well as a DownloadClient
/// </summary>
public abstract class Connector
public abstract class MangaConnector : GlobalBase
{
protected CommonObjects commonObjects;
protected TrangaSettings settings { get; }
internal DownloadClient downloadClient { get; init; } = null!;
protected Connector(TrangaSettings settings, CommonObjects commonObjects)
public void StopDownloadClient()
{
this.settings = settings;
this.commonObjects = commonObjects;
if (!Directory.Exists(settings.coverImageCache))
Directory.CreateDirectory(settings.coverImageCache);
downloadClient.Close();
}
public abstract string name { get; } //Name of the Connector (e.g. Website)
public Publication[] GetPublications(ref HashSet<Publication> publicationCollection, string publicationTitle = "")
protected MangaConnector(GlobalBase clone, string name) : base(clone)
{
Publication[] ret = GetPublicationsInternal(publicationTitle);
foreach (Publication p in ret)
publicationCollection.Add(p);
return ret;
this.name = name;
Directory.CreateDirectory(settings.coverImageCache);
}
public string name { get; } //Name of the Connector (e.g. Website)
/// <summary>
/// Returns all Publications with the given string.
/// If the string is empty or null, returns all Publication of the Connector
/// </summary>
/// <param name="publicationTitle">Search-Query</param>
/// <returns>Publications matching the query</returns>
protected abstract Publication[] GetPublicationsInternal(string publicationTitle = "");
public abstract Manga[] GetManga(string publicationTitle = "");
public abstract Manga? GetMangaFromUrl(string url);
/// <summary>
/// Returns all Chapters of the publication in the provided language.
/// If the language is empty or null, returns all Chapters in all Languages.
/// </summary>
/// <param name="publication">Publication to get Chapters for</param>
/// <param name="manga">Publication to get Chapters for</param>
/// <param name="language">Language of the Chapters</param>
/// <returns>Array of Chapters matching Publication and Language</returns>
public abstract Chapter[] GetChapters(Publication publication, string language = "");
public abstract Chapter[] GetChapters(Manga manga, string language="en");
/// <summary>
/// Updates the available Chapters of a Publication
/// </summary>
/// <param name="publication">Publication to check</param>
/// <param name="manga">Publication to check</param>
/// <param name="language">Language to receive chapters for</param>
/// <param name="collection"></param>
/// <returns>List of Chapters that were previously not in collection</returns>
public List<Chapter> GetNewChaptersList(Publication publication, string language, ref HashSet<Publication> collection)
public Chapter[] GetNewChapters(Manga manga, string language = "en")
{
Chapter[] newChapters = this.GetChapters(publication, language);
collection.Add(publication);
NumberFormatInfo decimalPoint = new (){ NumberDecimalSeparator = "." };
commonObjects.logger?.WriteLine(this.GetType().ToString(), "Checking for duplicates");
Log($"Getting new Chapters for {manga}");
Chapter[] newChapters = this.GetChapters(manga, language);
Log($"Checking for duplicates {manga}");
List<Chapter> newChaptersList = newChapters.Where(nChapter =>
float.Parse(nChapter.chapterNumber, decimalPoint) > publication.ignoreChaptersBelow &&
float.Parse(nChapter.chapterNumber, numberFormatDecimalPoint) > manga.ignoreChaptersBelow &&
!nChapter.CheckChapterIsDownloaded(settings.downloadLocation)).ToList();
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"{newChaptersList.Count} new chapters.");
Log($"{newChaptersList.Count} new chapters. {manga}");
return newChaptersList;
return newChaptersList.ToArray();
}
public Chapter[] SelectChapters(Publication publication, string searchTerm, string? language = null)
public Chapter[] SelectChapters(Manga manga, string searchTerm, string? language = null)
{
Chapter[] availableChapters = this.GetChapters(publication, language??"en");
Chapter[] availableChapters = this.GetChapters(manga, language??"en");
Regex volumeRegex = new ("((v(ol)*(olume)*){1} *([0-9]+(-[0-9]+)?){1})", RegexOptions.IgnoreCase);
Regex chapterRegex = new ("((c(h)*(hapter)*){1} *([0-9]+(-[0-9]+)?){1})", RegexOptions.IgnoreCase);
Regex singleResultRegex = new("([0-9]+)", RegexOptions.IgnoreCase);
@ -147,35 +139,27 @@ public abstract class Connector
return Array.Empty<Chapter>();
}
/// <summary>
/// Retrieves the Chapter (+Images) from the website.
/// Should later call DownloadChapterImages to retrieve the individual Images of the Chapter and create .cbz archive.
/// </summary>
/// <param name="publication">Publication that contains Chapter</param>
/// <param name="chapter">Chapter with Images to retrieve</param>
/// <param name="parentTask">Will be used for progress-tracking</param>
/// <param name="cancellationToken"></param>
public abstract HttpStatusCode DownloadChapter(Publication publication, Chapter chapter, DownloadChapterTask parentTask, CancellationToken? cancellationToken = null);
public abstract HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null);
/// <summary>
/// Copies the already downloaded cover from cache to downloadLocation
/// </summary>
/// <param name="publication">Publication to retrieve Cover for</param>
public void CopyCoverFromCacheToDownloadLocation(Publication publication)
/// <param name="manga">Publication to retrieve Cover for</param>
public void CopyCoverFromCacheToDownloadLocation(Manga manga)
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Cloning cover {publication.sortName} -> {publication.internalId}");
Log($"Copy cover {manga}");
//Check if Publication already has a Folder and cover
string publicationFolder = publication.CreatePublicationFolder(settings.downloadLocation);
string publicationFolder = manga.CreatePublicationFolder(settings.downloadLocation);
DirectoryInfo dirInfo = new (publicationFolder);
if (dirInfo.EnumerateFiles().Any(info => info.Name.Contains("cover", StringComparison.InvariantCultureIgnoreCase)))
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Cover exists {publication.sortName}");
Log($"Cover exists {manga}");
return;
}
string fileInCache = Path.Join(settings.coverImageCache, publication.coverFileNameInCache);
string fileInCache = Path.Join(settings.coverImageCache, manga.coverFileNameInCache);
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" );
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Cloning cover {fileInCache} -> {newFilePath}");
Log($"Cloning cover {fileInCache} -> {newFilePath}");
File.Copy(fileInCache, newFilePath, true);
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | OtherRead | OtherWrite | UserRead | UserWrite);
@ -199,21 +183,13 @@ public abstract class Connector
return requestResult.statusCode;
}
/// <summary>
/// Downloads all Images from URLs, Compresses to zip(cbz) and saves.
/// </summary>
/// <param name="imageUrls">List of URLs to download Images from</param>
/// <param name="saveArchiveFilePath">Full path to save archive to (without file ending .cbz)</param>
/// <param name="parentTask">Used for progress tracking</param>
/// <param name="comicInfoPath">Path of the generate Chapter ComicInfo.xml, if it was generated</param>
/// <param name="requestType">RequestType for RateLimits</param>
/// <param name="referrer">Used in http request header</param>
/// <param name="cancellationToken"></param>
protected HttpStatusCode DownloadChapterImages(string[] imageUrls, string saveArchiveFilePath, byte requestType, DownloadChapterTask parentTask, string? comicInfoPath = null, string? referrer = null, CancellationToken? cancellationToken = null)
protected HttpStatusCode DownloadChapterImages(string[] imageUrls, string saveArchiveFilePath, byte requestType, string? comicInfoPath = null, string? referrer = null, ProgressToken? progressToken = null)
{
if (cancellationToken?.IsCancellationRequested ?? false)
if (progressToken?.cancellationRequested ?? false)
return HttpStatusCode.RequestTimeout;
commonObjects.logger?.WriteLine("Connector", $"Downloading Images for {saveArchiveFilePath}");
Log($"Downloading Images for {saveArchiveFilePath}");
if(progressToken is not null)
progressToken.increments = imageUrls.Length;
//Check if Publication Directory already exists
string directoryPath = Path.GetDirectoryName(saveArchiveFilePath)!;
if (!Directory.Exists(directoryPath))
@ -231,31 +207,38 @@ public abstract class Connector
{
string[] split = imageUrl.Split('.');
string extension = split[^1];
commonObjects.logger?.WriteLine("Connector", $"Downloading Image {chapter + 1:000}/{imageUrls.Length:000} {parentTask.publication.sortName} {parentTask.publication.internalId} Vol.{parentTask.chapter.volumeNumber} Ch.{parentTask.chapter.chapterNumber} {parentTask.progress:P2}");
Log($"Downloading image {chapter + 1:000}/{imageUrls.Length:000}"); //TODO
HttpStatusCode status = DownloadImage(imageUrl, Path.Join(tempFolder, $"{chapter++}.{extension}"), requestType, referrer);
if ((int)status < 200 || (int)status >= 300)
{
progressToken?.Complete();
return status;
parentTask.IncrementProgress(1.0 / imageUrls.Length);
if (cancellationToken?.IsCancellationRequested ?? false)
}
if (progressToken?.cancellationRequested ?? false)
{
progressToken?.Complete();
return HttpStatusCode.RequestTimeout;
}
progressToken?.Increment();
}
if(comicInfoPath is not null)
File.Copy(comicInfoPath, Path.Join(tempFolder, "ComicInfo.xml"));
commonObjects.logger?.WriteLine("Connector", $"Creating archive {saveArchiveFilePath}");
Log($"Creating archive {saveArchiveFilePath}");
//ZIP-it and ship-it
ZipFile.CreateFromDirectory(tempFolder, saveArchiveFilePath);
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(saveArchiveFilePath, GroupRead | GroupWrite | OtherRead | OtherWrite | UserRead | UserWrite);
Directory.Delete(tempFolder, true); //Cleanup
progressToken?.Complete();
return HttpStatusCode.OK;
}
protected string SaveCoverImageToCache(string url, byte requestType)
{
string[] split = url.Split('/');
string filename = split[^1];
string filename = url.Split('/')[^1].Split('?')[0];
string saveImagePath = Path.Join(settings.coverImageCache, filename);
if (File.Exists(saveImagePath))
@ -265,7 +248,7 @@ public abstract class Connector
using MemoryStream ms = new();
coverResult.result.CopyTo(ms);
File.WriteAllBytes(saveImagePath, ms.ToArray());
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Saving image to {saveImagePath}");
Log($"Saving cover to {saveImagePath}");
return filename;
}
}

View File

@ -0,0 +1,51 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Tranga.MangaConnectors;
public class MangaConnectorJsonConverter : JsonConverter
{
private GlobalBase _clone;
private HashSet<MangaConnector> connectors;
internal MangaConnectorJsonConverter(GlobalBase clone, HashSet<MangaConnector> connectors)
{
this._clone = clone;
this.connectors = connectors;
}
public override bool CanConvert(Type objectType)
{
return (objectType == typeof(MangaConnector));
}
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
JObject jo = JObject.Load(reader);
switch (jo.GetValue("name")!.Value<string>()!)
{
case "MangaDex":
return this.connectors.First(c => c is MangaDex);
case "Manganato":
return this.connectors.First(c => c is Manganato);
case "MangaKatana":
return this.connectors.First(c => c is MangaKatana);
case "Mangasee":
return this.connectors.First(c => c is Mangasee);
case "Mangaworld":
return this.connectors.First(c => c is Mangaworld);
}
throw new Exception();
}
public override bool CanWrite => false;
/// <summary>
/// Don't call this
/// </summary>
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
throw new Exception("Dont call this");
}
}

View File

@ -1,14 +1,13 @@
using System.Globalization;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using Tranga.TrangaTasks;
using System.Text.RegularExpressions;
using Tranga.Jobs;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace Tranga.Connectors;
public class MangaDex : Connector
namespace Tranga.MangaConnectors;
public class MangaDex : MangaConnector
{
public override string name { get; }
private enum RequestType : byte
{
Manga,
@ -18,26 +17,25 @@ public class MangaDex : Connector
Author,
}
public MangaDex(TrangaSettings settings, CommonObjects commonObjects) : base(settings, commonObjects)
public MangaDex(GlobalBase clone) : base(clone, "MangaDex")
{
name = "MangaDex";
this.downloadClient = new DownloadClient(new Dictionary<byte, int>()
this.downloadClient = new HttpDownloadClient(clone, new Dictionary<byte, int>()
{
{(byte)RequestType.Manga, 250},
{(byte)RequestType.Feed, 250},
{(byte)RequestType.AtHomeServer, 40},
{(byte)RequestType.CoverUrl, 250},
{(byte)RequestType.Author, 250}
}, commonObjects.logger);
});
}
protected override Publication[] GetPublicationsInternal(string publicationTitle = "")
public override Manga[] GetManga(string publicationTitle = "")
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Getting Publications (title={publicationTitle})");
Log($"Searching Publications. Term=\"{publicationTitle}\"");
const int limit = 100; //How many values we want returned at once
int offset = 0; //"Page"
int total = int.MaxValue; //How many total results are there, is updated on first request
HashSet<Publication> publications = new();
HashSet<Manga> retManga = new();
int loadedPublicationData = 0;
while (offset < total) //As long as we haven't requested all "Pages"
{
@ -57,102 +55,123 @@ public class MangaDex : Connector
JsonArray mangaInResult = result["data"]!.AsArray(); //Manga-data-Array
//Loop each Manga and extract information from JSON
foreach (JsonNode? mangeNode in mangaInResult)
foreach (JsonNode? mangaNode in mangaInResult)
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Getting publication data. {++loadedPublicationData}/{total}");
JsonObject manga = (JsonObject)mangeNode!;
JsonObject attributes = manga["attributes"]!.AsObject();
Log($"Getting publication data. {++loadedPublicationData}/{total}");
Manga manga = MangaFromJsonObject((JsonObject)mangaNode);
retManga.Add(manga); //Add Publication (Manga) to result
}
}
Log($"Retrieved {retManga.Count} publications. Term=\"{publicationTitle}\"");
return retManga.ToArray();
}
string publicationId = manga["id"]!.GetValue<string>();
public override Manga? GetMangaFromUrl(string url)
{
Regex idRex = new (@"https:\/\/mangadex.org\/title\/([A-z0-9-]*)\/.*");
string id = idRex.Match(url).Groups[1].Value;
Log($"Got id {id} from {url}");
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest($"https://api.mangadex.org/manga/{id}", (byte)RequestType.Manga);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
if(result is not null)
return MangaFromJsonObject(result["data"]!.AsObject());
return null;
}
string title = attributes["title"]!.AsObject().ContainsKey("en") && attributes["title"]!["en"] is not null
private Manga MangaFromJsonObject(JsonObject manga)
{
JsonObject attributes = manga["attributes"]!.AsObject();
string publicationId = manga["id"]!.GetValue<string>();
string title = attributes["title"]!.AsObject().ContainsKey("en") && attributes["title"]!["en"] is not null
? attributes["title"]!["en"]!.GetValue<string>()
: attributes["title"]![((IDictionary<string, JsonNode?>)attributes["title"]!.AsObject()).Keys.First()]!.GetValue<string>();
string? description = attributes["description"]!.AsObject().ContainsKey("en") && attributes["description"]!["en"] is not null
string? description = attributes["description"]!.AsObject().ContainsKey("en") && attributes["description"]!["en"] is not null
? attributes["description"]!["en"]!.GetValue<string?>()
: null;
JsonArray altTitlesObject = attributes["altTitles"]!.AsArray();
Dictionary<string, string> altTitlesDict = new();
foreach (JsonNode? altTitleNode in altTitlesObject)
{
JsonObject altTitleObject = (JsonObject)altTitleNode!;
string key = ((IDictionary<string, JsonNode?>)altTitleObject).Keys.ToArray()[0];
altTitlesDict.TryAdd(key, altTitleObject[key]!.GetValue<string>());
}
JsonArray altTitlesObject = attributes["altTitles"]!.AsArray();
Dictionary<string, string> altTitlesDict = new();
foreach (JsonNode? altTitleNode in altTitlesObject)
{
JsonObject altTitleObject = (JsonObject)altTitleNode!;
string key = ((IDictionary<string, JsonNode?>)altTitleObject).Keys.ToArray()[0];
altTitlesDict.TryAdd(key, altTitleObject[key]!.GetValue<string>());
}
JsonArray tagsObject = attributes["tags"]!.AsArray();
HashSet<string> tags = new();
foreach (JsonNode? tagNode in tagsObject)
{
JsonObject tagObject = (JsonObject)tagNode!;
if(tagObject["attributes"]!["name"]!.AsObject().ContainsKey("en"))
tags.Add(tagObject["attributes"]!["name"]!["en"]!.GetValue<string>());
}
JsonArray tagsObject = attributes["tags"]!.AsArray();
HashSet<string> tags = new();
foreach (JsonNode? tagNode in tagsObject)
{
JsonObject tagObject = (JsonObject)tagNode!;
if(tagObject["attributes"]!["name"]!.AsObject().ContainsKey("en"))
tags.Add(tagObject["attributes"]!["name"]!["en"]!.GetValue<string>());
}
string? posterId = null;
HashSet<string> authorIds = new();
if (manga.ContainsKey("relationships") && manga["relationships"] is not null)
{
JsonArray relationships = manga["relationships"]!.AsArray();
posterId = relationships.FirstOrDefault(relationship => relationship!["type"]!.GetValue<string>() == "cover_art")!["id"]!.GetValue<string>();
foreach (JsonNode? node in relationships.Where(relationship =>
relationship!["type"]!.GetValue<string>() == "author"))
authorIds.Add(node!["id"]!.GetValue<string>());
}
string? coverUrl = GetCoverUrl(publicationId, posterId);
string? coverCacheName = null;
if (coverUrl is not null)
coverCacheName = SaveCoverImageToCache(coverUrl, (byte)RequestType.AtHomeServer);
string? posterId = null;
HashSet<string> authorIds = new();
if (manga.ContainsKey("relationships") && manga["relationships"] is not null)
{
JsonArray relationships = manga["relationships"]!.AsArray();
posterId = relationships.FirstOrDefault(relationship => relationship!["type"]!.GetValue<string>() == "cover_art")!["id"]!.GetValue<string>();
foreach (JsonNode? node in relationships.Where(relationship =>
relationship!["type"]!.GetValue<string>() == "author"))
authorIds.Add(node!["id"]!.GetValue<string>());
}
string? coverUrl = GetCoverUrl(publicationId, posterId);
string? coverCacheName = null;
if (coverUrl is not null)
coverCacheName = SaveCoverImageToCache(coverUrl, (byte)RequestType.AtHomeServer);
List<string> authors = GetAuthors(authorIds);
List<string> authors = GetAuthors(authorIds);
Dictionary<string, string> linksDict = new();
if (attributes.ContainsKey("links") && attributes["links"] is not null)
{
JsonObject linksObject = attributes["links"]!.AsObject();
foreach (string key in ((IDictionary<string, JsonNode?>)linksObject).Keys)
{
linksDict.Add(key, linksObject[key]!.GetValue<string>());
}
}
int? year = attributes.ContainsKey("year") && attributes["year"] is not null
? attributes["year"]!.GetValue<int?>()
: null;
string? originalLanguage = attributes.ContainsKey("originalLanguage") && attributes["originalLanguage"] is not null
? attributes["originalLanguage"]!.GetValue<string?>()
: null;
string status = attributes["status"]!.GetValue<string>();
Publication pub = new (
title,
authors,
description,
altTitlesDict,
tags.ToArray(),
coverUrl,
coverCacheName,
linksDict,
year,
originalLanguage,
status,
publicationId
);
publications.Add(pub); //Add Publication (Manga) to result
Dictionary<string, string> linksDict = new();
if (attributes.ContainsKey("links") && attributes["links"] is not null)
{
JsonObject linksObject = attributes["links"]!.AsObject();
foreach (string key in ((IDictionary<string, JsonNode?>)linksObject).Keys)
{
linksDict.Add(key, linksObject[key]!.GetValue<string>());
}
}
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Done getting publications (title={publicationTitle})");
return publications.ToArray();
int? year = attributes.ContainsKey("year") && attributes["year"] is not null
? attributes["year"]!.GetValue<int?>()
: null;
string? originalLanguage =
attributes.ContainsKey("originalLanguage") && attributes["originalLanguage"] is not null
? attributes["originalLanguage"]!.GetValue<string?>()
: null;
string status = attributes["status"]!.GetValue<string>();
Manga pub = new(
title,
authors,
description,
altTitlesDict,
tags.ToArray(),
coverUrl,
coverCacheName,
linksDict,
year,
originalLanguage,
status,
publicationId
);
cachedPublications.Add(pub);
return pub;
}
public override Chapter[] GetChapters(Publication publication, string language = "")
public override Chapter[] GetChapters(Manga manga, string language="en")
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Getting Chapters for {publication.sortName} {publication.internalId} (language={language})");
Log($"Getting chapters {manga}");
const int limit = 100; //How many values we want returned at once
int offset = 0; //"Page"
int total = int.MaxValue; //How many total results are there, is updated on first request
@ -163,7 +182,7 @@ public class MangaDex : Connector
//Request next "Page"
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(
$"https://api.mangadex.org/manga/{publication.publicationId}/feed?limit={limit}&offset={offset}&translatedLanguage%5B%5D={language}", (byte)RequestType.Feed);
$"https://api.mangadex.org/manga/{manga.publicationId}/feed?limit={limit}&offset={offset}&translatedLanguage%5B%5D={language}", (byte)RequestType.Feed);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
break;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
@ -194,24 +213,21 @@ public class MangaDex : Connector
: "null";
if(chapterNum is not "null")
chapters.Add(new Chapter(publication, title, volume, chapterNum, chapterId));
chapters.Add(new Chapter(manga, title, volume, chapterNum, chapterId));
}
}
//Return Chapters ordered by Chapter-Number
NumberFormatInfo chapterNumberFormatInfo = new()
{
NumberDecimalSeparator = "."
};
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Done getting {chapters.Count} Chapters for {publication.internalId}");
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, chapterNumberFormatInfo)).ToArray();
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, numberFormatDecimalPoint)).ToArray();
}
public override HttpStatusCode DownloadChapter(Publication publication, Chapter chapter, DownloadChapterTask parentTask, CancellationToken? cancellationToken = null)
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
{
if (cancellationToken?.IsCancellationRequested ?? false)
if (progressToken?.cancellationRequested ?? false)
return HttpStatusCode.RequestTimeout;
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Downloading Chapter-Info {publication.sortName} {publication.internalId} {chapter.volumeNumber}-{chapter.chapterNumber}");
Manga chapterParentManga = chapter.parentManga;
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
//Request URLs for Chapter-Images
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest($"https://api.mangadex.org/at-home/server/{chapter.url}?forcePort443=false'", (byte)RequestType.AtHomeServer);
@ -233,15 +249,15 @@ public class MangaDex : Connector
File.WriteAllText(comicInfoPath, chapter.GetComicInfoXmlString());
//Download Chapter-Images
return DownloadChapterImages(imageUrls.ToArray(), chapter.GetArchiveFilePath(settings.downloadLocation), (byte)RequestType.AtHomeServer, parentTask, comicInfoPath, cancellationToken:cancellationToken);
return DownloadChapterImages(imageUrls.ToArray(), chapter.GetArchiveFilePath(settings.downloadLocation), (byte)RequestType.AtHomeServer, comicInfoPath, progressToken:progressToken);
}
private string? GetCoverUrl(string publicationId, string? posterId)
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Getting CoverUrl for {publicationId}");
Log($"Getting CoverUrl for Publication {publicationId}");
if (posterId is null)
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"No posterId, aborting");
Log("No cover.");
return null;
}
@ -257,12 +273,13 @@ public class MangaDex : Connector
string fileName = result["data"]!["attributes"]!["fileName"]!.GetValue<string>();
string coverUrl = $"https://uploads.mangadex.org/covers/{publicationId}/{fileName}";
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Got Cover-Url for {publicationId} -> {coverUrl}");
Log($"Cover-Url {publicationId} -> {coverUrl}");
return coverUrl;
}
private List<string> GetAuthors(IEnumerable<string> authorIds)
{
Log("Retrieving authors.");
List<string> ret = new();
foreach (string authorId in authorIds)
{
@ -276,7 +293,7 @@ public class MangaDex : Connector
string authorName = result["data"]!["attributes"]!["name"]!.GetValue<string>();
ret.Add(authorName);
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Got author {authorId} -> {authorName}");
Log($"Got author {authorId} -> {authorName}");
}
return ret;
}

View File

@ -2,32 +2,29 @@
using System.Net;
using System.Text.RegularExpressions;
using HtmlAgilityPack;
using Tranga.TrangaTasks;
using Tranga.Jobs;
namespace Tranga.Connectors;
namespace Tranga.MangaConnectors;
public class MangaKatana : Connector
public class MangaKatana : MangaConnector
{
public override string name { get; }
public MangaKatana(TrangaSettings settings, CommonObjects commonObjects) : base(settings, commonObjects)
public MangaKatana(GlobalBase clone) : base(clone, "MangaKatana")
{
this.name = "MangaKatana";
this.downloadClient = new DownloadClient(new Dictionary<byte, int>()
this.downloadClient = new HttpDownloadClient(clone, new Dictionary<byte, int>()
{
{1, 60}
}, commonObjects.logger);
});
}
protected override Publication[] GetPublicationsInternal(string publicationTitle = "")
public override Manga[] GetManga(string publicationTitle = "")
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Getting Publications (title={publicationTitle})");
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string sanitizedTitle = string.Join('_', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower();
string requestUrl = $"https://mangakatana.com/?search={sanitizedTitle}&search_by=book_name";
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Publication>();
return Array.Empty<Manga>();
// ReSharper disable once MergeIntoPattern
// If a single result is found, the user will be redirected to the results directly instead of a result page
@ -38,10 +35,21 @@ public class MangaKatana : Connector
return new [] { ParseSinglePublicationFromHtml(requestResult.result, requestResult.redirectedToUrl.Split('/')[^1]) };
}
return ParsePublicationsFromHtml(requestResult.result);
Manga[] publications = ParsePublicationsFromHtml(requestResult.result);
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
return publications;
}
private Publication[] ParsePublicationsFromHtml(Stream html)
public override Manga? GetMangaFromUrl(string url)
{
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(url, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null;
return ParseSinglePublicationFromHtml(requestResult.result, url.Split('/')[^1]);
}
private Manga[] ParsePublicationsFromHtml(Stream html)
{
StreamReader reader = new(html);
string htmlString = reader.ReadToEnd();
@ -49,7 +57,7 @@ public class MangaKatana : Connector
document.LoadHtml(htmlString);
IEnumerable<HtmlNode> searchResults = document.DocumentNode.SelectNodes("//*[@id='book_list']/div");
if (searchResults is null || !searchResults.Any())
return Array.Empty<Publication>();
return Array.Empty<Manga>();
List<string> urls = new();
foreach (HtmlNode mangaResult in searchResults)
{
@ -57,21 +65,18 @@ public class MangaKatana : Connector
.First(a => a.Name == "href").Value);
}
HashSet<Publication> ret = new();
HashSet<Manga> ret = new();
foreach (string url in urls)
{
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(url, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Publication>();
ret.Add(ParseSinglePublicationFromHtml(requestResult.result, url.Split('/')[^1]));
Manga? manga = GetMangaFromUrl(url);
if (manga is not null)
ret.Add((Manga)manga);
}
return ret.ToArray();
}
private Publication ParseSinglePublicationFromHtml(Stream html, string publicationId)
private Manga ParseSinglePublicationFromHtml(Stream html, string publicationId)
{
StreamReader reader = new(html);
string htmlString = reader.ReadToEnd();
@ -131,14 +136,16 @@ public class MangaKatana : Connector
year = Convert.ToInt32(yearString);
}
return new Publication(sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links,
Manga manga = new (sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links,
year, originalLanguage, status, publicationId);
cachedPublications.Add(manga);
return manga;
}
public override Chapter[] GetChapters(Publication publication, string language = "")
public override Chapter[] GetChapters(Manga manga, string language="en")
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Getting Chapters for {publication.sortName} {publication.internalId} (language={language})");
string requestUrl = $"https://mangakatana.com/manga/{publication.publicationId}";
Log($"Getting chapters {manga}");
string requestUrl = $"https://mangakatana.com/manga/{manga.publicationId}";
// Leaving this in for verification if the page exists
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
@ -146,16 +153,12 @@ public class MangaKatana : Connector
return Array.Empty<Chapter>();
//Return Chapters ordered by Chapter-Number
NumberFormatInfo chapterNumberFormatInfo = new()
{
NumberDecimalSeparator = "."
};
List<Chapter> chapters = ParseChaptersFromHtml(publication, requestUrl);
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Done getting Chapters for {publication.internalId}");
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, chapterNumberFormatInfo)).ToArray();
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestUrl);
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, numberFormatDecimalPoint)).ToArray();
}
private List<Chapter> ParseChaptersFromHtml(Publication publication, string mangaUrl)
private List<Chapter> ParseChaptersFromHtml(Manga manga, string mangaUrl)
{
// Using HtmlWeb will include the chapters since they are loaded with js
HtmlWeb web = new();
@ -174,17 +177,18 @@ public class MangaKatana : Connector
string chapterName = string.Concat(fullString.Split(':')[1..]);
string url = chapterInfo.Descendants("a").First()
.GetAttributeValue("href", "");
ret.Add(new Chapter(publication, chapterName, volumeNumber, chapterNumber, url));
ret.Add(new Chapter(manga, chapterName, volumeNumber, chapterNumber, url));
}
return ret;
}
public override HttpStatusCode DownloadChapter(Publication publication, Chapter chapter, DownloadChapterTask parentTask, CancellationToken? cancellationToken = null)
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
{
if (cancellationToken?.IsCancellationRequested ?? false)
if (progressToken?.cancellationRequested ?? false)
return HttpStatusCode.RequestTimeout;
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Downloading Chapter-Info {publication.sortName} {publication.internalId} {chapter.volumeNumber}-{chapter.chapterNumber}");
Manga chapterParentManga = chapter.parentManga;
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
string requestUrl = chapter.url;
// Leaving this in to check if the page exists
DownloadClient.RequestResult requestResult =
@ -197,7 +201,7 @@ public class MangaKatana : Connector
string comicInfoPath = Path.GetTempFileName();
File.WriteAllText(comicInfoPath, chapter.GetComicInfoXmlString());
return DownloadChapterImages(imageUrls, chapter.GetArchiveFilePath(settings.downloadLocation), 1, parentTask, comicInfoPath, "https://mangakatana.com/", cancellationToken);
return DownloadChapterImages(imageUrls, chapter.GetArchiveFilePath(settings.downloadLocation), 1, comicInfoPath, "https://mangakatana.com/", progressToken:progressToken);
}
private string[] ParseImageUrlsFromHtml(string mangaUrl)

View File

@ -2,42 +2,39 @@
using System.Net;
using System.Text.RegularExpressions;
using HtmlAgilityPack;
using Tranga.TrangaTasks;
using Tranga.Jobs;
namespace Tranga.Connectors;
namespace Tranga.MangaConnectors;
public class Manganato : Connector
public class Manganato : MangaConnector
{
public override string name { get; }
public Manganato(TrangaSettings settings, CommonObjects commonObjects) : base(settings, commonObjects)
public Manganato(GlobalBase clone) : base(clone, "Manganato")
{
this.name = "Manganato";
this.downloadClient = new DownloadClient(new Dictionary<byte, int>()
this.downloadClient = new HttpDownloadClient(clone, new Dictionary<byte, int>()
{
{1, 60}
}, commonObjects.logger);
});
}
protected override Publication[] GetPublicationsInternal(string publicationTitle = "")
public override Manga[] GetManga(string publicationTitle = "")
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Getting Publications (title={publicationTitle})");
string sanitizedTitle = string.Join('_', Regex.Matches(publicationTitle, "[A-z]*")).ToLower();
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string sanitizedTitle = string.Join('_', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower();
string requestUrl = $"https://manganato.com/search/story/{sanitizedTitle}";
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Publication>();
return Array.Empty<Manga>();
return ParsePublicationsFromHtml(requestResult.result);
if (requestResult.htmlDocument is null)
return Array.Empty<Manga>();
Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
return publications;
}
private Publication[] ParsePublicationsFromHtml(Stream html)
private Manga[] ParsePublicationsFromHtml(HtmlDocument document)
{
StreamReader reader = new (html);
string htmlString = reader.ReadToEnd();
HtmlDocument document = new ();
document.LoadHtml(htmlString);
IEnumerable<HtmlNode> searchResults = document.DocumentNode.Descendants("div").Where(n => n.HasClass("search-story-item"));
List<string> urls = new();
foreach (HtmlNode mangaResult in searchResults)
@ -46,26 +43,31 @@ public class Manganato : Connector
.First(a => a.Name == "href").Value);
}
HashSet<Publication> ret = new();
HashSet<Manga> ret = new();
foreach (string url in urls)
{
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(url, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Publication>();
ret.Add(ParseSinglePublicationFromHtml(requestResult.result, url.Split('/')[^1]));
Manga? manga = GetMangaFromUrl(url);
if (manga is not null)
ret.Add((Manga)manga);
}
return ret.ToArray();
}
private Publication ParseSinglePublicationFromHtml(Stream html, string publicationId)
public override Manga? GetMangaFromUrl(string url)
{
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(url, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null;
if (requestResult.htmlDocument is null)
return null;
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^1]);
}
private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId)
{
StreamReader reader = new (html);
string htmlString = reader.ReadToEnd();
HtmlDocument document = new ();
document.LoadHtml(htmlString);
string status = "";
Dictionary<string, string> altTitles = new();
Dictionary<string, string>? links = null;
@ -119,79 +121,78 @@ public class Manganato : Connector
.First(s => s.HasClass("chapter-time")).InnerText;
int year = Convert.ToInt32(yearString.Split(',')[^1]) + 2000;
return new Publication(sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links,
Manga manga = new (sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links,
year, originalLanguage, status, publicationId);
cachedPublications.Add(manga);
return manga;
}
public override Chapter[] GetChapters(Publication publication, string language = "")
public override Chapter[] GetChapters(Manga manga, string language="en")
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Getting Chapters for {publication.sortName} {publication.internalId} (language={language})");
string requestUrl = $"https://chapmanganato.com/{publication.publicationId}";
Log($"Getting chapters {manga}");
string requestUrl = $"https://chapmanganato.com/{manga.publicationId}";
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Chapter>();
//Return Chapters ordered by Chapter-Number
NumberFormatInfo chapterNumberFormatInfo = new()
{
NumberDecimalSeparator = "."
};
List<Chapter> chapters = ParseChaptersFromHtml(publication, requestResult.result);
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Done getting Chapters for {publication.internalId}");
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, chapterNumberFormatInfo)).ToArray();
if (requestResult.htmlDocument is null)
return Array.Empty<Chapter>();
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument);
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, numberFormatDecimalPoint)).ToArray();
}
private List<Chapter> ParseChaptersFromHtml(Publication publication, Stream html)
private List<Chapter> ParseChaptersFromHtml(Manga manga, HtmlDocument document)
{
StreamReader reader = new (html);
string htmlString = reader.ReadToEnd();
HtmlDocument document = new ();
document.LoadHtml(htmlString);
List<Chapter> ret = new();
HtmlNode chapterList = document.DocumentNode.Descendants("ul").First(l => l.HasClass("row-content-chapter"));
Regex volRex = new(@"Vol\.([0-9]+).*");
Regex chapterRex = new(@"Chapter ([0-9]+(\.[0-9]+)*){1}.*");
Regex nameRex = new(@"Chapter ([0-9]+(\.[0-9]+)*){1}:? (.*)");
foreach (HtmlNode chapterInfo in chapterList.Descendants("li"))
{
string fullString = chapterInfo.Descendants("a").First(d => d.HasClass("chapter-name")).InnerText;
string? volumeNumber = fullString.Contains("Vol.") ? fullString.Replace("Vol.", "").Split(' ')[0] : null;
string chapterNumber = fullString.Split(':')[0].Split("Chapter ")[1].Replace('-','.');
string chapterName = string.Concat(fullString.Split(':')[1..]);
string? volumeNumber = volRex.IsMatch(fullString) ? volRex.Match(fullString).Groups[1].Value : null;
string chapterNumber = chapterRex.Match(fullString).Groups[1].Value;
string chapterName = nameRex.Match(fullString).Groups[3].Value;
string url = chapterInfo.Descendants("a").First(d => d.HasClass("chapter-name"))
.GetAttributeValue("href", "");
ret.Add(new Chapter(publication, chapterName, volumeNumber, chapterNumber, url));
ret.Add(new Chapter(manga, chapterName, volumeNumber, chapterNumber, url));
}
ret.Reverse();
return ret;
}
public override HttpStatusCode DownloadChapter(Publication publication, Chapter chapter, DownloadChapterTask parentTask, CancellationToken? cancellationToken = null)
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
{
if (cancellationToken?.IsCancellationRequested ?? false)
if (progressToken?.cancellationRequested ?? false)
return HttpStatusCode.RequestTimeout;
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Downloading Chapter-Info {publication.sortName} {publication.internalId} {chapter.volumeNumber}-{chapter.chapterNumber}");
Manga chapterParentManga = chapter.parentManga;
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
string requestUrl = chapter.url;
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return requestResult.statusCode;
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.result);
if (requestResult.htmlDocument is null)
return HttpStatusCode.InternalServerError;
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
string comicInfoPath = Path.GetTempFileName();
File.WriteAllText(comicInfoPath, chapter.GetComicInfoXmlString());
return DownloadChapterImages(imageUrls, chapter.GetArchiveFilePath(settings.downloadLocation), 1, parentTask, comicInfoPath, "https://chapmanganato.com/", cancellationToken);
return DownloadChapterImages(imageUrls, chapter.GetArchiveFilePath(settings.downloadLocation), 1, comicInfoPath, "https://chapmanganato.com/", progressToken:progressToken);
}
private string[] ParseImageUrlsFromHtml(Stream html)
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
{
StreamReader reader = new (html);
string htmlString = reader.ReadToEnd();
HtmlDocument document = new ();
document.LoadHtml(htmlString);
List<string> ret = new();
HtmlNode imageContainer =

View File

@ -0,0 +1,205 @@
using System.Net;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using HtmlAgilityPack;
using Newtonsoft.Json;
using Tranga.Jobs;
namespace Tranga.MangaConnectors;
public class Mangasee : MangaConnector
{
public Mangasee(GlobalBase clone) : base(clone, "Mangasee")
{
this.downloadClient = new ChromiumDownloadClient(clone, new Dictionary<byte, int>()
{
{ 1, 60 }
});
}
public override Manga[] GetManga(string publicationTitle = "")
{
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string requestUrl = $"https://mangasee123.com/_search.php";
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Manga>();
if (requestResult.htmlDocument is null)
return Array.Empty<Manga>();
Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument, publicationTitle);
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
return publications;
}
public override Manga? GetMangaFromUrl(string url)
{
Regex publicationIdRex = new(@"https:\/\/mangasee123.com\/manga\/(.*)(\/.*)*");
string publicationId = publicationIdRex.Match(url).Groups[1].Value;
DownloadClient.RequestResult requestResult = this.downloadClient.MakeRequest(url, 1);
if(requestResult.htmlDocument is not null)
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, publicationId);
return null;
}
private Manga[] ParsePublicationsFromHtml(HtmlDocument document, string publicationTitle)
{
string jsonString = document.DocumentNode.SelectSingleNode("//body").InnerText;
List<SearchResultItem> result = JsonConvert.DeserializeObject<List<SearchResultItem>>(jsonString)!;
Dictionary<SearchResultItem, int> queryFiltered = new();
foreach (SearchResultItem resultItem in result)
{
int matches = resultItem.GetMatches(publicationTitle);
if (matches > 0)
queryFiltered.TryAdd(resultItem, matches);
}
queryFiltered = queryFiltered.Where(item => item.Value >= publicationTitle.Split(' ').Length - 1)
.ToDictionary(item => item.Key, item => item.Value);
Log($"Retrieved {queryFiltered.Count} publications.");
HashSet<Manga> ret = new();
List<SearchResultItem> orderedFiltered =
queryFiltered.OrderBy(item => item.Value).ToDictionary(item => item.Key, item => item.Value).Keys.ToList();
foreach (SearchResultItem orderedItem in orderedFiltered)
{
Manga? manga = GetMangaFromUrl($"https://mangasee123.com/manga/{orderedItem.i}");
if (manga is not null)
ret.Add((Manga)manga);
}
return ret.ToArray();
}
private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId)
{
string originalLanguage = "", status = "";
Dictionary<string, string> altTitles = new(), links = new();
HashSet<string> tags = new();
HtmlNode posterNode = document.DocumentNode.SelectSingleNode("//div[@class='BoxBody']//div[@class='row']//img");
string posterUrl = posterNode.GetAttributeValue("src", "");
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, 1);
HtmlNode titleNode = document.DocumentNode.SelectSingleNode("//div[@class='BoxBody']//div[@class='row']//h1");
string sortName = titleNode.InnerText;
HtmlNode[] authorsNodes = document.DocumentNode.SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Author(s):']/..").Descendants("a").ToArray();
List<string> authors = new();
foreach(HtmlNode authorNode in authorsNodes)
authors.Add(authorNode.InnerText);
HtmlNode[] genreNodes = document.DocumentNode.SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Genre(s):']/..").Descendants("a").ToArray();
foreach (HtmlNode genreNode in genreNodes)
tags.Add(genreNode.InnerText);
HtmlNode yearNode = document.DocumentNode.SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Released:']/..").Descendants("a").First();
int year = Convert.ToInt32(yearNode.InnerText);
HtmlNode[] statusNodes = document.DocumentNode.SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Status:']/..").Descendants("a").ToArray();
foreach(HtmlNode statusNode in statusNodes)
if (statusNode.InnerText.Contains("publish", StringComparison.CurrentCultureIgnoreCase))
status = statusNode.InnerText.Split(' ')[0];
HtmlNode descriptionNode = document.DocumentNode.SelectNodes("//div[@class='BoxBody']//div[@class='row']//span[text()='Description:']/..").Descendants("div").First();
string description = descriptionNode.InnerText;
Manga manga = new (sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links,
year, originalLanguage, status, publicationId);
cachedPublications.Add(manga);
return manga;
}
// ReSharper disable once ClassNeverInstantiated.Local Will be instantiated during deserialization
private class SearchResultItem
{
public string i { get; init; }
public string s { get; init; }
public string[] a { get; init; }
[JsonConstructor]
public SearchResultItem(string i, string s, string[] a)
{
this.i = i;
this.s = s;
this.a = a;
}
public int GetMatches(string title)
{
int ret = 0;
Regex cleanRex = new("[A-z0-9]*");
string[] badWords = { "a", "an", "no", "ni", "so", "as", "and", "the", "of", "that", "in", "is", "for" };
string[] titleTerms = title.Split(new[] { ' ', '-' }).Where(str => !badWords.Contains(str)).ToArray();
foreach (Match matchTerm in cleanRex.Matches(this.i))
ret += titleTerms.Count(titleTerm =>
titleTerm.Equals(matchTerm.Value, StringComparison.OrdinalIgnoreCase));
foreach (Match matchTerm in cleanRex.Matches(this.s))
ret += titleTerms.Count(titleTerm =>
titleTerm.Equals(matchTerm.Value, StringComparison.OrdinalIgnoreCase));
foreach(string alt in this.a)
foreach (Match matchTerm in cleanRex.Matches(alt))
ret += titleTerms.Count(titleTerm =>
titleTerm.Equals(matchTerm.Value, StringComparison.OrdinalIgnoreCase));
return ret;
}
}
public override Chapter[] GetChapters(Manga manga, string language="en")
{
Log($"Getting chapters {manga}");
XDocument doc = XDocument.Load($"https://mangasee123.com/rss/{manga.publicationId}.xml");
XElement[] chapterItems = doc.Descendants("item").ToArray();
List<Chapter> chapters = new();
foreach (XElement chapter in chapterItems)
{
string volumeNumber = "1";
string chapterName = chapter.Descendants("title").First().Value;
string chapterNumber = Regex.Matches(chapterName, "[0-9]+")[^1].ToString();
string url = chapter.Descendants("link").First().Value;
url = url.Replace(Regex.Matches(url,"(-page-[0-9])")[0].ToString(),"");
chapters.Add(new Chapter(manga, "", volumeNumber, chapterNumber, url));
}
//Return Chapters ordered by Chapter-Number
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, numberFormatDecimalPoint)).ToArray();
}
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
{
if (progressToken?.cancellationRequested ?? false)
return HttpStatusCode.RequestTimeout;
Manga chapterParentManga = chapter.parentManga;
if (progressToken?.cancellationRequested??false)
return HttpStatusCode.RequestTimeout;
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
DownloadClient.RequestResult requestResult = this.downloadClient.MakeRequest(chapter.url, 1);
if(requestResult.htmlDocument is null)
return HttpStatusCode.RequestTimeout;
HtmlDocument document = requestResult.htmlDocument;
HtmlNode gallery = document.DocumentNode.Descendants("div").First(div => div.HasClass("ImageGallery"));
HtmlNode[] images = gallery.Descendants("img").Where(img => img.HasClass("img-fluid")).ToArray();
List<string> urls = new();
foreach(HtmlNode galleryImage in images)
urls.Add(galleryImage.GetAttributeValue("src", ""));
string comicInfoPath = Path.GetTempFileName();
File.WriteAllText(comicInfoPath, chapter.GetComicInfoXmlString());
return DownloadChapterImages(urls.ToArray(), chapter.GetArchiveFilePath(settings.downloadLocation), 1, comicInfoPath, progressToken:progressToken);
}
}

View File

@ -0,0 +1,186 @@
using System.Net;
using System.Text.RegularExpressions;
using HtmlAgilityPack;
using Tranga.Jobs;
namespace Tranga.MangaConnectors;
public class Mangaworld: MangaConnector
{
public Mangaworld(GlobalBase clone) : base(clone, "Mangaworld")
{
this.downloadClient = new HttpDownloadClient(clone, new Dictionary<byte, int>()
{
{1, 60}
});
}
public override Manga[] GetManga(string publicationTitle = "")
{
Log($"Searching Publications. Term=\"{publicationTitle}\"");
string sanitizedTitle = string.Join(' ', Regex.Matches(publicationTitle, "[A-z]*").Where(str => str.Length > 0)).ToLower();
string requestUrl = $"https://www.mangaworld.bz/archive?keyword={sanitizedTitle}";
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Manga>();
if (requestResult.htmlDocument is null)
return Array.Empty<Manga>();
Manga[] publications = ParsePublicationsFromHtml(requestResult.htmlDocument);
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
return publications;
}
private Manga[] ParsePublicationsFromHtml(HtmlDocument document)
{
if (!document.DocumentNode.SelectSingleNode("//div[@class='comics-grid']").ChildNodes
.Any(node => node.HasClass("entry")))
return Array.Empty<Manga>();
List<string> urls = document.DocumentNode
.SelectNodes(
"//div[@class='comics-grid']//div[@class='entry']//a[contains(concat(' ',normalize-space(@class),' '),'thumb')]")
.Select(thumb => thumb.GetAttributeValue("href", "")).ToList();
HashSet<Manga> ret = new();
foreach (string url in urls)
{
Manga? manga = GetMangaFromUrl(url);
if (manga is not null)
ret.Add((Manga)manga);
}
return ret.ToArray();
}
public override Manga? GetMangaFromUrl(string url)
{
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(url, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return null;
if (requestResult.htmlDocument is null)
return null;
return ParseSinglePublicationFromHtml(requestResult.htmlDocument, url.Split('/')[^2]);
}
private Manga ParseSinglePublicationFromHtml(HtmlDocument document, string publicationId)
{
string status = "";
Dictionary<string, string> altTitles = new();
Dictionary<string, string>? links = null;
HashSet<string> tags = new();
string[] authors = Array.Empty<string>();
string originalLanguage = "";
HtmlNode infoNode = document.DocumentNode.Descendants("div").First(d => d.HasClass("info"));
string sortName = infoNode.Descendants("h1").First().InnerText;
HtmlNode metadata = infoNode.Descendants().First(d => d.HasClass("meta-data"));
HtmlNode altTitlesNode = metadata.SelectSingleNode("//span[text()='Titoli alternativi: ']/..").ChildNodes[1];
string[] alts = altTitlesNode.InnerText.Split(", ");
for(int i = 0; i < alts.Length; i++)
altTitles.Add(i.ToString(), alts[i]);
HtmlNode genresNode =
metadata.SelectSingleNode("//span[text()='Generi: ']/..");
tags = genresNode.SelectNodes("a").Select(node => node.InnerText).ToHashSet();
HtmlNode authorsNode =
metadata.SelectSingleNode("//span[text()='Autore: ']/..");
authors = new[] { authorsNode.SelectNodes("a").First().InnerText };
status = metadata.SelectSingleNode("//span[text()='Stato: ']/..").SelectNodes("a").First().InnerText;
string posterUrl = document.DocumentNode.SelectSingleNode("//img[@class='rounded']").GetAttributeValue("src", "");
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, 1);
string description = document.DocumentNode.SelectSingleNode("//div[@id='noidungm']").InnerText;
string yearString = metadata.SelectSingleNode("//span[text()='Anno di uscita: ']/..").SelectNodes("a").First().InnerText;
int year = Convert.ToInt32(yearString);
Manga manga = new (sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links,
year, originalLanguage, status, publicationId);
cachedPublications.Add(manga);
return manga;
}
public override Chapter[] GetChapters(Manga manga, string language="en")
{
Log($"Getting chapters {manga}");
string requestUrl = $"https://www.mangaworld.bz/manga/{manga.publicationId}";
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return Array.Empty<Chapter>();
//Return Chapters ordered by Chapter-Number
if (requestResult.htmlDocument is null)
return Array.Empty<Chapter>();
List<Chapter> chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument);
Log($"Got {chapters.Count} chapters. {manga}");
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, numberFormatDecimalPoint)).ToArray();
}
private List<Chapter> ParseChaptersFromHtml(Manga manga, HtmlDocument document)
{
List<Chapter> ret = new();
foreach (HtmlNode volNode in document.DocumentNode.SelectNodes(
"//div[contains(concat(' ',normalize-space(@class),' '),'chapters-wrapper')]//div[contains(concat(' ',normalize-space(@class),' '),'volume-element')]"))
{
string volume = volNode.SelectNodes("div").First(node => node.HasClass("volume")).SelectSingleNode("p").InnerText.Split(' ')[^1];
foreach (HtmlNode chNode in volNode.SelectNodes("div").First(node => node.HasClass("volume-chapters")).SelectNodes("div"))
{
string number = chNode.SelectSingleNode("a").SelectSingleNode("span").InnerText.Split(" ")[^1];
string url = chNode.SelectSingleNode("a").GetAttributeValue("href", "");
ret.Add(new Chapter(manga, null, volume, number, url));
}
}
ret.Reverse();
return ret;
}
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
{
if (progressToken?.cancellationRequested ?? false)
return HttpStatusCode.RequestTimeout;
Manga chapterParentManga = chapter.parentManga;
Log($"Retrieving chapter-info {chapter} {chapterParentManga}");
string requestUrl = $"{chapter.url}?style=list";
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(requestUrl, 1);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return requestResult.statusCode;
if (requestResult.htmlDocument is null)
return HttpStatusCode.InternalServerError;
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.htmlDocument);
string comicInfoPath = Path.GetTempFileName();
File.WriteAllText(comicInfoPath, chapter.GetComicInfoXmlString());
return DownloadChapterImages(imageUrls, chapter.GetArchiveFilePath(settings.downloadLocation), 1, comicInfoPath, "https://www.mangaworld.bz/", progressToken:progressToken);
}
private string[] ParseImageUrlsFromHtml(HtmlDocument document)
{
List<string> ret = new();
HtmlNode imageContainer =
document.DocumentNode.SelectSingleNode("//div[@id='page']");
foreach(HtmlNode imageNode in imageContainer.Descendants("img"))
ret.Add(imageNode.GetAttributeValue("src", ""));
return ret.ToArray();
}
}

View File

@ -1,99 +0,0 @@
using System.Text.Json.Nodes;
using Logging;
using Newtonsoft.Json;
using Tranga.LibraryManagers;
using Tranga.NotificationManagers;
using Tranga.TrangaTasks;
namespace Tranga;
public static class Migrator
{
internal static readonly ushort CurrentVersion = 17;
public static void Migrate(string settingsFilePath, Logger? logger)
{
if (!File.Exists(settingsFilePath))
return;
JsonNode settingsNode = JsonNode.Parse(File.ReadAllText(settingsFilePath))!;
ushort version = settingsNode["version"] is not null
? settingsNode["version"]!.GetValue<ushort>()
: settingsNode["ts"]!["version"]!.GetValue<ushort>();
logger?.WriteLine("Migrator", $"Migrating {version} -> {CurrentVersion}");
switch (version)
{
case 15:
MoveToCommonObjects(settingsFilePath, logger);
TrangaSettings.SettingsJsonObject sjo = JsonConvert.DeserializeObject<TrangaSettings.SettingsJsonObject>(File.ReadAllText(settingsFilePath))!;
RemoveUpdateLibraryTask(sjo.ts!, logger);
break;
case 16:
MoveToCommonObjects(settingsFilePath, logger);
break;
}
TrangaSettings.SettingsJsonObject sjo2 = JsonConvert.DeserializeObject<TrangaSettings.SettingsJsonObject>(
File.ReadAllText(settingsFilePath),
new JsonSerializerSettings
{
Converters =
{
new TrangaTask.TrangaTaskJsonConverter(),
new NotificationManager.NotificationManagerJsonConverter(),
new LibraryManager.LibraryManagerJsonConverter()
}
})!;
sjo2.ts!.version = CurrentVersion;
sjo2.ts!.ExportSettings();
}
private static void RemoveUpdateLibraryTask(TrangaSettings settings, Logger? logger)
{
if (!File.Exists(settings.tasksFilePath))
return;
logger?.WriteLine("Migrator", "Removing old/deprecated UpdateLibraryTasks (v16)");
string tasksJsonString = File.ReadAllText(settings.tasksFilePath);
HashSet<TrangaTask> tasks = JsonConvert.DeserializeObject<HashSet<TrangaTask>>(tasksJsonString,
new JsonSerializerSettings { Converters = { new TrangaTask.TrangaTaskJsonConverter() } })!;
tasks.RemoveWhere(t => t.task == TrangaTask.Task.UpdateLibraries);
File.WriteAllText(settings.tasksFilePath, JsonConvert.SerializeObject(tasks));
}
public static void MoveToCommonObjects(string settingsFilePath, Logger? logger)
{
if (!File.Exists(settingsFilePath))
return;
logger?.WriteLine("Migrator", "Moving Settings to commonObjects-structure (v17)");
JsonNode node = JsonNode.Parse(File.ReadAllText(settingsFilePath))!;
TrangaSettings ts = new(
node["downloadLocation"]!.GetValue<string>(),
node["workingDirectory"]!.GetValue<string>());
JsonArray libraryManagers = node["libraryManagers"]!.AsArray();
logger?.WriteLine("Migrator", $"\tGot {libraryManagers.Count} libraryManagers.");
JsonNode? komgaNode = libraryManagers.FirstOrDefault(lm => lm["libraryType"].GetValue<byte>() == (byte)LibraryManager.LibraryType.Komga);
JsonNode? kavitaNode = libraryManagers.FirstOrDefault(lm => lm["libraryType"].GetValue<byte>() == (byte)LibraryManager.LibraryType.Kavita);
HashSet<LibraryManager> lms = new();
if (komgaNode is not null)
lms.Add(new Komga(komgaNode["baseUrl"]!.GetValue<string>(), komgaNode["auth"]!.GetValue<string>(), null));
if (kavitaNode is not null)
lms.Add(new Kavita(kavitaNode["baseUrl"]!.GetValue<string>(), kavitaNode["auth"]!.GetValue<string>(), null));
JsonArray notificationManagers = node["notificationManagers"]!.AsArray();
logger?.WriteLine("Migrator", $"\tGot {notificationManagers.Count} notificationManagers.");
JsonNode? gotifyNode = notificationManagers.FirstOrDefault(nm =>
nm["notificationManagerType"].GetValue<byte>() == (byte)NotificationManager.NotificationManagerType.Gotify);
JsonNode? lunaSeaNode = notificationManagers.FirstOrDefault(nm =>
nm["notificationManagerType"].GetValue<byte>() == (byte)NotificationManager.NotificationManagerType.LunaSea);
HashSet<NotificationManager> nms = new();
if (gotifyNode is not null)
nms.Add(new Gotify(gotifyNode["endpoint"]!.GetValue<string>(), gotifyNode["appToken"]!.GetValue<string>()));
if (lunaSeaNode is not null)
nms.Add(new LunaSea(lunaSeaNode["id"]!.GetValue<string>()));
CommonObjects co = new (lms, nms, logger, settingsFilePath);
TrangaSettings.SettingsJsonObject sjo = new(ts, co);
File.WriteAllText(settingsFilePath, JsonConvert.SerializeObject(sjo));
}
}

View File

@ -1,10 +1,9 @@
using System.Text;
using Logging;
using Newtonsoft.Json;
namespace Tranga.NotificationManagers;
namespace Tranga.NotificationConnectors;
public class Gotify : NotificationManager
public class Gotify : NotificationConnector
{
public string endpoint { get; }
// ReSharper disable once MemberCanBePrivate.Global
@ -12,15 +11,20 @@ public class Gotify : NotificationManager
private readonly HttpClient _client = new();
[JsonConstructor]
public Gotify(string endpoint, string appToken, Logger? logger = null) : base(NotificationManagerType.Gotify, logger)
public Gotify(GlobalBase clone, string endpoint, string appToken) : base(clone, NotificationConnectorType.Gotify)
{
this.endpoint = endpoint;
this.appToken = appToken;
}
public override string ToString()
{
return $"Gotify {endpoint}";
}
public override void SendNotification(string title, string notificationText)
{
logger?.WriteLine(this.GetType().ToString(), $"Sending notification: {title} - {notificationText}");
Log($"Sending notification: {title} - {notificationText}");
MessageData message = new(title, notificationText);
HttpRequestMessage request = new(HttpMethod.Post, $"{endpoint}/message");
request.Headers.Add("X-Gotify-Key", this.appToken);
@ -29,7 +33,7 @@ public class Gotify : NotificationManager
if (!response.IsSuccessStatusCode)
{
StreamReader sr = new (response.Content.ReadAsStream());
logger?.WriteLine(this.GetType().ToString(), $"{response.StatusCode}: {sr.ReadToEnd()}");
Log($"{response.StatusCode}: {sr.ReadToEnd()}");
}
}

View File

@ -1,24 +1,28 @@
using System.Text;
using Logging;
using Newtonsoft.Json;
namespace Tranga.NotificationManagers;
namespace Tranga.NotificationConnectors;
public class LunaSea : NotificationManager
public class LunaSea : NotificationConnector
{
// ReSharper disable once MemberCanBePrivate.Global
public string id { get; init; }
private readonly HttpClient _client = new();
[JsonConstructor]
public LunaSea(string id, Logger? logger = null) : base(NotificationManagerType.LunaSea, logger)
public LunaSea(GlobalBase clone, string id) : base(clone, NotificationConnectorType.LunaSea)
{
this.id = id;
}
public override string ToString()
{
return $"LunaSea {id}";
}
public override void SendNotification(string title, string notificationText)
{
logger?.WriteLine(this.GetType().ToString(), $"Sending notification: {title} - {notificationText}");
Log($"Sending notification: {title} - {notificationText}");
MessageData message = new(title, notificationText);
HttpRequestMessage request = new(HttpMethod.Post, $"https://notify.lunasea.app/v1/custom/{id}");
request.Content = new StringContent(JsonConvert.SerializeObject(message, Formatting.None), Encoding.UTF8, "application/json");
@ -26,7 +30,7 @@ public class LunaSea : NotificationManager
if (!response.IsSuccessStatusCode)
{
StreamReader sr = new (response.Content.ReadAsStream());
logger?.WriteLine(this.GetType().ToString(), $"{response.StatusCode}: {sr.ReadToEnd()}");
Log($"{response.StatusCode}: {sr.ReadToEnd()}");
}
}

View File

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

View File

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

View File

@ -1,56 +0,0 @@
using Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Tranga.NotificationManagers;
public abstract class NotificationManager
{
protected Logger? logger;
public NotificationManagerType notificationManagerType;
protected NotificationManager(NotificationManagerType notificationManagerType, Logger? logger = null)
{
this.notificationManagerType = notificationManagerType;
this.logger = logger;
}
public enum NotificationManagerType : byte { Gotify = 0, LunaSea = 1 }
public abstract void SendNotification(string title, string notificationText);
public void AddLogger(Logger pLogger)
{
this.logger = pLogger;
}
public class NotificationManagerJsonConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return (objectType == typeof(NotificationManager));
}
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue,
JsonSerializer serializer)
{
JObject jo = JObject.Load(reader);
if (jo["notificationManagerType"]!.Value<byte>() == (byte)NotificationManagerType.Gotify)
return jo.ToObject<Gotify>(serializer)!;
else if (jo["notificationManagerType"]!.Value<byte>() == (byte)NotificationManagerType.LunaSea)
return jo.ToObject<LunaSea>(serializer)!;
throw new Exception();
}
public override bool CanWrite => false;
/// <summary>
/// Don't call this
/// </summary>
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
throw new Exception("Dont call this");
}
}
}

564
Tranga/Server.cs Normal file
View File

@ -0,0 +1,564 @@
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Tranga.Jobs;
using Tranga.LibraryConnectors;
using Tranga.MangaConnectors;
using Tranga.NotificationConnectors;
namespace Tranga;
public class Server : GlobalBase
{
private readonly HttpListener _listener = new ();
private readonly Tranga _parent;
public Server(Tranga parent) : base(parent)
{
this._parent = parent;
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
this._listener.Prefixes.Add($"http://*:{settings.apiPortNumber}/");
else
this._listener.Prefixes.Add($"http://localhost:{settings.apiPortNumber}/");
Thread listenThread = new (Listen);
listenThread.Start();
Thread watchThread = new(WatchRunning);
watchThread.Start();
}
private void WatchRunning()
{
while(_parent.keepRunning)
Thread.Sleep(1000);
this._listener.Close();
}
private void Listen()
{
this._listener.Start();
foreach(string prefix in this._listener.Prefixes)
Log($"Listening on {prefix}");
while (this._listener.IsListening && _parent.keepRunning)
{
try
{
HttpListenerContext context = this._listener.GetContext();
//Log($"{context.Request.HttpMethod} {context.Request.Url} {context.Request.UserAgent}");
Task t = new(() =>
{
HandleRequest(context);
});
t.Start();
}
catch (HttpListenerException e)
{
}
}
}
private void HandleRequest(HttpListenerContext context)
{
HttpListenerRequest request = context.Request;
HttpListenerResponse response = context.Response;
if(request.HttpMethod == "OPTIONS")
SendResponse(HttpStatusCode.OK, context.Response);
if(request.Url!.LocalPath.Contains("favicon"))
SendResponse(HttpStatusCode.NoContent, response);
switch (request.HttpMethod)
{
case "GET":
HandleGet(request, response);
break;
case "POST":
HandlePost(request, response);
break;
case "DELETE":
HandleDelete(request, response);
break;
default:
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
}
private Dictionary<string, string> GetRequestVariables(string query)
{
Dictionary<string, string> ret = new();
Regex queryRex = new (@"\?{1}&?([A-z0-9-=]+=[A-z0-9-=]+)+(&[A-z0-9-=]+=[A-z0-9-=]+)*");
if (!queryRex.IsMatch(query))
return ret;
query = query.Substring(1);
foreach (string keyValuePair in query.Split('&').Where(str => str.Length >= 3))
{
string var = keyValuePair.Split('=')[0];
string val = Regex.Replace(keyValuePair.Substring(var.Length + 1), "%20", " ");
val = Regex.Replace(val, "%[0-9]{2}", "");
ret.Add(var, val);
}
return ret;
}
private void HandleGet(HttpListenerRequest request, HttpListenerResponse response)
{
Dictionary<string, string> requestVariables = GetRequestVariables(request.Url!.Query);
string? connectorName, jobId, internalId;
MangaConnector? connector;
Manga? manga;
string path = Regex.Match(request.Url!.LocalPath, @"[A-z0-9]+(\/[A-z0-9]+)*").Value;
switch (path)
{
case "Connectors":
SendResponse(HttpStatusCode.OK, response, _parent.GetConnectors().Select(con => con.name).ToArray());
break;
case "Manga/Cover":
if (!requestVariables.TryGetValue("internalId", out internalId) ||
!_parent.TryGetPublicationById(internalId, out manga))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
string filePath = settings.GetFullCoverPath((Manga)manga!);
if (File.Exists(filePath))
{
FileStream coverStream = new(filePath, FileMode.Open);
SendResponse(HttpStatusCode.OK, response, coverStream);
}
else
{
SendResponse(HttpStatusCode.NotFound, response);
}
break;
case "Manga/FromConnector":
requestVariables.TryGetValue("title", out string? title);
requestVariables.TryGetValue("url", out string? url);
if (!requestVariables.TryGetValue("connector", out connectorName) ||
!_parent.TryGetConnector(connectorName, out connector) ||
(title is null && url is null))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
if (url is not null)
{
HashSet<Manga> ret = new();
manga = connector!.GetMangaFromUrl(url);
if (manga is not null)
ret.Add((Manga)manga);
SendResponse(HttpStatusCode.OK, response, ret);
}else
SendResponse(HttpStatusCode.OK, response, connector!.GetManga(title!));
break;
case "Manga/Chapters":
if(!requestVariables.TryGetValue("connector", out connectorName) ||
!requestVariables.TryGetValue("internalId", out internalId) ||
!_parent.TryGetConnector(connectorName, out connector) ||
!_parent.TryGetPublicationById(internalId, out manga))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
requestVariables.TryGetValue("translatedLanguage", out string? translatedLanguage);
SendResponse(HttpStatusCode.OK, response, connector!.GetChapters((Manga)manga!, translatedLanguage??"en"));
break;
case "Jobs":
if (!requestVariables.TryGetValue("jobId", out jobId))
{
if(!_parent.jobBoss.jobs.Any(jjob => jjob.id == jobId))
SendResponse(HttpStatusCode.BadRequest, response);
else
SendResponse(HttpStatusCode.OK, response, _parent.jobBoss.jobs.First(jjob => jjob.id == jobId));
break;
}
SendResponse(HttpStatusCode.OK, response, _parent.jobBoss.jobs);
break;
case "Jobs/Progress":
if (requestVariables.TryGetValue("jobId", out jobId))
{
if(!_parent.jobBoss.jobs.Any(jjob => jjob.id == jobId))
SendResponse(HttpStatusCode.BadRequest, response);
else
SendResponse(HttpStatusCode.OK, response, _parent.jobBoss.jobs.First(jjob => jjob.id == jobId).progressToken);
break;
}
SendResponse(HttpStatusCode.OK, response, _parent.jobBoss.jobs.Select(jjob => jjob.progressToken));
break;
case "Jobs/Running":
SendResponse(HttpStatusCode.OK, response, _parent.jobBoss.jobs.Where(jjob => jjob.progressToken.state is ProgressToken.State.Running));
break;
case "Jobs/Waiting":
SendResponse(HttpStatusCode.OK, response, _parent.jobBoss.jobs.Where(jjob => jjob.progressToken.state is ProgressToken.State.Standby));
break;
case "Jobs/MonitorJobs":
SendResponse(HttpStatusCode.OK, response, _parent.jobBoss.jobs.Where(jjob => jjob is DownloadNewChapters));
break;
case "Settings":
SendResponse(HttpStatusCode.OK, response, settings);
break;
case "NotificationConnectors":
SendResponse(HttpStatusCode.OK, response, notificationConnectors);
break;
case "NotificationConnectors/Types":
SendResponse(HttpStatusCode.OK, response,
Enum.GetValues<NotificationConnector.NotificationConnectorType>().Select(nc => new KeyValuePair<byte, string?>((byte)nc, Enum.GetName(nc))));
break;
case "LibraryConnectors":
SendResponse(HttpStatusCode.OK, response, libraryConnectors);
break;
case "LibraryConnectors/Types":
SendResponse(HttpStatusCode.OK, response,
Enum.GetValues<LibraryConnector.LibraryType>().Select(lc => new KeyValuePair<byte, string?>((byte)lc, Enum.GetName(lc))));
break;
case "Ping":
SendResponse(HttpStatusCode.OK, response, "Pong");
break;
default:
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
}
private void HandlePost(HttpListenerRequest request, HttpListenerResponse response)
{
Dictionary<string, string> requestVariables = GetRequestVariables(request.Url!.Query);
string? connectorName, internalId, jobId, chapterNumStr, customFolderName, translatedLanguage;
MangaConnector? connector;
Manga? tmpManga;
Manga manga;
Job? job;
string path = Regex.Match(request.Url!.LocalPath, @"[A-z0-9]+(\/[A-z0-9]+)*").Value;
switch (path)
{
case "Manga":
if(!requestVariables.TryGetValue("internalId", out internalId) ||
!_parent.TryGetPublicationById(internalId, out tmpManga))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
manga = (Manga)tmpManga!;
SendResponse(HttpStatusCode.OK, response, manga);
break;
case "Jobs/MonitorManga":
if(!requestVariables.TryGetValue("connector", out connectorName) ||
!requestVariables.TryGetValue("internalId", out internalId) ||
!requestVariables.TryGetValue("interval", out string? intervalStr) ||
!_parent.TryGetConnector(connectorName, out connector)||
!_parent.TryGetPublicationById(internalId, out tmpManga) ||
!TimeSpan.TryParse(intervalStr, out TimeSpan interval))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
manga = (Manga)tmpManga!;
if (requestVariables.TryGetValue("ignoreBelowChapterNum", out chapterNumStr))
{
if (!float.TryParse(chapterNumStr, numberFormatDecimalPoint, out float chapterNum))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
manga.ignoreChaptersBelow = chapterNum;
}
if (requestVariables.TryGetValue("customFolderName", out customFolderName))
manga.MovePublicationFolder(settings.downloadLocation, customFolderName);
requestVariables.TryGetValue("translatedLanguage", out translatedLanguage);
_parent.jobBoss.AddJob(new DownloadNewChapters(this, connector!, manga, true, interval, translatedLanguage: translatedLanguage??"en"));
SendResponse(HttpStatusCode.Accepted, response);
break;
case "Jobs/DownloadNewChapters":
if(!requestVariables.TryGetValue("connector", out connectorName) ||
!requestVariables.TryGetValue("internalId", out internalId) ||
!_parent.TryGetConnector(connectorName, out connector)||
!_parent.TryGetPublicationById(internalId, out tmpManga))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
manga = (Manga)tmpManga!;
if (requestVariables.TryGetValue("ignoreBelowChapterNum", out chapterNumStr))
{
if (!float.TryParse(chapterNumStr, numberFormatDecimalPoint, out float chapterNum))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
manga.ignoreChaptersBelow = chapterNum;
}
if (requestVariables.TryGetValue("customFolderName", out customFolderName))
manga.MovePublicationFolder(settings.downloadLocation, customFolderName);
requestVariables.TryGetValue("translatedLanguage", out translatedLanguage);
_parent.jobBoss.AddJob(new DownloadNewChapters(this, connector!, manga, false, translatedLanguage: translatedLanguage??"en"));
SendResponse(HttpStatusCode.Accepted, response);
break;
case "Jobs/StartNow":
if (!requestVariables.TryGetValue("jobId", out jobId) ||
!_parent.jobBoss.TryGetJobById(jobId, out job))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
_parent.jobBoss.AddJobToQueue(job!);
SendResponse(HttpStatusCode.Accepted, response);
break;
case "Jobs/Cancel":
if (!requestVariables.TryGetValue("jobId", out jobId) ||
!_parent.jobBoss.TryGetJobById(jobId, out job))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
job!.Cancel();
SendResponse(HttpStatusCode.Accepted, response);
break;
case "Settings/UpdateDownloadLocation":
if (!requestVariables.TryGetValue("downloadLocation", out string? downloadLocation) ||
!requestVariables.TryGetValue("moveFiles", out string? moveFilesStr) ||
!Boolean.TryParse(moveFilesStr, out bool moveFiles))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
settings.UpdateDownloadLocation(downloadLocation, moveFiles);
SendResponse(HttpStatusCode.Accepted, response);
break;
/*case "Settings/UpdateWorkingDirectory":
if (!requestVariables.TryGetValue("workingDirectory", out string? workingDirectory))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
settings.UpdateWorkingDirectory(workingDirectory);
SendResponse(HttpStatusCode.Accepted, response);
break;*/
case "NotificationConnectors/Update":
if (!requestVariables.TryGetValue("notificationConnector", out string? notificationConnectorStr) ||
!Enum.TryParse(notificationConnectorStr, out NotificationConnector.NotificationConnectorType notificationConnectorType))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
if (notificationConnectorType is NotificationConnector.NotificationConnectorType.Gotify)
{
if (!requestVariables.TryGetValue("gotifyUrl", out string? gotifyUrl) ||
!requestVariables.TryGetValue("gotifyAppToken", out string? gotifyAppToken))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
AddNotificationConnector(new Gotify(this, gotifyUrl, gotifyAppToken));
SendResponse(HttpStatusCode.Accepted, response);
break;
}
if (notificationConnectorType is NotificationConnector.NotificationConnectorType.LunaSea)
{
if (!requestVariables.TryGetValue("lunaseaWebhook", out string? lunaseaWebhook))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
AddNotificationConnector(new LunaSea(this, lunaseaWebhook));
SendResponse(HttpStatusCode.Accepted, response);
break;
}
break;
case "LibraryConnectors/Update":
if (!requestVariables.TryGetValue("libraryConnector", out string? libraryConnectorStr) ||
!Enum.TryParse(libraryConnectorStr,
out LibraryConnector.LibraryType libraryConnectorType))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
if (libraryConnectorType is LibraryConnector.LibraryType.Kavita)
{
if (!requestVariables.TryGetValue("kavitaUrl", out string? kavitaUrl) ||
!requestVariables.TryGetValue("kavitaUsername", out string? kavitaUsername) ||
!requestVariables.TryGetValue("kavitaPassword", out string? kavitaPassword))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
AddLibraryConnector(new Kavita(this, kavitaUrl, kavitaUsername, kavitaPassword));
SendResponse(HttpStatusCode.Accepted, response);
break;
}
if (libraryConnectorType is LibraryConnector.LibraryType.Komga)
{
if (!requestVariables.TryGetValue("komgaUrl", out string? komgaUrl) ||
!requestVariables.TryGetValue("komgaAuth", out string? komgaAuth))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
AddLibraryConnector(new Komga(this, komgaUrl, komgaAuth));
SendResponse(HttpStatusCode.Accepted, response);
break;
}
break;
case "LogMessages":
if (logger is null || !File.Exists(logger?.logFilePath))
{
SendResponse(HttpStatusCode.NotFound, response);
break;
}
if (requestVariables.TryGetValue("count", out string? count))
{
try
{
uint messageCount = uint.Parse(count);
SendResponse(HttpStatusCode.OK, response, logger.Tail(messageCount));
}
catch (FormatException f)
{
SendResponse(HttpStatusCode.InternalServerError, response, f);
}
}else
SendResponse(HttpStatusCode.OK, response, logger.GetLog());
break;
case "LogFile":
if (logger is null || !File.Exists(logger?.logFilePath))
{
SendResponse(HttpStatusCode.NotFound, response);
break;
}
string logDir = new FileInfo(logger.logFilePath).DirectoryName!;
string tmpFilePath = Path.Join(logDir, "Tranga.log");
File.Copy(logger.logFilePath, tmpFilePath);
SendResponse(HttpStatusCode.OK, response, new FileStream(tmpFilePath, FileMode.Open));
File.Delete(tmpFilePath);
break;
default:
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
}
private void HandleDelete(HttpListenerRequest request, HttpListenerResponse response)
{
Dictionary<string, string> requestVariables = GetRequestVariables(request.Url!.Query);
string? connectorName, internalId;
MangaConnector connector;
Manga manga;
string path = Regex.Match(request.Url!.LocalPath, @"[A-z0-9]+(\/[A-z0-9]+)*").Value;
switch (path)
{
case "Jobs":
if (!requestVariables.TryGetValue("jobId", out string? jobId) ||
!_parent.jobBoss.TryGetJobById(jobId, out Job? job))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
_parent.jobBoss.RemoveJob(job!);
SendResponse(HttpStatusCode.Accepted, response);
break;
case "Jobs/DownloadNewChapters":
if(!requestVariables.TryGetValue("connector", out connectorName) ||
!requestVariables.TryGetValue("internalId", out internalId) ||
_parent.GetConnector(connectorName) is null ||
_parent.GetPublicationById(internalId) is null)
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
connector = _parent.GetConnector(connectorName)!;
manga = (Manga)_parent.GetPublicationById(internalId)!;
_parent.jobBoss.RemoveJobs(_parent.jobBoss.GetJobsLike(connector, manga));
SendResponse(HttpStatusCode.Accepted, response);
break;
case "NotificationConnectors":
if (!requestVariables.TryGetValue("notificationConnector", out string? notificationConnectorStr) ||
!Enum.TryParse(notificationConnectorStr, out NotificationConnector.NotificationConnectorType notificationConnectorType))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
DeleteNotificationConnector(notificationConnectorType);
SendResponse(HttpStatusCode.Accepted, response);
break;
case "LibraryConnectors":
if (!requestVariables.TryGetValue("libraryConnectors", out string? libraryConnectorStr) ||
!Enum.TryParse(libraryConnectorStr,
out LibraryConnector.LibraryType libraryConnectoryType))
{
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
DeleteLibraryConnector(libraryConnectoryType);
SendResponse(HttpStatusCode.Accepted, response);
break;
default:
SendResponse(HttpStatusCode.BadRequest, response);
break;
}
}
private void SendResponse(HttpStatusCode statusCode, HttpListenerResponse response, object? content = null)
{
//Log($"Response: {statusCode} {content}");
response.StatusCode = (int)statusCode;
response.AddHeader("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With");
response.AddHeader("Access-Control-Allow-Methods", "GET, POST, DELETE");
response.AddHeader("Access-Control-Max-Age", "1728000");
response.AppendHeader("Access-Control-Allow-Origin", "*");
if (content is not Stream)
{
response.ContentType = "application/json";
try
{
response.OutputStream.Write(content is not null
? Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(content))
: Array.Empty<byte>());
response.OutputStream.Close();
}
catch (HttpListenerException e)
{
Log(e.ToString());
}
}
else if(content is FileStream stream)
{
string contentType = stream.Name.Split('.')[^1];
switch (contentType.ToLower())
{
case "gif":
response.ContentType = "image/gif";
break;
case "png":
response.ContentType = "image/png";
break;
case "jpg":
case "jpeg":
response.ContentType = "image/jpeg";
break;
case "log":
response.ContentType = "text/plain";
break;
}
stream.CopyTo(response.OutputStream);
response.OutputStream.Close();
stream.Close();
}
}
}

View File

@ -1,385 +0,0 @@
using Newtonsoft.Json;
using Tranga.Connectors;
using Tranga.TrangaTasks;
namespace Tranga;
/// <summary>
/// Manages all TrangaTasks.
/// Provides a Threaded environment to execute Tasks, and still manage the Task-Collection
/// </summary>
public class TaskManager
{
public HashSet<Publication> collection = new();
private HashSet<TrangaTask> _allTasks = new();
private readonly Dictionary<TrangaTask, CancellationTokenSource> _runningTasks = new ();
public bool _continueRunning = true;
private readonly Connector[] _connectors;
public TrangaSettings settings { get; }
public CommonObjects commonObjects { get; init; }
public TaskManager(TrangaSettings settings, Logging.Logger? logger)
{
commonObjects = CommonObjects.LoadSettings(settings.settingsFilePath, logger);
commonObjects.logger?.WriteLine(this.GetType().ToString(), value: "\n"+
@"-----------------------------------------------------------------"+"\n"+
@" |¯¯¯¯¯¯|°|¯¯¯¯¯¯\ /¯¯¯¯¯¯| |¯¯¯\|¯¯¯| /¯¯¯¯¯¯\' /¯¯¯¯¯¯| "+"\n"+
@" | | | x <|' / ! | | '| | (/¯¯¯\° / ! | "+ "\n"+
@" ¯|__|¯ |__|\\__\\ /___/¯|_'| |___|\\__| \\_____/' /___/¯|_'| "+ "\n"+
@"-----------------------------------------------------------------");
this._connectors = new Connector[]
{
new MangaDex(settings, commonObjects),
new Manganato(settings, commonObjects),
new Mangasee(settings, commonObjects),
new MangaKatana(settings, commonObjects)
};
this.settings = settings;
ImportData();
ExportDataAndSettings();
Thread taskChecker = new(TaskCheckerThread);
taskChecker.Start();
}
/// <summary>
/// Runs continuously until shutdown.
/// Checks if tasks have to be executed (time elapsed)
/// </summary>
private void TaskCheckerThread()
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), "Starting TaskCheckerThread.");
int waitingTasksCount = _allTasks.Count(task => task.state is TrangaTask.ExecutionState.Waiting);
while (_continueRunning)
{
foreach (TrangaTask waitingButExecute in _allTasks.Where(taskQuery =>
taskQuery.nextExecution < DateTime.Now &&
taskQuery.state is TrangaTask.ExecutionState.Waiting))
{
waitingButExecute.state = TrangaTask.ExecutionState.Enqueued;
}
foreach (TrangaTask enqueuedTask in _allTasks.Where(enqueuedTask => enqueuedTask.state is TrangaTask.ExecutionState.Enqueued).OrderBy(enqueuedTask => enqueuedTask.nextExecution))
{
switch (enqueuedTask.task)
{
case TrangaTask.Task.DownloadChapter:
case TrangaTask.Task.MonitorPublication:
if (!_allTasks.Any(taskQuery =>
{
if (taskQuery.state is not TrangaTask.ExecutionState.Running) return false;
switch (taskQuery)
{
case DownloadChapterTask dct when enqueuedTask is DownloadChapterTask eDct && dct.connectorName == eDct.connectorName:
case MonitorPublicationTask mpt when enqueuedTask is MonitorPublicationTask eMpt && mpt.connectorName == eMpt.connectorName:
return true;
default:
return false;
}
}))
{
ExecuteTaskNow(enqueuedTask);
}
break;
case TrangaTask.Task.UpdateLibraries:
ExecuteTaskNow(enqueuedTask);
break;
}
}
foreach (TrangaTask timedOutTask in _runningTasks.Keys
.Where(taskQuery => taskQuery.lastChange < DateTime.Now.Subtract(TimeSpan.FromMinutes(3))))
{
_runningTasks[timedOutTask].Cancel();
timedOutTask.state = TrangaTask.ExecutionState.Failed;
}
foreach (TrangaTask finishedTask in _allTasks
.Where(taskQuery => taskQuery.state is TrangaTask.ExecutionState.Success).ToArray())
{
if(finishedTask is DownloadChapterTask)
{
DeleteTask(finishedTask);
finishedTask.state = TrangaTask.ExecutionState.Success;
}
else
{
finishedTask.state = TrangaTask.ExecutionState.Waiting;
this._runningTasks.Remove(finishedTask);
}
}
foreach (TrangaTask failedTask in _allTasks.Where(taskQuery =>
taskQuery.state is TrangaTask.ExecutionState.Failed).ToArray())
{
DeleteTask(failedTask);
TrangaTask newTask = failedTask.Clone();
failedTask.parentTask?.AddChildTask(newTask);
AddTask(newTask);
}
if(waitingTasksCount != _allTasks.Count(task => task.state is TrangaTask.ExecutionState.Waiting))
ExportDataAndSettings();
waitingTasksCount = _allTasks.Count(task => task.state is TrangaTask.ExecutionState.Waiting);
Thread.Sleep(1000);
}
}
/// <summary>
/// Forces the execution of a given task
/// </summary>
/// <param name="task">Task to execute</param>
public void ExecuteTaskNow(TrangaTask task)
{
task.state = TrangaTask.ExecutionState.Running;
CancellationTokenSource cToken = new ();
Task t = new(() =>
{
task.Execute(this, cToken.Token);
}, cToken.Token);
_runningTasks.Add(task, cToken);
t.Start();
}
public void AddTask(TrangaTask newTask)
{
switch (newTask.task)
{
case TrangaTask.Task.UpdateLibraries:
//Only one UpdateKomgaLibrary Task
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Replacing old {newTask.task}-Task.");
if (GetTasksMatching(newTask).FirstOrDefault() is { } exists)
_allTasks.Remove(exists);
_allTasks.Add(newTask);
ExportDataAndSettings();
break;
default:
if (!GetTasksMatching(newTask).Any())
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Adding new Task {newTask}");
_allTasks.Add(newTask);
ExportDataAndSettings();
}
else
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Task already exists {newTask}");
break;
}
}
public void DeleteTask(TrangaTask removeTask)
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Removing Task {removeTask}");
if(_allTasks.Contains(removeTask))
_allTasks.Remove(removeTask);
removeTask.parentTask?.RemoveChildTask(removeTask);
if (_runningTasks.ContainsKey(removeTask))
{
_runningTasks[removeTask].Cancel();
_runningTasks.Remove(removeTask);
}
foreach(TrangaTask childTask in removeTask.childTasks)
DeleteTask(childTask);
ExportDataAndSettings();
}
// ReSharper disable once MemberCanBePrivate.Global
public IEnumerable<TrangaTask> GetTasksMatching(TrangaTask mTask)
{
switch (mTask.task)
{
case TrangaTask.Task.UpdateLibraries:
return GetTasksMatching(TrangaTask.Task.UpdateLibraries);
case TrangaTask.Task.DownloadChapter:
DownloadChapterTask dct = (DownloadChapterTask)mTask;
return GetTasksMatching(TrangaTask.Task.DownloadChapter, connectorName: dct.connectorName,
internalId: dct.publication.internalId, chapterNumber: dct.chapter.chapterNumber);
case TrangaTask.Task.MonitorPublication:
MonitorPublicationTask mpt = (MonitorPublicationTask)mTask;
return GetTasksMatching(TrangaTask.Task.MonitorPublication, connectorName: mpt.connectorName,
internalId: mpt.publication.internalId);
}
return Array.Empty<TrangaTask>();
}
public IEnumerable<TrangaTask> GetTasksMatching(TrangaTask.Task taskType, string? connectorName = null, string? searchString = null, string? internalId = null, string? chapterNumber = null)
{
switch (taskType)
{
case TrangaTask.Task.MonitorPublication:
if(connectorName is null)
return _allTasks.Where(tTask => tTask.task == taskType);
GetConnector(connectorName);//Name check
if (searchString is not null)
{
return _allTasks.Where(mTask =>
mTask is MonitorPublicationTask mpt && mpt.connectorName == connectorName &&
mpt.ToString().Contains(searchString, StringComparison.InvariantCultureIgnoreCase));
}
else if (internalId is not null)
{
return _allTasks.Where(mTask =>
mTask is MonitorPublicationTask mpt && mpt.connectorName == connectorName &&
mpt.publication.internalId == internalId);
}
else
return _allTasks.Where(tTask =>
tTask is MonitorPublicationTask mpt && mpt.connectorName == connectorName);
case TrangaTask.Task.DownloadChapter:
if(connectorName is null)
return _allTasks.Where(tTask => tTask.task == taskType);
GetConnector(connectorName);//Name check
if (searchString is not null)
{
return _allTasks.Where(mTask =>
mTask is DownloadChapterTask dct && dct.connectorName == connectorName &&
dct.ToString().Contains(searchString, StringComparison.InvariantCultureIgnoreCase));
}
else if (internalId is not null && chapterNumber is not null)
{
return _allTasks.Where(mTask =>
mTask is DownloadChapterTask dct && dct.connectorName == connectorName &&
dct.publication.internalId == internalId &&
dct.chapter.chapterNumber == chapterNumber);
}
else
return _allTasks.Where(mTask =>
mTask is DownloadChapterTask dct && dct.connectorName == connectorName);
default:
return Array.Empty<TrangaTask>();
}
}
/// <summary>
/// Removes a Task from the queue
/// </summary>
/// <param name="task"></param>
public void RemoveTaskFromQueue(TrangaTask task)
{
task.lastExecuted = DateTime.Now;
task.state = TrangaTask.ExecutionState.Waiting;
}
/// <summary>
/// Sets last execution time to start of time
/// Let taskManager handle enqueuing
/// </summary>
/// <param name="task"></param>
public void AddTaskToQueue(TrangaTask task)
{
task.lastExecuted = DateTime.UnixEpoch;
}
/// <returns>All available Connectors</returns>
public Dictionary<string, Connector> GetAvailableConnectors()
{
return this._connectors.ToDictionary(connector => connector.name, connector => connector);
}
/// <returns>All TrangaTasks in task-collection</returns>
public TrangaTask[] GetAllTasks()
{
TrangaTask[] ret = new TrangaTask[_allTasks.Count];
_allTasks.CopyTo(ret);
return ret;
}
/// <returns>All added Publications</returns>
public Publication[] GetAllPublications()
{
return this.collection.ToArray();
}
public List<Chapter> GetExistingChaptersList(Connector connector, Publication publication, string language)
{
Chapter[] newChapters = connector.GetChapters(publication, language);
return newChapters.Where(nChapter => nChapter.CheckChapterIsDownloaded(settings.downloadLocation)).ToList();
}
/// <summary>
/// Return Connector with given Name
/// </summary>
/// <param name="connectorName">Connector-name (exact)</param>
/// <exception cref="Exception">If Connector is not available</exception>
public Connector GetConnector(string? connectorName)
{
if(connectorName is null)
throw new Exception($"connectorName can not be null");
Connector? ret = this._connectors.FirstOrDefault(connector => connector.name == connectorName);
if (ret is null)
throw new Exception($"Connector {connectorName} is not an available Connector.");
return ret;
}
/// <summary>
/// Shuts down the taskManager.
/// </summary>
/// <param name="force">If force is true, tasks are aborted.</param>
public void Shutdown(bool force = false)
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Shutting down (forced={force})");
_continueRunning = false;
ExportDataAndSettings();
if(force)
Environment.Exit(_allTasks.Count(task => task.state is TrangaTask.ExecutionState.Enqueued or TrangaTask.ExecutionState.Running));
//Wait for tasks to finish
while(_allTasks.Any(task => task.state is TrangaTask.ExecutionState.Running or TrangaTask.ExecutionState.Enqueued))
Thread.Sleep(10);
commonObjects.logger?.WriteLine(this.GetType().ToString(), "Tasks finished. Bye!");
Environment.Exit(0);
}
private void ImportData()
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), "Importing Data");
if (File.Exists(settings.tasksFilePath))
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Importing tasks from {settings.tasksFilePath}");
string buffer = File.ReadAllText(settings.tasksFilePath);
this._allTasks = JsonConvert.DeserializeObject<HashSet<TrangaTask>>(buffer, new JsonSerializerSettings() { Converters = { new TrangaTask.TrangaTaskJsonConverter() } })!;
}
foreach (TrangaTask task in this._allTasks.Where(tTask => tTask.parentTaskId is not null).ToArray())
{
TrangaTask? parentTask = this._allTasks.FirstOrDefault(pTask => pTask.taskId == task.parentTaskId);
if (parentTask is not null)
{
this.DeleteTask(task);
parentTask.lastExecuted = DateTime.UnixEpoch;
}
}
}
/// <summary>
/// Exports data (settings, tasks) to file
/// </summary>
private void ExportDataAndSettings()
{
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Exporting settings to {settings.settingsFilePath}");
settings.ExportSettings();
commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Exporting tasks to {settings.tasksFilePath}");
while(IsFileInUse(settings.tasksFilePath))
Thread.Sleep(50);
File.WriteAllText(settings.tasksFilePath, JsonConvert.SerializeObject(this._allTasks));
}
private bool IsFileInUse(string path)
{
if (!File.Exists(path))
return false;
try
{
using FileStream stream = new (path, FileMode.Open, FileAccess.Read, FileShare.None);
stream.Close();
}
catch (IOException)
{
return true;
}
return false;
}
}

View File

@ -1,580 +1,81 @@
using System.Globalization;
using System.Runtime.InteropServices;
using Logging;
using Tranga.API;
using Tranga.Connectors;
using Tranga.NotificationManagers;
using Tranga.TrangaTasks;
using Logging;
using Tranga.Jobs;
using Tranga.MangaConnectors;
namespace Tranga;
public static class Tranga
public partial class Tranga : GlobalBase
{
public static void Main(string[] args)
public bool keepRunning;
public JobBoss jobBoss;
private Server _server;
private HashSet<MangaConnector> _connectors;
public Tranga(Logger? logger, TrangaSettings settings) : base(logger, settings)
{
bool isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
string applicationFolderPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Tranga-API");
string downloadFolderPath = isLinux ? "/Manga" : Path.Join(applicationFolderPath, "Manga");
string logsFolderPath = isLinux ? "/var/log/Tranga" : Path.Join(applicationFolderPath, "log");
string logFilePath = Path.Join(logsFolderPath, $"log-{DateTime.Now:dd-M-yyyy-HH-mm-ss}.txt");
string settingsFilePath = Path.Join(applicationFolderPath, "settings.json");
Directory.CreateDirectory(logsFolderPath);
Logger logger = isLinux
? new Logger(new[] { Logger.LoggerType.FileLogger, Logger.LoggerType.ConsoleLogger }, Console.Out, Console.Out.Encoding, logFilePath)
: new Logger(new[] { Logger.LoggerType.FileLogger }, Console.Out, Console.Out.Encoding, logFilePath);
logger.WriteLine("Tranga",value: "\n"+
"-------------------------------------------\n"+
" Starting Tranga-API\n"+
"-------------------------------------------");
logger.WriteLine("Tranga", "Migrating...");
Migrator.Migrate(settingsFilePath, logger);
TrangaSettings settings;
if (File.Exists(settingsFilePath))
Log("\n\n _______ \n|_ _|.----..---.-..-----..-----..---.-.\n | | | _|| _ || || _ || _ |\n |___| |__| |___._||__|__||___ ||___._|\n |_____| \n\n");
Log(settings.ToString());
keepRunning = true;
_connectors = new HashSet<MangaConnector>()
{
logger.WriteLine("Tranga", $"Loading settings {settingsFilePath}");
settings = TrangaSettings.LoadSettings(settingsFilePath);
}
else
{
settings = new TrangaSettings(downloadFolderPath, applicationFolderPath);
settings.version = Migrator.CurrentVersion;
}
Directory.CreateDirectory(settings.workingDirectory);
Directory.CreateDirectory(settings.downloadLocation);
Directory.CreateDirectory(settings.coverImageCache);
logger.WriteLine("Tranga", $"Is Linux: {isLinux}");
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);
Server _ = new (6531, taskManager);
foreach(NotificationManager nm in taskManager.commonObjects.notificationManagers)
nm.SendNotification("Tranga-API", "Started Tranga-API");
if(!isLinux)
TaskMode(taskManager, logger);
new Manganato(this),
new Mangasee(this),
new MangaDex(this),
new MangaKatana(this),
new Mangaworld(this)
};
jobBoss = new(this, this._connectors);
StartJobBoss();
this._server = new Server(this);
}
private static void TaskMode(TaskManager taskManager, Logger logger)
public MangaConnector? GetConnector(string name)
{
ConsoleKey selection = ConsoleKey.EraseEndOfFile;
PrintMenu(taskManager, taskManager.settings.downloadLocation);
while (selection != ConsoleKey.Q)
{
int taskCount = taskManager.GetAllTasks().Length;
int taskRunningCount = taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Running);
int taskEnqueuedCount =
taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Enqueued);
Console.SetCursorPosition(0,1);
Console.WriteLine($"Tasks (Running/Queue/Total)): {taskRunningCount}/{taskEnqueuedCount}/{taskCount}");
if (Console.KeyAvailable)
{
selection = Console.ReadKey().Key;
switch (selection)
{
case ConsoleKey.L:
while (!Console.KeyAvailable)
{
PrintTasks(taskManager.GetAllTasks(), logger);
Console.WriteLine("Press any key.");
Thread.Sleep(500);
}
Console.ReadKey();
break;
case ConsoleKey.C:
CreateTask(taskManager);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.D:
DeleteTask(taskManager);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.E:
ExecuteTaskNow(taskManager);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.S:
SearchTasks(taskManager);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.R:
while (!Console.KeyAvailable)
{
PrintTasks(
taskManager.GetAllTasks().Where(eTask => eTask.state == TrangaTask.ExecutionState.Running)
.ToArray(), logger);
Console.WriteLine("Press any key.");
Thread.Sleep(500);
}
Console.ReadKey();
break;
case ConsoleKey.K:
while (!Console.KeyAvailable)
{
PrintTasks(
taskManager.GetAllTasks()
.Where(qTask => qTask.state is TrangaTask.ExecutionState.Enqueued)
.ToArray(), logger);
Console.WriteLine("Press any key.");
Thread.Sleep(500);
}
Console.ReadKey();
break;
case ConsoleKey.F:
TailLog(logger);
Console.ReadKey();
break;
case ConsoleKey.G:
RemoveTaskFromQueue(taskManager, logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.B:
AddTaskToQueue(taskManager, logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
case ConsoleKey.M:
AddMangaTaskToQueue(taskManager, logger);
Console.WriteLine("Press any key.");
Console.ReadKey();
break;
}
PrintMenu(taskManager, taskManager.settings.downloadLocation);
}
Thread.Sleep(200);
}
logger.WriteLine("Tranga_CLI", "Exiting.");
Console.Clear();
Console.WriteLine("Exiting.");
if (taskManager.GetAllTasks().Any(task => task.state == TrangaTask.ExecutionState.Running))
{
Console.WriteLine("Force quit (Even with running tasks?) y/N");
selection = Console.ReadKey().Key;
while(selection != ConsoleKey.Y && selection != ConsoleKey.N)
selection = Console.ReadKey().Key;
taskManager.Shutdown(selection == ConsoleKey.Y);
}else
// ReSharper disable once RedundantArgumentDefaultValue Better readability
taskManager.Shutdown(false);
}
private static void PrintMenu(TaskManager taskManager, string folderPath)
{
int taskCount = taskManager.GetAllTasks().Length;
int taskRunningCount = taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Running);
int taskEnqueuedCount =
taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Enqueued);
Console.Clear();
Console.WriteLine($"Download Folder: {folderPath}");
Console.WriteLine($"Tasks (Running/Queue/Total)): {taskRunningCount}/{taskEnqueuedCount}/{taskCount}");
Console.WriteLine();
Console.WriteLine($"{"C: Create Task",-30}{"L: List tasks",-30}{"B: Enqueue Task", -30}");
Console.WriteLine($"{"D: Delete Task",-30}{"S: Search Tasks", -30}{"K: List Task Queue", -30}");
Console.WriteLine($"{"E: Execute Task now",-30}{"R: List Running Tasks", -30}{"G: Remove Task from Queue", -30}");
Console.WriteLine($"{"M: New Download Manga Task",-30}{"", -30}{"", -30}");
Console.WriteLine($"{"",-30}{"F: Show Log",-30}{"Q: Exit",-30}");
}
private static void PrintTasks(TrangaTask[] tasks, Logger? logger)
{
logger?.WriteLine("Tranga_CLI", "Printing Tasks");
int taskCount = tasks.Length;
int taskRunningCount = tasks.Count(task => task.state == TrangaTask.ExecutionState.Running);
int taskEnqueuedCount = tasks.Count(task => task.state == TrangaTask.ExecutionState.Enqueued);
Console.Clear();
int tIndex = 0;
Console.WriteLine($"Tasks (Running/Queue/Total): {taskRunningCount}/{taskEnqueuedCount}/{taskCount}");
string header =
$"{"",-5}{"Task",-20} | {"Last Executed",-20} | {"Reoccurrence",-12} | {"State",-10} | {"Progress",-9} | {"Finished",-20} | {"Remaining",-12} | {"Connector",-15} | Publication/Manga ";
Console.WriteLine(header);
Console.WriteLine(new string('-', header.Length));
foreach (TrangaTask trangaTask in tasks)
{
string[] taskSplit = trangaTask.ToString().Split(", ");
Console.WriteLine($"{tIndex++:000}: {taskSplit[0],-20} | {taskSplit[1],-20} | {taskSplit[2],-12} | {taskSplit[3],-10} | {taskSplit[4],-9} | {taskSplit[5],-20} | {taskSplit[6][..12],-12} | {(taskSplit.Length > 7 ? taskSplit[7] : ""),-15} | {(taskSplit.Length > 8 ? taskSplit[8] : "")} {(taskSplit.Length > 9 ? taskSplit[9] : "")} {(taskSplit.Length > 10 ? taskSplit[10] : "")}");
}
}
private static TrangaTask[] SelectTasks(TrangaTask[] tasks, Logger? logger)
{
logger?.WriteLine("Tranga_CLI", "Menu: Select task");
if (tasks.Length < 1)
{
Console.Clear();
Console.WriteLine("There are no available Tasks.");
logger?.WriteLine("Tranga_CLI", "No available Tasks.");
return Array.Empty<TrangaTask>();
}
PrintTasks(tasks, logger);
logger?.WriteLine("Tranga_CLI", "Selecting Task to Remove (from queue)");
Console.WriteLine("Enter q to abort");
Console.WriteLine($"Select Task(s) (0-{tasks.Length - 1}):");
string? selectedTask = Console.ReadLine();
while(selectedTask is null || selectedTask.Length < 1)
selectedTask = Console.ReadLine();
if (selectedTask.Length == 1 && selectedTask.ToLower() == "q")
{
Console.Clear();
Console.WriteLine("aborted.");
logger?.WriteLine("Tranga_CLI", "aborted");
return Array.Empty<TrangaTask>();
}
if (selectedTask.Contains('-'))
{
int start = Convert.ToInt32(selectedTask.Split('-')[0]);
int end = Convert.ToInt32(selectedTask.Split('-')[1]);
return tasks[start..end];
}
else
{
int selectedTaskIndex = Convert.ToInt32(selectedTask);
return new[] { tasks[selectedTaskIndex] };
}
}
private static void AddMangaTaskToQueue(TaskManager taskManager, Logger logger)
{
Console.Clear();
logger.WriteLine("Tranga_CLI", "Menu: Add Manga Download to queue");
Connector? connector = SelectConnector(taskManager.GetAvailableConnectors().Values.ToArray(), logger);
if (connector is null)
return;
Publication? publication = SelectPublication(taskManager, connector);
if (publication is null)
return;
TimeSpan reoccurrence = SelectReoccurrence(logger);
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
TrangaTask nTask = new MonitorPublicationTask(connector.name, (Publication)publication, reoccurrence, "en");
taskManager.AddTask(nTask);
Console.WriteLine(nTask);
}
private static void AddTaskToQueue(TaskManager taskManager, Logger logger)
{
Console.Clear();
logger.WriteLine("Tranga_CLI", "Menu: Add Task to queue");
TrangaTask[] tasks = taskManager.GetAllTasks().Where(rTask =>
rTask.state is not TrangaTask.ExecutionState.Enqueued and not TrangaTask.ExecutionState.Running).ToArray();
TrangaTask[] selectedTasks = SelectTasks(tasks, logger);
logger.WriteLine("Tranga_CLI", $"Sending {selectedTasks.Length} Tasks to TaskManager");
foreach(TrangaTask task in selectedTasks)
taskManager.AddTaskToQueue(task);
}
private static void RemoveTaskFromQueue(TaskManager taskManager, Logger logger)
{
Console.Clear();
logger.WriteLine("Tranga_CLI", "Menu: Remove Task from queue");
TrangaTask[] tasks = taskManager.GetAllTasks().Where(rTask => rTask.state is TrangaTask.ExecutionState.Enqueued).ToArray();
TrangaTask[] selectedTasks = SelectTasks(tasks, logger);
logger.WriteLine("Tranga_CLI", $"Sending {selectedTasks.Length} Tasks to TaskManager");
foreach(TrangaTask task in selectedTasks)
taskManager.RemoveTaskFromQueue(task);
}
private static void TailLog(Logger logger)
{
logger.WriteLine("Tranga_CLI", "Menu: Show Log-lines");
Console.Clear();
string[] lines = logger.Tail(20);
foreach (string message in lines)
Console.Write(message);
while (!Console.KeyAvailable)
{
string[] newLines = logger.GetNewLines();
foreach(string message in newLines)
Console.Write(message);
Thread.Sleep(40);
}
}
private static void CreateTask(TaskManager taskManager)
{
taskManager.commonObjects.logger?.WriteLine("Tranga_CLI", "Menu: Creating Task");
TrangaTask.Task? tmpTask = SelectTaskType(taskManager.commonObjects.logger);
if (tmpTask is null)
return;
TrangaTask.Task task = (TrangaTask.Task)tmpTask;
Connector? connector = null;
if (task != TrangaTask.Task.UpdateLibraries)
{
connector = SelectConnector(taskManager.GetAvailableConnectors().Values.ToArray(), taskManager.commonObjects.logger);
if (connector is null)
return;
}
Publication? publication = null;
if (task != TrangaTask.Task.UpdateLibraries)
{
publication = SelectPublication(taskManager, connector!);
if (publication is null)
return;
}
if (task is TrangaTask.Task.MonitorPublication)
{
TimeSpan reoccurrence = SelectReoccurrence(taskManager.commonObjects.logger);
taskManager.commonObjects.logger?.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
TrangaTask newTask = new MonitorPublicationTask(connector!.name, (Publication)publication!, reoccurrence, "en");
taskManager.AddTask(newTask);
Console.WriteLine(newTask);
}else if (task is TrangaTask.Task.DownloadChapter)
{
foreach (Chapter chapter in SelectChapters(connector!, (Publication)publication!, taskManager.commonObjects.logger))
{
TrangaTask newTask = new DownloadChapterTask(connector!.name, (Publication)publication, chapter, "en");
taskManager.AddTask(newTask);
Console.WriteLine(newTask);
}
}
}
private static void ExecuteTaskNow(TaskManager taskManager)
{
taskManager.commonObjects.logger?.WriteLine("Tranga_CLI", "Menu: Executing Task");
TrangaTask[] tasks = taskManager.GetAllTasks().Where(nTask => nTask.state is not TrangaTask.ExecutionState.Running).ToArray();
TrangaTask[] selectedTasks = SelectTasks(tasks, taskManager.commonObjects.logger);
taskManager.commonObjects.logger?.WriteLine("Tranga_CLI", $"Sending {selectedTasks.Length} Tasks to TaskManager");
foreach(TrangaTask task in selectedTasks)
taskManager.ExecuteTaskNow(task);
}
private static void DeleteTask(TaskManager taskManager)
{
taskManager.commonObjects.logger?.WriteLine("Tranga_CLI", "Menu: Delete Task");
TrangaTask[] tasks = taskManager.GetAllTasks();
TrangaTask[] selectedTasks = SelectTasks(tasks, taskManager.commonObjects.logger);
taskManager.commonObjects.logger?.WriteLine("Tranga_CLI", $"Sending {selectedTasks.Length} Tasks to TaskManager");
foreach(TrangaTask task in selectedTasks)
taskManager.DeleteTask(task);
}
private static TrangaTask.Task? SelectTaskType(Logger? logger)
{
logger?.WriteLine("Tranga_CLI", "Menu: Select TaskType");
Console.Clear();
string[] taskNames = Enum.GetNames<TrangaTask.Task>();
int tIndex = 0;
Console.WriteLine("Available Tasks:");
foreach (string taskName in taskNames)
Console.WriteLine($"{tIndex++}: {taskName}");
Console.WriteLine("Enter q to abort");
Console.WriteLine($"Select Task (0-{taskNames.Length - 1}):");
string? selectedTask = Console.ReadLine();
while(selectedTask is null || selectedTask.Length < 1)
selectedTask = Console.ReadLine();
if (selectedTask.Length == 1 && selectedTask.ToLower() == "q")
{
Console.Clear();
Console.WriteLine("aborted.");
logger?.WriteLine("Tranga_CLI", "aborted.");
return null;
}
try
{
int selectedTaskIndex = Convert.ToInt32(selectedTask);
string selectedTaskName = taskNames[selectedTaskIndex];
return Enum.Parse<TrangaTask.Task>(selectedTaskName);
}
catch (Exception e)
{
Console.WriteLine($"Exception: {e.Message}");
logger?.WriteLine("Tranga_CLI", e.Message);
}
foreach(MangaConnector mc in _connectors)
if (mc.name.Equals(name, StringComparison.InvariantCultureIgnoreCase))
return mc;
return null;
}
private static TimeSpan SelectReoccurrence(Logger? logger)
public bool TryGetConnector(string name, out MangaConnector? connector)
{
logger?.WriteLine("Tranga_CLI", "Menu: Select Reoccurrence");
Console.WriteLine("Select reoccurrence Timer (Format hh:mm:ss):");
return TimeSpan.Parse(Console.ReadLine()!, new CultureInfo("en-US"));
connector = GetConnector(name);
return connector is not null;
}
private static Chapter[] SelectChapters(Connector connector, Publication publication, Logger? logger)
public IEnumerable<MangaConnector> GetConnectors()
{
logger?.WriteLine("Tranga_CLI", "Menu: Select Chapters");
Chapter[] availableChapters = connector.GetChapters(publication, "en");
int cIndex = 0;
Console.WriteLine("Chapters:");
System.Text.StringBuilder sb = new();
foreach(Chapter chapter in availableChapters)
{
sb.Append($"{cIndex++}: ");
if(string.IsNullOrWhiteSpace(chapter.volumeNumber) == false)
{
sb.Append($"Vol.{chapter.volumeNumber} ");
}
if(string.IsNullOrWhiteSpace(chapter.chapterNumber) == false)
{
sb.Append($"Ch.{chapter.chapterNumber} ");
}
if(string.IsNullOrWhiteSpace(chapter.name) == false)
{
sb.Append($" - {chapter.name}");
}
Console.WriteLine(sb.ToString());
sb.Clear();
}
Console.WriteLine("Enter q to abort");
Console.WriteLine($"Select Chapter(s):");
string? selectedChapters = Console.ReadLine();
while(selectedChapters is null || selectedChapters.Length < 1)
selectedChapters = Console.ReadLine();
return connector.SelectChapters(publication, selectedChapters);
return _connectors;
}
private static Connector? SelectConnector(Connector[] connectors, Logger? logger)
public Manga? GetPublicationById(string internalId)
{
logger?.WriteLine("Tranga_CLI", "Menu: Select Connector");
Console.Clear();
int cIndex = 0;
Console.WriteLine("Connectors:");
foreach (Connector connector in connectors)
Console.WriteLine($"{cIndex++}: {connector.name}");
Console.WriteLine("Enter q to abort");
Console.WriteLine($"Select Connector (0-{connectors.Length - 1}):");
string? selectedConnector = Console.ReadLine();
while(selectedConnector is null || selectedConnector.Length < 1)
selectedConnector = Console.ReadLine();
if (selectedConnector.Length == 1 && selectedConnector.ToLower() == "q")
{
Console.Clear();
Console.WriteLine("aborted.");
logger?.WriteLine("Tranga_CLI", "aborted.");
return null;
}
try
{
int selectedConnectorIndex = Convert.ToInt32(selectedConnector);
return connectors[selectedConnectorIndex];
}
catch (Exception e)
{
Console.WriteLine($"Exception: {e.Message}");
logger?.WriteLine("Tranga_CLI", e.Message);
}
if (cachedPublications.Exists(publication => publication.internalId == internalId))
return cachedPublications.First(publication => publication.internalId == internalId);
return null;
}
private static Publication? SelectPublication(TaskManager taskManager, Connector connector)
public bool TryGetPublicationById(string internalId, out Manga? manga)
{
taskManager.commonObjects.logger?.WriteLine("Tranga_CLI", "Menu: Select Publication");
Console.Clear();
Console.WriteLine($"Connector: {connector.name}");
Console.WriteLine("Publication search query (leave empty for all):");
string? query = Console.ReadLine();
Publication[] publications = connector.GetPublications(ref taskManager.collection, query ?? "");
if (publications.Length < 1)
{
taskManager.commonObjects.logger?.WriteLine("Tranga_CLI", "No publications returned");
Console.WriteLine($"No publications for query '{query}' returned;");
return null;
}
int pIndex = 0;
Console.WriteLine("Publications:");
foreach(Publication publication in publications)
Console.WriteLine($"{pIndex++}: {publication.sortName}");
Console.WriteLine("Enter q to abort");
Console.WriteLine($"Select publication to Download (0-{publications.Length - 1}):");
string? selectedPublication = Console.ReadLine();
while(selectedPublication is null || selectedPublication.Length < 1)
selectedPublication = Console.ReadLine();
if (selectedPublication.Length == 1 && selectedPublication.ToLower() == "q")
{
Console.Clear();
Console.WriteLine("aborted.");
taskManager.commonObjects.logger?.WriteLine("Tranga_CLI", "aborted.");
return null;
}
try
{
int selectedPublicationIndex = Convert.ToInt32(selectedPublication);
return publications[selectedPublicationIndex];
}
catch (Exception e)
{
Console.WriteLine($"Exception: {e.Message}");
taskManager.commonObjects.logger?.WriteLine("Tranga_CLI", e.Message);
}
return null;
manga = GetPublicationById(internalId);
return manga is not null;
}
private static void SearchTasks(TaskManager taskManager)
private void StartJobBoss()
{
taskManager.commonObjects.logger?.WriteLine("Tranga_CLI", "Menu: Search task");
Console.Clear();
Console.WriteLine("Enter search query:");
string? query = Console.ReadLine();
while (query is null || query.Length < 4)
query = Console.ReadLine();
PrintTasks(taskManager.GetAllTasks().Where(qTask =>
qTask.ToString().ToLower().Contains(query, StringComparison.OrdinalIgnoreCase)).ToArray(), taskManager.commonObjects.logger);
Thread t = new (() =>
{
while (keepRunning)
{
jobBoss.CheckJobs();
Thread.Sleep(100);
}
foreach (MangaConnector connector in _connectors)
{
}
});
t.Start();
}
}

136
Tranga/TrangaArgs.cs Normal file
View File

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

View File

@ -1,41 +1,111 @@
using Newtonsoft.Json;
using Tranga.LibraryManagers;
using Tranga.NotificationManagers;
using System.Runtime.InteropServices;
using Newtonsoft.Json;
using Tranga.LibraryConnectors;
using Tranga.NotificationConnectors;
using static System.IO.UnixFileMode;
namespace Tranga;
public class TrangaSettings
{
public string downloadLocation { get; private set; }
public string workingDirectory { get; init; }
public string workingDirectory { get; private set; }
public int apiPortNumber { get; init; }
[JsonIgnore] public string settingsFilePath => Path.Join(workingDirectory, "settings.json");
[JsonIgnore] public string tasksFilePath => Path.Join(workingDirectory, "tasks.json");
[JsonIgnore] public string libraryConnectorsFilePath => Path.Join(workingDirectory, "libraryConnectors.json");
[JsonIgnore] public string notificationConnectorsFilePath => Path.Join(workingDirectory, "notificationConnectors.json");
[JsonIgnore] public string jobsFolderPath => Path.Join(workingDirectory, "jobs");
[JsonIgnore] public string coverImageCache => Path.Join(workingDirectory, "imageCache");
public ushort? version { get; set; }
public TrangaSettings(string downloadLocation, string workingDirectory)
public TrangaSettings(string? downloadLocation = null, string? workingDirectory = null, int? apiPortNumber = null)
{
if (downloadLocation.Length < 1 || workingDirectory.Length < 1)
throw new ArgumentException("Download-location and working-directory paths can not be empty!");
this.workingDirectory = workingDirectory;
this.downloadLocation = downloadLocation;
string lockFilePath = $"{settingsFilePath}.lock";
if (File.Exists(settingsFilePath) && !File.Exists(lockFilePath))
{//Load from settings file
FileStream lockFile = File.Create(lockFilePath,0, FileOptions.DeleteOnClose); //lock settingsfile
string settingsStr = File.ReadAllText(settingsFilePath);
TrangaSettings settings = JsonConvert.DeserializeObject<TrangaSettings>(settingsStr)!;
this.downloadLocation = downloadLocation ?? settings.downloadLocation;
this.workingDirectory = workingDirectory ?? settings.workingDirectory;
this.apiPortNumber = apiPortNumber ?? settings.apiPortNumber;
lockFile.Close(); //unlock settingsfile
}
else if(!File.Exists(settingsFilePath))
{//No settings file exists
if (downloadLocation?.Length < 1 || workingDirectory?.Length < 1)
throw new ArgumentException("Download-location and working-directory paths can not be empty!");
this.apiPortNumber = apiPortNumber ?? 6531;
this.downloadLocation = downloadLocation ?? (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/Manga" : Path.Join(Directory.GetCurrentDirectory(), "Downloads"));
this.workingDirectory = workingDirectory ?? Path.Join(RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/usr/share" : Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "tranga-api");
ExportSettings();
}
else
{//Settingsfile is locked
this.apiPortNumber = apiPortNumber!.Value;
this.downloadLocation = downloadLocation!;
this.workingDirectory = workingDirectory!;
}
UpdateDownloadLocation(this.downloadLocation!, false);
}
public static TrangaSettings LoadSettings(string importFilePath)
public HashSet<LibraryConnector> LoadLibraryConnectors(GlobalBase clone)
{
if (!File.Exists(importFilePath))
return new TrangaSettings(Path.Join(Directory.GetCurrentDirectory(), "Downloads"), Directory.GetCurrentDirectory());
if (!File.Exists(libraryConnectorsFilePath))
return new HashSet<LibraryConnector>();
return JsonConvert.DeserializeObject<HashSet<LibraryConnector>>(File.ReadAllText(libraryConnectorsFilePath),
new JsonSerializerSettings()
{
Converters =
{
new LibraryManagerJsonConverter(clone)
}
})!;
}
string toRead = File.ReadAllText(importFilePath);
SettingsJsonObject settings = JsonConvert.DeserializeObject<SettingsJsonObject>(toRead,
new JsonSerializerSettings { Converters = { new NotificationManager.NotificationManagerJsonConverter(), new LibraryManager.LibraryManagerJsonConverter() } })!;
return settings.ts ?? new TrangaSettings(Path.Join(Directory.GetCurrentDirectory(), "Downloads"), Directory.GetCurrentDirectory());
public HashSet<NotificationConnector> LoadNotificationConnectors(GlobalBase clone)
{
if (!File.Exists(notificationConnectorsFilePath))
return new HashSet<NotificationConnector>();
return JsonConvert.DeserializeObject<HashSet<NotificationConnector>>(File.ReadAllText(notificationConnectorsFilePath),
new JsonSerializerSettings()
{
Converters =
{
new NotificationManagerJsonConverter(clone)
}
})!;
}
public void UpdateDownloadLocation(string newPath, bool moveFiles = true)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
Directory.CreateDirectory(newPath,
GroupRead | GroupWrite | None | OtherRead | OtherWrite | UserRead | UserWrite);
else
Directory.CreateDirectory(newPath);
if (moveFiles && Directory.Exists(this.downloadLocation))
Directory.Move(this.downloadLocation, newPath);
this.downloadLocation = newPath;
ExportSettings();
}
public void UpdateWorkingDirectory(string newPath)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
Directory.CreateDirectory(newPath,
GroupRead | GroupWrite | None | OtherRead | OtherWrite | UserRead | UserWrite);
else
Directory.CreateDirectory(newPath);
Directory.Move(this.workingDirectory, newPath);
this.workingDirectory = newPath;
ExportSettings();
}
public void ExportSettings()
{
SettingsJsonObject? settings = null;
if (File.Exists(settingsFilePath))
{
bool inUse = true;
@ -43,55 +113,34 @@ public class TrangaSettings
{
try
{
using FileStream stream = new (settingsFilePath, FileMode.Open, FileAccess.Read, FileShare.None);
using FileStream stream = new(settingsFilePath, FileMode.Open, FileAccess.Read, FileShare.None);
stream.Close();
inUse = false;
}
catch (IOException)
{
inUse = true;
Thread.Sleep(50);
Thread.Sleep(100);
}
}
string toRead = File.ReadAllText(settingsFilePath);
settings = JsonConvert.DeserializeObject<SettingsJsonObject>(toRead,
new JsonSerializerSettings
{
Converters =
{
new NotificationManager.NotificationManagerJsonConverter(),
new LibraryManager.LibraryManagerJsonConverter()
}
});
}
settings = new SettingsJsonObject(this, settings?.co);
File.WriteAllText(settingsFilePath, JsonConvert.SerializeObject(settings));
else
Directory.CreateDirectory(new FileInfo(settingsFilePath).DirectoryName!);
File.WriteAllText(settingsFilePath, JsonConvert.SerializeObject(this));
}
public void UpdateSettings(UpdateField field, params string[] values)
public string GetFullCoverPath(Manga manga)
{
switch (field)
{
case UpdateField.DownloadLocation:
if (values.Length != 1)
return;
this.downloadLocation = values[0];
break;
}
ExportSettings();
return Path.Join(this.coverImageCache, manga.coverFileNameInCache);
}
public enum UpdateField { DownloadLocation, Komga, Kavita, Gotify, LunaSea}
internal class SettingsJsonObject
public override string ToString()
{
public TrangaSettings? ts { get; }
public CommonObjects? co { get; }
public SettingsJsonObject(TrangaSettings? ts, CommonObjects? co)
{
this.ts = ts;
this.co = co;
}
return $"TrangaSettings:\n" +
$"\tDownloadLocation: {downloadLocation}\n" +
$"\tworkingDirectory: {workingDirectory}\n" +
$"\tjobsFolderPath: {jobsFolderPath}\n" +
$"\tsettingsFilePath: {settingsFilePath}\n" +
$"\t\tnotificationConnectors: {notificationConnectorsFilePath}\n" +
$"\t\tlibraryConnectors: {libraryConnectorsFilePath}\n";
}
}

View File

@ -1,67 +0,0 @@
using System.Net;
using Tranga.Connectors;
using Tranga.NotificationManagers;
using Tranga.LibraryManagers;
namespace Tranga.TrangaTasks;
public class DownloadChapterTask : TrangaTask
{
public string connectorName { get; }
public Publication publication { get; }
// ReSharper disable once MemberCanBePrivate.Global
public string language { get; }
public Chapter chapter { get; }
private double _dctProgress;
public DownloadChapterTask(string connectorName, Publication publication, Chapter chapter, string language = "en", MonitorPublicationTask? parentTask = null) : base(Task.DownloadChapter, TimeSpan.Zero, parentTask)
{
this.chapter = chapter;
this.connectorName = connectorName;
this.publication = publication;
this.language = language;
}
protected override HttpStatusCode ExecuteTask(TaskManager taskManager, CancellationToken? cancellationToken = null)
{
if (cancellationToken?.IsCancellationRequested ?? false)
return HttpStatusCode.RequestTimeout;
Connector connector = taskManager.GetConnector(this.connectorName);
connector.CopyCoverFromCacheToDownloadLocation(this.publication);
HttpStatusCode downloadSuccess = connector.DownloadChapter(this.publication, this.chapter, this, cancellationToken);
if ((int)downloadSuccess >= 200 && (int)downloadSuccess < 300)
{
foreach(NotificationManager nm in taskManager.commonObjects.notificationManagers)
nm.SendNotification("Chapter downloaded", $"{this.publication.sortName} {this.chapter.chapterNumber} {this.chapter.name}");
foreach (LibraryManager lm in taskManager.commonObjects.libraryManagers)
lm.UpdateLibrary();
}
return downloadSuccess;
}
public override TrangaTask Clone()
{
return new DownloadChapterTask(this.connectorName, this.publication, this.chapter,
this.language, (MonitorPublicationTask?)this.parentTask);
}
protected override double GetProgress()
{
return _dctProgress;
}
internal void IncrementProgress(double amount)
{
this._dctProgress += amount;
this.lastChange = DateTime.Now;
if(this.parentTask is not null)
this.parentTask.lastChange = DateTime.Now;
}
public override string ToString()
{
return $"{base.ToString()}, {connectorName}, {publication.sortName} {publication.internalId}, Vol.{chapter.volumeNumber} Ch.{chapter.chapterNumber}";
}
}

View File

@ -1,61 +0,0 @@
using System.Net;
using Tranga.Connectors;
namespace Tranga.TrangaTasks;
public class MonitorPublicationTask : TrangaTask
{
public string connectorName { get; }
public Publication publication { get; }
// ReSharper disable once MemberCanBePrivate.Global
public string language { get; }
public MonitorPublicationTask(string connectorName, Publication publication, TimeSpan reoccurrence, string language = "en") : base(Task.MonitorPublication, reoccurrence)
{
this.connectorName = connectorName;
this.publication = publication;
this.language = language;
}
protected override HttpStatusCode ExecuteTask(TaskManager taskManager, CancellationToken? cancellationToken = null)
{
if (cancellationToken?.IsCancellationRequested ?? false)
return HttpStatusCode.RequestTimeout;
Connector connector = taskManager.GetConnector(this.connectorName);
//Check if Publication already has a Folder
publication.CreatePublicationFolder(taskManager.settings.downloadLocation);
List<Chapter> newChapters = connector.GetNewChaptersList(publication, language, ref taskManager.collection);
connector.CopyCoverFromCacheToDownloadLocation(publication);
publication.SaveSeriesInfoJson(taskManager.settings.downloadLocation);
foreach (Chapter newChapter in newChapters)
{
DownloadChapterTask newTask = new (this.connectorName, publication, newChapter, this.language, this);
this.childTasks.Add(newTask);
newTask.state = ExecutionState.Enqueued;
taskManager.AddTask(newTask);
}
return HttpStatusCode.OK;
}
public override TrangaTask Clone()
{
return new MonitorPublicationTask(this.connectorName, this.publication, this.reoccurrence,
this.language);
}
protected override double GetProgress()
{
if (this.childTasks.Count > 0)
return this.childTasks.Sum(ct => ct.progress) / childTasks.Count;
return 1;
}
public override string ToString()
{
return $"{base.ToString()}, {connectorName}, {publication.sortName} {publication.internalId}";
}
}

View File

@ -1,157 +0,0 @@
using System.Net;
using System.Text.Json.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using JsonConverter = Newtonsoft.Json.JsonConverter;
namespace Tranga.TrangaTasks;
/// <summary>
/// Stores information on Task, when implementing new Tasks also update the serializer
/// </summary>
[JsonDerivedType(typeof(MonitorPublicationTask), 2)]
[JsonDerivedType(typeof(UpdateLibrariesTask), 3)]
[JsonDerivedType(typeof(DownloadChapterTask), 4)]
public abstract class TrangaTask
{
// ReSharper disable once MemberCanBeProtected.Global
public TimeSpan reoccurrence { get; }
public DateTime lastExecuted { get; set; }
[Newtonsoft.Json.JsonIgnore] public ExecutionState state { get; set; }
public Task task { get; }
public string taskId { get; init; }
[Newtonsoft.Json.JsonIgnore] public TrangaTask? parentTask { get; set; }
public string? parentTaskId { get; set; }
[Newtonsoft.Json.JsonIgnore] internal HashSet<TrangaTask> childTasks { get; }
public double progress => GetProgress();
// ReSharper disable once MemberCanBePrivate.Global
[Newtonsoft.Json.JsonIgnore]public DateTime executionStarted { get; private set; }
[Newtonsoft.Json.JsonIgnore]public DateTime lastChange { get; internal set; }
// ReSharper disable once MemberCanBePrivate.Global
[Newtonsoft.Json.JsonIgnore]public DateTime executionApproximatelyFinished => lastChange.Add(GetRemainingTime());
// ReSharper disable once MemberCanBePrivate.Global
public TimeSpan executionApproximatelyRemaining => executionApproximatelyFinished.Subtract(DateTime.Now);
[Newtonsoft.Json.JsonIgnore]public DateTime nextExecution => lastExecuted.Add(reoccurrence);
public enum ExecutionState { Waiting, Enqueued, Running, Failed, Success }
protected TrangaTask(Task task, TimeSpan reoccurrence, TrangaTask? parentTask = null)
{
this.reoccurrence = reoccurrence;
this.lastExecuted = DateTime.Now.Subtract(reoccurrence);
this.task = task;
this.executionStarted = DateTime.UnixEpoch;
this.lastChange = DateTime.MaxValue;
this.taskId = Convert.ToBase64String(BitConverter.GetBytes(new Random().Next()));
this.childTasks = new();
this.parentTask = parentTask;
this.parentTaskId = parentTask?.taskId;
}
/// <summary>
/// BL for concrete Tasks
/// </summary>
/// <param name="taskManager"></param>
/// <param name="cancellationToken"></param>
protected abstract HttpStatusCode ExecuteTask(TaskManager taskManager, CancellationToken? cancellationToken = null);
public abstract TrangaTask Clone();
protected abstract double GetProgress();
/// <summary>
/// Execute the task
/// </summary>
/// <param name="taskManager">Should be the parent taskManager</param>
/// <param name="cancellationToken"></param>
public void Execute(TaskManager taskManager, CancellationToken? cancellationToken = null)
{
taskManager.commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Executing Task {this}");
this.state = ExecutionState.Running;
this.executionStarted = DateTime.Now;
this.lastChange = DateTime.Now;
if(parentTask is not null && parentTask.childTasks.All(ct => ct.state is ExecutionState.Waiting or ExecutionState.Failed))
parentTask.executionStarted = DateTime.Now;
HttpStatusCode statusCode = ExecuteTask(taskManager, cancellationToken);
if ((int)statusCode >= 200 && (int)statusCode < 300)
{
this.lastExecuted = DateTime.Now;
this.state = ExecutionState.Success;
}
else
{
this.state = ExecutionState.Failed;
this.lastExecuted = DateTime.MaxValue;
}
if (this is DownloadChapterTask)
taskManager.DeleteTask(this);
taskManager.commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Finished Executing Task {this}");
}
public void AddChildTask(TrangaTask childTask)
{
this.childTasks.Add(childTask);
}
public void RemoveChildTask(TrangaTask childTask)
{
this.childTasks.Remove(childTask);
}
private TimeSpan GetRemainingTime()
{
if(progress == 0 || state is ExecutionState.Enqueued or ExecutionState.Waiting or ExecutionState.Failed || lastChange == DateTime.MaxValue)
return DateTime.MaxValue.Subtract(lastChange).Subtract(TimeSpan.FromHours(1));
TimeSpan elapsed = lastChange.Subtract(executionStarted);
return elapsed.Divide(progress).Multiply(1 - progress);
}
public enum Task : byte
{
MonitorPublication = 2,
UpdateLibraries = 3,
DownloadChapter = 4,
}
public override string ToString()
{
return $"{task}, {lastExecuted}, {reoccurrence}, {state}, {progress:P2}, {executionApproximatelyFinished}, {executionApproximatelyRemaining}";
}
public class TrangaTaskJsonConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return objectType == typeof(TrangaTask);
}
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
JObject jo = JObject.Load(reader);
if (jo["task"]!.Value<Int64>() == (Int64)Task.MonitorPublication)
return jo.ToObject<MonitorPublicationTask>(serializer)!;
if (jo["task"]!.Value<Int64>() == (Int64)Task.UpdateLibraries)
return jo.ToObject<UpdateLibrariesTask>(serializer)!;
if (jo["task"]!.Value<Int64>() == (Int64)Task.DownloadChapter)
return jo.ToObject<DownloadChapterTask>(serializer)!;
throw new Exception();
}
public override bool CanWrite => false;
/// <summary>
/// Don't call this
/// </summary>
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
throw new Exception("Dont call this");
}
}
}

View File

@ -1,28 +0,0 @@
using System.Net;
namespace Tranga.TrangaTasks;
/// <summary>
/// LEGACY DEPRECATED
/// </summary>
public class UpdateLibrariesTask : TrangaTask
{
public UpdateLibrariesTask(TimeSpan reoccurrence) : base(Task.UpdateLibraries, reoccurrence)
{
}
protected override HttpStatusCode ExecuteTask(TaskManager taskManager, CancellationToken? cancellationToken = null)
{
return HttpStatusCode.BadRequest;
}
public override TrangaTask Clone()
{
return new UpdateLibrariesTask(this.reoccurrence);
}
protected override double GetProgress()
{
return 1;
}
}

View File

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

View File

@ -1,168 +0,0 @@
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 + "/Connectors";
let json = await GetData(uri);
return json;
}
async function GetPublicationFromConnector(connectorName, title){
var uri = apiUri + `/Publications/FromConnector?connectorName=${connectorName}&title=${title}`;
let json = await GetData(uri);
return json;
}
async function GetKnownPublications(){
var uri = apiUri + "/Publications/Known";
let json = await GetData(uri);
return json;
}
async function GetPublication(internalId){
var uri = apiUri + `/Publications/Known?internalId=${internalId}`;
let json = await GetData(uri);
return json;
}
async function GetChapters(internalId, connectorName, onlyNew, language){
var uri = apiUri + `/Publications/Chapters?internalId=${internalId}&connectorName=${connectorName}&onlyNew=${onlyNew}&language=${language}`;
let json = await GetData(uri);
return json;
}
async function GetTaskTypes(){
var uri = apiUri + "/Tasks/Types";
let json = await GetData(uri);
return json;
}
async function GetRunningTasks(){
var uri = apiUri + "/Tasks/RunningTasks";
let json = await GetData(uri);
return json;
}
async function GetDownloadTasks(){
var uri = apiUri + "/Tasks?taskType=MonitorPublication";
let json = await GetData(uri);
return json;
}
async function GetSettings(){
var uri = apiUri + "/Settings";
let json = await GetData(uri);
return json;
}
async function GetKomgaTask(){
var uri = apiUri + "/Tasks?taskType=UpdateLibraries";
let json = await GetData(uri);
return json;
}
function CreateMonitorTask(connectorName, internalId, reoccurrence, language){
var uri = apiUri + `/Tasks/CreateMonitorTask?connectorName=${connectorName}&internalId=${internalId}&reoccurrenceTime=${reoccurrence}&language=${language}`;
PostData(uri);
}
function CreateDownloadChaptersTask(connectorName, internalId, chapters, language){
var uri = apiUri + `/Tasks/CreateDownloadChaptersTask?connectorName=${connectorName}&internalId=${internalId}&chapters=${chapters}&language=${language}`;
PostData(uri);
}
function StartTask(taskType, connectorName, internalId){
var uri = apiUri + `/Tasks/Start?taskType=${taskType}&connectorName=${connectorName}&internalId=${internalId}`;
PostData(uri);
}
function EnqueueTask(taskType, connectorName, publicationId){
var uri = apiUri + `/Queue/Enqueue?taskType=${taskType}&connectorName=${connectorName}&publicationId=${publicationId}`;
PostData(uri);
}
function UpdateDownloadLocation(downloadLocation){
var uri = apiUri + "/Settings/Update?"
uri += "&downloadLocation="+downloadLocation;
PostData(uri);
}
function UpdateKomga(komgaUrl, komgaAuth){
var uri = apiUri + "/Settings/Update?"
uri += `&komgaUrl=${komgaUrl}&komgaAuth=${komgaAuth}`;
PostData(uri);
}
function UpdateKavita(kavitaUrl, kavitaUser, kavitaPass){
var uri = apiUri + "/Settings/Update?"
uri += `&kavitaUrl=${kavitaUrl}&kavitaUsername=${kavitaUser}&kavitaPassword=${kavitaPass}`;
PostData(uri);
}
function UpdateGotify(gotifyUrl, gotifyAppToken){
var uri = apiUri + "/Settings/Update?"
uri += `&gotifyUrl=${gotifyUrl}&gotifyAppToken=${gotifyAppToken}`;
PostData(uri);
}
function UpdateLunaSea(lunaseaWebhook){
var uri = apiUri + "/Settings/Update?"
uri += `&lunaseaWebhook=${lunaseaWebhook}`;
PostData(uri);
}
function DeleteTask(taskType, connectorName, publicationId){
var uri = apiUri + `/Tasks?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/List";
let json = await GetData(uri);
return json;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

View File

@ -1,179 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tranga</title>
<link rel="stylesheet" href="style.css">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<wrapper>
<topbar>
<titlebox>
<img alt="website image is Blahaj" src="media/blahaj.png">
<span>Tranga</span>
</titlebox>
<spacer></spacer>
<searchdiv>
<label for="searchbox"></label><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 alt="cover" 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="selectPublicationPopup">
<blur-background id="blurBackgroundTaskPopup"></blur-background>
<popup-window>
<popup-title>Select Publication</popup-title>
<popup-content>
<div>
<label for="connectors">Connector</label>
<select id="connectors">
<option value=""></option>
</select>
</div>
<div>
<label for="searchPublicationQuery">Search Title</label><input id="searchPublicationQuery" type="text"></addtask-setting>
</div>
<input type="submit" value="Search" style="font-weight: bolder" onclick="NewSearch();">
</popup-content>
<div id="taskSelectOutput"></div>
</popup-window>
</popup>
<popup id="createMonitorTaskPopup">
<blur-background id="blurBackgroundCreateMonitorTaskPopup"></blur-background>
<popup-window>
<popup-title>Create Task: Monitor Publication</popup-title>
<popup-content>
<div>
<span>Run every</span>
<label for="hours"></label><input id="hours" type="number" value="3" min="0" max="23"><span>hours</span>
<label for="minutes"></label><input id="minutes" type="number" value="0" min="0" max="59"><span>minutes</span>
<input type="submit" value="Create" onclick="AddMonitorTask()">
</div>
</popup-content>
</popup-window>
</popup>
<popup id="createDownloadChaptersTask">
<blur-background id="blurBackgroundCreateDownloadChaptersTask"></blur-background>
<popup-window>
<popup-title>Create Task: Download Chapter(s)</popup-title>
<popup-content>
<div>
<label for="selectedChapters">Chapters:</label><input id="selectedChapters" placeholder="Select"><input type="submit" value="Select" onclick="DownloadChapterTaskClick()">
</div>
<div id="chapterOutput">
</div>
</popup-content>
</popup-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-tags id="publicationViewerTags"></publication-tags>
<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 id="createMonitorTaskButton">Monitor </publication-add>
<publication-add id="createDownloadChapterTaskButton">Download Chapter </publication-add>
</publication-interactions>
</publication-information>
</publication-viewer>
</popup>
<popup id="settingsPopup">
<blur-background id="blurBackgroundSettingsPopup"></blur-background>
<popup-window>
<popup-title>Settings</popup-title>
<popup-content>
<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>
<div>
<span class="title">Komga</span>
<div>Configured: <span id="komgaConfigured">✅❌</span></div>
<label for="komgaUrl"></label><input placeholder="URL" id="komgaUrl" type="text">
<label for="komgaUsername"></label><input placeholder="Username" id="komgaUsername" type="text">
<label for="komgaPassword"></label><input placeholder="Password" id="komgaPassword" type="password">
</div>
<div>
<span class="title">Kavita</span>
<div>Configured: <span id="kavitaConfigured">✅❌</span></div>
<label for="kavitaUrl"></label><input placeholder="URL" id="kavitaUrl" type="text">
<label for="kavitaUsername"></label><input placeholder="Username" id="kavitaUsername" type="text">
<label for="kavitaPassword"></label><input placeholder="Password" id="kavitaPassword" type="password">
</div>
<div>
<span class="title">Gotify</span>
<div>Configured: <span id="gotifyConfigured">✅❌</span></div>
<label for="gotifyUrl"></label><input placeholder="URL" id="gotifyUrl" type="text">
<label for="gotifyAppToken"></label><input placeholder="App-Token" id="gotifyAppToken" type="text">
</div>
<div>
<span class="title">LunaSea</span>
<div>Configured: <span id="lunaseaConfigured">✅❌</span></div>
<label for="lunaseaWebhook"></label><input placeholder="device/:id or user/:id" id="lunaseaWebhook" type="text">
</div>
<div>
<label for="libraryUpdateTime" style="margin-right: 5px;">Update Time</label><input id="libraryUpdateTime" type="time" value="00:01:00" step="10">
<input type="submit" value="Update" onclick="UpdateLibrarySettings()">
</div>
</popup-content>
</popup-window>
</popup>
<popup id="downloadTasksPopup">
<blur-background id="blurBackgroundTasksQueuePopup"></blur-background>
<popup-window>
<popup-title>Task Progress</popup-title>
<popup-content>
</popup-content>
</popup-window>
</popup>
</viewport>
<footer>
<div onclick="ShowTasksQueue();">
<img src="media/running.svg" alt="running"><div id="tasksRunningTag">0</div>
</div>
<div onclick="ShowTasksQueue();">
<img src="media/queue.svg" alt="queue"><div id="tasksQueuedTag">0</div>
</div>
<p id="madeWith">Made with Blåhaj 🦈</p>
</footer>
</wrapper>
<script src="apiConnector.js"></script>
<script src="interaction.js"></script>
</body>
</html>

View File

@ -1,525 +0,0 @@
let publications = [];
let tasks = [];
let toEditId;
const searchBox = document.querySelector("#searchbox");
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 selectPublicationPopup = document.querySelector("#selectPublicationPopup");
const createMonitorTaskButton = document.querySelector("#createMonitorTaskButton");
const createDownloadChapterTaskButton = document.querySelector("#createDownloadChapterTaskButton");
const createMonitorTaskPopup = document.querySelector("#createMonitorTaskPopup");
const createDownloadChaptersTask = document.querySelector("#createDownloadChaptersTask");
const chapterOutput = document.querySelector("#chapterOutput");
const selectedChapters = document.querySelector("#selectedChapters");
const publicationViewerPopup = document.querySelector("#publicationViewerPopup");
const publicationViewerWindow = document.querySelector("publication-viewer");
const publicationViewerDescription = document.querySelector("#publicationViewerDescription");
const publicationViewerName = document.querySelector("#publicationViewerName");
const publicationViewerTags = document.querySelector("#publicationViewerTags");
const publicationViewerAuthor = document.querySelector("#publicationViewerAuthor");
const pubviewcover = document.querySelector("#pubviewcover");
const publicationDelete = document.querySelector("publication-delete");
const publicationTaskStart = document.querySelector("publication-starttask");
const settingDownloadLocation = document.querySelector("#downloadLocation");
const settingKomgaUrl = document.querySelector("#komgaUrl");
const settingKomgaUser = document.querySelector("#komgaUsername");
const settingKomgaPass = document.querySelector("#komgaPassword");
const settingKavitaUrl = document.querySelector("#kavitaUrl");
const settingKavitaUser = document.querySelector("#kavitaUsername");
const settingKavitaPass = document.querySelector("#kavitaPassword");
const settingGotifyUrl = document.querySelector("#gotifyUrl");
const settingGotifyAppToken = document.querySelector("#gotifyAppToken");
const settingLunaseaWebhook = document.querySelector("#lunaseaWebhook");
const libraryUpdateTime = document.querySelector("#libraryUpdateTime");
const settingKomgaConfigured = document.querySelector("#komgaConfigured");
const settingKavitaConfigured = document.querySelector("#kavitaConfigured");
const settingGotifyConfigured = document.querySelector("#gotifyConfigured");
const settingLunaseaConfigured = document.querySelector("#lunaseaConfigured");
const settingApiUri = document.querySelector("#settingApiUri");
const tagTasksRunning = document.querySelector("#tasksRunningTag");
const tagTasksQueued = document.querySelector("#tasksQueuedTag");
const downloadTasksPopup = document.querySelector("#downloadTasksPopup");
const downloadTasksOutput = downloadTasksPopup.querySelector("popup-content");
searchbox.addEventListener("keyup", (event) => FilterResults());
settingsCog.addEventListener("click", () => OpenSettings());
document.querySelector("#blurBackgroundSettingsPopup").addEventListener("click", () => settingsPopup.style.display = "none");
document.querySelector("#blurBackgroundTaskPopup").addEventListener("click", () => selectPublicationPopup.style.display = "none");
document.querySelector("#blurBackgroundPublicationPopup").addEventListener("click", () => HidePublicationPopup());
document.querySelector("#blurBackgroundCreateMonitorTaskPopup").addEventListener("click", () => createMonitorTaskPopup.style.display = "none");
document.querySelector("#blurBackgroundCreateDownloadChaptersTask").addEventListener("click", () => createDownloadChaptersTask.style.display = "none");
document.querySelector("#blurBackgroundTasksQueuePopup").addEventListener("click", () => downloadTasksPopup.style.display = "none");
selectedChapters.addEventListener("keypress", (event) => {
if(event.key === "Enter"){
DownloadChapterTaskClick();
}
})
publicationDelete.addEventListener("click", () => DeleteTaskClick());
createMonitorTaskButton.addEventListener("click", () => {
HidePublicationPopup();
createMonitorTaskPopup.style.display = "block";
});
createDownloadChapterTaskButton.addEventListener("click", () => {
HidePublicationPopup();
OpenDownloadChapterTaskPopup();
});
publicationTaskStart.addEventListener("click", () => StartTaskClick());
searchPublicationQuery.addEventListener("keypress", (event) => {
if(event.key === "Enter"){
NewSearch();
}
});
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
connectorSelect.disabled = true;
searchPublicationQuery.disabled = true;
//Waitcursor
document.body.style.cursor = "wait";
//Empty previous results
selectPublication.replaceChildren();
GetPublicationFromConnector(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
connectorSelect.disabled = false;
searchPublicationQuery.disabled = false;
//Cursor
document.body.style.cursor = "initial";
});
}
//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 AddMonitorTask(){
var hours = document.querySelector("#hours").value;
var minutes = document.querySelector("#minutes").value;
CreateMonitorTask(connectorSelect.value, toEditId, `${hours}:${minutes}:00`, "en");
HidePublicationPopup();
createMonitorTaskPopup.style.display = "none";
selectPublicationPopup.style.display = "none";
}
function OpenDownloadChapterTaskPopup(){
selectedChapters.value = "";
chapterOutput.replaceChildren();
createDownloadChaptersTask.style.display = "block";
GetChapters(toEditId, connectorSelect.value, true, "en").then((json) => {
var i = 0;
json.forEach(chapter => {
var chapterDom = document.createElement("div");
var indexDom = document.createElement("span");
indexDom.className = "index";
indexDom.innerText = i++;
chapterDom.appendChild(indexDom);
var volDom = document.createElement("span");
volDom.className = "vol";
volDom.innerText = chapter.volumeNumber;
chapterDom.appendChild(volDom);
var chDom = document.createElement("span");
chDom.className = "ch";
chDom.innerText = chapter.chapterNumber;
chapterDom.appendChild(chDom);
var titleDom = document.createElement("span");
titleDom.innerText = chapter.name;
chapterDom.appendChild(titleDom);
chapterOutput.appendChild(chapterDom);
});
});
}
function DownloadChapterTaskClick(){
CreateDownloadChaptersTask(connectorSelect.value, toEditId, selectedChapters.value, "en");
HidePublicationPopup();
createDownloadChaptersTask.style.display = "none";
selectPublicationPopup.style.display = "none";
}
function DeleteTaskClick(){
taskToDelete = tasks.filter(tTask => tTask.publication.internalId === toEditId)[0];
DeleteTask("MonitorPublication", taskToDelete.connectorName, toEditId);
HidePublicationPopup();
}
function StartTaskClick(){
var toEditTask = tasks.filter(task => task.publication.internalId == toEditId)[0];
StartTask("MonitorPublication", 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;
publicationViewerTags.innerText = publication.tags.join(", ");
publicationViewerDescription.innerText = publication.description;
publicationViewerAuthor.innerText = publication.authors.join(',');
pubviewcover.src = `imageCache/${publication.coverFileNameInCache}`;
toEditId = publicationId;
//Check what action should be listed
if(add){
createMonitorTaskButton.style.display = "initial";
createDownloadChapterTaskButton.style.display = "initial";
publicationDelete.style.display = "none";
publicationTaskStart.style.display = "none";
}
else{
createMonitorTaskButton.style.display = "none";
createDownloadChapterTaskButton.style.display = "none";
publicationDelete.style.display = "initial";
publicationTaskStart.style.display = "initial";
}
}
function HidePublicationPopup(){
publicationViewerPopup.style.display = "none";
}
function ShowNewTaskWindow(){
selectPublication.replaceChildren();
searchPublicationQuery.value = "";
selectPublicationPopup.style.display = "flex";
}
const fadeIn = [
{ opacity: "0" },
{ opacity: "1" }
];
const fadeInTiming = {
duration: 50,
iterations: 1,
fill: "forwards"
}
function OpenSettings(){
GetSettingsClick();
settingsPopup.style.display = "flex";
}
function GetSettingsClick(){
settingApiUri.value = "";
settingKomgaUrl.value = "";
settingKomgaUser.value = "";
settingKomgaPass.value = "";
settingKomgaConfigured.innerText = "❌";
settingKavitaUrl.value = "";
settingKavitaUser.value = "";
settingKavitaPass.value = "";
settingKavitaConfigured.innerText = "❌";
settingGotifyUrl.value = "";
settingGotifyAppToken.value = "";
settingGotifyConfigured.innerText = "❌";
settingLunaseaWebhook.value = "";
settingLunaseaConfigured.innerText = "❌";
settingApiUri.placeholder = apiUri;
GetSettings().then(json => {
settingDownloadLocation.innerText = json.downloadLocation;
json.libraryManagers.forEach(lm => {
if(lm.libraryType == 0){
settingKomgaUrl.placeholder = lm.baseUrl;
settingKomgaUser.placeholder = "User";
settingKomgaPass.placeholder = "***";
settingKomgaConfigured.innerText = "✅";
} else if(lm.libraryType == 1){
settingKavitaUrl.placeholder = lm.baseUrl;
settingKavitaUser.placeholder = "User";
settingKavitaPass.placeholder = "***";
settingKavitaConfigured.innerText = "✅";
}
});
json.notificationManagers.forEach(nm => {
if(nm.notificationManagerType == 0){
settingGotifyConfigured.innerText = "✅";
} else if(nm.notificationManagerType == 1){
settingLunaseaConfigured.innerText = "✅";
}
});
});
GetKomgaTask().then(json => {
if(json.length > 0)
libraryUpdateTime.value = json[0].reoccurrence;
});
}
function UpdateLibrarySettings(){
if(settingKomgaUrl.value != "" && settingKomgaUser.value != "" && settingKomgaPass.value != ""){
var auth = utf8_to_b64(`${settingKomgaUser.value}:${settingKomgaPass.value}`);
console.log(auth);
UpdateKomga(settingKomgaUrl.value, auth);
}
if(settingKavitaUrl.value != "" && settingKavitaUser.value != "" && settingKavitaPass.value != ""){
UpdateKavita(settingKavitaUrl.value, settingKavitaUser.value, settingKavitaPass.value);
}
if(settingGotifyUrl.value != "" && settingGotifyAppToken.value != ""){
UpdateGotify(settingGotifyUrl.value, settingGotifyAppToken.value);
}
if(settingLunaseaWebhook.value != ""){
UpdateLunaSea(settingLunaseaWebhook.value);
}
if(settingApiUri.value != ""){
apiUri = settingApiUri.value;
document.cookie = `apiUri=${apiUri};`;
}
setTimeout(() => GetSettingsClick(), 200);
}
function utf8_to_b64( str ) {
return window.btoa(unescape(encodeURIComponent( str )));
}
function FilterResults(){
if(searchBox.value.length > 0){
tasksContent.childNodes.forEach(publication => {
publication.childNodes.forEach(item => {
if(item.nodeName.toLowerCase() == "publication-information"){
item.childNodes.forEach(information => {
if(information.nodeName.toLowerCase() == "publication-name"){
if(!information.textContent.toLowerCase().includes(searchBox.value.toLowerCase())){
publication.style.display = "none";
}else{
publication.style.display = "initial";
}
}
});
}
});
});
}else{
tasksContent.childNodes.forEach(publication => publication.style.display = "initial");
}
}
function ShowTasksQueue(){
downloadTasksOutput.replaceChildren();
GetRunningTasks()
.then(json => {
tagTasksRunning.innerText = json.length;
json.forEach(task => {
if(task.task == 2 || task.task == 4) {
downloadTasksOutput.appendChild(CreateProgressChild(task));
document.querySelector(`#progress${GetValidSelector(task.taskId)}`).value = task.progress;
var finishedHours = task.executionApproximatelyRemaining.split(':')[0];
var finishedMinutes = task.executionApproximatelyRemaining.split(':')[1];
var finishedSeconds = task.executionApproximatelyRemaining.split(':')[2].split('.')[0];
document.querySelector(`#progressStr${GetValidSelector(task.taskId)}`).innerText = `${finishedHours}:${finishedMinutes}:${finishedSeconds}`;
}
});
});
GetQueue()
.then(json => {
tagTasksQueued.innerText = json.length;
json.forEach(task => {
downloadTasksOutput.appendChild(CreateProgressChild(task));
});
});
downloadTasksPopup.style.display = "flex";
}
function CreateProgressChild(task){
var child = document.createElement("div");
var img = document.createElement('img');
img.src = `imageCache/${task.publication.coverFileNameInCache}`;
child.appendChild(img);
var name = document.createElement("span");
name.innerText = task.publication.sortName;
name.className = "pubTitle";
child.appendChild(name);
var progress = document.createElement("progress");
progress.id = `progress${GetValidSelector(task.taskId)}`;
child.appendChild(progress);
var progressStr = document.createElement("span");
progressStr.innerText = " \t∞";
progressStr.className = "progressStr";
progressStr.id = `progressStr${GetValidSelector(task.taskId)}`;
child.appendChild(progressStr);
if(task.chapter != undefined){
var chapterNumber = document.createElement("span");
chapterNumber.className = "chapterNumber";
chapterNumber.innerText = `Vol.${task.chapter.volumeNumber} Ch.${task.chapter.chapterNumber}`;
child.appendChild(chapterNumber);
var chapterName = document.createElement("span");
chapterName.className = "chapterName";
chapterName.innerText = task.chapter.name;
child.appendChild(chapterName);
}
return child;
}
//Resets the tasks shown
ResetContent();
downloadTasksOutput.replaceChildren();
//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;
json.forEach(task => {
downloadTasksOutput.appendChild(CreateProgressChild(task));
});
});
GetQueue()
.then(json => {
tagTasksQueued.innerText = json.length;
json.forEach(task => {
downloadTasksOutput.appendChild(CreateProgressChild(task));
});
})
setInterval(() => {
//Tasks from API
var cTasks = [];
GetDownloadTasks()
.then(json => json.forEach(task => cTasks.push(task)))
.then(() => {
//Only update view if tasks-amount has changed
if(tasks.length != cTasks.length) {
//Resets the tasks shown
ResetContent();
//Add all currenttasks to view
cTasks.forEach(task => {
var publication = CreatePublication(task.publication, task.connectorName);
publication.addEventListener("click", (event) => ShowPublicationViewerWindow(task.publication.internalId, event, false));
tasksContent.appendChild(publication);
})
tasks = cTasks;
}
}
);
GetRunningTasks()
.then(json => {
tagTasksRunning.innerText = json.length;
});
GetQueue()
.then(json => {
tagTasksQueued.innerText = json.length;
});
}, 1000);
setInterval(() => {
GetRunningTasks().then((json) => {
json.forEach(task => {
if(task.task == 2 || task.task == 4){
document.querySelector(`#progress${GetValidSelector(task.taskId)}`).value = task.progress;
var finishedHours = task.executionApproximatelyRemaining.split(':')[0];
var finishedMinutes = task.executionApproximatelyRemaining.split(':')[1];
var finishedSeconds = task.executionApproximatelyRemaining.split(':')[2].split('.')[0];
document.querySelector(`#progressStr${GetValidSelector(task.taskId)}`).innerText = `${finishedHours}:${finishedMinutes}:${finishedSeconds}`;
}
});
});
},500);
function GetValidSelector(str){
var clean = [...str.matchAll(/[a-zA-Z0-9]*-*_*/g)];
return clean.join('');
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

View File

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

Before

Width:  |  Height:  |  Size: 804 B

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
<g fill="#000000">
<path d="M2.23 2.674a.75.75 0 00-.96 1.152L3.578 5.75 1.27 7.674a.75.75 0 00.96 1.152l3-2.5a.75.75 0 000-1.152l-3-2.5zM8.25 5a.75.75 0 000 1.5h6a.75.75 0 000-1.5h-6zM5.5 9.25a.75.75 0 01.75-.75h8a.75.75 0 010 1.5h-8a.75.75 0 01-.75-.75zM6.25 12a.75.75 0 000 1.5h8a.75.75 0 000-1.5h-8z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 545 B

View File

@ -1,53 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 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>

Before

Width:  |  Height:  |  Size: 4.6 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

Before

Width:  |  Height:  |  Size: 603 B

View File

@ -1,603 +0,0 @@
: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{
cursor: default;
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;
cursor: grab;
}
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;
cursor: pointer;
}
footer > div > *{
height: 40%;
margin: 0 5px;
}
#madeWith {
flex-grow: 1;
text-align: right;
margin-right: 20px;
cursor: url("media/blahaj.png"), grab;
}
content {
position: relative;
flex-grow: 1;
border-radius: 5px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: start;
align-content: start;
}
#settingsPopup{
z-index: 10;
}
#settingsPopup popup-content{
flex-direction: column;
align-items: start;
margin: 15px 10px;
}
#settingsPopup popup-content > * {
margin: 5px 10px;
}
#settingsPopup popup-content .title {
font-weight: bolder;
}
#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;
flex-direction: column;
}
popup popup-window {
position: absolute;
z-index: 3;
left: 25%;
top: 100px;
width: 50%;
display: flex;
flex-direction: column;
background-color: var(--second-background-color);
border-radius: 3px;
overflow: hidden;
}
popup popup-window popup-title {
height: 30px;
font-size: 14pt;
font-weight: bolder;
padding: 5px 10px;
margin: 0;
display: flex;
align-items: center;
background-color: var(--primary-color);
color: var(--accent-color)
}
popup popup-window popup-content{
margin: 15px 10px;
display: flex;
align-items: center;
justify-content: space-evenly;
}
popup popup-window popup-content div > * {
margin: 2px 3px 0 0;
}
popup popup-window popup-content input, select {
padding: 3px 4px;
width: 130px;
border: 1px solid lightgrey;
background-color: var(--accent-color);
border-radius: 3px;
}
#selectPublicationPopup publication {
width: 150px;
height: 250px;
}
#createTaskPopup {
z-index: 7;
}
#createTaskPopup input {
height: 30px;
width: 200px;
}
#createMonitorTaskPopup, #createDownloadChaptersTask {
z-index: 9;
}
#createMonitorTaskPopup input[type="number"] {
width: 40px;
}
#createDownloadChaptersTask popup-content {
flex-direction: column;
align-items: start;
}
#createDownloadChaptersTask popup-content > * {
margin: 3px 0;
}
#createDownloadChaptersTask #chapterOutput {
max-height: 50vh;
overflow-y: scroll;
}
#createDownloadChaptersTask #chapterOutput .index{
display: inline-block;
width: 25px;
}
#createDownloadChaptersTask #chapterOutput .index::after{
content: ':';
}
#createDownloadChaptersTask #chapterOutput .vol::before{
content: 'Vol.';
}
#createDownloadChaptersTask #chapterOutput .vol{
display: inline-block;
width: 45px;
}
#createDownloadChaptersTask #chapterOutput .ch::before{
content: 'Ch.';
}
#createDownloadChaptersTask #chapterOutput .ch {
display: inline-block;
width: 60px;
}
#downloadTasksPopup popup-window {
left: 0;
top: 80px;
margin: 0 0 0 10px;
height: calc(100vh - 140px);
width: 400px;
max-width: 95vw;
overflow-y: scroll;
}
#downloadTasksPopup popup-content {
flex-direction: column;
align-items: start;
margin: 5px;
}
#downloadTasksPopup popup-content > div {
display: block;
height: 80px;
position: relative;
margin: 5px 0;
}
#downloadTasksPopup popup-content > div > img {
display: block;
position: absolute;
height: 100%;
width: 60px;
left: 0;
top: 0;
object-fit: cover;
border-radius: 4px;
}
#downloadTasksPopup popup-content > div > span {
display: block;
position: absolute;
width: max-content;
}
#downloadTasksPopup popup-content > div > .pubTitle {
left: 70px;
top: 0;
}
#downloadTasksPopup popup-content > div > .chapterName {
left: 70px;
top: 28pt;
}
#downloadTasksPopup popup-content > div > .chapterNumber {
left: 70px;
top: 14pt;
}
#downloadTasksPopup popup-content > div > progress {
display: block;
position: absolute;
left: 150px;
bottom: 0;
width: 200px;
}
#downloadTasksPopup popup-content > div > .progressStr {
display: block;
position: absolute;
left: 70px;
bottom: 0;
width: 70px;
}
blur-background {
width: 100%;
height: 100%;
position: absolute;
left: 0;
background-color: black;
opacity: 0.5;
}
#taskSelectOutput{
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: start;
align-content: start;
max-height: 70vh;
overflow-y: scroll;
}
#publicationViewerPopup{
z-index: 5;
}
publication-viewer{
display: block;
width: 450px;
position: absolute;
top: 200px;
left: 400px;
background-color: var(--accent-color);
border-radius: 5px;
overflow: hidden;
padding: 15px;
}
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 > * {
margin: 5px 0;
}
publication-viewer publication-information publication-name {
width: initial;
overflow-x: scroll;
white-space: nowrap;
scrollbar-width: none;
}
publication-viewer publication-information publication-tags::before {
content: "Tags";
display: block;
font-weight: bolder;
}
publication-viewer publication-information publication-tags {
overflow-x: scroll;
white-space: nowrap;
scrollbar-width: none;
}
publication-viewer publication-information publication-author::before {
content: "Author: ";
font-weight: bolder;
}
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;
height: 145px;
overflow-x: scroll;
}
publication-viewer publication-information publication-interactions {
display: flex;
flex-direction: row;
justify-content: end;
align-items: start;
width: 100%;
}
publication-viewer publication-information publication-interactions > * {
margin: 0 10px;
font-size: 16pt;
cursor: pointer;
}
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 5px;
}
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: 0;
bottom: -17px;
border-radius: 0 0 0 5px;
}

View File

@ -1,19 +1,17 @@
version: '3'
services:
tranga-api:
image: glax/tranga-api:latest
image: glax/tranga-api:cuttingedge
container_name: tranga-api
volumes:
- ./tranga:/usr/share/Tranga-API #1 when replacing ./tranga replace #2 with same value
- ./Manga:/Manga
- ./settings:/usr/share/tranga-api
ports:
- "6531:6531"
restart: unless-stopped
tranga-website:
image: glax/tranga-website:latest
image: glax/tranga-website:cuttingedge
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:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB