diff --git a/.github/workflows/docker-image-cuttingedge.yml b/.github/workflows/docker-image-cuttingedge.yml index 989e2e4..d1a8d82 100644 --- a/.github/workflows/docker-image-cuttingedge.yml +++ b/.github/workflows/docker-image-cuttingedge.yml @@ -42,17 +42,4 @@ jobs: pull: true 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 + glax/tranga-api:cuttingedge \ No newline at end of file diff --git a/.github/workflows/docker-image-master.yml b/.github/workflows/docker-image-master.yml index 48cd8d9..48cea90 100644 --- a/.github/workflows/docker-image-master.yml +++ b/.github/workflows/docker-image-master.yml @@ -44,17 +44,4 @@ jobs: pull: true 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 + glax/tranga-api:latest \ No newline at end of file diff --git a/CLI/CLI.csproj b/CLI/CLI.csproj new file mode 100644 index 0000000..abf151f --- /dev/null +++ b/CLI/CLI.csproj @@ -0,0 +1,18 @@ + + + + Exe + net7.0 + enable + enable + + + + + + + + + + + diff --git a/CLI/Program.cs b/CLI/Program.cs new file mode 100644 index 0000000..9543773 --- /dev/null +++ b/CLI/Program.cs @@ -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(); +return app.Run(args); + +internal sealed class TrangaCli : Command +{ + 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 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() + .Title("Menu") + .PageSize(10) + .MoreChoicesText("Up/Down") + .AddChoices(new[] + { + "CustomRequest", + "Log", + "Exit" + })); + + switch (menuSelect) + { + case "CustomRequest": + HttpMethod requestMethod = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Request Type") + .AddChoices(new[] + { + HttpMethod.Get, + HttpMethod.Delete, + HttpMethod.Post + })); + string requestPath = AnsiConsole.Prompt( + new TextPrompt("Request Path:")); + List> parameters = new(); + while (AnsiConsole.Confirm("Add Parameter?")) + { + string name = AnsiConsole.Ask("Parameter Name:"); + string value = AnsiConsole.Ask("Parameter Value:"); + parameters.Add(new ValueTuple(name, value)); + } + + string requestString = $"http://localhost:{trangaSettings.apiPortNumber}/{requestPath}"; + if (parameters.Any()) + { + requestString += "?"; + foreach (ValueTuple 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 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; + } +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 17b6cde..6bab183 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/Logging/FileLogger.cs b/Logging/FileLogger.cs index 10fd0ec..b87e2b0 100644 --- a/Logging/FileLogger.cs +++ b/Logging/FileLogger.cs @@ -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) diff --git a/Logging/LogMessage.cs b/Logging/LogMessage.cs index 56515c0..ac73c44 100644 --- a/Logging/LogMessage.cs +++ b/Logging/LogMessage.cs @@ -1,6 +1,6 @@ namespace Logging; -public class LogMessage +public readonly struct LogMessage { public DateTime logTime { get; } public string caller { get; } diff --git a/Logging/Logger.cs b/Logging/Logger.cs index 9ca3d28..f34bfe1 100644 --- a/Logging/Logger.cs +++ b/Logging/Logger.cs @@ -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(); + } } \ No newline at end of file diff --git a/Logging/MemoryLogger.cs b/Logging/MemoryLogger.cs index 47581f2..a612587 100644 --- a/Logging/MemoryLogger.cs +++ b/Logging/MemoryLogger.cs @@ -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 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(); } } \ No newline at end of file diff --git a/README.md b/README.md index 0ff1fd2..4b7b5cb 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,15 @@

Automatic Manga and Metadata downloader

+

+ This is the API for Tranga-Website +

- +# 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.
@@ -30,9 +36,6 @@
  • Built With
  • -
  • - Screenshots -
  • Getting Started
      @@ -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

      (back to top)

      - -## Screenshots - -| ![image](screenshots/overview.png) | ![image](screenshots/addtask.png) | -|-----------------------------------:|:----------------------------------| - -| ![image](screenshots/settings.png) | ![image](screenshots/publication-description.png) | ![image](screenshots/progress.png) | -|-----------------------------------:|:-------------------------------------------------:|:-----------------------------------| - -

      (back to top)

      - ## 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). +Download [docker-compose.yaml](https://git.bernloehr.eu/glax/Tranga/src/branch/master/docker-compose.yaml) and configure to your needs. +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 diff --git a/Tranga.sln b/Tranga.sln index 78a7590..501c3c3 100644 --- a/Tranga.sln +++ b/Tranga.sln @@ -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 diff --git a/Tranga.sln.DotSettings b/Tranga.sln.DotSettings index 7f9a86f..1162c8f 100644 --- a/Tranga.sln.DotSettings +++ b/Tranga.sln.DotSettings @@ -2,7 +2,9 @@ True True True + True True + True True True True diff --git a/Tranga/API/RequestHandler.cs b/Tranga/API/RequestHandler.cs deleted file mode 100644 index 62f7f5c..0000000 --- a/Tranga/API/RequestHandler.cs +++ /dev/null @@ -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> _validRequestPaths = new() - { - new(HttpMethod.Get, "/", Array.Empty()), - new(HttpMethod.Get, "/Connectors", Array.Empty()), - 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()), - 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()), - new(HttpMethod.Get, "/Queue/List", Array.Empty()), - new(HttpMethod.Post, "/Queue/Enqueue", new[] { "taskType", "connectorName?", "publicationId?" }), - new(HttpMethod.Delete, "/Queue/Dequeue", new[] { "taskType", "connectorName?", "publicationId?" }), - new(HttpMethod.Get, "/Settings", Array.Empty()), - 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 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 GetRequestVariables(string query) - { - Dictionary 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 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(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(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 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 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(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(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 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(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(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; - } - } -} \ No newline at end of file diff --git a/Tranga/API/Server.cs b/Tranga/API/Server.cs deleted file mode 100644 index 8d19bc4..0000000 --- a/Tranga/API/Server.cs +++ /dev/null @@ -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()); - response.OutputStream.Close(); - } - catch (HttpListenerException) - { - - } - } -} \ No newline at end of file diff --git a/Tranga/Chapter.cs b/Tranga/Chapter.cs index 0559f40..4af7722 100644 --- a/Tranga/Chapter.cs +++ b/Tranga/Chapter.cs @@ -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; @@ -35,8 +35,12 @@ public readonly struct Chapter chNameStr = IllegalStrings.Replace(chNameStr, ""); this.fileName = $"{volStr}{chNumberStr}{chNameStr}"; } - - + + public override string ToString() + { + return $"Chapter {parentManga.sortName} {parentManga.internalId} {chapterNumber} {name}"; + } + /// /// 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 /// Filepath 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"); } /// @@ -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) ); diff --git a/Tranga/CommonObjects.cs b/Tranga/CommonObjects.cs deleted file mode 100644 index 0d9c5f4..0000000 --- a/Tranga/CommonObjects.cs +++ /dev/null @@ -1,125 +0,0 @@ -using Logging; -using Newtonsoft.Json; -using Tranga.LibraryManagers; -using Tranga.NotificationManagers; - -namespace Tranga; - -public class CommonObjects -{ - public HashSet libraryManagers { get; init; } - public HashSet notificationManagers { get; init; } - [JsonIgnore]public Logger? logger { get; set; } - [JsonIgnore]private string settingsFilePath { get; init; } - - public CommonObjects(HashSet? libraryManagers, HashSet? 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( - 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(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(); - } -} \ No newline at end of file diff --git a/Tranga/Connectors/Mangasee.cs b/Tranga/Connectors/Mangasee.cs deleted file mode 100644 index 76e60be..0000000 --- a/Tranga/Connectors/Mangasee.cs +++ /dev/null @@ -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() - { - { 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(); - - return ParsePublicationsFromHtml(requestResult.result, publicationTitle); - } - - private Publication[] ParsePublicationsFromHtml(Stream html, string publicationTitle) - { - string jsonString = new StreamReader(html).ReadToEnd(); - List result = JsonConvert.DeserializeObject>(jsonString)!; - Dictionary 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 ret = new(); - List 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 altTitles = new(), links = new(); - HashSet 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 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 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 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; - } -} \ No newline at end of file diff --git a/Tranga/DownloadClient.cs b/Tranga/DownloadClient.cs deleted file mode 100644 index 5b00a06..0000000 --- a/Tranga/DownloadClient.cs +++ /dev/null @@ -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 _lastExecutedRateLimit; - private readonly Dictionary _rateLimit; - // ReSharper disable once InconsistentNaming - private readonly Logger? logger; - - /// - /// Creates a httpClient - /// - /// Rate limits for requests. byte is RequestType, int maximum requests per minute for RequestType - /// - public DownloadClient(Dictionary rateLimitRequestsPerMinute, Logger? logger) - { - this.logger = logger; - _lastExecutedRateLimit = new(); - _rateLimit = new(); - foreach(KeyValuePair limit in rateLimitRequestsPerMinute) - _rateLimit.Add(limit.Key, TimeSpan.FromMinutes(1).Divide(limit.Value)); - } - - /// - /// Request Webpage - /// - /// - /// For RateLimits: Same Endpoints use same type - /// Used in http request header - /// RequestResult with StatusCode and Stream of received data - 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; - } - } - } \ No newline at end of file diff --git a/Tranga/GlobalBase.cs b/Tranga/GlobalBase.cs new file mode 100644 index 0000000..34b3cd7 --- /dev/null +++ b/Tranga/GlobalBase.cs @@ -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 notificationConnectors { get; init; } + protected HashSet libraryConnectors { get; init; } + protected List 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; + } + } +} \ No newline at end of file diff --git a/Tranga/Jobs/DownloadChapter.cs b/Tranga/Jobs/DownloadChapter.cs new file mode 100644 index 0000000..8a27929 --- /dev/null +++ b/Tranga/Jobs/DownloadChapter.cs @@ -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 ExecuteReturnSubTasksInternal() + { + Task downloadTask = new(delegate + { + mangaConnector.DownloadChapter(chapter, this.progressToken); + UpdateLibraries(); + SendNotifications("Chapter downloaded", $"{chapter.parentManga.sortName} - {chapter.chapterNumber}"); + }); + downloadTask.Start(); + return Array.Empty(); + } +} \ No newline at end of file diff --git a/Tranga/Jobs/DownloadNewChapters.cs b/Tranga/Jobs/DownloadNewChapters.cs new file mode 100644 index 0000000..a386b98 --- /dev/null +++ b/Tranga/Jobs/DownloadNewChapters.cs @@ -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 ExecuteReturnSubTasksInternal() + { + Chapter[] chapters = mangaConnector.GetNewChapters(manga, this.translatedLanguage); + this.progressToken.increments = chapters.Length; + List jobs = new(); + foreach (Chapter chapter in chapters) + { + DownloadChapter downloadChapterJob = new(this, this.mangaConnector, chapter, parentJobId: this.id); + jobs.Add(downloadChapterJob); + } + progressToken.Complete(); + return jobs; + } +} \ No newline at end of file diff --git a/Tranga/Jobs/Job.cs b/Tranga/Jobs/Job.cs new file mode 100644 index 0000000..86d5b19 --- /dev/null +++ b/Tranga/Jobs/Job.cs @@ -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? 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(); + 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 ExecuteReturnSubTasks() + { + progressToken.Start(); + subJobs = ExecuteReturnSubTasksInternal(); + lastExecution = DateTime.Now; + return subJobs; + } + + protected abstract IEnumerable ExecuteReturnSubTasksInternal(); +} \ No newline at end of file diff --git a/Tranga/Jobs/JobBoss.cs b/Tranga/Jobs/JobBoss.cs new file mode 100644 index 0000000..a54a023 --- /dev/null +++ b/Tranga/Jobs/JobBoss.cs @@ -0,0 +1,232 @@ +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Tranga.MangaConnectors; + +namespace Tranga.Jobs; + +public class JobBoss : GlobalBase +{ + public HashSet jobs { get; init; } + private Dictionary> mangaConnectorJobQueue { get; init; } + + public JobBoss(GlobalBase clone, HashSet 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 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 jobsToRemove) + { + Log($"Removing {jobsToRemove.Count()} jobs."); + foreach (Job? job in jobsToRemove) + if(job is not null) + RemoveJob(job); + } + + public IEnumerable GetJobsLike(string? connectorName = null, string? internalId = null, string? chapterNumber = null) + { + IEnumerable 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 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()); + return mangaConnectorJobQueue[job.mangaConnector].Contains(job); + } + + public void AddJobToQueue(Job job) + { + Log($"Adding Job to Queue. {job}"); + mangaConnectorJobQueue.TryAdd(job.mangaConnector, new Queue()); + Queue connectorJobQueue = mangaConnectorJobQueue[job.mangaConnector]; + if(!connectorJobQueue.Contains(job)) + connectorJobQueue.Enqueue(job); + job.ExecutionEnqueue(); + } + + public void AddJobsToQueue(IEnumerable jobs) + { + foreach(Job job in jobs) + AddJobToQueue(job); + } + + public void LoadJobsList(HashSet 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(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 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); + } + } + } + } \ No newline at end of file diff --git a/Tranga/Jobs/JobJsonConverter.cs b/Tranga/Jobs/JobJsonConverter.cs new file mode 100644 index 0000000..794075e --- /dev/null +++ b/Tranga/Jobs/JobJsonConverter.cs @@ -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(JsonSerializer.Create(new JsonSerializerSettings() + { + Converters = + { + this._mangaConnectorJsonConverter + } + }))!, + jo.GetValue("manga")!.ToObject(), + jo.GetValue("lastExecution")!.ToObject(), + jo.GetValue("recurring")!.Value(), + jo.GetValue("recurrenceTime")!.ToObject(), + jo.GetValue("parentJobId")!.Value()); + } + + if (jo.ContainsKey("chapter"))//DownloadChapter + { + return new DownloadChapter(this._clone, + jo.GetValue("mangaConnector")!.ToObject(JsonSerializer.Create(new JsonSerializerSettings() + { + Converters = + { + this._mangaConnectorJsonConverter + } + }))!, + jo.GetValue("chapter")!.ToObject(), + DateTime.UnixEpoch, + jo.GetValue("parentJobId")!.Value()); + } + + throw new Exception(); + } + + public override bool CanWrite => false; + + /// + /// Don't call this + /// + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + throw new Exception("Dont call this"); + } +} \ No newline at end of file diff --git a/Tranga/Jobs/ProgressToken.cs b/Tranga/Jobs/ProgressToken.cs new file mode 100644 index 0000000..9bada05 --- /dev/null +++ b/Tranga/Jobs/ProgressToken.cs @@ -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; + } +} \ No newline at end of file diff --git a/Tranga/LibraryManagers/Kavita.cs b/Tranga/LibraryConnectors/Kavita.cs similarity index 77% rename from Tranga/LibraryManagers/Kavita.cs rename to Tranga/LibraryConnectors/Kavita.cs index 04cbe67..a6442f2 100644 --- a/Tranga/LibraryManagers/Kavita.cs +++ b/Tranga/LibraryConnectors/Kavita.cs @@ -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(response.Content.ReadAsStream()); if (result is not null) return result["token"]!.GetValue(); - 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 /// Array of KavitaLibrary private IEnumerable 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(); } JsonArray? result = JsonSerializer.Deserialize(data); if (result is null) { - logger?.WriteLine(this.GetType().ToString(), $"No libraries returned"); + Log("No libraries returned"); return Array.Empty(); } diff --git a/Tranga/LibraryManagers/Komga.cs b/Tranga/LibraryConnectors/Komga.cs similarity index 70% rename from Tranga/LibraryManagers/Komga.cs rename to Tranga/LibraryConnectors/Komga.cs index 545ec91..7a0c485 100644 --- a/Tranga/LibraryManagers/Komga.cs +++ b/Tranga/LibraryConnectors/Komga.cs @@ -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; /// /// Provides connectivity to Komga-API /// Can fetch and update libraries /// -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 /// Array of KomgaLibraries private IEnumerable 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(); } JsonArray? result = JsonSerializer.Deserialize(data); if (result is null) { - logger?.WriteLine(this.GetType().ToString(), $"No libraries returned"); + Log("No libraries returned"); return Array.Empty(); } diff --git a/Tranga/LibraryManagers/LibraryManager.cs b/Tranga/LibraryConnectors/LibraryConnector.cs similarity index 58% rename from Tranga/LibraryManagers/LibraryManager.cs rename to Tranga/LibraryConnectors/LibraryConnector.cs index 88e7b98..e3e3caa 100644 --- a/Tranga/LibraryManagers/LibraryManager.cs +++ b/Tranga/LibraryConnectors/LibraryConnector.cs @@ -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; - /// Base-URL of Komga instance, no trailing slashes(/) - /// Base64 string of username and password (username):(password) - /// - /// - 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)LibraryType.Komga) - return jo.ToObject(serializer)!; - - if (jo["libraryType"]!.Value() == (Int64)LibraryType.Kavita) - return jo.ToObject(serializer)!; - - throw new Exception(); - } - - public override bool CanWrite => false; - - /// - /// Don't call this - /// - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) - { - throw new Exception("Dont call this"); - } - } } \ No newline at end of file diff --git a/Tranga/LibraryConnectors/LibraryManagerJsonConverter.cs b/Tranga/LibraryConnectors/LibraryManagerJsonConverter.cs new file mode 100644 index 0000000..be8983f --- /dev/null +++ b/Tranga/LibraryConnectors/LibraryManagerJsonConverter.cs @@ -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)LibraryConnector.LibraryType.Komga) + return new Komga(this._clone, + jo.GetValue("baseUrl")!.Value()!, + jo.GetValue("auth")!.Value()!); + + if (jo["libraryType"]!.Value() == (byte)LibraryConnector.LibraryType.Kavita) + return new Kavita(this._clone, + jo.GetValue("baseUrl")!.Value()!, + jo.GetValue("auth")!.Value()!); + + throw new Exception(); + } + + public override bool CanWrite => false; + + /// + /// Don't call this + /// + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + throw new Exception("Dont call this"); + } +} \ No newline at end of file diff --git a/Tranga/Publication.cs b/Tranga/Manga.cs similarity index 76% rename from Tranga/Publication.cs rename to Tranga/Manga.cs index 330e7c0..9bde5c6 100644 --- a/Tranga/Publication.cs +++ b/Tranga/Manga.cs @@ -9,7 +9,7 @@ namespace Tranga; /// /// Contains information on a Publication (Manga) /// -public struct Publication +public struct Manga { public string sortName { get; } public List 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 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 authors, string? description, Dictionary altTitles, string[] tags, string? posterUrl, string? coverFileNameInCache, Dictionary? links, int? year, string? originalLanguage, string status, string publicationId, string? folderName = null, float? ignoreChaptersBelow = 0) + public Manga(string sortName, List authors, string? description, Dictionary altTitles, string[] tags, string? coverUrl, string? coverFileNameInCache, Dictionary? 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(); 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; diff --git a/Tranga/MangaConnectors/ChromiumDownloadClient.cs b/Tranga/MangaConnectors/ChromiumDownloadClient.cs new file mode 100644 index 0000000..bbb5949 --- /dev/null +++ b/Tranga/MangaConnectors/ChromiumDownloadClient.cs @@ -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 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 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(); + } +} \ No newline at end of file diff --git a/Tranga/MangaConnectors/DownloadClient.cs b/Tranga/MangaConnectors/DownloadClient.cs new file mode 100644 index 0000000..1b87413 --- /dev/null +++ b/Tranga/MangaConnectors/DownloadClient.cs @@ -0,0 +1,66 @@ +using System.Net; +using HtmlAgilityPack; + +namespace Tranga.MangaConnectors; + +internal abstract class DownloadClient : GlobalBase +{ + private readonly Dictionary _lastExecutedRateLimit; + private readonly Dictionary _rateLimit; + + protected DownloadClient(GlobalBase clone, Dictionary rateLimitRequestsPerMinute) : base(clone) + { + this._lastExecutedRateLimit = new(); + _rateLimit = new(); + foreach (KeyValuePair 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; + } + } + +} \ No newline at end of file diff --git a/Tranga/MangaConnectors/HttpDownloadClient.cs b/Tranga/MangaConnectors/HttpDownloadClient.cs new file mode 100644 index 0000000..da5b795 --- /dev/null +++ b/Tranga/MangaConnectors/HttpDownloadClient.cs @@ -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 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."); + } +} \ No newline at end of file diff --git a/Tranga/Connectors/Connector.cs b/Tranga/MangaConnectors/MangaConnector.cs similarity index 63% rename from Tranga/Connectors/Connector.cs rename to Tranga/MangaConnectors/MangaConnector.cs index 997aeec..74b49e9 100644 --- a/Tranga/Connectors/Connector.cs +++ b/Tranga/MangaConnectors/MangaConnector.cs @@ -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; /// /// Base-Class for all Connectors /// Provides some methods to be used by all Connectors, as well as a DownloadClient /// -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) - { - this.settings = settings; - this.commonObjects = commonObjects; - if (!Directory.Exists(settings.coverImageCache)) - Directory.CreateDirectory(settings.coverImageCache); - } - - public abstract string name { get; } //Name of the Connector (e.g. Website) - public Publication[] GetPublications(ref HashSet publicationCollection, string publicationTitle = "") + public void StopDownloadClient() { - Publication[] ret = GetPublicationsInternal(publicationTitle); - foreach (Publication p in ret) - publicationCollection.Add(p); - return ret; + downloadClient.Close(); + } + + protected MangaConnector(GlobalBase clone, string name) : base(clone) + { + this.name = name; + Directory.CreateDirectory(settings.coverImageCache); } + public string name { get; } //Name of the Connector (e.g. Website) + /// /// Returns all Publications with the given string. /// If the string is empty or null, returns all Publication of the Connector /// /// Search-Query /// Publications matching the query - protected abstract Publication[] GetPublicationsInternal(string publicationTitle = ""); + public abstract Manga[] GetManga(string publicationTitle = ""); + + public abstract Manga? GetMangaFromUrl(string url); /// /// Returns all Chapters of the publication in the provided language. /// If the language is empty or null, returns all Chapters in all Languages. /// - /// Publication to get Chapters for + /// Publication to get Chapters for /// Language of the Chapters /// Array of Chapters matching Publication and Language - public abstract Chapter[] GetChapters(Publication publication, string language = ""); + public abstract Chapter[] GetChapters(Manga manga, string language="en"); /// /// Updates the available Chapters of a Publication /// - /// Publication to check + /// Publication to check /// Language to receive chapters for - /// /// List of Chapters that were previously not in collection - public List GetNewChaptersList(Publication publication, string language, ref HashSet 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 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(); } - /// - /// Retrieves the Chapter (+Images) from the website. - /// Should later call DownloadChapterImages to retrieve the individual Images of the Chapter and create .cbz archive. - /// - /// Publication that contains Chapter - /// Chapter with Images to retrieve - /// Will be used for progress-tracking - /// - public abstract HttpStatusCode DownloadChapter(Publication publication, Chapter chapter, DownloadChapterTask parentTask, CancellationToken? cancellationToken = null); + public abstract HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null); /// /// Copies the already downloaded cover from cache to downloadLocation /// - /// Publication to retrieve Cover for - public void CopyCoverFromCacheToDownloadLocation(Publication publication) + /// Publication to retrieve Cover for + 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; } - /// - /// Downloads all Images from URLs, Compresses to zip(cbz) and saves. - /// - /// List of URLs to download Images from - /// Full path to save archive to (without file ending .cbz) - /// Used for progress tracking - /// Path of the generate Chapter ComicInfo.xml, if it was generated - /// RequestType for RateLimits - /// Used in http request header - /// - 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; } } \ No newline at end of file diff --git a/Tranga/MangaConnectors/MangaConnectorJsonConverter.cs b/Tranga/MangaConnectors/MangaConnectorJsonConverter.cs new file mode 100644 index 0000000..b9f3af0 --- /dev/null +++ b/Tranga/MangaConnectors/MangaConnectorJsonConverter.cs @@ -0,0 +1,51 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Tranga.MangaConnectors; + +public class MangaConnectorJsonConverter : JsonConverter +{ + private GlobalBase _clone; + private HashSet connectors; + + internal MangaConnectorJsonConverter(GlobalBase clone, HashSet 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()!) + { + 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; + + /// + /// Don't call this + /// + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + throw new Exception("Dont call this"); + } +} \ No newline at end of file diff --git a/Tranga/Connectors/MangaDex.cs b/Tranga/MangaConnectors/MangaDex.cs similarity index 51% rename from Tranga/Connectors/MangaDex.cs rename to Tranga/MangaConnectors/MangaDex.cs index b63f7a3..f0203bd 100644 --- a/Tranga/Connectors/MangaDex.cs +++ b/Tranga/MangaConnectors/MangaDex.cs @@ -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() + this.downloadClient = new HttpDownloadClient(clone, new Dictionary() { {(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 publications = new(); + HashSet 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(); + } + + 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(requestResult.result); + if(result is not null) + return MangaFromJsonObject(result["data"]!.AsObject()); + return null; + } + + private Manga MangaFromJsonObject(JsonObject manga) + { + JsonObject attributes = manga["attributes"]!.AsObject(); - string publicationId = manga["id"]!.GetValue(); + string publicationId = manga["id"]!.GetValue(); - string title = attributes["title"]!.AsObject().ContainsKey("en") && attributes["title"]!["en"] is not null + string title = attributes["title"]!.AsObject().ContainsKey("en") && attributes["title"]!["en"] is not null ? attributes["title"]!["en"]!.GetValue() : attributes["title"]![((IDictionary)attributes["title"]!.AsObject()).Keys.First()]!.GetValue(); - 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() : null; - JsonArray altTitlesObject = attributes["altTitles"]!.AsArray(); - Dictionary altTitlesDict = new(); - foreach (JsonNode? altTitleNode in altTitlesObject) - { - JsonObject altTitleObject = (JsonObject)altTitleNode!; - string key = ((IDictionary)altTitleObject).Keys.ToArray()[0]; - altTitlesDict.TryAdd(key, altTitleObject[key]!.GetValue()); - } + JsonArray altTitlesObject = attributes["altTitles"]!.AsArray(); + Dictionary altTitlesDict = new(); + foreach (JsonNode? altTitleNode in altTitlesObject) + { + JsonObject altTitleObject = (JsonObject)altTitleNode!; + string key = ((IDictionary)altTitleObject).Keys.ToArray()[0]; + altTitlesDict.TryAdd(key, altTitleObject[key]!.GetValue()); + } - JsonArray tagsObject = attributes["tags"]!.AsArray(); - HashSet 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()); - } + JsonArray tagsObject = attributes["tags"]!.AsArray(); + HashSet 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? posterId = null; - HashSet authorIds = new(); - if (manga.ContainsKey("relationships") && manga["relationships"] is not null) - { - JsonArray relationships = manga["relationships"]!.AsArray(); - posterId = relationships.FirstOrDefault(relationship => relationship!["type"]!.GetValue() == "cover_art")!["id"]!.GetValue(); - foreach (JsonNode? node in relationships.Where(relationship => - relationship!["type"]!.GetValue() == "author")) - authorIds.Add(node!["id"]!.GetValue()); - } - string? coverUrl = GetCoverUrl(publicationId, posterId); - string? coverCacheName = null; - if (coverUrl is not null) - coverCacheName = SaveCoverImageToCache(coverUrl, (byte)RequestType.AtHomeServer); + string? posterId = null; + HashSet authorIds = new(); + if (manga.ContainsKey("relationships") && manga["relationships"] is not null) + { + JsonArray relationships = manga["relationships"]!.AsArray(); + posterId = relationships.FirstOrDefault(relationship => relationship!["type"]!.GetValue() == "cover_art")!["id"]!.GetValue(); + foreach (JsonNode? node in relationships.Where(relationship => + relationship!["type"]!.GetValue() == "author")) + authorIds.Add(node!["id"]!.GetValue()); + } + string? coverUrl = GetCoverUrl(publicationId, posterId); + string? coverCacheName = null; + if (coverUrl is not null) + coverCacheName = SaveCoverImageToCache(coverUrl, (byte)RequestType.AtHomeServer); - List authors = GetAuthors(authorIds); + List authors = GetAuthors(authorIds); - Dictionary linksDict = new(); - if (attributes.ContainsKey("links") && attributes["links"] is not null) - { - JsonObject linksObject = attributes["links"]!.AsObject(); - foreach (string key in ((IDictionary)linksObject).Keys) - { - linksDict.Add(key, linksObject[key]!.GetValue()); - } - } - - int? year = attributes.ContainsKey("year") && attributes["year"] is not null - ? attributes["year"]!.GetValue() - : null; - - string? originalLanguage = attributes.ContainsKey("originalLanguage") && attributes["originalLanguage"] is not null - ? attributes["originalLanguage"]!.GetValue() - : null; - - string status = attributes["status"]!.GetValue(); - - 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 linksDict = new(); + if (attributes.ContainsKey("links") && attributes["links"] is not null) + { + JsonObject linksObject = attributes["links"]!.AsObject(); + foreach (string key in ((IDictionary)linksObject).Keys) + { + linksDict.Add(key, linksObject[key]!.GetValue()); } } - 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() + : null; + + string? originalLanguage = + attributes.ContainsKey("originalLanguage") && attributes["originalLanguage"] is not null + ? attributes["originalLanguage"]!.GetValue() + : null; + + string status = attributes["status"]!.GetValue(); + + 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(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 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 GetAuthors(IEnumerable authorIds) { + Log("Retrieving authors."); List ret = new(); foreach (string authorId in authorIds) { @@ -276,7 +293,7 @@ public class MangaDex : Connector string authorName = result["data"]!["attributes"]!["name"]!.GetValue(); ret.Add(authorName); - commonObjects.logger?.WriteLine(this.GetType().ToString(), $"Got author {authorId} -> {authorName}"); + Log($"Got author {authorId} -> {authorName}"); } return ret; } diff --git a/Tranga/Connectors/MangaKatana.cs b/Tranga/MangaConnectors/MangaKatana.cs similarity index 69% rename from Tranga/Connectors/MangaKatana.cs rename to Tranga/MangaConnectors/MangaKatana.cs index 6205d38..12f15cd 100644 --- a/Tranga/Connectors/MangaKatana.cs +++ b/Tranga/MangaConnectors/MangaKatana.cs @@ -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() + this.downloadClient = new HttpDownloadClient(clone, new Dictionary() { {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(); + return Array.Empty(); // 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 searchResults = document.DocumentNode.SelectNodes("//*[@id='book_list']/div"); if (searchResults is null || !searchResults.Any()) - return Array.Empty(); + return Array.Empty(); List urls = new(); foreach (HtmlNode mangaResult in searchResults) { @@ -57,21 +65,18 @@ public class MangaKatana : Connector .First(a => a.Name == "href").Value); } - HashSet ret = new(); + HashSet 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(); - - 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(); //Return Chapters ordered by Chapter-Number - NumberFormatInfo chapterNumberFormatInfo = new() - { - NumberDecimalSeparator = "." - }; - List 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 chapters = ParseChaptersFromHtml(manga, requestUrl); + Log($"Got {chapters.Count} chapters. {manga}"); + return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, numberFormatDecimalPoint)).ToArray(); } - private List ParseChaptersFromHtml(Publication publication, string mangaUrl) + private List 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) diff --git a/Tranga/Connectors/Manganato.cs b/Tranga/MangaConnectors/Manganato.cs similarity index 57% rename from Tranga/Connectors/Manganato.cs rename to Tranga/MangaConnectors/Manganato.cs index cfc9791..6b3cd23 100644 --- a/Tranga/Connectors/Manganato.cs +++ b/Tranga/MangaConnectors/Manganato.cs @@ -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() + this.downloadClient = new HttpDownloadClient(clone, new Dictionary() { {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(); + return Array.Empty(); - return ParsePublicationsFromHtml(requestResult.result); + if (requestResult.htmlDocument is null) + return Array.Empty(); + 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 searchResults = document.DocumentNode.Descendants("div").Where(n => n.HasClass("search-story-item")); List urls = new(); foreach (HtmlNode mangaResult in searchResults) @@ -46,26 +43,31 @@ public class Manganato : Connector .First(a => a.Name == "href").Value); } - HashSet ret = new(); + HashSet 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(); - - 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 altTitles = new(); Dictionary? 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(); //Return Chapters ordered by Chapter-Number - NumberFormatInfo chapterNumberFormatInfo = new() - { - NumberDecimalSeparator = "." - }; - List 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(); + List chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument); + Log($"Got {chapters.Count} chapters. {manga}"); + return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, numberFormatDecimalPoint)).ToArray(); } - private List ParseChaptersFromHtml(Publication publication, Stream html) + private List ParseChaptersFromHtml(Manga manga, HtmlDocument document) { - StreamReader reader = new (html); - string htmlString = reader.ReadToEnd(); - HtmlDocument document = new (); - document.LoadHtml(htmlString); List 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 ret = new(); HtmlNode imageContainer = diff --git a/Tranga/MangaConnectors/Mangasee.cs b/Tranga/MangaConnectors/Mangasee.cs new file mode 100644 index 0000000..aeae553 --- /dev/null +++ b/Tranga/MangaConnectors/Mangasee.cs @@ -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() + { + { 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(); + + if (requestResult.htmlDocument is null) + return Array.Empty(); + 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 result = JsonConvert.DeserializeObject>(jsonString)!; + Dictionary 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 ret = new(); + List 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 altTitles = new(), links = new(); + HashSet 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 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 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 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); + } +} \ No newline at end of file diff --git a/Tranga/MangaConnectors/Mangaworld.cs b/Tranga/MangaConnectors/Mangaworld.cs new file mode 100644 index 0000000..ae61871 --- /dev/null +++ b/Tranga/MangaConnectors/Mangaworld.cs @@ -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() + { + {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(); + + if (requestResult.htmlDocument is null) + return Array.Empty(); + 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(); + + List 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 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 altTitles = new(); + Dictionary? links = null; + HashSet tags = new(); + string[] authors = Array.Empty(); + 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(); + + //Return Chapters ordered by Chapter-Number + if (requestResult.htmlDocument is null) + return Array.Empty(); + List chapters = ParseChaptersFromHtml(manga, requestResult.htmlDocument); + Log($"Got {chapters.Count} chapters. {manga}"); + return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, numberFormatDecimalPoint)).ToArray(); + } + + private List ParseChaptersFromHtml(Manga manga, HtmlDocument document) + { + List 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 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(); + } +} \ No newline at end of file diff --git a/Tranga/Migrator.cs b/Tranga/Migrator.cs deleted file mode 100644 index 0ac0e91..0000000 --- a/Tranga/Migrator.cs +++ /dev/null @@ -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() - : settingsNode["ts"]!["version"]!.GetValue(); - logger?.WriteLine("Migrator", $"Migrating {version} -> {CurrentVersion}"); - switch (version) - { - case 15: - MoveToCommonObjects(settingsFilePath, logger); - TrangaSettings.SettingsJsonObject sjo = JsonConvert.DeserializeObject(File.ReadAllText(settingsFilePath))!; - RemoveUpdateLibraryTask(sjo.ts!, logger); - break; - case 16: - MoveToCommonObjects(settingsFilePath, logger); - break; - } - - TrangaSettings.SettingsJsonObject sjo2 = JsonConvert.DeserializeObject( - 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 tasks = JsonConvert.DeserializeObject>(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(), - node["workingDirectory"]!.GetValue()); - JsonArray libraryManagers = node["libraryManagers"]!.AsArray(); - logger?.WriteLine("Migrator", $"\tGot {libraryManagers.Count} libraryManagers."); - JsonNode? komgaNode = libraryManagers.FirstOrDefault(lm => lm["libraryType"].GetValue() == (byte)LibraryManager.LibraryType.Komga); - JsonNode? kavitaNode = libraryManagers.FirstOrDefault(lm => lm["libraryType"].GetValue() == (byte)LibraryManager.LibraryType.Kavita); - HashSet lms = new(); - if (komgaNode is not null) - lms.Add(new Komga(komgaNode["baseUrl"]!.GetValue(), komgaNode["auth"]!.GetValue(), null)); - if (kavitaNode is not null) - lms.Add(new Kavita(kavitaNode["baseUrl"]!.GetValue(), kavitaNode["auth"]!.GetValue(), null)); - - JsonArray notificationManagers = node["notificationManagers"]!.AsArray(); - logger?.WriteLine("Migrator", $"\tGot {notificationManagers.Count} notificationManagers."); - JsonNode? gotifyNode = notificationManagers.FirstOrDefault(nm => - nm["notificationManagerType"].GetValue() == (byte)NotificationManager.NotificationManagerType.Gotify); - JsonNode? lunaSeaNode = notificationManagers.FirstOrDefault(nm => - nm["notificationManagerType"].GetValue() == (byte)NotificationManager.NotificationManagerType.LunaSea); - HashSet nms = new(); - if (gotifyNode is not null) - nms.Add(new Gotify(gotifyNode["endpoint"]!.GetValue(), gotifyNode["appToken"]!.GetValue())); - if (lunaSeaNode is not null) - nms.Add(new LunaSea(lunaSeaNode["id"]!.GetValue())); - - CommonObjects co = new (lms, nms, logger, settingsFilePath); - - TrangaSettings.SettingsJsonObject sjo = new(ts, co); - File.WriteAllText(settingsFilePath, JsonConvert.SerializeObject(sjo)); - } -} \ No newline at end of file diff --git a/Tranga/NotificationManagers/Gotify.cs b/Tranga/NotificationConnectors/Gotify.cs similarity index 77% rename from Tranga/NotificationManagers/Gotify.cs rename to Tranga/NotificationConnectors/Gotify.cs index a823665..deb8b57 100644 --- a/Tranga/NotificationManagers/Gotify.cs +++ b/Tranga/NotificationConnectors/Gotify.cs @@ -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()}"); } } diff --git a/Tranga/NotificationManagers/LunaSea.cs b/Tranga/NotificationConnectors/LunaSea.cs similarity index 73% rename from Tranga/NotificationManagers/LunaSea.cs rename to Tranga/NotificationConnectors/LunaSea.cs index aef14c5..41c220a 100644 --- a/Tranga/NotificationManagers/LunaSea.cs +++ b/Tranga/NotificationConnectors/LunaSea.cs @@ -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()}"); } } diff --git a/Tranga/NotificationConnectors/NotificationConnector.cs b/Tranga/NotificationConnectors/NotificationConnector.cs new file mode 100644 index 0000000..7734371 --- /dev/null +++ b/Tranga/NotificationConnectors/NotificationConnector.cs @@ -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); +} \ No newline at end of file diff --git a/Tranga/NotificationConnectors/NotificationManagerJsonConverter.cs b/Tranga/NotificationConnectors/NotificationManagerJsonConverter.cs new file mode 100644 index 0000000..5214183 --- /dev/null +++ b/Tranga/NotificationConnectors/NotificationManagerJsonConverter.cs @@ -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)NotificationConnector.NotificationConnectorType.Gotify) + return new Gotify(this._clone, jo.GetValue("endpoint")!.Value()!, jo.GetValue("appToken")!.Value()!); + else if (jo["notificationConnectorType"]!.Value() == + (byte)NotificationConnector.NotificationConnectorType.LunaSea) + return new LunaSea(this._clone, jo.GetValue("id")!.Value()!); + + throw new Exception(); + } + + public override bool CanWrite => false; + + /// + /// Don't call this + /// + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + throw new Exception("Dont call this"); + } +} \ No newline at end of file diff --git a/Tranga/NotificationManagers/NotificationManager.cs b/Tranga/NotificationManagers/NotificationManager.cs deleted file mode 100644 index 7c65063..0000000 --- a/Tranga/NotificationManagers/NotificationManager.cs +++ /dev/null @@ -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)NotificationManagerType.Gotify) - return jo.ToObject(serializer)!; - else if (jo["notificationManagerType"]!.Value() == (byte)NotificationManagerType.LunaSea) - return jo.ToObject(serializer)!; - - throw new Exception(); - } - - public override bool CanWrite => false; - - /// - /// Don't call this - /// - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) - { - throw new Exception("Dont call this"); - } - } -} \ No newline at end of file diff --git a/Tranga/Server.cs b/Tranga/Server.cs new file mode 100644 index 0000000..bf71ffc --- /dev/null +++ b/Tranga/Server.cs @@ -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 GetRequestVariables(string query) + { + Dictionary 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 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 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().Select(nc => new KeyValuePair((byte)nc, Enum.GetName(nc)))); + break; + case "LibraryConnectors": + SendResponse(HttpStatusCode.OK, response, libraryConnectors); + break; + case "LibraryConnectors/Types": + SendResponse(HttpStatusCode.OK, response, + Enum.GetValues().Select(lc => new KeyValuePair((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 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 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()); + 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(); + } + } +} \ No newline at end of file diff --git a/Tranga/TaskManager.cs b/Tranga/TaskManager.cs deleted file mode 100644 index 518c5ad..0000000 --- a/Tranga/TaskManager.cs +++ /dev/null @@ -1,385 +0,0 @@ -using Newtonsoft.Json; -using Tranga.Connectors; -using Tranga.TrangaTasks; - -namespace Tranga; - -/// -/// Manages all TrangaTasks. -/// Provides a Threaded environment to execute Tasks, and still manage the Task-Collection -/// -public class TaskManager -{ - public HashSet collection = new(); - private HashSet _allTasks = new(); - private readonly Dictionary _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(); - } - - /// - /// Runs continuously until shutdown. - /// Checks if tasks have to be executed (time elapsed) - /// - 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); - } - } - - /// - /// Forces the execution of a given task - /// - /// Task to execute - 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 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(); - } - - public IEnumerable 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(); - } - } - - /// - /// Removes a Task from the queue - /// - /// - public void RemoveTaskFromQueue(TrangaTask task) - { - task.lastExecuted = DateTime.Now; - task.state = TrangaTask.ExecutionState.Waiting; - } - - /// - /// Sets last execution time to start of time - /// Let taskManager handle enqueuing - /// - /// - public void AddTaskToQueue(TrangaTask task) - { - task.lastExecuted = DateTime.UnixEpoch; - } - - /// All available Connectors - public Dictionary GetAvailableConnectors() - { - return this._connectors.ToDictionary(connector => connector.name, connector => connector); - } - - /// All TrangaTasks in task-collection - public TrangaTask[] GetAllTasks() - { - TrangaTask[] ret = new TrangaTask[_allTasks.Count]; - _allTasks.CopyTo(ret); - return ret; - } - - /// All added Publications - public Publication[] GetAllPublications() - { - return this.collection.ToArray(); - } - - public List GetExistingChaptersList(Connector connector, Publication publication, string language) - { - Chapter[] newChapters = connector.GetChapters(publication, language); - return newChapters.Where(nChapter => nChapter.CheckChapterIsDownloaded(settings.downloadLocation)).ToList(); - } - - /// - /// Return Connector with given Name - /// - /// Connector-name (exact) - /// If Connector is not available - 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; - } - - /// - /// Shuts down the taskManager. - /// - /// If force is true, tasks are aborted. - 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>(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; - } - } - } - - /// - /// Exports data (settings, tasks) to file - /// - 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; - } -} \ No newline at end of file diff --git a/Tranga/Tranga.cs b/Tranga/Tranga.cs index 997a787..02ccded 100644 --- a/Tranga/Tranga.cs +++ b/Tranga/Tranga.cs @@ -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 _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)) - { - 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); - } - - private static void TaskMode(TaskManager taskManager, Logger logger) - { - 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] : "")}"); - } - + Log("\n\n _______ \n|_ _|.----..---.-..-----..-----..---.-.\n | | | _|| _ || || _ || _ |\n |___| |__| |___._||__|__||___ ||___._|\n |_____| \n\n"); + Log(settings.ToString()); + keepRunning = true; + _connectors = new HashSet() + { + 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 TrangaTask[] SelectTasks(TrangaTask[] tasks, Logger? logger) + public MangaConnector? GetConnector(string name) { - 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(); - } - 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(); - } - - 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(); - - 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(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 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(); } } \ No newline at end of file diff --git a/Tranga/TrangaArgs.cs b/Tranga/TrangaArgs.cs new file mode 100644 index 0000000..23be84e --- /dev/null +++ b/Tranga/TrangaArgs.cs @@ -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 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); + } + } + + /// + /// Returns an array containing the parameters for the argument. + /// + /// List of argument-strings + /// Requested parameter + /// + /// If there are no parameters for an argument, returns an empty array. + /// If the argument is not found returns null. + /// + private static string[]? GetArg(string[] args, ArgEnum arg) + { + List argsList = args.ToList(); + List 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 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; + } + } +} \ No newline at end of file diff --git a/Tranga/TrangaSettings.cs b/Tranga/TrangaSettings.cs index a37de07..1da2d2e 100644 --- a/Tranga/TrangaSettings.cs +++ b/Tranga/TrangaSettings.cs @@ -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(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 LoadLibraryConnectors(GlobalBase clone) { - if (!File.Exists(importFilePath)) - return new TrangaSettings(Path.Join(Directory.GetCurrentDirectory(), "Downloads"), Directory.GetCurrentDirectory()); + if (!File.Exists(libraryConnectorsFilePath)) + return new HashSet(); + return JsonConvert.DeserializeObject>(File.ReadAllText(libraryConnectorsFilePath), + new JsonSerializerSettings() + { + Converters = + { + new LibraryManagerJsonConverter(clone) + } + })!; + } - string toRead = File.ReadAllText(importFilePath); - SettingsJsonObject settings = JsonConvert.DeserializeObject(toRead, - new JsonSerializerSettings { Converters = { new NotificationManager.NotificationManagerJsonConverter(), new LibraryManager.LibraryManagerJsonConverter() } })!; - return settings.ts ?? new TrangaSettings(Path.Join(Directory.GetCurrentDirectory(), "Downloads"), Directory.GetCurrentDirectory()); + public HashSet LoadNotificationConnectors(GlobalBase clone) + { + if (!File.Exists(notificationConnectorsFilePath)) + return new HashSet(); + return JsonConvert.DeserializeObject>(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(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"; } } \ No newline at end of file diff --git a/Tranga/TrangaTasks/DownloadChapterTask.cs b/Tranga/TrangaTasks/DownloadChapterTask.cs deleted file mode 100644 index 8134be6..0000000 --- a/Tranga/TrangaTasks/DownloadChapterTask.cs +++ /dev/null @@ -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}"; - } -} \ No newline at end of file diff --git a/Tranga/TrangaTasks/MonitorPublicationTask.cs b/Tranga/TrangaTasks/MonitorPublicationTask.cs deleted file mode 100644 index 5319759..0000000 --- a/Tranga/TrangaTasks/MonitorPublicationTask.cs +++ /dev/null @@ -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 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}"; - } -} \ No newline at end of file diff --git a/Tranga/TrangaTasks/TrangaTask.cs b/Tranga/TrangaTasks/TrangaTask.cs deleted file mode 100644 index 189884c..0000000 --- a/Tranga/TrangaTasks/TrangaTask.cs +++ /dev/null @@ -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; - -/// -/// Stores information on Task, when implementing new Tasks also update the serializer -/// -[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 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; - } - - /// - /// BL for concrete Tasks - /// - /// - /// - protected abstract HttpStatusCode ExecuteTask(TaskManager taskManager, CancellationToken? cancellationToken = null); - - public abstract TrangaTask Clone(); - - protected abstract double GetProgress(); - - /// - /// Execute the task - /// - /// Should be the parent taskManager - /// - 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)Task.MonitorPublication) - return jo.ToObject(serializer)!; - - if (jo["task"]!.Value() == (Int64)Task.UpdateLibraries) - return jo.ToObject(serializer)!; - - if (jo["task"]!.Value() == (Int64)Task.DownloadChapter) - return jo.ToObject(serializer)!; - - throw new Exception(); - } - - public override bool CanWrite => false; - - /// - /// Don't call this - /// - public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) - { - throw new Exception("Dont call this"); - } - } -} \ No newline at end of file diff --git a/Tranga/TrangaTasks/UpdateLibrariesTask.cs b/Tranga/TrangaTasks/UpdateLibrariesTask.cs deleted file mode 100644 index e7ecb02..0000000 --- a/Tranga/TrangaTasks/UpdateLibrariesTask.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Net; - -namespace Tranga.TrangaTasks; - -/// -/// LEGACY DEPRECATED -/// -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; - } -} \ No newline at end of file diff --git a/Website/Dockerfile b/Website/Dockerfile deleted file mode 100644 index d178671..0000000 --- a/Website/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM nginx:alpine3.17-slim -COPY . /usr/share/nginx/html -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/Website/apiConnector.js b/Website/apiConnector.js deleted file mode 100644 index 560d3a5..0000000 --- a/Website/apiConnector.js +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/Website/favicon.ico b/Website/favicon.ico deleted file mode 100644 index ffb44eb..0000000 Binary files a/Website/favicon.ico and /dev/null differ diff --git a/Website/index.html b/Website/index.html deleted file mode 100644 index d6e9667..0000000 --- a/Website/index.html +++ /dev/null @@ -1,179 +0,0 @@ - - - - - Tranga - - - - - - - - website image is Blahaj - Tranga - - - - - - settingscog - - - -
      -

      +

      -
      - - cover - - MangaDex - Tensei Pandemic - - -
      - - - - - Select Publication - -
      - - -
      -
      - -
      - -
      -
      -
      -
      - - - - - Create Task: Monitor Publication - -
      - Run every - hours - minutes - -
      -
      -
      -
      - - - - - Create Task: Download Chapter(s) - -
      - -
      -
      - -
      -
      -
      -
      - - - - - cover - - Tensei Pandemic - - Imamura Hinata - 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! - - - Start Task ▶️ - Delete Task ❌ - Monitor ➕ - Download Chapter ➕ - - - - - - - - - Settings - -
      -

      Download Location:

      - -
      -
      -

      API-URI

      - -
      -
      - Komga -
      Configured: ✅❌
      - - - -
      -
      - Kavita -
      Configured: ✅❌
      - - - -
      -
      - Gotify -
      Configured: ✅❌
      - - -
      -
      - LunaSea -
      Configured: ✅❌
      - -
      -
      - - -
      -
      -
      -
      - - - - - Task Progress - - - - - -
      -
      -
      - running
      0
      -
      -
      - queue
      0
      -
      -

      Made with Blåhaj 🦈

      -
      -
      - - - - - \ No newline at end of file diff --git a/Website/interaction.js b/Website/interaction.js deleted file mode 100644 index e6695db..0000000 --- a/Website/interaction.js +++ /dev/null @@ -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(''); -} \ No newline at end of file diff --git a/Website/media/blahaj.png b/Website/media/blahaj.png deleted file mode 100644 index dbbff93..0000000 Binary files a/Website/media/blahaj.png and /dev/null differ diff --git a/Website/media/close-x.svg b/Website/media/close-x.svg deleted file mode 100644 index fc8cc4d..0000000 --- a/Website/media/close-x.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/Website/media/queue.svg b/Website/media/queue.svg deleted file mode 100644 index 30b9620..0000000 --- a/Website/media/queue.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/Website/media/running.svg b/Website/media/running.svg deleted file mode 100644 index ddde0a6..0000000 --- a/Website/media/running.svg +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Website/media/settings-cogwheel.svg b/Website/media/settings-cogwheel.svg deleted file mode 100644 index 7e61388..0000000 --- a/Website/media/settings-cogwheel.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/Website/media/tasks.svg b/Website/media/tasks.svg deleted file mode 100644 index 6e64e66..0000000 --- a/Website/media/tasks.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/Website/style.css b/Website/style.css deleted file mode 100644 index f7114ed..0000000 --- a/Website/style.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 9dcf31f..348166e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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: diff --git a/screenshots/addtask.png b/screenshots/addtask.png deleted file mode 100644 index ebaf431..0000000 Binary files a/screenshots/addtask.png and /dev/null differ diff --git a/screenshots/overview.png b/screenshots/overview.png deleted file mode 100644 index 012b0b8..0000000 Binary files a/screenshots/overview.png and /dev/null differ diff --git a/screenshots/progress.png b/screenshots/progress.png deleted file mode 100644 index 728aec7..0000000 Binary files a/screenshots/progress.png and /dev/null differ diff --git a/screenshots/publication-description.png b/screenshots/publication-description.png deleted file mode 100644 index d852b00..0000000 Binary files a/screenshots/publication-description.png and /dev/null differ diff --git a/screenshots/settings.png b/screenshots/settings.png deleted file mode 100644 index 14039e0..0000000 Binary files a/screenshots/settings.png and /dev/null differ