Compare commits
356 Commits
1.1.1
...
e274c864f9
Author | SHA1 | Date | |
---|---|---|---|
e274c864f9 | |||
f4bc182954 | |||
3365be219c | |||
10708b3abd | |||
c1e939f1e3 | |||
21d53dabec | |||
a9417dbba6 | |||
61024bcee9 | |||
ea1b8749a6 | |||
2fcab1f1b1 | |||
bbd716383a | |||
6e1a0ab06c | |||
181942153b | |||
fe04af4a2b | |||
4240a1eb6a | |||
32349c1ddf | |||
a94d3d6b40 | |||
f916cda0f1 | |||
a8f0f1af15 | |||
0cf3a95f58 | |||
a89a526fda | |||
4d1e43e7b3 | |||
7614f9aad3 | |||
97c0e42512 | |||
565bc0775d | |||
e6a3fa2899 | |||
2d82279d98 | |||
c5559a4ceb | |||
2572a537ab | |||
58db049496 | |||
8f309fcfd7 | |||
11461051f3 | |||
a4aa571870 | |||
e4086a8892 | |||
c45e4ddf90 | |||
675effd317 | |||
a4f67c9ab4 | |||
2538a29788 | |||
81d5802092 | |||
436edfde66 | |||
00c1cd56b8 | |||
a63154b581 | |||
53fe7ee983 | |||
6fb4098c16 | |||
7a024e8733 | |||
835e239be5 | |||
df8538c3b4 | |||
f832fe0de3 | |||
ebdb38bd57 | |||
e3201a9b99 | |||
eb50b84266 | |||
b3d778ff56 | |||
00861c406a | |||
01c8784bab | |||
3aa299e48a | |||
d1ce244135 | |||
c91754614b | |||
70b1ae4812 | |||
336e08aebf | |||
18134cdf01 | |||
5b89cbd042 | |||
74aca86b62 | |||
e5abaa4549 | |||
eb0eb71e86 | |||
4e73b0a4cf | |||
140074208f | |||
fa19d3da14 | |||
3d6657b483 | |||
f9b5e05974 | |||
ad4027779f | |||
98ec0b837f | |||
1afa3df316 | |||
d83aa1ef5b | |||
b610ec734e | |||
abf587377c | |||
437349bd27 | |||
000539d6a6 | |||
b4bef25a22 | |||
579e400a5d | |||
8af2b12fc0 | |||
bad4330330 | |||
42596752d3 | |||
16238c590b | |||
9f38dc3b6a | |||
485637d99a | |||
de14ff0b75 | |||
f947c37bd6 | |||
77eec0f696 | |||
18323f9f51 | |||
2cd2b6842d | |||
09f815903f | |||
c108478039 | |||
74289e43b7 | |||
2779f9ba09 | |||
59a8e556f0 | |||
074b137b5c | |||
3cb2540794 | |||
02c9934896 | |||
b2e1c95bca | |||
8c9e3ea6b6 | |||
db441607ad | |||
91c56783dc | |||
2c288eeeea | |||
57a1ea91fc | |||
06138a3927 | |||
84b053e672 | |||
0fe0cbc4ad | |||
62e6ce8363 | |||
a4f3ec6580 | |||
8b4e996b7e | |||
964540d30f | |||
fa69f4488f | |||
42c2876188 | |||
715244ff1b | |||
2333cd9095 | |||
c8225db4fe | |||
6741ca096b | |||
a897a7b3a2 | |||
0f8932e712 | |||
78023ef0fd | |||
d171f34e4e | |||
aa0dc4fa35 | |||
25f48592c0 | |||
398ac304d2 | |||
58a62f8272 | |||
86752c9a7e | |||
f9a7828d02 | |||
c97ff69148 | |||
1735bbcf8a | |||
9ae8ca65df | |||
00599cd24e | |||
6d5618a1f7 | |||
a1202a875d | |||
98946b4aa3 | |||
41b6bb77b6 | |||
e70a14ca56 | |||
b099da1156 | |||
01d1f922c2 | |||
47a80d67a8 | |||
16e3549455 | |||
be8c6b50ba | |||
a38fcf50ca | |||
82f6c7b3fe | |||
5586d2c104 | |||
62dc9fee2a | |||
ac96fca6dc | |||
25a6ceff10 | |||
b3e1d39d0f | |||
2833b7f22a | |||
cbdd305b69 | |||
b88890817e | |||
f66ab7d40b | |||
4cb3694cd5 | |||
a05d4c8bd9 | |||
22f87a74b2 | |||
ba57282879 | |||
9ccba6fba6 | |||
4f01c1166f | |||
0a51e7ad3d | |||
e541b922dc | |||
604abd5f9a | |||
7b311eae75 | |||
d4eb72cd99 | |||
b515215f4b | |||
a16686dfbf | |||
4275703941 | |||
c3342984ea | |||
ed4bdb5b33 | |||
0f0902c932 | |||
6508055b43 | |||
abc66511d8 | |||
9ed36c47b5 | |||
fd1b2a8470 | |||
8058749ab5 | |||
8737617e5f | |||
7e4f43f1e2 | |||
12b1b2afd6 | |||
0f9ac60fcd | |||
8c87f2948c | |||
e0fb817256 | |||
cdd2d94ba1 | |||
d5b7645cd2 | |||
9af5c1603e | |||
1035939309 | |||
3b542c04f6 | |||
a809b7c285 | |||
e883277400 | |||
23dfdc0933 | |||
edc24fff5b | |||
6cdccdf66b | |||
a4c9168551 | |||
821a1b7c3a | |||
b2b4256972 | |||
d2f46e4637 | |||
303fc293ba | |||
36c145da26 | |||
c822c74f42 | |||
dda4054d34 | |||
5b2546fdbc | |||
c11e3993ea | |||
02a382a99a | |||
c6c8f5cdf6 | |||
84842aed3c | |||
d9ced11cd1 | |||
25c90782dc | |||
e789c429cd | |||
93de471836 | |||
8b58e7dd13 | |||
b571bfa43d | |||
088d1c4647 | |||
f280c01802 | |||
1be10b310d | |||
a0469f3145 | |||
fcd81f03b3 | |||
76604d84d8 | |||
af822febbe | |||
8e207c3119 | |||
b6f8c8aab5 | |||
36f7cbd3e9 | |||
3b2643d949 | |||
9fd8bf1741 | |||
d5c9c5ba96 | |||
c8e27921ab | |||
6eaba07801 | |||
41929e0c72 | |||
4fcaca1a6e | |||
0e3c7f32d7 | |||
1c94625840 | |||
32f89f9dce | |||
234735a562 | |||
8b916eb854 | |||
29e1790c93 | |||
ac4c799a74 | |||
7c62883c37 | |||
02018253bf | |||
2aec884009 | |||
b3321ff030 | |||
16c1094875 | |||
5763d50409 | |||
ad43297358 | |||
b17800e0ef | |||
89c80d2997 | |||
6485b8744f | |||
a3a96b6b55 | |||
5bce3c6fdd | |||
5fa0c98d05 | |||
b166013770 | |||
02fe849046 | |||
d42393c83a | |||
c685bd622f | |||
dc83cc2194 | |||
7784f2024e | |||
4895079887 | |||
ab1ddc6dc8 | |||
87eade10cf | |||
1f3ac41b30 | |||
6a304bb330 | |||
b0642d1251 | |||
63b5139e93 | |||
e938784388 | |||
c436389426 | |||
5099e25f3f | |||
cf6fc3b8f6 | |||
f5141d0f8e | |||
5c753e7a7d | |||
17ce820cf3 | |||
5b4a3b9d7c | |||
f73997e563 | |||
437136804d | |||
e14683d21a | |||
5ae02ee0ed | |||
a2e9a3f34a | |||
bbf05e3dec | |||
d95839e5df | |||
5a303598fe | |||
db2103963e | |||
2c1105527a | |||
ed19dcb5c3 | |||
46f06c2685 | |||
d4f47e057c | |||
61712d0537 | |||
1f8f8c09e3 | |||
0522fa6215 | |||
0383a7d686 | |||
bd189984a9 | |||
58c01b2174 | |||
459558a514 | |||
721b316209 | |||
b1befa2ecc | |||
57a4cc4ab5 | |||
655e8db2b6 | |||
7cdf77cbb9 | |||
5a9aed4969 | |||
5b41f687d0 | |||
d6a62dc315 | |||
6d91788655 | |||
14785e5672 | |||
496d502cd2 | |||
7612411917 | |||
ed1402b5ec | |||
5adaee4821 | |||
2d82fe1489 | |||
783fd8129e | |||
4f29eff48e | |||
e0e7abb62b | |||
ae63a216a5 | |||
5d98295c59 | |||
0c580933f9 | |||
06f735aadd | |||
439d69d8e0 | |||
933df58712 | |||
165bbc412e | |||
6158fa072b | |||
0d3799e00d | |||
e977bed5a5 | |||
cacd5fede2 | |||
1bca99cb6a | |||
15fc367263 | |||
8bb6fb902b | |||
a57903cd5a | |||
1cd819b21d | |||
27afedc1b4 | |||
fac0a3f7eb | |||
03ca480fe8 | |||
c2915468a5 | |||
8805c53cb8 | |||
adbbe3f6cc | |||
14b694d3be | |||
72ce75c6e0 | |||
8381951168 | |||
b24032d124 | |||
8bc23f7c69 | |||
48b7371a18 | |||
61ecefb615 | |||
8ff65bf400 | |||
932057cca0 | |||
67d06cd887 | |||
cbb012a659 | |||
e4f33bcca9 | |||
fbba7c45b9 | |||
d9b6062767 | |||
d477cd1ccd | |||
f892db7dda | |||
16c1b5c506 | |||
d5ecc1c37d | |||
1b9ebd096b | |||
8619630269 | |||
2bc92556e3 | |||
f1ab823e7f | |||
8261d02cc7 | |||
8d3b8be95c | |||
60519910de | |||
0940afe41f | |||
3dc376c19f | |||
3e56ef842b | |||
5a44e3b8b9 |
7
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Maintain dependencies for GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
43
.github/workflows/docker-base.yml
vendored
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# https://github.com/docker/setup-qemu-action#usage
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2.2.0
|
||||||
|
|
||||||
|
# https://github.com/marketplace/actions/docker-setup-buildx
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v2.9.1
|
||||||
|
|
||||||
|
# https://github.com/docker/login-action#docker-hub
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
# https://github.com/docker/build-push-action#multi-platform-image
|
||||||
|
- name: Build and push base
|
||||||
|
uses: docker/build-push-action@v4.1.1
|
||||||
|
with:
|
||||||
|
context: ./
|
||||||
|
file: ./Dockerfile-base
|
||||||
|
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
||||||
|
platforms: linux/amd64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
glax/tranga-base:latest
|
45
.github/workflows/docker-image-cuttingedge.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "cuttingedge" ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# https://github.com/docker/setup-qemu-action#usage
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2.2.0
|
||||||
|
|
||||||
|
# https://github.com/marketplace/actions/docker-setup-buildx
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v2.9.1
|
||||||
|
|
||||||
|
# https://github.com/docker/login-action#docker-hub
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
# https://github.com/docker/build-push-action#multi-platform-image
|
||||||
|
- name: Build and push API
|
||||||
|
uses: docker/build-push-action@v4.1.1
|
||||||
|
with:
|
||||||
|
context: ./
|
||||||
|
file: ./Dockerfile
|
||||||
|
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
||||||
|
platforms: linux/amd64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
glax/tranga-api:cuttingedge
|
47
.github/workflows/docker-image-master.yml
vendored
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "master" ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# https://github.com/docker/setup-qemu-action#usage
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2.2.0
|
||||||
|
|
||||||
|
# https://github.com/marketplace/actions/docker-setup-buildx
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v2.9.1
|
||||||
|
|
||||||
|
# https://github.com/docker/login-action#docker-hub
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
# https://github.com/docker/build-push-action#multi-platform-image
|
||||||
|
- name: Build and push API
|
||||||
|
uses: docker/build-push-action@v4.1.1
|
||||||
|
with:
|
||||||
|
context: ./
|
||||||
|
file: ./Dockerfile
|
||||||
|
#platforms: linux/amd64,linux/arm64,linux/riscv64,linux/ppc64le,linux/s390x,linux/386,linux/mips64le,linux/mips64,linux/arm/v7,linux/arm/v6
|
||||||
|
platforms: linux/amd64
|
||||||
|
pull: true
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
glax/tranga-api:latest
|
@ -3,16 +3,12 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
<RootNamespace>Tranga_CLI</RootNamespace>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="..\.dockerignore">
|
<PackageReference Include="Spectre.Console.Cli" Version="0.47.1-preview.0.11" />
|
||||||
<Link>.dockerignore</Link>
|
|
||||||
</Content>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
154
CLI/Program.cs
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Logging;
|
||||||
|
using Spectre.Console;
|
||||||
|
using Spectre.Console.Cli;
|
||||||
|
using Tranga;
|
||||||
|
|
||||||
|
var app = new CommandApp<TrangaCli>();
|
||||||
|
return app.Run(args);
|
||||||
|
|
||||||
|
internal sealed class TrangaCli : Command<TrangaCli.Settings>
|
||||||
|
{
|
||||||
|
public sealed class Settings : CommandSettings
|
||||||
|
{
|
||||||
|
[Description("Directory to which downloaded Manga are saved")]
|
||||||
|
[CommandOption("-d|--downloadLocation")]
|
||||||
|
[DefaultValue(null)]
|
||||||
|
public string? downloadLocation { get; init; }
|
||||||
|
|
||||||
|
[Description("Directory in which application-data is saved")]
|
||||||
|
[CommandOption("-w|--workingDirectory")]
|
||||||
|
[DefaultValue(null)]
|
||||||
|
public string? workingDirectory { get; init; }
|
||||||
|
|
||||||
|
[Description("Enables the file-logger")]
|
||||||
|
[CommandOption("-f")]
|
||||||
|
[DefaultValue(null)]
|
||||||
|
public bool? fileLogger { get; init; }
|
||||||
|
|
||||||
|
[Description("Path to save logfile to")]
|
||||||
|
[CommandOption("-l|--fPath")]
|
||||||
|
[DefaultValue(null)]
|
||||||
|
public string? fileLoggerPath { get; init; }
|
||||||
|
|
||||||
|
[Description("Port on which to run API on")]
|
||||||
|
[CommandOption("-p|--port")]
|
||||||
|
[DefaultValue(null)]
|
||||||
|
public int? apiPort { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int Execute([NotNull] CommandContext context, [NotNull] Settings settings)
|
||||||
|
{
|
||||||
|
List<Logger.LoggerType> enabledLoggers = new();
|
||||||
|
if(settings.fileLogger.HasValue && settings.fileLogger.Value == true)
|
||||||
|
enabledLoggers.Add(Logger.LoggerType.FileLogger);
|
||||||
|
string? logFilePath = settings.fileLoggerPath ?? "";//TODO path
|
||||||
|
Logger logger = new(enabledLoggers.ToArray(), Console.Out, Console.OutputEncoding, logFilePath);
|
||||||
|
|
||||||
|
TrangaSettings trangaSettings = new (settings.downloadLocation, settings.workingDirectory, settings.apiPort);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(trangaSettings.downloadLocation);
|
||||||
|
Directory.CreateDirectory(trangaSettings.workingDirectory);
|
||||||
|
|
||||||
|
Tranga.Tranga? api = null;
|
||||||
|
|
||||||
|
Thread trangaApi = new Thread(() =>
|
||||||
|
{
|
||||||
|
api = new(logger, trangaSettings);
|
||||||
|
});
|
||||||
|
trangaApi.Start();
|
||||||
|
|
||||||
|
HttpClient client = new();
|
||||||
|
|
||||||
|
bool exit = false;
|
||||||
|
while (!exit)
|
||||||
|
{
|
||||||
|
string menuSelect = AnsiConsole.Prompt(
|
||||||
|
new SelectionPrompt<string>()
|
||||||
|
.Title("Menu")
|
||||||
|
.PageSize(10)
|
||||||
|
.MoreChoicesText("Up/Down")
|
||||||
|
.AddChoices(new[]
|
||||||
|
{
|
||||||
|
"CustomRequest",
|
||||||
|
"Log",
|
||||||
|
"Exit"
|
||||||
|
}));
|
||||||
|
|
||||||
|
switch (menuSelect)
|
||||||
|
{
|
||||||
|
case "CustomRequest":
|
||||||
|
HttpMethod requestMethod = AnsiConsole.Prompt(
|
||||||
|
new SelectionPrompt<HttpMethod>()
|
||||||
|
.Title("Request Type")
|
||||||
|
.AddChoices(new[]
|
||||||
|
{
|
||||||
|
HttpMethod.Get,
|
||||||
|
HttpMethod.Delete,
|
||||||
|
HttpMethod.Post
|
||||||
|
}));
|
||||||
|
string requestPath = AnsiConsole.Prompt(
|
||||||
|
new TextPrompt<string>("Request Path:"));
|
||||||
|
List<ValueTuple<string, string>> parameters = new();
|
||||||
|
while (AnsiConsole.Confirm("Add Parameter?"))
|
||||||
|
{
|
||||||
|
string name = AnsiConsole.Ask<string>("Parameter Name:");
|
||||||
|
string value = AnsiConsole.Ask<string>("Parameter Value:");
|
||||||
|
parameters.Add(new ValueTuple<string, string>(name, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
string requestString = $"http://localhost:{trangaSettings.apiPortNumber}/{requestPath}";
|
||||||
|
if (parameters.Any())
|
||||||
|
{
|
||||||
|
requestString += "?";
|
||||||
|
foreach (ValueTuple<string, string> parameter in parameters)
|
||||||
|
requestString += $"{parameter.Item1}={parameter.Item2}&";
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpRequestMessage request = new (requestMethod, requestString);
|
||||||
|
AnsiConsole.WriteLine($"Request: {request.Method} {request.RequestUri}");
|
||||||
|
HttpResponseMessage response;
|
||||||
|
if (AnsiConsole.Confirm("Send Request?"))
|
||||||
|
response = client.Send(request);
|
||||||
|
else break;
|
||||||
|
AnsiConsole.WriteLine($"Response: {(int)response.StatusCode} {response.StatusCode}");
|
||||||
|
AnsiConsole.WriteLine(response.Content.ReadAsStringAsync().Result);
|
||||||
|
break;
|
||||||
|
case "Log":
|
||||||
|
List<string> lines = logger.Tail(10).ToList();
|
||||||
|
Rows rows = new Rows(lines.Select(line => new Text(line)));
|
||||||
|
|
||||||
|
AnsiConsole.Live(rows).Start(context =>
|
||||||
|
{
|
||||||
|
bool running = true;
|
||||||
|
while (running)
|
||||||
|
{
|
||||||
|
string[] newLines = logger.GetNewLines();
|
||||||
|
if (newLines.Length > 0)
|
||||||
|
{
|
||||||
|
lines.AddRange(newLines);
|
||||||
|
rows = new Rows(lines.Select(line => new Text(line)));
|
||||||
|
context.UpdateTarget(rows);
|
||||||
|
}
|
||||||
|
Thread.Sleep(100);
|
||||||
|
if (AnsiConsole.Console.Input.IsKeyAvailable())
|
||||||
|
{
|
||||||
|
AnsiConsole.Console.Input.ReadKey(true); //Do not process input
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "Exit":
|
||||||
|
exit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (api is not null)
|
||||||
|
api.keepRunning = false;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
12
Dockerfile
@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
|
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY . /src/
|
COPY Tranga /src/Tranga
|
||||||
RUN dotnet restore Tranga-API/Tranga-API.csproj
|
COPY Logging /src/Logging
|
||||||
|
COPY Tranga.sln /src
|
||||||
|
RUN dotnet restore /src/Tranga/Tranga.csproj
|
||||||
RUN dotnet publish -c Release -o /publish
|
RUN dotnet publish -c Release -o /publish
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:7.0 as runtime
|
FROM glax/tranga-base:latest as runtime
|
||||||
WORKDIR /publish
|
WORKDIR /publish
|
||||||
COPY --from=build-env /publish .
|
COPY --from=build-env /publish .
|
||||||
EXPOSE 80
|
EXPOSE 6531
|
||||||
ENTRYPOINT ["dotnet", "/publish/Tranga-API.dll"]
|
ENTRYPOINT ["dotnet", "/publish/Tranga.dll"]
|
||||||
|
8
Dockerfile-base
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#FROM mcr.microsoft.com/dotnet/aspnet:7.0 as runtime
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime:7.0 as runtime
|
||||||
|
WORKDIR /publish
|
||||||
|
RUN apt-get update
|
||||||
|
RUN apt-get install -y libx11-6 libx11-xcb1 libatk1.0-0 libgtk-3-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 libxshmfence1 libnss3
|
||||||
|
RUN apt-get autopurge -y
|
||||||
|
RUN apt-get autoclean -y
|
@ -1,5 +1,4 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Logging;
|
namespace Logging;
|
||||||
|
|
||||||
@ -8,7 +7,7 @@ public class FileLogger : LoggerBase
|
|||||||
private string logFilePath { get; }
|
private string logFilePath { get; }
|
||||||
private const int MaxNumberOfLogFiles = 5;
|
private const int MaxNumberOfLogFiles = 5;
|
||||||
|
|
||||||
public FileLogger(string logFilePath, TextWriter? stdOut, Encoding? encoding = null) : base (stdOut, encoding)
|
public FileLogger(string logFilePath, Encoding? encoding = null) : base (encoding)
|
||||||
{
|
{
|
||||||
this.logFilePath = logFilePath;
|
this.logFilePath = logFilePath;
|
||||||
|
|
||||||
@ -22,11 +21,11 @@ public class FileLogger : LoggerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.AppendAllText(logFilePath, logMessage.ToString());
|
File.AppendAllText(logFilePath, logMessage.formattedMessage);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
stdOut?.WriteLine(e);
|
// ignored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -4,14 +4,14 @@ namespace Logging;
|
|||||||
|
|
||||||
public class FormattedConsoleLogger : LoggerBase
|
public class FormattedConsoleLogger : LoggerBase
|
||||||
{
|
{
|
||||||
|
private readonly TextWriter _stdOut;
|
||||||
public FormattedConsoleLogger(TextWriter? stdOut, Encoding? encoding = null) : base(stdOut, encoding)
|
public FormattedConsoleLogger(TextWriter stdOut, Encoding? encoding = null) : base(encoding)
|
||||||
{
|
{
|
||||||
|
this._stdOut = stdOut;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void Write(LogMessage message)
|
protected override void Write(LogMessage message)
|
||||||
{
|
{
|
||||||
//Nothing to do yet
|
this._stdOut.Write(message.formattedMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
23
Logging/LogMessage.cs
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
namespace Logging;
|
||||||
|
|
||||||
|
public class LogMessage
|
||||||
|
{
|
||||||
|
public DateTime logTime { get; }
|
||||||
|
public string caller { get; }
|
||||||
|
public string value { get; }
|
||||||
|
public string formattedMessage => ToString();
|
||||||
|
|
||||||
|
public LogMessage(DateTime messageTime, string caller, string value)
|
||||||
|
{
|
||||||
|
this.logTime = messageTime;
|
||||||
|
this.caller = caller;
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
string dateTimeString = $"{logTime.ToShortDateString()} {logTime.ToLongTimeString()}.{logTime.Millisecond,-3}";
|
||||||
|
string name = caller.Split(new char[] { '.', '+' }).Last();
|
||||||
|
return $"[{dateTimeString}] {name.Substring(0, name.Length >= 13 ? 13 : name.Length),13} | {value}";
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,4 @@
|
|||||||
using System.Net.Mime;
|
using System.Text;
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Logging;
|
namespace Logging;
|
||||||
|
|
||||||
@ -12,24 +11,31 @@ public class Logger : TextWriter
|
|||||||
ConsoleLogger
|
ConsoleLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
private FileLogger? _fileLogger;
|
private readonly FileLogger? _fileLogger;
|
||||||
private FormattedConsoleLogger? _formattedConsoleLogger;
|
private readonly FormattedConsoleLogger? _formattedConsoleLogger;
|
||||||
private MemoryLogger _memoryLogger;
|
private readonly MemoryLogger _memoryLogger;
|
||||||
private TextWriter? stdOut;
|
|
||||||
|
|
||||||
public Logger(LoggerType[] enabledLoggers, TextWriter? stdOut, Encoding? encoding, string? logFilePath)
|
public Logger(LoggerType[] enabledLoggers, TextWriter? stdOut, Encoding? encoding, string? logFilePath)
|
||||||
{
|
{
|
||||||
this.Encoding = encoding ?? Encoding.ASCII;
|
this.Encoding = encoding ?? Encoding.ASCII;
|
||||||
this.stdOut = stdOut ?? null;
|
|
||||||
if (enabledLoggers.Contains(LoggerType.FileLogger) && logFilePath is not null)
|
if (enabledLoggers.Contains(LoggerType.FileLogger) && logFilePath is not null)
|
||||||
_fileLogger = new FileLogger(logFilePath, null, encoding);
|
_fileLogger = new FileLogger(logFilePath, encoding);
|
||||||
else
|
else if(enabledLoggers.Contains(LoggerType.FileLogger) && logFilePath is null)
|
||||||
{
|
{
|
||||||
_fileLogger = null;
|
_fileLogger = null;
|
||||||
throw new ArgumentException($"logFilePath can not be null for LoggerType {LoggerType.FileLogger}");
|
throw new ArgumentException($"logFilePath can not be null for LoggerType {LoggerType.FileLogger}");
|
||||||
}
|
}
|
||||||
_formattedConsoleLogger = enabledLoggers.Contains(LoggerType.ConsoleLogger) ? new FormattedConsoleLogger(null, encoding) : null;
|
|
||||||
_memoryLogger = new MemoryLogger(null, encoding);
|
if (enabledLoggers.Contains(LoggerType.ConsoleLogger) && stdOut is not null)
|
||||||
|
{
|
||||||
|
_formattedConsoleLogger = new FormattedConsoleLogger(stdOut, encoding);
|
||||||
|
}
|
||||||
|
else if (enabledLoggers.Contains(LoggerType.ConsoleLogger) && stdOut is null)
|
||||||
|
{
|
||||||
|
_formattedConsoleLogger = null;
|
||||||
|
throw new ArgumentException($"stdOut can not be null for LoggerType {LoggerType.ConsoleLogger}");
|
||||||
|
}
|
||||||
|
_memoryLogger = new MemoryLogger(encoding);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void WriteLine(string caller, string? value)
|
public void WriteLine(string caller, string? value)
|
||||||
@ -46,9 +52,7 @@ public class Logger : TextWriter
|
|||||||
|
|
||||||
_fileLogger?.Write(caller, value);
|
_fileLogger?.Write(caller, value);
|
||||||
_formattedConsoleLogger?.Write(caller, value);
|
_formattedConsoleLogger?.Write(caller, value);
|
||||||
|
|
||||||
_memoryLogger.Write(caller, value);
|
_memoryLogger.Write(caller, value);
|
||||||
stdOut?.Write(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string[] Tail(uint? lines)
|
public string[] Tail(uint? lines)
|
||||||
|
@ -5,21 +5,10 @@ namespace Logging;
|
|||||||
public abstract class LoggerBase : TextWriter
|
public abstract class LoggerBase : TextWriter
|
||||||
{
|
{
|
||||||
public override Encoding Encoding { get; }
|
public override Encoding Encoding { get; }
|
||||||
protected TextWriter? stdOut { get; }
|
|
||||||
|
|
||||||
public LoggerBase(TextWriter? stdOut, Encoding? encoding = null)
|
public LoggerBase(Encoding? encoding = null)
|
||||||
{
|
{
|
||||||
this.Encoding = encoding ?? Encoding.ASCII;
|
this.Encoding = encoding ?? Encoding.ASCII;
|
||||||
this.stdOut = stdOut;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void WriteLine(string caller, string? value)
|
|
||||||
{
|
|
||||||
value = value is null ? Environment.NewLine : string.Join(value, Environment.NewLine);
|
|
||||||
|
|
||||||
LogMessage message = new LogMessage(DateTime.Now, caller, value);
|
|
||||||
|
|
||||||
Write(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Write(string caller, string? value)
|
public void Write(string caller, string? value)
|
||||||
@ -27,32 +16,10 @@ public abstract class LoggerBase : TextWriter
|
|||||||
if (value is null)
|
if (value is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
LogMessage message = new LogMessage(DateTime.Now, caller, value);
|
LogMessage message = new (DateTime.Now, caller, value);
|
||||||
|
|
||||||
stdOut?.Write(message.ToString());
|
|
||||||
|
|
||||||
Write(message);
|
Write(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract void Write(LogMessage message);
|
protected abstract void Write(LogMessage message);
|
||||||
|
|
||||||
public class LogMessage
|
|
||||||
{
|
|
||||||
public DateTime logTime { get; }
|
|
||||||
public string caller { get; }
|
|
||||||
public string value { get; }
|
|
||||||
|
|
||||||
public LogMessage(DateTime now, string caller, string value)
|
|
||||||
{
|
|
||||||
this.logTime = now;
|
|
||||||
this.caller = caller;
|
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
string dateTimeString = $"{logTime.ToShortDateString()} {logTime.ToLongTimeString()}";
|
|
||||||
return $"[{dateTimeString}] {caller.Split(new char[]{'.','+'}).Last(),15} | {value}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -7,14 +7,15 @@ public class MemoryLogger : LoggerBase
|
|||||||
private readonly SortedList<DateTime, LogMessage> _logMessages = new();
|
private readonly SortedList<DateTime, LogMessage> _logMessages = new();
|
||||||
private int _lastLogMessageIndex = 0;
|
private int _lastLogMessageIndex = 0;
|
||||||
|
|
||||||
public MemoryLogger(TextWriter? stdOut, Encoding? encoding = null) : base(stdOut, encoding)
|
public MemoryLogger(Encoding? encoding = null) : base(encoding)
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void Write(LogMessage value)
|
protected override void Write(LogMessage value)
|
||||||
{
|
{
|
||||||
_logMessages.Add(value.logTime, value);
|
while(!_logMessages.TryAdd(DateTime.Now, value))
|
||||||
|
Thread.Sleep(10);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string[] GetLogMessage()
|
public string[] GetLogMessage()
|
||||||
|
58
README.md
@ -36,6 +36,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="#getting-started">Getting Started</a>
|
<a href="#getting-started">Getting Started</a>
|
||||||
<ul>
|
<ul>
|
||||||
|
<li><a href="#prerequisites">Usage</a></li>
|
||||||
<li><a href="#prerequisites">Prerequisites</a></li>
|
<li><a href="#prerequisites">Prerequisites</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
@ -51,12 +52,15 @@
|
|||||||
<!-- ABOUT THE PROJECT -->
|
<!-- ABOUT THE PROJECT -->
|
||||||
## About The Project
|
## About The Project
|
||||||
|
|
||||||
Tranga can download Chapters and Metadata from Scanlation sites such as
|
Tranga can download Chapters and Metadata from "Scanlation" sites such as
|
||||||
|
|
||||||
- [MangaDex.org](https://mangadex.org/)
|
- [MangaDex.org](https://mangadex.org/)
|
||||||
|
- [Manganato.com](https://manganato.com/)
|
||||||
|
- [Mangasee](https://mangasee123.com/)
|
||||||
|
- [MangaKatana](https://mangakatana.com)
|
||||||
|
- ❓ Open an [issue](https://github.com/C9Glax/tranga/issues)
|
||||||
|
|
||||||
and automatically start updates in [Komga](https://komga.org/) to import them.
|
and automatically import them with [Komga](https://komga.org/) and [Kavita](https://www.kavitareader.com/). Also Notifications will be sent to your devices using [Gotify](https://gotify.net/) and [LunaSea](https://www.lunasea.app/).
|
||||||
|
|
||||||
### Inspiration:
|
### Inspiration:
|
||||||
|
|
||||||
Because [Kaizoku](https://github.com/oae/kaizoku) was relying on [mangal](https://github.com/metafates/mangal) and mangal
|
Because [Kaizoku](https://github.com/oae/kaizoku) was relying on [mangal](https://github.com/metafates/mangal) and mangal
|
||||||
@ -71,19 +75,20 @@ That is why I wanted to create my own project, in a language I understand, and t
|
|||||||
|
|
||||||
- .NET-Core
|
- .NET-Core
|
||||||
- Newtonsoft.JSON
|
- Newtonsoft.JSON
|
||||||
- Love <3 Blåhaj 🦈
|
- [PuppeteerSharp](https://www.puppeteersharp.com/)
|
||||||
|
- [Html Agility Pack (HAP)](https://html-agility-pack.net/)
|
||||||
|
- 💙 Blåhaj 🦈
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|
|  |  |
|
||||||
|
|-----------------------------------:|:----------------------------------|
|
||||||
|
|
||||||

|
|  |  |  |
|
||||||
|
|-----------------------------------:|:-------------------------------------------------:|:-----------------------------------|
|
||||||
|  |  |
|
|
||||||
|-----------------------------------:|:-------------------------------------------------:|
|
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
@ -105,19 +110,42 @@ Download [docker-compose.yaml](https://git.bernloehr.eu/glax/Tranga/src/branch/m
|
|||||||
|
|
||||||
Wherever you are mounting `/usr/share/Tranga-API` you also need to mount that same path + `/imageCache` in the webserver container.
|
Wherever you are mounting `/usr/share/Tranga-API` you also need to mount that same path + `/imageCache` in the webserver container.
|
||||||
|
|
||||||
|
### Docker-Website usage
|
||||||
|
|
||||||
|
There is two ways to download Mangas:
|
||||||
|
- Downloading everything and monitor for new Chapters
|
||||||
|
- Selecting specific Volumes/Chapters
|
||||||
|
|
||||||
|
On the website you add new tasks, by selecting the blue '+' field. Next select the connector/site you want to use, and enter a search term.
|
||||||
|
After clicking 'Search' (or pressing Enter), the results will be presented below - this might, depending on the result-size, take a while because we are already preloading the cover-images.
|
||||||
|
Next select the publication (by selecting the cover) and a new popup will open with two options:
|
||||||
|
- "Monitor" - Download all chapters and monitor for new ones
|
||||||
|
- "Download Chapter" - Download specific chapters only
|
||||||
|
|
||||||
|
When selecting `Monitor` you will be presented with a new window and the selection of the interval you want to check for new chapters (Default: Every 3 hours).
|
||||||
|
When selecting `Download Chapter` a list will open with all available chapters - that have not yet been downloaded - from which you can then select a range (see below).
|
||||||
|
|
||||||
|
The syntax for selecting chapters is as follows:
|
||||||
|
- To download a single Chapter enter either the index number (the number at the very start of the line) or its absolute number like so: `c(h)(apter)[number]`, spaces are allowed.
|
||||||
|
- To download a range of chapters enter either a range of index numbers (`3-6`) or chapters (`ch 12-23`).
|
||||||
|
- For volumes the syntax is as follows: `v(ol)[number](-[number])`, again spaces allowed.
|
||||||
|
|
||||||
|
Examples: `2-12`, `c1`, `ch 2`, `chapter 3`, `v 2`, `vol3-4`, `v2c4` (note: you can only specify a single chapter with this last syntax).
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
[.NET-Core 7.0](https://dotnet.microsoft.com/en-us/download/dotnet/7.0)
|
#### To Build
|
||||||
|
[.NET-Core 7.0 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/7.0)
|
||||||
|
#### To Run
|
||||||
|
[.NET-Core 7.0 Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/7.0) scroll down a bit, should be on the right the second item.
|
||||||
|
|
||||||
<!-- ROADMAP -->
|
<!-- ROADMAP -->
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- [x] Web-UI #1
|
- [ ] Docker ARM support
|
||||||
- [ ] More Connectors
|
- [ ] ❓
|
||||||
- [ ] Manganato #2
|
|
||||||
- [ ] ?
|
|
||||||
|
|
||||||
See the [open issues](https://git.bernloehr.eu/glax/Tranga/issues) for a full list of proposed features (and known issues).
|
See the [open issues](https://github.com/C9Glax/tranga/issues) for a full list of proposed features (and known issues).
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
|
@ -1,191 +0,0 @@
|
|||||||
using System.Runtime.InteropServices;
|
|
||||||
using Logging;
|
|
||||||
using Tranga;
|
|
||||||
|
|
||||||
string applicationFolderPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Tranga-API");
|
|
||||||
string downloadFolderPath = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/Manga" : Path.Join(applicationFolderPath, "Manga");
|
|
||||||
string logsFolderPath = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "/var/logs/Tranga" : Path.Join(applicationFolderPath, "logs");
|
|
||||||
string logFilePath = Path.Join(logsFolderPath, $"log-{DateTime.Now:dd-M-yyyy-HH-mm-ss}.txt");
|
|
||||||
string settingsFilePath = Path.Join(applicationFolderPath, "settings.json");
|
|
||||||
|
|
||||||
Directory.CreateDirectory(logsFolderPath);
|
|
||||||
Logger logger = new(new[] { Logger.LoggerType.FileLogger, Logger.LoggerType.ConsoleLogger }, Console.Out, Console.Out.Encoding, logFilePath);
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga", "Loading settings.");
|
|
||||||
|
|
||||||
TrangaSettings settings;
|
|
||||||
if (File.Exists(settingsFilePath))
|
|
||||||
settings = TrangaSettings.LoadSettings(settingsFilePath);
|
|
||||||
else
|
|
||||||
settings = new TrangaSettings(downloadFolderPath, applicationFolderPath, null);
|
|
||||||
|
|
||||||
Directory.CreateDirectory(settings.workingDirectory);
|
|
||||||
Directory.CreateDirectory(settings.downloadLocation);
|
|
||||||
Directory.CreateDirectory(settings.coverImageCache);
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga",$"Application-Folder: {settings.workingDirectory}");
|
|
||||||
logger.WriteLine("Tranga",$"Settings-File-Path: {settings.settingsFilePath}");
|
|
||||||
logger.WriteLine("Tranga",$"Download-Folder-Path: {settings.downloadLocation}");
|
|
||||||
logger.WriteLine("Tranga",$"Logfile-Path: {logFilePath}");
|
|
||||||
logger.WriteLine("Tranga",$"Image-Cache-Path: {settings.coverImageCache}");
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga", "Loading Taskmanager.");
|
|
||||||
TaskManager taskManager = new (settings, logger);
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen();
|
|
||||||
builder.Services.AddControllers().AddNewtonsoftJson();
|
|
||||||
|
|
||||||
string corsHeader = "Tranga";
|
|
||||||
builder.Services.AddCors(options =>
|
|
||||||
{
|
|
||||||
options.AddPolicy(name: corsHeader,
|
|
||||||
policy =>
|
|
||||||
{
|
|
||||||
policy.AllowAnyOrigin();
|
|
||||||
policy.WithMethods("GET", "POST", "DELETE");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
|
|
||||||
app.UseCors(corsHeader);
|
|
||||||
|
|
||||||
app.MapGet("/Tranga/GetAvailableControllers", () => taskManager.GetAvailableConnectors().Keys.ToArray());
|
|
||||||
|
|
||||||
app.MapGet("/Tranga/GetKnownPublications", () => taskManager.GetAllPublications());
|
|
||||||
|
|
||||||
app.MapGet("/Tranga/GetPublicationsFromConnector", (string connectorName, string title) =>
|
|
||||||
{
|
|
||||||
Connector? connector = taskManager.GetAvailableConnectors().FirstOrDefault(con => con.Key == connectorName).Value;
|
|
||||||
if (connector is null)
|
|
||||||
return Array.Empty<Publication>();
|
|
||||||
if(title.Length < 4)
|
|
||||||
return Array.Empty<Publication>();
|
|
||||||
return taskManager.GetPublicationsFromConnector(connector, title);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapGet("/Tasks/GetTaskTypes", () => Enum.GetNames(typeof(TrangaTask.Task)));
|
|
||||||
|
|
||||||
|
|
||||||
app.MapPost("/Tasks/Create", (string taskType, string? connectorName, string? publicationId, string reoccurrenceTime, string? language) =>
|
|
||||||
{
|
|
||||||
Publication? publication = taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == publicationId);
|
|
||||||
TrangaTask.Task task = Enum.Parse<TrangaTask.Task>(taskType);
|
|
||||||
taskManager.AddTask(task, connectorName, publication, TimeSpan.Parse(reoccurrenceTime), language??"");
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapDelete("/Tasks/Delete", (string taskType, string? connectorName, string? publicationId) =>
|
|
||||||
{
|
|
||||||
Publication? publication = taskManager.GetAllPublications().FirstOrDefault(pub => pub.internalId == publicationId);
|
|
||||||
TrangaTask.Task task = Enum.Parse<TrangaTask.Task>(taskType);
|
|
||||||
taskManager.DeleteTask(task, connectorName, publication);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapGet("/Tasks/Get", (string taskType, string? connectorName, string? searchString) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
TrangaTask.Task task = Enum.Parse<TrangaTask.Task>(taskType);
|
|
||||||
if (searchString is null || connectorName is null)
|
|
||||||
return taskManager.GetAllTasks().Where(tTask => tTask.task == task);
|
|
||||||
else
|
|
||||||
return taskManager.GetAllTasks().Where(tTask =>
|
|
||||||
tTask.task == task && tTask.connectorName == connectorName && tTask.ToString()
|
|
||||||
.Contains(searchString, StringComparison.InvariantCultureIgnoreCase));
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
return Array.Empty<TrangaTask>();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapPost("/Tasks/Start", (string taskType, string? connectorName, string? publicationId) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType);
|
|
||||||
TrangaTask? task = null;
|
|
||||||
if (connectorName is null || publicationId is null)
|
|
||||||
task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
|
|
||||||
tTask.task == pTask);
|
|
||||||
else
|
|
||||||
task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
|
|
||||||
tTask.task == pTask && tTask.publication?.internalId == publicationId &&
|
|
||||||
tTask.connectorName == connectorName);
|
|
||||||
|
|
||||||
if (task is null)
|
|
||||||
return;
|
|
||||||
taskManager.ExecuteTaskNow(task);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapGet("/Tasks/GetRunningTasks",
|
|
||||||
() => taskManager.GetAllTasks().Where(task => task.state is TrangaTask.ExecutionState.Running));
|
|
||||||
|
|
||||||
app.MapGet("/Queue/GetList",
|
|
||||||
() => taskManager.GetAllTasks().Where(task => task.state is TrangaTask.ExecutionState.Enqueued));
|
|
||||||
|
|
||||||
app.MapPost("/Queue/Enqueue", (string taskType, string? connectorName, string? publicationId) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType);
|
|
||||||
TrangaTask? task = null;
|
|
||||||
if (connectorName is null || publicationId is null)
|
|
||||||
task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
|
|
||||||
tTask.task == pTask);
|
|
||||||
else
|
|
||||||
task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
|
|
||||||
tTask.task == pTask && tTask.publication?.internalId == publicationId &&
|
|
||||||
tTask.connectorName == connectorName);
|
|
||||||
|
|
||||||
if (task is null)
|
|
||||||
return;
|
|
||||||
taskManager.AddTaskToQueue(task);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapDelete("/Queue/Dequeue", (string taskType, string? connectorName, string? publicationId) =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
TrangaTask.Task pTask = Enum.Parse<TrangaTask.Task>(taskType);
|
|
||||||
TrangaTask? task = null;
|
|
||||||
if (connectorName is null || publicationId is null)
|
|
||||||
task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
|
|
||||||
tTask.task == pTask);
|
|
||||||
else
|
|
||||||
task = taskManager.GetAllTasks().FirstOrDefault(tTask =>
|
|
||||||
tTask.task == pTask && tTask.publication?.internalId == publicationId &&
|
|
||||||
tTask.connectorName == connectorName);
|
|
||||||
|
|
||||||
if (task is null)
|
|
||||||
return;
|
|
||||||
taskManager.RemoveTaskFromQueue(task);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapGet("/Settings/Get", () => taskManager.settings);
|
|
||||||
|
|
||||||
app.MapPost("/Settings/Update", (string? downloadLocation, string? komgaUrl, string? komgaAuth) => taskManager.UpdateSettings(downloadLocation, komgaUrl, komgaAuth) );
|
|
||||||
|
|
||||||
app.Run();
|
|
@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"iisSettings": {
|
|
||||||
"windowsAuthentication": false,
|
|
||||||
"anonymousAuthentication": true,
|
|
||||||
"iisExpress": {
|
|
||||||
"applicationUrl": "http://localhost:1716",
|
|
||||||
"sslPort": 44391
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"profiles": {
|
|
||||||
"http": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"applicationUrl": "http://localhost:5177",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"https": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"applicationUrl": "https://localhost:7036;http://localhost:5177",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"IIS Express": {
|
|
||||||
"commandName": "IISExpress",
|
|
||||||
"launchBrowser": true,
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<RootNamespace>Tranga_API</RootNamespace>
|
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Content Include="..\.dockerignore">
|
|
||||||
<Link>.dockerignore</Link>
|
|
||||||
</Content>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Logging\Logging.csproj" />
|
|
||||||
<ProjectReference Include="..\Tranga\Tranga.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.5" />
|
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="7.0.6" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*"
|
|
||||||
}
|
|
@ -1,536 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using Logging;
|
|
||||||
using Tranga;
|
|
||||||
|
|
||||||
namespace Tranga_CLI;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This is written with pure hatred for readability.
|
|
||||||
* At some point do this properly.
|
|
||||||
* Read at own risk.
|
|
||||||
*/
|
|
||||||
|
|
||||||
public static class Tranga_Cli
|
|
||||||
{
|
|
||||||
public static void Main(string[] args)
|
|
||||||
{
|
|
||||||
string applicationFolderPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Tranga");
|
|
||||||
string logsFolderPath = Path.Join(applicationFolderPath, "logs");
|
|
||||||
string logFilePath = Path.Join(logsFolderPath, $"log-{DateTime.Now:dd-M-yyyy-HH-mm-ss}.txt");
|
|
||||||
string settingsFilePath = Path.Join(applicationFolderPath, "settings.json");
|
|
||||||
|
|
||||||
Directory.CreateDirectory(applicationFolderPath);
|
|
||||||
Directory.CreateDirectory(logsFolderPath);
|
|
||||||
|
|
||||||
Console.WriteLine($"Logfile-Path: {logFilePath}");
|
|
||||||
Console.WriteLine($"Settings-File-Path: {settingsFilePath}");
|
|
||||||
|
|
||||||
Logger logger = new(new[] { Logger.LoggerType.FileLogger }, null, null, logFilePath);
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "Loading Taskmanager.");
|
|
||||||
TrangaSettings settings;
|
|
||||||
if (File.Exists(settingsFilePath))
|
|
||||||
settings = TrangaSettings.LoadSettings(settingsFilePath);
|
|
||||||
else
|
|
||||||
settings = new TrangaSettings(Directory.GetCurrentDirectory(), applicationFolderPath, null);
|
|
||||||
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "User Input");
|
|
||||||
Console.WriteLine($"Output folder path [{settings.downloadLocation}]:");
|
|
||||||
string? tmpPath = Console.ReadLine();
|
|
||||||
while(tmpPath is null)
|
|
||||||
tmpPath = Console.ReadLine();
|
|
||||||
if (tmpPath.Length > 0)
|
|
||||||
settings.downloadLocation = tmpPath;
|
|
||||||
|
|
||||||
Console.WriteLine($"Komga BaseURL [{settings.komga?.baseUrl}]:");
|
|
||||||
string? tmpUrl = Console.ReadLine();
|
|
||||||
while (tmpUrl is null)
|
|
||||||
tmpUrl = Console.ReadLine();
|
|
||||||
if (tmpUrl.Length > 0)
|
|
||||||
{
|
|
||||||
Console.WriteLine("Username:");
|
|
||||||
string? tmpUser = Console.ReadLine();
|
|
||||||
while (tmpUser is null || tmpUser.Length < 1)
|
|
||||||
tmpUser = Console.ReadLine();
|
|
||||||
|
|
||||||
Console.WriteLine("Password:");
|
|
||||||
string tmpPass = string.Empty;
|
|
||||||
ConsoleKey key;
|
|
||||||
do
|
|
||||||
{
|
|
||||||
var keyInfo = Console.ReadKey(intercept: true);
|
|
||||||
key = keyInfo.Key;
|
|
||||||
|
|
||||||
if (key == ConsoleKey.Backspace && tmpPass.Length > 0)
|
|
||||||
{
|
|
||||||
Console.Write("\b \b");
|
|
||||||
tmpPass = tmpPass[0..^1];
|
|
||||||
}
|
|
||||||
else if (!char.IsControl(keyInfo.KeyChar))
|
|
||||||
{
|
|
||||||
Console.Write("*");
|
|
||||||
tmpPass += keyInfo.KeyChar;
|
|
||||||
}
|
|
||||||
} while (key != ConsoleKey.Enter);
|
|
||||||
|
|
||||||
settings.komga = new Komga(tmpUrl, tmpUser, tmpPass, logger);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "Loaded.");
|
|
||||||
TaskMode(settings, logger);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void TaskMode(TrangaSettings settings, Logger logger)
|
|
||||||
{
|
|
||||||
TaskManager taskManager = new (settings, logger);
|
|
||||||
ConsoleKey selection = ConsoleKey.EraseEndOfFile;
|
|
||||||
PrintMenu(taskManager, taskManager.settings.downloadLocation, logger);
|
|
||||||
while (selection != ConsoleKey.Q)
|
|
||||||
{
|
|
||||||
int taskCount = taskManager.GetAllTasks().Length;
|
|
||||||
int taskRunningCount = taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Running);
|
|
||||||
int taskEnqueuedCount =
|
|
||||||
taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Enqueued);
|
|
||||||
Console.SetCursorPosition(0,1);
|
|
||||||
Console.WriteLine($"Tasks (Running/Queue/Total)): {taskRunningCount}/{taskEnqueuedCount}/{taskCount}");
|
|
||||||
|
|
||||||
if (Console.KeyAvailable)
|
|
||||||
{
|
|
||||||
selection = Console.ReadKey().Key;
|
|
||||||
switch (selection)
|
|
||||||
{
|
|
||||||
case ConsoleKey.L:
|
|
||||||
PrintTasks(taskManager.GetAllTasks(), logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.C:
|
|
||||||
CreateTask(taskManager, taskManager.settings, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.D:
|
|
||||||
DeleteTask(taskManager, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.E:
|
|
||||||
ExecuteTaskNow(taskManager, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.S:
|
|
||||||
SearchTasks(taskManager, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.R:
|
|
||||||
PrintTasks(
|
|
||||||
taskManager.GetAllTasks().Where(eTask => eTask.state == TrangaTask.ExecutionState.Running)
|
|
||||||
.ToArray(), logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.K:
|
|
||||||
PrintTasks(
|
|
||||||
taskManager.GetAllTasks().Where(qTask => qTask.state is TrangaTask.ExecutionState.Enqueued)
|
|
||||||
.ToArray(), logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.F:
|
|
||||||
TailLog(logger);
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.G:
|
|
||||||
RemoveTaskFromQueue(taskManager, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.B:
|
|
||||||
AddTaskToQueue(taskManager, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
case ConsoleKey.M:
|
|
||||||
AddMangaTaskToQueue(taskManager, logger);
|
|
||||||
Console.WriteLine("Press any key.");
|
|
||||||
Console.ReadKey();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
PrintMenu(taskManager, taskManager.settings.downloadLocation, logger);
|
|
||||||
}
|
|
||||||
Thread.Sleep(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "Exiting.");
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("Exiting.");
|
|
||||||
if (taskManager.GetAllTasks().Any(task => task.state == TrangaTask.ExecutionState.Running))
|
|
||||||
{
|
|
||||||
Console.WriteLine("Force quit (Even with running tasks?) y/N");
|
|
||||||
selection = Console.ReadKey().Key;
|
|
||||||
while(selection != ConsoleKey.Y && selection != ConsoleKey.N)
|
|
||||||
selection = Console.ReadKey().Key;
|
|
||||||
taskManager.Shutdown(selection == ConsoleKey.Y);
|
|
||||||
}else
|
|
||||||
// ReSharper disable once RedundantArgumentDefaultValue Better readability
|
|
||||||
taskManager.Shutdown(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PrintMenu(TaskManager taskManager, string folderPath, Logger logger)
|
|
||||||
{
|
|
||||||
int taskCount = taskManager.GetAllTasks().Length;
|
|
||||||
int taskRunningCount = taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Running);
|
|
||||||
int taskEnqueuedCount =
|
|
||||||
taskManager.GetAllTasks().Count(task => task.state == TrangaTask.ExecutionState.Enqueued);
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine($"Download Folder: {folderPath}");
|
|
||||||
Console.WriteLine($"Tasks (Running/Queue/Total)): {taskRunningCount}/{taskEnqueuedCount}/{taskCount}");
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.WriteLine($"{"C: Create Task",-30}{"L: List tasks",-30}{"B: Enqueue Task", -30}");
|
|
||||||
Console.WriteLine($"{"D: Delete Task",-30}{"S: Search Tasks", -30}{"K: List Task Queue", -30}");
|
|
||||||
Console.WriteLine($"{"E: Execute Task now",-30}{"R: List Running Tasks", -30}{"G: Remove Task from Queue", -30}");
|
|
||||||
Console.WriteLine($"{"M: New Download Manga Task",-30}{"", -30}{"", -30}");
|
|
||||||
Console.WriteLine($"{"",-30}{"F: Show Log",-30}{"Q: Exit",-30}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void PrintTasks(TrangaTask[] tasks, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Printing Tasks");
|
|
||||||
int taskCount = tasks.Length;
|
|
||||||
int taskRunningCount = tasks.Count(task => task.state == TrangaTask.ExecutionState.Running);
|
|
||||||
int taskEnqueuedCount = tasks.Count(task => task.state == TrangaTask.ExecutionState.Enqueued);
|
|
||||||
Console.Clear();
|
|
||||||
int tIndex = 0;
|
|
||||||
Console.WriteLine($"Tasks (Running/Queue/Total): {taskRunningCount}/{taskEnqueuedCount}/{taskCount}");
|
|
||||||
string header =
|
|
||||||
$"{"",-5}{"Task",-20} | {"Last Executed",-20} | {"Reoccurrence",-12} | {"State",-10} | {"Connector",-15} | Publication/Manga";
|
|
||||||
Console.WriteLine(header);
|
|
||||||
Console.WriteLine(new string('-', header.Length));
|
|
||||||
foreach (TrangaTask trangaTask in tasks)
|
|
||||||
{
|
|
||||||
string[] taskSplit = trangaTask.ToString().Split(", ");
|
|
||||||
Console.WriteLine($"{tIndex++:000}: {taskSplit[0],-20} | {taskSplit[1],-20} | {taskSplit[2],-12} | {taskSplit[3],-10} | {(taskSplit.Length > 4 ? taskSplit[4] : ""),-15} | {(taskSplit.Length > 5 ? taskSplit[5] : "")}");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TrangaTask? SelectTask(TrangaTask[] tasks, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Select task");
|
|
||||||
if (tasks.Length < 1)
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("There are no available Tasks.");
|
|
||||||
logger.WriteLine("Tranga_CLI", "No available Tasks.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
PrintTasks(tasks, logger);
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "Selecting Task to Remove (from queue)");
|
|
||||||
Console.WriteLine("Enter q to abort");
|
|
||||||
Console.WriteLine($"Select Task (0-{tasks.Length - 1}):");
|
|
||||||
|
|
||||||
string? selectedTask = Console.ReadLine();
|
|
||||||
while(selectedTask is null || selectedTask.Length < 1)
|
|
||||||
selectedTask = Console.ReadLine();
|
|
||||||
|
|
||||||
if (selectedTask.Length == 1 && selectedTask.ToLower() == "q")
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("aborted.");
|
|
||||||
logger.WriteLine("Tranga_CLI", "aborted");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
int selectedTaskIndex = Convert.ToInt32(selectedTask);
|
|
||||||
return tasks[selectedTaskIndex];
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Exception: {e.Message}");
|
|
||||||
logger.WriteLine("Tranga_CLI", e.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddMangaTaskToQueue(TaskManager taskManager, Logger logger)
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Add Manga Download to queue");
|
|
||||||
|
|
||||||
Connector? connector = SelectConnector(taskManager.settings.downloadLocation, taskManager.GetAvailableConnectors().Values.ToArray(), logger);
|
|
||||||
if (connector is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Publication? publication = SelectPublication(taskManager, connector!, logger);
|
|
||||||
if (publication is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
TimeSpan reoccurrence = SelectReoccurrence(logger);
|
|
||||||
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
|
|
||||||
TrangaTask newTask = taskManager.AddTask(TrangaTask.Task.DownloadNewChapters, connector?.name, publication, reoccurrence, "en");
|
|
||||||
Console.WriteLine(newTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddTaskToQueue(TaskManager taskManager, Logger logger)
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Add Task to queue");
|
|
||||||
|
|
||||||
TrangaTask[] tasks = taskManager.GetAllTasks().Where(rTask =>
|
|
||||||
rTask.state is not TrangaTask.ExecutionState.Enqueued and not TrangaTask.ExecutionState.Running).ToArray();
|
|
||||||
|
|
||||||
TrangaTask? selectedTask = SelectTask(tasks, logger);
|
|
||||||
if (selectedTask is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
|
|
||||||
taskManager.AddTaskToQueue(selectedTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void RemoveTaskFromQueue(TaskManager taskManager, Logger logger)
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Remove Task from queue");
|
|
||||||
|
|
||||||
TrangaTask[] tasks = taskManager.GetAllTasks().Where(rTask => rTask.state is TrangaTask.ExecutionState.Enqueued).ToArray();
|
|
||||||
|
|
||||||
TrangaTask? selectedTask = SelectTask(tasks, logger);
|
|
||||||
if (selectedTask is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
|
|
||||||
taskManager.RemoveTaskFromQueue(selectedTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void TailLog(Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Show Log-lines");
|
|
||||||
Console.Clear();
|
|
||||||
|
|
||||||
string[] lines = logger.Tail(20);
|
|
||||||
foreach (string message in lines)
|
|
||||||
Console.Write(message);
|
|
||||||
|
|
||||||
while (!Console.KeyAvailable)
|
|
||||||
{
|
|
||||||
string[] newLines = logger.GetNewLines();
|
|
||||||
foreach(string message in newLines)
|
|
||||||
Console.Write(message);
|
|
||||||
Thread.Sleep(40);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CreateTask(TaskManager taskManager, TrangaSettings settings, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Creating Task");
|
|
||||||
TrangaTask.Task? tmpTask = SelectTaskType(logger);
|
|
||||||
if (tmpTask is null)
|
|
||||||
return;
|
|
||||||
TrangaTask.Task task = (TrangaTask.Task)tmpTask!;
|
|
||||||
|
|
||||||
Connector? connector = null;
|
|
||||||
if (task != TrangaTask.Task.UpdateKomgaLibrary)
|
|
||||||
{
|
|
||||||
connector = SelectConnector(settings.downloadLocation, taskManager.GetAvailableConnectors().Values.ToArray(), logger);
|
|
||||||
if (connector is null)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Publication? publication = null;
|
|
||||||
if (task != TrangaTask.Task.UpdatePublications && task != TrangaTask.Task.UpdateKomgaLibrary)
|
|
||||||
{
|
|
||||||
publication = SelectPublication(taskManager, connector!, logger);
|
|
||||||
if (publication is null)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
TimeSpan reoccurrence = SelectReoccurrence(logger);
|
|
||||||
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
|
|
||||||
TrangaTask newTask = taskManager.AddTask(task, connector?.name, publication, reoccurrence, "en");
|
|
||||||
Console.WriteLine(newTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ExecuteTaskNow(TaskManager taskManager, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Executing Task");
|
|
||||||
TrangaTask[] tasks = taskManager.GetAllTasks().Where(nTask => nTask.state is not TrangaTask.ExecutionState.Running).ToArray();
|
|
||||||
|
|
||||||
TrangaTask? selectedTask = SelectTask(tasks, logger);
|
|
||||||
if (selectedTask is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
|
|
||||||
taskManager.ExecuteTaskNow(selectedTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void DeleteTask(TaskManager taskManager, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Delete Task");
|
|
||||||
TrangaTask[] tasks = taskManager.GetAllTasks();
|
|
||||||
|
|
||||||
TrangaTask? selectedTask = SelectTask(tasks, logger);
|
|
||||||
if (selectedTask is null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
logger.WriteLine("Tranga_CLI", "Sending Task to TaskManager");
|
|
||||||
taskManager.DeleteTask(selectedTask.task, selectedTask.connectorName, selectedTask.publication);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TrangaTask.Task? SelectTaskType(Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Select TaskType");
|
|
||||||
Console.Clear();
|
|
||||||
string[] taskNames = Enum.GetNames<TrangaTask.Task>();
|
|
||||||
|
|
||||||
int tIndex = 0;
|
|
||||||
Console.WriteLine("Available Tasks:");
|
|
||||||
foreach (string taskName in taskNames)
|
|
||||||
Console.WriteLine($"{tIndex++}: {taskName}");
|
|
||||||
|
|
||||||
Console.WriteLine("Enter q to abort");
|
|
||||||
Console.WriteLine($"Select Task (0-{taskNames.Length - 1}):");
|
|
||||||
|
|
||||||
string? selectedTask = Console.ReadLine();
|
|
||||||
while(selectedTask is null || selectedTask.Length < 1)
|
|
||||||
selectedTask = Console.ReadLine();
|
|
||||||
|
|
||||||
if (selectedTask.Length == 1 && selectedTask.ToLower() == "q")
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("aborted.");
|
|
||||||
logger.WriteLine("Tranga_CLI", "aborted.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
int selectedTaskIndex = Convert.ToInt32(selectedTask);
|
|
||||||
string selectedTaskName = taskNames[selectedTaskIndex];
|
|
||||||
return Enum.Parse<TrangaTask.Task>(selectedTaskName);
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Exception: {e.Message}");
|
|
||||||
logger.WriteLine("Tranga_CLI", e.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static TimeSpan SelectReoccurrence(Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Select Reoccurrence");
|
|
||||||
Console.WriteLine("Select reoccurrence Timer (Format hh:mm:ss):");
|
|
||||||
return TimeSpan.Parse(Console.ReadLine()!, new CultureInfo("en-US"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Connector? SelectConnector(string folderPath, Connector[] connectors, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Select Connector");
|
|
||||||
Console.Clear();
|
|
||||||
|
|
||||||
int cIndex = 0;
|
|
||||||
Console.WriteLine("Connectors:");
|
|
||||||
foreach (Connector connector in connectors)
|
|
||||||
Console.WriteLine($"{cIndex++}: {connector.name}");
|
|
||||||
|
|
||||||
Console.WriteLine("Enter q to abort");
|
|
||||||
Console.WriteLine($"Select Connector (0-{connectors.Length - 1}):");
|
|
||||||
|
|
||||||
string? selectedConnector = Console.ReadLine();
|
|
||||||
while(selectedConnector is null || selectedConnector.Length < 1)
|
|
||||||
selectedConnector = Console.ReadLine();
|
|
||||||
|
|
||||||
if (selectedConnector.Length == 1 && selectedConnector.ToLower() == "q")
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("aborted.");
|
|
||||||
logger.WriteLine("Tranga_CLI", "aborted.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
int selectedConnectorIndex = Convert.ToInt32(selectedConnector);
|
|
||||||
return connectors[selectedConnectorIndex];
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Exception: {e.Message}");
|
|
||||||
logger.WriteLine("Tranga_CLI", e.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Publication? SelectPublication(TaskManager taskManager, Connector connector, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Select Publication");
|
|
||||||
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine($"Connector: {connector.name}");
|
|
||||||
Console.WriteLine("Publication search query (leave empty for all):");
|
|
||||||
string? query = Console.ReadLine();
|
|
||||||
|
|
||||||
Publication[] publications = taskManager.GetPublicationsFromConnector(connector, query ?? "");
|
|
||||||
|
|
||||||
if (publications.Length < 1)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "No publications returned");
|
|
||||||
Console.WriteLine($"No publications for query '{query}' returned;");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
int pIndex = 0;
|
|
||||||
Console.WriteLine("Publications:");
|
|
||||||
foreach(Publication publication in publications)
|
|
||||||
Console.WriteLine($"{pIndex++}: {publication.sortName}");
|
|
||||||
|
|
||||||
Console.WriteLine("Enter q to abort");
|
|
||||||
Console.WriteLine($"Select publication to Download (0-{publications.Length - 1}):");
|
|
||||||
|
|
||||||
string? selectedPublication = Console.ReadLine();
|
|
||||||
while(selectedPublication is null || selectedPublication.Length < 1)
|
|
||||||
selectedPublication = Console.ReadLine();
|
|
||||||
|
|
||||||
if (selectedPublication.Length == 1 && selectedPublication.ToLower() == "q")
|
|
||||||
{
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("aborted.");
|
|
||||||
logger.WriteLine("Tranga_CLI", "aborted.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
int selectedPublicationIndex = Convert.ToInt32(selectedPublication);
|
|
||||||
return publications[selectedPublicationIndex];
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Exception: {e.Message}");
|
|
||||||
logger.WriteLine("Tranga_CLI", e.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SearchTasks(TaskManager taskManager, Logger logger)
|
|
||||||
{
|
|
||||||
logger.WriteLine("Tranga_CLI", "Menu: Search task");
|
|
||||||
Console.Clear();
|
|
||||||
Console.WriteLine("Enter search query:");
|
|
||||||
string? query = Console.ReadLine();
|
|
||||||
while (query is null || query.Length < 4)
|
|
||||||
query = Console.ReadLine();
|
|
||||||
PrintTasks(taskManager.GetAllTasks().Where(qTask =>
|
|
||||||
qTask.ToString().ToLower().Contains(query, StringComparison.OrdinalIgnoreCase)).ToArray(), logger);
|
|
||||||
}
|
|
||||||
}
|
|
16
Tranga.sln
@ -2,11 +2,9 @@
|
|||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tranga", ".\Tranga\Tranga.csproj", "{545E81B9-D96B-4C8F-A97F-2C02414DE566}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tranga", ".\Tranga\Tranga.csproj", "{545E81B9-D96B-4C8F-A97F-2C02414DE566}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tranga-CLI", "Tranga-CLI\Tranga-CLI.csproj", "{4899E3B2-B259-479A-B43E-042D043E9501}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logging", "Logging\Logging.csproj", "{415BE889-BB7D-426F-976F-8D977876A462}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logging", "Logging\Logging.csproj", "{415BE889-BB7D-426F-976F-8D977876A462}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tranga-API", "Tranga-API\Tranga-API.csproj", "{48F4E495-75BC-4402-8E03-DEC5B79D7E83}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CLI", "CLI\CLI.csproj", "{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@ -18,17 +16,13 @@ Global
|
|||||||
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.Build.0 = Release|Any CPU
|
{545E81B9-D96B-4C8F-A97F-2C02414DE566}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{4899E3B2-B259-479A-B43E-042D043E9501}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{4899E3B2-B259-479A-B43E-042D043E9501}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{4899E3B2-B259-479A-B43E-042D043E9501}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{4899E3B2-B259-479A-B43E-042D043E9501}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{415BE889-BB7D-426F-976F-8D977876A462}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{415BE889-BB7D-426F-976F-8D977876A462}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{415BE889-BB7D-426F-976F-8D977876A462}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{415BE889-BB7D-426F-976F-8D977876A462}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.Build.0 = Release|Any CPU
|
{415BE889-BB7D-426F-976F-8D977876A462}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{48F4E495-75BC-4402-8E03-DEC5B79D7E83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{48F4E495-75BC-4402-8E03-DEC5B79D7E83}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{48F4E495-75BC-4402-8E03-DEC5B79D7E83}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{48F4E495-75BC-4402-8E03-DEC5B79D7E83}.Release|Any CPU.Build.0 = Release|Any CPU
|
{4324C816-F9D2-468F-8ED6-397FE2F0DCB3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
@ -1,4 +1,12 @@
|
|||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=altnames/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=authorsartists/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Gotify/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=jjob/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Komga/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Komga/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=lunasea/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=mangakatana/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Manganato/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Mangasee/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Taskmanager/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Taskmanager/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Tranga/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=Tranga/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
@ -1,5 +1,5 @@
|
|||||||
using System.Globalization;
|
using System.Text.RegularExpressions;
|
||||||
using System.Text.RegularExpressions;
|
using System.Xml.Linq;
|
||||||
|
|
||||||
namespace Tranga;
|
namespace Tranga;
|
||||||
|
|
||||||
@ -7,29 +7,88 @@ namespace Tranga;
|
|||||||
/// Has to be Part of a publication
|
/// Has to be Part of a publication
|
||||||
/// Includes the Chapter-Name, -VolumeNumber, -ChapterNumber, the location of the chapter on the internet and the saveName of the local file.
|
/// Includes the Chapter-Name, -VolumeNumber, -ChapterNumber, the location of the chapter on the internet and the saveName of the local file.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public struct Chapter
|
public readonly struct Chapter
|
||||||
{
|
{
|
||||||
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
|
public Publication parentPublication { get; }
|
||||||
public string? name { get; }
|
public string? name { get; }
|
||||||
public string? volumeNumber { get; }
|
public string? volumeNumber { get; }
|
||||||
public string? chapterNumber { get; }
|
public string chapterNumber { get; }
|
||||||
public string url { get; }
|
public string url { get; }
|
||||||
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
public string fileName { get; }
|
public string fileName { get; }
|
||||||
public string sortNumber { get; }
|
|
||||||
|
|
||||||
private static readonly Regex LegalCharacters = new Regex(@"([A-z]*[0-9]* *\.*-*,*\]*\[*'*\'*\)*\(*~*!*)*");
|
private static readonly Regex LegalCharacters = new (@"([A-z]*[0-9]* *\.*-*,*\]*\[*'*\'*\)*\(*~*!*)*");
|
||||||
public Chapter(string? name, string? volumeNumber, string? chapterNumber, string url)
|
private static readonly Regex IllegalStrings = new(@"Vol(ume)?.?", RegexOptions.IgnoreCase);
|
||||||
|
public Chapter(Publication parentPublication, string? name, string? volumeNumber, string chapterNumber, string url)
|
||||||
{
|
{
|
||||||
|
this.parentPublication = parentPublication;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.volumeNumber = volumeNumber is { Length: > 0 } ? volumeNumber : "1";
|
this.volumeNumber = volumeNumber;
|
||||||
this.chapterNumber = chapterNumber;
|
this.chapterNumber = chapterNumber;
|
||||||
this.url = url;
|
this.url = url;
|
||||||
|
|
||||||
string chapterName = string.Concat(LegalCharacters.Matches(name ?? ""));
|
string chapterName = string.Concat(LegalCharacters.Matches(name ?? ""));
|
||||||
NumberFormatInfo nfi = new NumberFormatInfo()
|
string volStr = this.volumeNumber is not null ? $"Vol.{this.volumeNumber} " : "";
|
||||||
|
string chNumberStr = $"Ch.{chapterNumber} ";
|
||||||
|
string chNameStr = chapterName.Length > 0 ? $"- {chapterName}" : "";
|
||||||
|
chNameStr = IllegalStrings.Replace(chNameStr, "");
|
||||||
|
this.fileName = $"{volStr}{chNumberStr}{chNameStr}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"Chapter {parentPublication.sortName} {parentPublication.internalId} {chapterNumber} {name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a chapter-archive is already present
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>true if chapter is present</returns>
|
||||||
|
internal bool CheckChapterIsDownloaded(string downloadLocation)
|
||||||
|
{
|
||||||
|
string newFilePath = GetArchiveFilePath(downloadLocation);
|
||||||
|
if (!Directory.Exists(Path.Join(downloadLocation, parentPublication.folderName)))
|
||||||
|
return false;
|
||||||
|
FileInfo[] archives = new DirectoryInfo(Path.Join(downloadLocation, parentPublication.folderName)).GetFiles();
|
||||||
|
Regex chapterInfoRex = new(@"Ch\.[0-9.]+");
|
||||||
|
Regex chapterRex = new(@"[0-9]+(\.[0-9]+)?");
|
||||||
|
|
||||||
|
if (File.Exists(newFilePath))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
string cn = this.chapterNumber;
|
||||||
|
if (archives.FirstOrDefault(archive => chapterRex.Match(chapterInfoRex.Match(archive.Name).Value).Value == cn) is { } path)
|
||||||
{
|
{
|
||||||
NumberDecimalSeparator = "."
|
File.Move(path.FullName, newFilePath);
|
||||||
};
|
return true;
|
||||||
sortNumber = decimal.Round(Convert.ToDecimal(this.volumeNumber) * Convert.ToDecimal(this.chapterNumber, nfi), 1)
|
}
|
||||||
.ToString(nfi);
|
return false;
|
||||||
this.fileName = $"{chapterName} - V{volumeNumber}C{chapterNumber} - {sortNumber}";
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Creates full file path of chapter-archive
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Filepath</returns>
|
||||||
|
internal string GetArchiveFilePath(string downloadLocation)
|
||||||
|
{
|
||||||
|
return Path.Join(downloadLocation, parentPublication.folderName, $"{parentPublication.folderName} - {this.fileName}.cbz");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a string containing XML of publication and chapter.
|
||||||
|
/// See ComicInfo.xml
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>XML-string</returns>
|
||||||
|
internal string GetComicInfoXmlString()
|
||||||
|
{
|
||||||
|
XElement comicInfo = new XElement("ComicInfo",
|
||||||
|
new XElement("Tags", string.Join(',', parentPublication.tags)),
|
||||||
|
new XElement("LanguageISO", parentPublication.originalLanguage),
|
||||||
|
new XElement("Title", this.name),
|
||||||
|
new XElement("Writer", string.Join(',', parentPublication.authors)),
|
||||||
|
new XElement("Volume", this.volumeNumber),
|
||||||
|
new XElement("Number", this.chapterNumber)
|
||||||
|
);
|
||||||
|
return comicInfo.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,255 +0,0 @@
|
|||||||
using System.IO.Compression;
|
|
||||||
using System.Net;
|
|
||||||
using System.Xml.Linq;
|
|
||||||
using Logging;
|
|
||||||
|
|
||||||
namespace Tranga;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Base-Class for all Connectors
|
|
||||||
/// Provides some methods to be used by all Connectors, as well as a DownloadClient
|
|
||||||
/// </summary>
|
|
||||||
public abstract class Connector
|
|
||||||
{
|
|
||||||
internal string downloadLocation { get; } //Location of local files
|
|
||||||
protected DownloadClient downloadClient { get; init; }
|
|
||||||
|
|
||||||
protected Logger? logger;
|
|
||||||
|
|
||||||
protected string imageCachePath;
|
|
||||||
|
|
||||||
protected Connector(string downloadLocation, string imageCachePath, Logger? logger)
|
|
||||||
{
|
|
||||||
this.downloadLocation = downloadLocation;
|
|
||||||
this.logger = logger;
|
|
||||||
this.downloadClient = new DownloadClient(new Dictionary<byte, int>()
|
|
||||||
{
|
|
||||||
//RequestTypes for RateLimits
|
|
||||||
}, logger);
|
|
||||||
this.imageCachePath = imageCachePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract string name { get; } //Name of the Connector (e.g. Website)
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns all Publications with the given string.
|
|
||||||
/// If the string is empty or null, returns all Publication of the Connector
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="publicationTitle">Search-Query</param>
|
|
||||||
/// <returns>Publications matching the query</returns>
|
|
||||||
public abstract Publication[] GetPublications(string publicationTitle = "");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns all Chapters of the publication in the provided language.
|
|
||||||
/// If the language is empty or null, returns all Chapters in all Languages.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="publication">Publication to get Chapters for</param>
|
|
||||||
/// <param name="language">Language of the Chapters</param>
|
|
||||||
/// <returns>Array of Chapters matching Publication and Language</returns>
|
|
||||||
public abstract Chapter[] GetChapters(Publication publication, string language = "");
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the Chapter (+Images) from the website.
|
|
||||||
/// Should later call DownloadChapterImages to retrieve the individual Images of the Chapter.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="publication">Publication that contains Chapter</param>
|
|
||||||
/// <param name="chapter">Chapter with Images to retrieve</param>
|
|
||||||
public abstract void DownloadChapter(Publication publication, Chapter chapter);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the Cover from the Website
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="publication">Publication to retrieve Cover for</param>
|
|
||||||
/// <param name="settings">TrangaSettings</param>
|
|
||||||
public abstract void CloneCoverFromCache(Publication publication, TrangaSettings settings);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Saves the series-info to series.json in the Publication Folder
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="publication">Publication to save series.json for</param>
|
|
||||||
public void SaveSeriesInfo(Publication publication)
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Saving series.json for {publication.sortName}");
|
|
||||||
//Check if Publication already has a Folder and a series.json
|
|
||||||
string publicationFolder = Path.Join(downloadLocation, publication.folderName);
|
|
||||||
if(!Directory.Exists(publicationFolder))
|
|
||||||
Directory.CreateDirectory(publicationFolder);
|
|
||||||
|
|
||||||
string seriesInfoPath = Path.Join(publicationFolder, "series.json");
|
|
||||||
if(!File.Exists(seriesInfoPath))
|
|
||||||
File.WriteAllText(seriesInfoPath,publication.GetSeriesInfoJson());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a string containing XML of publication and chapter.
|
|
||||||
/// See ComicInfo.xml
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>XML-string</returns>
|
|
||||||
protected static string CreateComicInfo(Publication publication, Chapter chapter, Logger? logger)
|
|
||||||
{
|
|
||||||
logger?.WriteLine("Connector", $"Creating ComicInfo.Xml for {publication.sortName} {publication.internalId} {chapter.volumeNumber}-{chapter.chapterNumber}");
|
|
||||||
XElement comicInfo = new XElement("ComicInfo",
|
|
||||||
new XElement("Tags", string.Join(',',publication.tags)),
|
|
||||||
new XElement("LanguageISO", publication.originalLanguage),
|
|
||||||
new XElement("Title", chapter.name),
|
|
||||||
new XElement("Writer", publication.author),
|
|
||||||
new XElement("Volume", chapter.volumeNumber),
|
|
||||||
new XElement("Number", chapter.chapterNumber) //TODO check if this is correct at some point
|
|
||||||
);
|
|
||||||
return comicInfo.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks if a chapter-archive is already present
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>true if chapter is present</returns>
|
|
||||||
public bool ChapterIsDownloaded(Publication publication, Chapter chapter)
|
|
||||||
{
|
|
||||||
return File.Exists(CreateFullFilepath(publication, chapter));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates full file path of chapter-archive
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Filepath</returns>
|
|
||||||
protected string CreateFullFilepath(Publication publication, Chapter chapter)
|
|
||||||
{
|
|
||||||
return Path.Join(downloadLocation, publication.folderName, $"{chapter.fileName}.cbz");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Downloads Image from URL and saves it to the given path(incl. fileName)
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="imageUrl"></param>
|
|
||||||
/// <param name="fullPath"></param>
|
|
||||||
/// <param name="downloadClient">DownloadClient of the connector</param>
|
|
||||||
/// <param name="requestType">Requesttype for ratelimit</param>
|
|
||||||
protected static void DownloadImage(string imageUrl, string fullPath, DownloadClient downloadClient, byte requestType)
|
|
||||||
{
|
|
||||||
DownloadClient.RequestResult requestResult = downloadClient.MakeRequest(imageUrl, requestType);
|
|
||||||
byte[] buffer = new byte[requestResult.result.Length];
|
|
||||||
requestResult.result.ReadExactly(buffer, 0, buffer.Length);
|
|
||||||
File.WriteAllBytes(fullPath, buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Downloads all Images from URLs, Compresses to zip(cbz) and saves.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="imageUrls">List of URLs to download Images from</param>
|
|
||||||
/// <param name="saveArchiveFilePath">Full path to save archive to (without file ending .cbz)</param>
|
|
||||||
/// <param name="downloadClient">DownloadClient of the connector</param>
|
|
||||||
/// <param name="logger"></param>
|
|
||||||
/// <param name="comicInfoPath">Path of the generate Chapter ComicInfo.xml, if it was generated</param>
|
|
||||||
/// <param name="requestType">RequestType for RateLimits</param>
|
|
||||||
protected static void DownloadChapterImages(string[] imageUrls, string saveArchiveFilePath, DownloadClient downloadClient, byte requestType, Logger? logger, string? comicInfoPath = null)
|
|
||||||
{
|
|
||||||
logger?.WriteLine("Connector", $"Downloading Images for {saveArchiveFilePath}");
|
|
||||||
//Check if Publication Directory already exists
|
|
||||||
string directoryPath = Path.GetDirectoryName(saveArchiveFilePath)!;
|
|
||||||
if (!Directory.Exists(directoryPath))
|
|
||||||
Directory.CreateDirectory(directoryPath);
|
|
||||||
|
|
||||||
if (File.Exists(saveArchiveFilePath)) //Don't download twice.
|
|
||||||
return;
|
|
||||||
|
|
||||||
//Create a temporary folder to store images
|
|
||||||
string tempFolder = Directory.CreateTempSubdirectory().FullName;
|
|
||||||
|
|
||||||
int chapter = 0;
|
|
||||||
//Download all Images to temporary Folder
|
|
||||||
foreach (string imageUrl in imageUrls)
|
|
||||||
{
|
|
||||||
string[] split = imageUrl.Split('.');
|
|
||||||
string extension = split[^1];
|
|
||||||
logger?.WriteLine("Connector", $"Downloading Image {chapter + 1}/{imageUrls.Length}");
|
|
||||||
DownloadImage(imageUrl, Path.Join(tempFolder, $"{chapter++}.{extension}"), downloadClient, requestType);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(comicInfoPath is not null)
|
|
||||||
File.Copy(comicInfoPath, Path.Join(tempFolder, "ComicInfo.xml"));
|
|
||||||
|
|
||||||
logger?.WriteLine("Connector", $"Creating archive {saveArchiveFilePath}");
|
|
||||||
//ZIP-it and ship-it
|
|
||||||
ZipFile.CreateFromDirectory(tempFolder, saveArchiveFilePath);
|
|
||||||
Directory.Delete(tempFolder, true); //Cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
protected class DownloadClient
|
|
||||||
{
|
|
||||||
private static readonly HttpClient Client = new();
|
|
||||||
|
|
||||||
private readonly Dictionary<byte, DateTime> _lastExecutedRateLimit;
|
|
||||||
private readonly Dictionary<byte, TimeSpan> _rateLimit;
|
|
||||||
private Logger? logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a httpClient
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="delay">minimum delay between requests (to avoid spam)</param>
|
|
||||||
/// <param name="rateLimitRequestsPerMinute">Rate limits for requests. byte is RequestType, int maximum requests per minute for RequestType</param>
|
|
||||||
public DownloadClient(Dictionary<byte, int> rateLimitRequestsPerMinute, Logger? logger)
|
|
||||||
{
|
|
||||||
this.logger = logger;
|
|
||||||
_lastExecutedRateLimit = new();
|
|
||||||
_rateLimit = new();
|
|
||||||
foreach(KeyValuePair<byte, int> limit in rateLimitRequestsPerMinute)
|
|
||||||
_rateLimit.Add(limit.Key, TimeSpan.FromMinutes(1).Divide(limit.Value));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request Webpage
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="url"></param>
|
|
||||||
/// <param name="requestType">For RateLimits: Same Endpoints use same type</param>
|
|
||||||
/// <returns>RequestResult with StatusCode and Stream of received data</returns>
|
|
||||||
public RequestResult MakeRequest(string url, byte requestType)
|
|
||||||
{
|
|
||||||
if (_rateLimit.TryGetValue(requestType, out TimeSpan value))
|
|
||||||
_lastExecutedRateLimit.TryAdd(requestType, DateTime.Now.Subtract(value));
|
|
||||||
else
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), "RequestType not configured for rate-limit.");
|
|
||||||
return new RequestResult(HttpStatusCode.NotAcceptable, Stream.Null);
|
|
||||||
}
|
|
||||||
|
|
||||||
TimeSpan rateLimitTimeout = _rateLimit[requestType]
|
|
||||||
.Subtract(DateTime.Now.Subtract(_lastExecutedRateLimit[requestType]));
|
|
||||||
|
|
||||||
if(rateLimitTimeout > TimeSpan.Zero)
|
|
||||||
Thread.Sleep(rateLimitTimeout);
|
|
||||||
|
|
||||||
HttpResponseMessage? response = null;
|
|
||||||
while (response is null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
HttpRequestMessage requestMessage = new(HttpMethod.Get, url);
|
|
||||||
_lastExecutedRateLimit[requestType] = DateTime.Now;
|
|
||||||
response = Client.Send(requestMessage);
|
|
||||||
}
|
|
||||||
catch (HttpRequestException e)
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), e.Message);
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Waiting {_rateLimit[requestType] * 2}");
|
|
||||||
Thread.Sleep(_rateLimit[requestType] * 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Stream resultString = response.IsSuccessStatusCode ? response.Content.ReadAsStream() : Stream.Null;
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Request-Error {response.StatusCode}: {response.ReasonPhrase}");
|
|
||||||
return new RequestResult(response.StatusCode, resultString);
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RequestResult
|
|
||||||
{
|
|
||||||
public HttpStatusCode statusCode { get; }
|
|
||||||
public Stream result { get; }
|
|
||||||
|
|
||||||
public RequestResult(HttpStatusCode statusCode, Stream result)
|
|
||||||
{
|
|
||||||
this.statusCode = statusCode;
|
|
||||||
this.result = result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
106
Tranga/GlobalBase.cs
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
using Logging;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Tranga.LibraryConnectors;
|
||||||
|
using Tranga.NotificationConnectors;
|
||||||
|
|
||||||
|
namespace Tranga;
|
||||||
|
|
||||||
|
public abstract class GlobalBase
|
||||||
|
{
|
||||||
|
protected Logger? logger { get; init; }
|
||||||
|
protected TrangaSettings settings { get; init; }
|
||||||
|
protected HashSet<NotificationConnector> notificationConnectors { get; init; }
|
||||||
|
protected HashSet<LibraryConnector> libraryConnectors { get; init; }
|
||||||
|
protected List<Publication> cachedPublications { get; init; }
|
||||||
|
|
||||||
|
protected GlobalBase(GlobalBase clone)
|
||||||
|
{
|
||||||
|
this.logger = clone.logger;
|
||||||
|
this.settings = clone.settings;
|
||||||
|
this.notificationConnectors = clone.notificationConnectors;
|
||||||
|
this.libraryConnectors = clone.libraryConnectors;
|
||||||
|
this.cachedPublications = clone.cachedPublications;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected GlobalBase(Logger? logger, TrangaSettings settings)
|
||||||
|
{
|
||||||
|
this.logger = logger;
|
||||||
|
this.settings = settings;
|
||||||
|
this.notificationConnectors = settings.LoadNotificationConnectors();
|
||||||
|
this.libraryConnectors = settings.LoadLibraryConnectors();
|
||||||
|
this.cachedPublications = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void Log(string message)
|
||||||
|
{
|
||||||
|
logger?.WriteLine(this.GetType().Name, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void Log(string fStr, params object?[] replace)
|
||||||
|
{
|
||||||
|
Log(string.Format(fStr, replace));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void SendNotifications(string title, string text)
|
||||||
|
{
|
||||||
|
foreach (NotificationConnector nc in notificationConnectors)
|
||||||
|
nc.SendNotification(title, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void AddNotificationConnector(NotificationConnector notificationConnector)
|
||||||
|
{
|
||||||
|
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.NotificationManagerType notificationManagerType)
|
||||||
|
{
|
||||||
|
Log($"Removing {notificationManagerType}");
|
||||||
|
notificationConnectors.RemoveWhere(nc => nc.notificationManagerType == notificationManagerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
36
Tranga/Jobs/DownloadChapter.cs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Tranga.MangaConnectors;
|
||||||
|
|
||||||
|
namespace Tranga.Jobs;
|
||||||
|
|
||||||
|
public class DownloadChapter : Job
|
||||||
|
{
|
||||||
|
public Chapter chapter { get; init; }
|
||||||
|
|
||||||
|
public DownloadChapter(GlobalBase clone, MangaConnector connector, Chapter chapter) : base(clone, connector)
|
||||||
|
{
|
||||||
|
this.chapter = chapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override string GetId()
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(Encoding.ASCII.GetBytes(string.Concat(this.GetType().ToString(), chapter.parentPublication.internalId, chapter.chapterNumber)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"DownloadChapter {id} {chapter}";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override IEnumerable<Job> ExecuteReturnSubTasksInternal()
|
||||||
|
{
|
||||||
|
Task downloadTask = new(delegate
|
||||||
|
{
|
||||||
|
mangaConnector.DownloadChapter(chapter, this.progressToken);
|
||||||
|
UpdateLibraries();
|
||||||
|
SendNotifications("Chapter downloaded", $"{chapter.parentPublication.sortName} - {chapter.chapterNumber}");
|
||||||
|
});
|
||||||
|
downloadTask.Start();
|
||||||
|
return Array.Empty<Job>();
|
||||||
|
}
|
||||||
|
}
|
38
Tranga/Jobs/DownloadNewChapters.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Tranga.MangaConnectors;
|
||||||
|
|
||||||
|
namespace Tranga.Jobs;
|
||||||
|
|
||||||
|
public class DownloadNewChapters : Job
|
||||||
|
{
|
||||||
|
public Publication publication { get; init; }
|
||||||
|
|
||||||
|
public DownloadNewChapters(GlobalBase clone, MangaConnector connector, Publication publication, bool recurring = false, TimeSpan? recurrence = null) : base (clone, connector, recurring, recurrence)
|
||||||
|
{
|
||||||
|
this.publication = publication;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override string GetId()
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(Encoding.ASCII.GetBytes(string.Concat(this.GetType().ToString(), publication.internalId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"DownloadChapter {id} {publication}";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override IEnumerable<Job> ExecuteReturnSubTasksInternal()
|
||||||
|
{
|
||||||
|
Chapter[] chapters = mangaConnector.GetNewChapters(publication);
|
||||||
|
this.progressToken.increments = chapters.Length;
|
||||||
|
List<Job> subJobs = new();
|
||||||
|
foreach (Chapter chapter in chapters)
|
||||||
|
{
|
||||||
|
DownloadChapter downloadChapterJob = new(this, this.mangaConnector, chapter);
|
||||||
|
subJobs.Add(downloadChapterJob);
|
||||||
|
}
|
||||||
|
progressToken.Complete();
|
||||||
|
return subJobs;
|
||||||
|
}
|
||||||
|
}
|
76
Tranga/Jobs/Job.cs
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
using Tranga.MangaConnectors;
|
||||||
|
|
||||||
|
namespace Tranga.Jobs;
|
||||||
|
|
||||||
|
public abstract class Job : GlobalBase
|
||||||
|
{
|
||||||
|
public MangaConnector mangaConnector { get; init; }
|
||||||
|
public ProgressToken progressToken { get; private set; }
|
||||||
|
public bool recurring { get; init; }
|
||||||
|
public TimeSpan? recurrenceTime { get; set; }
|
||||||
|
public DateTime? lastExecution { get; private set; }
|
||||||
|
public DateTime nextExecution => NextExecution();
|
||||||
|
public string id => GetId();
|
||||||
|
|
||||||
|
public Job(GlobalBase clone, MangaConnector connector, bool recurring = false, TimeSpan? recurrenceTime = null) : base(clone)
|
||||||
|
{
|
||||||
|
this.mangaConnector = connector;
|
||||||
|
this.progressToken = new ProgressToken(0);
|
||||||
|
this.recurring = recurring;
|
||||||
|
if (recurring && recurrenceTime is null)
|
||||||
|
throw new ArgumentException("If recurrence is set to true, a recurrence time has to be provided.");
|
||||||
|
this.recurrenceTime = recurrenceTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract string GetId();
|
||||||
|
|
||||||
|
public Job(GlobalBase clone, MangaConnector connector, ProgressToken progressToken, bool recurring = false, TimeSpan? recurrenceTime = null) : base(clone)
|
||||||
|
{
|
||||||
|
this.mangaConnector = connector;
|
||||||
|
this.progressToken = progressToken;
|
||||||
|
this.recurring = recurring;
|
||||||
|
if (recurring && recurrenceTime is null)
|
||||||
|
throw new ArgumentException("If recurrence is set to true, a recurrence time has to be provided.");
|
||||||
|
this.recurrenceTime = recurrenceTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Job(GlobalBase clone, MangaConnector connector, int taskIncrements, bool recurring = false, TimeSpan? recurrenceTime = null) : base(clone)
|
||||||
|
{
|
||||||
|
this.mangaConnector = connector;
|
||||||
|
this.progressToken = new ProgressToken(taskIncrements);
|
||||||
|
this.recurring = recurring;
|
||||||
|
if (recurring && recurrenceTime is null)
|
||||||
|
throw new ArgumentException("If recurrence is set to true, a recurrence time has to be provided.");
|
||||||
|
this.recurrenceTime = recurrenceTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTime NextExecution()
|
||||||
|
{
|
||||||
|
if(recurring && recurrenceTime.HasValue && lastExecution.HasValue)
|
||||||
|
return lastExecution.Value.Add(recurrenceTime.Value);
|
||||||
|
if(recurring && recurrenceTime.HasValue && !lastExecution.HasValue)
|
||||||
|
return DateTime.Now;
|
||||||
|
return DateTime.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
this.progressToken = new ProgressToken(this.progressToken.increments);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Cancel()
|
||||||
|
{
|
||||||
|
this.progressToken.cancellationRequested = true;
|
||||||
|
this.progressToken.Complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<Job> ExecuteReturnSubTasks()
|
||||||
|
{
|
||||||
|
progressToken.Start();
|
||||||
|
IEnumerable<Job> ret = ExecuteReturnSubTasksInternal();
|
||||||
|
lastExecution = DateTime.Now;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract IEnumerable<Job> ExecuteReturnSubTasksInternal();
|
||||||
|
}
|
103
Tranga/Jobs/JobBoss.cs
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
using Tranga.MangaConnectors;
|
||||||
|
|
||||||
|
namespace Tranga.Jobs;
|
||||||
|
|
||||||
|
public class JobBoss : GlobalBase
|
||||||
|
{
|
||||||
|
public HashSet<Job> jobs { get; init; }
|
||||||
|
private Dictionary<MangaConnector, Queue<Job>> mangaConnectorJobQueue { get; init; }
|
||||||
|
|
||||||
|
public JobBoss(GlobalBase clone) : base(clone)
|
||||||
|
{
|
||||||
|
this.jobs = new();
|
||||||
|
this.mangaConnectorJobQueue = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddJob(Job job)
|
||||||
|
{
|
||||||
|
Log($"Added {job}");
|
||||||
|
this.jobs.Add(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveJob(Job job)
|
||||||
|
{
|
||||||
|
Log($"Removing {job}");
|
||||||
|
job.Cancel();
|
||||||
|
this.jobs.Remove(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveJobs(IEnumerable<Job> jobsToRemove)
|
||||||
|
{
|
||||||
|
Log($"Removing {jobsToRemove.Count()} jobs.");
|
||||||
|
foreach (Job job in jobsToRemove)
|
||||||
|
RemoveJob(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<Job> GetJobsLike(string? connectorName = null, string? internalId = null, string? chapterNumber = null)
|
||||||
|
{
|
||||||
|
IEnumerable<Job> ret = this.jobs;
|
||||||
|
if (connectorName is not null)
|
||||||
|
ret = ret.Where(job => job.mangaConnector.name == connectorName);
|
||||||
|
|
||||||
|
if (internalId is not null && chapterNumber is not null)
|
||||||
|
ret = ret.Where(jjob =>
|
||||||
|
{
|
||||||
|
if (jjob is not DownloadChapter job)
|
||||||
|
return false;
|
||||||
|
return job.chapter.parentPublication.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.publication.internalId == internalId;
|
||||||
|
});
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<Job> GetJobsLike(MangaConnector? mangaConnector = null, Publication? publication = null,
|
||||||
|
Chapter? chapter = null)
|
||||||
|
{
|
||||||
|
return GetJobsLike(mangaConnector?.name, publication?.internalId, chapter?.chapterNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool QueueContainsJob(Job job)
|
||||||
|
{
|
||||||
|
mangaConnectorJobQueue.TryAdd(job.mangaConnector, new Queue<Job>());
|
||||||
|
return mangaConnectorJobQueue[job.mangaConnector].Contains(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddJobToQueue(Job job)
|
||||||
|
{
|
||||||
|
Log($"Adding Job to Queue. {job}");
|
||||||
|
mangaConnectorJobQueue.TryAdd(job.mangaConnector, new Queue<Job>());
|
||||||
|
Queue<Job> connectorJobQueue = mangaConnectorJobQueue[job.mangaConnector];
|
||||||
|
if(!connectorJobQueue.Contains(job))
|
||||||
|
connectorJobQueue.Enqueue(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddJobsToQueue(IEnumerable<Job> jobs)
|
||||||
|
{
|
||||||
|
foreach(Job job in jobs)
|
||||||
|
AddJobToQueue(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CheckJobs()
|
||||||
|
{
|
||||||
|
foreach (Job job in jobs.Where(job => job.nextExecution < DateTime.Now && !QueueContainsJob(job)).OrderBy(job => job.nextExecution))
|
||||||
|
AddJobToQueue(job);
|
||||||
|
foreach (Queue<Job> jobQueue in mangaConnectorJobQueue.Values)
|
||||||
|
{
|
||||||
|
Job queueHead = jobQueue.Peek();
|
||||||
|
if (queueHead.progressToken.state == ProgressToken.State.Complete)
|
||||||
|
{
|
||||||
|
if(queueHead.recurring)
|
||||||
|
queueHead.Reset();
|
||||||
|
jobQueue.Dequeue();
|
||||||
|
AddJobsToQueue(jobQueue.Peek().ExecuteReturnSubTasks());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
44
Tranga/Jobs/ProgressToken.cs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
namespace Tranga.Jobs;
|
||||||
|
|
||||||
|
public class ProgressToken
|
||||||
|
{
|
||||||
|
public bool cancellationRequested { get; set; }
|
||||||
|
public int increments { get; set; }
|
||||||
|
public int incrementsCompleted { get; set; }
|
||||||
|
public float progress => GetProgress();
|
||||||
|
|
||||||
|
public enum State { Running, Complete, Standby }
|
||||||
|
public State state { get; private set; }
|
||||||
|
|
||||||
|
public ProgressToken(int increments)
|
||||||
|
{
|
||||||
|
this.cancellationRequested = false;
|
||||||
|
this.increments = increments;
|
||||||
|
this.incrementsCompleted = 0;
|
||||||
|
this.state = State.Standby;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float GetProgress()
|
||||||
|
{
|
||||||
|
if(increments > 0 && incrementsCompleted > 0)
|
||||||
|
return (float)incrementsCompleted / (float)increments;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Increment()
|
||||||
|
{
|
||||||
|
this.incrementsCompleted++;
|
||||||
|
if (incrementsCompleted > increments)
|
||||||
|
state = State.Complete;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
state = State.Running;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Complete()
|
||||||
|
{
|
||||||
|
state = State.Complete;
|
||||||
|
}
|
||||||
|
}
|
148
Tranga/Komga.cs
@ -1,148 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
using Logging;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
|
||||||
|
|
||||||
namespace Tranga;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provides connectivity to Komga-API
|
|
||||||
/// Can fetch and update libraries
|
|
||||||
/// </summary>
|
|
||||||
public class Komga
|
|
||||||
{
|
|
||||||
public string baseUrl { get; }
|
|
||||||
public string auth { get; } //Base64 encoded, if you use your password everywhere, you have problems
|
|
||||||
|
|
||||||
private Logger? logger;
|
|
||||||
|
|
||||||
/// <param name="baseUrl">Base-URL of Komga instance, no trailing slashes(/)</param>
|
|
||||||
/// <param name="username">Komga Username</param>
|
|
||||||
/// <param name="password">Komga password, will be base64 encoded. yea</param>
|
|
||||||
public Komga(string baseUrl, string username, string password, Logger? logger)
|
|
||||||
{
|
|
||||||
this.baseUrl = baseUrl;
|
|
||||||
this.auth = Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($"{username}:{password}"));
|
|
||||||
this.logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <param name="baseUrl">Base-URL of Komga instance, no trailing slashes(/)</param>
|
|
||||||
/// <param name="auth">Base64 string of username and password (username):(password)</param>
|
|
||||||
[JsonConstructor]
|
|
||||||
public Komga(string baseUrl, string auth, Logger? logger)
|
|
||||||
{
|
|
||||||
this.baseUrl = baseUrl;
|
|
||||||
this.auth = auth;
|
|
||||||
this.logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fetches all libraries available to the user
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Array of KomgaLibraries</returns>
|
|
||||||
public KomgaLibrary[] GetLibraries()
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Getting Libraries");
|
|
||||||
Stream data = NetClient.MakeRequest($"{baseUrl}/api/v1/libraries", auth);
|
|
||||||
if (data == Stream.Null)
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"No libraries returned");
|
|
||||||
return Array.Empty<KomgaLibrary>();
|
|
||||||
}
|
|
||||||
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
|
|
||||||
if (result is null)
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"No libraries returned");
|
|
||||||
return Array.Empty<KomgaLibrary>();
|
|
||||||
}
|
|
||||||
|
|
||||||
HashSet<KomgaLibrary> ret = new();
|
|
||||||
|
|
||||||
foreach (JsonNode? jsonNode in result)
|
|
||||||
{
|
|
||||||
var jObject = (JsonObject?)jsonNode;
|
|
||||||
string libraryId = jObject!["id"]!.GetValue<string>();
|
|
||||||
string libraryName = jObject!["name"]!.GetValue<string>();
|
|
||||||
ret.Add(new KomgaLibrary(libraryId, libraryName));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates library with given id
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="libraryId">Id of the Komga-Library</param>
|
|
||||||
/// <returns>true if successful</returns>
|
|
||||||
public bool UpdateLibrary(string libraryId)
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Updating Libraries");
|
|
||||||
return NetClient.MakePost($"{baseUrl}/api/v1/libraries/{libraryId}/scan", auth);
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct KomgaLibrary
|
|
||||||
{
|
|
||||||
public string id { get; }
|
|
||||||
public string name { get; }
|
|
||||||
|
|
||||||
public KomgaLibrary(string id, string name)
|
|
||||||
{
|
|
||||||
this.id = id;
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class NetClient
|
|
||||||
{
|
|
||||||
public static Stream MakeRequest(string url, string auth)
|
|
||||||
{
|
|
||||||
HttpClientHandler clientHandler = new ();
|
|
||||||
clientHandler.ServerCertificateCustomValidationCallback = (message, cert, chain, sslPolicyErrors) => true;
|
|
||||||
HttpClient client = new(clientHandler);
|
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", auth);
|
|
||||||
|
|
||||||
HttpRequestMessage requestMessage = new ()
|
|
||||||
{
|
|
||||||
Method = HttpMethod.Get,
|
|
||||||
RequestUri = new Uri(url)
|
|
||||||
};
|
|
||||||
HttpResponseMessage response = client.Send(requestMessage);
|
|
||||||
Stream ret;
|
|
||||||
if (response.StatusCode is HttpStatusCode.Unauthorized)
|
|
||||||
{
|
|
||||||
ret = MakeRequest(response.RequestMessage!.RequestUri!.AbsoluteUri, auth);
|
|
||||||
}else
|
|
||||||
return response.IsSuccessStatusCode ? response.Content.ReadAsStream() : Stream.Null;
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool MakePost(string url, string auth)
|
|
||||||
{
|
|
||||||
HttpClientHandler clientHandler = new HttpClientHandler();
|
|
||||||
clientHandler.ServerCertificateCustomValidationCallback = (message, cert, chain, sslPolicyErrors) => true;
|
|
||||||
HttpClient client = new(clientHandler)
|
|
||||||
{
|
|
||||||
DefaultRequestHeaders =
|
|
||||||
{
|
|
||||||
{ "Accept", "application/json" },
|
|
||||||
{ "Authorization", new AuthenticationHeaderValue("Basic", auth).ToString() }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
HttpRequestMessage requestMessage = new HttpRequestMessage
|
|
||||||
{
|
|
||||||
Method = HttpMethod.Post,
|
|
||||||
RequestUri = new Uri(url)
|
|
||||||
};
|
|
||||||
HttpResponseMessage response = client.Send(requestMessage);
|
|
||||||
bool ret;
|
|
||||||
if (response.StatusCode is HttpStatusCode.Unauthorized)
|
|
||||||
{
|
|
||||||
ret = MakePost(response.RequestMessage!.RequestUri!.AbsoluteUri, auth);
|
|
||||||
}else
|
|
||||||
return response.IsSuccessStatusCode;
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
100
Tranga/LibraryConnectors/Kavita.cs
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||||
|
|
||||||
|
namespace Tranga.LibraryConnectors;
|
||||||
|
|
||||||
|
public class Kavita : LibraryConnector
|
||||||
|
{
|
||||||
|
|
||||||
|
public Kavita(GlobalBase clone, string baseUrl, string username, string password) :
|
||||||
|
base(clone, baseUrl, GetToken(baseUrl, username, password), LibraryType.Kavita)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConstructor]
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
DefaultRequestHeaders =
|
||||||
|
{
|
||||||
|
{ "Accept", "application/json" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
HttpRequestMessage requestMessage = new ()
|
||||||
|
{
|
||||||
|
Method = HttpMethod.Post,
|
||||||
|
RequestUri = new Uri($"{baseUrl}/api/Account/login"),
|
||||||
|
Content = new StringContent($"{{\"username\":\"{username}\",\"password\":\"{password}\"}}", System.Text.Encoding.UTF8, "application/json")
|
||||||
|
};
|
||||||
|
|
||||||
|
HttpResponseMessage response = client.Send(requestMessage);
|
||||||
|
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(response.Content.ReadAsStream());
|
||||||
|
if (result is not null)
|
||||||
|
return result["token"]!.GetValue<string>();
|
||||||
|
else throw new Exception("Did not receive token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void UpdateLibrary()
|
||||||
|
{
|
||||||
|
Log("Updating libraries.");
|
||||||
|
foreach (KavitaLibrary lib in GetLibraries())
|
||||||
|
NetClient.MakePost($"{baseUrl}/api/Library/scan?libraryId={lib.id}", "Bearer", auth, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches all libraries available to the user
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Array of KavitaLibrary</returns>
|
||||||
|
private IEnumerable<KavitaLibrary> GetLibraries()
|
||||||
|
{
|
||||||
|
Log("Getting libraries.");
|
||||||
|
Stream data = NetClient.MakeRequest($"{baseUrl}/api/Library", "Bearer", auth, logger);
|
||||||
|
if (data == Stream.Null)
|
||||||
|
{
|
||||||
|
Log("No libraries returned");
|
||||||
|
return Array.Empty<KavitaLibrary>();
|
||||||
|
}
|
||||||
|
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
Log("No libraries returned");
|
||||||
|
return Array.Empty<KavitaLibrary>();
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<KavitaLibrary> ret = new();
|
||||||
|
|
||||||
|
foreach (JsonNode? jsonNode in result)
|
||||||
|
{
|
||||||
|
var jObject = (JsonObject?)jsonNode;
|
||||||
|
int libraryId = jObject!["id"]!.GetValue<int>();
|
||||||
|
string libraryName = jObject["name"]!.GetValue<string>();
|
||||||
|
ret.Add(new KavitaLibrary(libraryId, libraryName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct KavitaLibrary
|
||||||
|
{
|
||||||
|
public int id { get; }
|
||||||
|
// ReSharper disable once UnusedAutoPropertyAccessor.Local
|
||||||
|
public string name { get; }
|
||||||
|
|
||||||
|
public KavitaLibrary(int id, string name)
|
||||||
|
{
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
80
Tranga/LibraryConnectors/Komga.cs
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||||
|
|
||||||
|
namespace Tranga.LibraryConnectors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides connectivity to Komga-API
|
||||||
|
/// Can fetch and update libraries
|
||||||
|
/// </summary>
|
||||||
|
public class Komga : LibraryConnector
|
||||||
|
{
|
||||||
|
public Komga(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(GlobalBase clone, string baseUrl, string auth) : base(clone, baseUrl, auth, LibraryType.Komga)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"Komga {baseUrl}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void UpdateLibrary()
|
||||||
|
{
|
||||||
|
Log("Updating libraries.");
|
||||||
|
foreach (KomgaLibrary lib in GetLibraries())
|
||||||
|
NetClient.MakePost($"{baseUrl}/api/v1/libraries/{lib.id}/scan", "Basic", auth, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches all libraries available to the user
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Array of KomgaLibraries</returns>
|
||||||
|
private IEnumerable<KomgaLibrary> GetLibraries()
|
||||||
|
{
|
||||||
|
Log("Getting Libraries");
|
||||||
|
Stream data = NetClient.MakeRequest($"{baseUrl}/api/v1/libraries", "Basic", auth, logger);
|
||||||
|
if (data == Stream.Null)
|
||||||
|
{
|
||||||
|
Log("No libraries returned");
|
||||||
|
return Array.Empty<KomgaLibrary>();
|
||||||
|
}
|
||||||
|
JsonArray? result = JsonSerializer.Deserialize<JsonArray>(data);
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
Log("No libraries returned");
|
||||||
|
return Array.Empty<KomgaLibrary>();
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<KomgaLibrary> ret = new();
|
||||||
|
|
||||||
|
foreach (JsonNode? jsonNode in result)
|
||||||
|
{
|
||||||
|
var jObject = (JsonObject?)jsonNode;
|
||||||
|
string libraryId = jObject!["id"]!.GetValue<string>();
|
||||||
|
string libraryName = jObject["name"]!.GetValue<string>();
|
||||||
|
ret.Add(new KomgaLibrary(libraryId, libraryName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct KomgaLibrary
|
||||||
|
{
|
||||||
|
public string id { get; }
|
||||||
|
// ReSharper disable once UnusedAutoPropertyAccessor.Local
|
||||||
|
public string name { get; }
|
||||||
|
|
||||||
|
public KomgaLibrary(string id, string name)
|
||||||
|
{
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
78
Tranga/LibraryConnectors/LibraryConnector.cs
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using Logging;
|
||||||
|
|
||||||
|
namespace Tranga.LibraryConnectors;
|
||||||
|
|
||||||
|
public abstract class LibraryConnector : GlobalBase
|
||||||
|
{
|
||||||
|
public enum LibraryType : byte
|
||||||
|
{
|
||||||
|
Komga = 0,
|
||||||
|
Kavita = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReSharper disable once UnusedAutoPropertyAccessor.Global
|
||||||
|
public LibraryType libraryType { get; }
|
||||||
|
public string baseUrl { get; }
|
||||||
|
// ReSharper disable once MemberCanBeProtected.Global
|
||||||
|
public string auth { get; } //Base64 encoded, if you use your password everywhere, you have problems
|
||||||
|
|
||||||
|
protected LibraryConnector(GlobalBase clone, string baseUrl, string auth, LibraryType libraryType) : base(clone)
|
||||||
|
{
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
this.auth = auth;
|
||||||
|
this.libraryType = libraryType;
|
||||||
|
}
|
||||||
|
public abstract void UpdateLibrary();
|
||||||
|
|
||||||
|
protected static class NetClient
|
||||||
|
{
|
||||||
|
public static Stream MakeRequest(string url, string authScheme, string auth, Logger? logger)
|
||||||
|
{
|
||||||
|
HttpClient client = new();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(authScheme, auth);
|
||||||
|
|
||||||
|
HttpRequestMessage requestMessage = new ()
|
||||||
|
{
|
||||||
|
Method = HttpMethod.Get,
|
||||||
|
RequestUri = new Uri(url)
|
||||||
|
};
|
||||||
|
HttpResponseMessage response = client.Send(requestMessage);
|
||||||
|
logger?.WriteLine("LibraryManager.NetClient", $"GET {url} -> {(int)response.StatusCode}: {response.ReasonPhrase}");
|
||||||
|
|
||||||
|
if(response.StatusCode is HttpStatusCode.Unauthorized && response.RequestMessage!.RequestUri!.AbsoluteUri != url)
|
||||||
|
return MakeRequest(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth, logger);
|
||||||
|
else if (response.IsSuccessStatusCode)
|
||||||
|
return response.Content.ReadAsStream();
|
||||||
|
else
|
||||||
|
return Stream.Null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool MakePost(string url, string authScheme, string auth, Logger? logger)
|
||||||
|
{
|
||||||
|
HttpClient client = new()
|
||||||
|
{
|
||||||
|
DefaultRequestHeaders =
|
||||||
|
{
|
||||||
|
{ "Accept", "application/json" },
|
||||||
|
{ "Authorization", new AuthenticationHeaderValue(authScheme, auth).ToString() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
HttpRequestMessage requestMessage = new ()
|
||||||
|
{
|
||||||
|
Method = HttpMethod.Post,
|
||||||
|
RequestUri = new Uri(url)
|
||||||
|
};
|
||||||
|
HttpResponseMessage response = client.Send(requestMessage);
|
||||||
|
logger?.WriteLine("LibraryManager.NetClient", $"POST {url} -> {(int)response.StatusCode}: {response.ReasonPhrase}");
|
||||||
|
|
||||||
|
if(response.StatusCode is HttpStatusCode.Unauthorized && response.RequestMessage!.RequestUri!.AbsoluteUri != url)
|
||||||
|
return MakePost(response.RequestMessage!.RequestUri!.AbsoluteUri, authScheme, auth, logger);
|
||||||
|
else if (response.IsSuccessStatusCode)
|
||||||
|
return true;
|
||||||
|
else
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
Tranga/LibraryConnectors/LibraryManagerJsonConverter.cs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Tranga.LibraryConnectors;
|
||||||
|
|
||||||
|
public class LibraryManagerJsonConverter : JsonConverter
|
||||||
|
{
|
||||||
|
public override bool CanConvert(Type objectType)
|
||||||
|
{
|
||||||
|
return (objectType == typeof(LibraryConnector));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
|
||||||
|
{
|
||||||
|
JObject jo = JObject.Load(reader);
|
||||||
|
if (jo["libraryType"]!.Value<Int64>() == (Int64)LibraryConnector.LibraryType.Komga)
|
||||||
|
return jo.ToObject<Komga>(serializer)!;
|
||||||
|
|
||||||
|
if (jo["libraryType"]!.Value<Int64>() == (Int64)LibraryConnector.LibraryType.Kavita)
|
||||||
|
return jo.ToObject<Kavita>(serializer)!;
|
||||||
|
|
||||||
|
throw new Exception();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanWrite => false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Don't call this
|
||||||
|
/// </summary>
|
||||||
|
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||||
|
{
|
||||||
|
throw new Exception("Dont call this");
|
||||||
|
}
|
||||||
|
}
|
107
Tranga/MangaConnectors/DownloadClient.cs
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace Tranga.MangaConnectors;
|
||||||
|
|
||||||
|
internal class DownloadClient : GlobalBase
|
||||||
|
{
|
||||||
|
private static readonly HttpClient Client = new()
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(60),
|
||||||
|
DefaultRequestHeaders =
|
||||||
|
{
|
||||||
|
UserAgent =
|
||||||
|
{
|
||||||
|
new ProductInfoHeaderValue("Tranga", "0.1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly Dictionary<byte, DateTime> _lastExecutedRateLimit;
|
||||||
|
private readonly Dictionary<byte, TimeSpan> _rateLimit;
|
||||||
|
|
||||||
|
public DownloadClient(GlobalBase clone, Dictionary<byte, int> rateLimitRequestsPerMinute) : base(clone)
|
||||||
|
{
|
||||||
|
_lastExecutedRateLimit = new();
|
||||||
|
_rateLimit = new();
|
||||||
|
foreach(KeyValuePair<byte, int> limit in rateLimitRequestsPerMinute)
|
||||||
|
_rateLimit.Add(limit.Key, TimeSpan.FromMinutes(1).Divide(limit.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request Webpage
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="url"></param>
|
||||||
|
/// <param name="requestType">For RateLimits: Same Endpoints use same type</param>
|
||||||
|
/// <param name="referrer">Used in http request header</param>
|
||||||
|
/// <returns>RequestResult with StatusCode and Stream of received data</returns>
|
||||||
|
public RequestResult MakeRequest(string url, byte requestType, string? referrer = null)
|
||||||
|
{
|
||||||
|
if (_rateLimit.TryGetValue(requestType, out TimeSpan value))
|
||||||
|
_lastExecutedRateLimit.TryAdd(requestType, DateTime.Now.Subtract(value));
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log("RequestType not configured for rate-limit.");
|
||||||
|
return new RequestResult(HttpStatusCode.NotAcceptable, Stream.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan rateLimitTimeout = _rateLimit[requestType]
|
||||||
|
.Subtract(DateTime.Now.Subtract(_lastExecutedRateLimit[requestType]));
|
||||||
|
|
||||||
|
if(rateLimitTimeout > TimeSpan.Zero)
|
||||||
|
Thread.Sleep(rateLimitTimeout);
|
||||||
|
|
||||||
|
HttpResponseMessage? response = null;
|
||||||
|
while (response is null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HttpRequestMessage requestMessage = new(HttpMethod.Get, url);
|
||||||
|
if(referrer is not null)
|
||||||
|
requestMessage.Headers.Referrer = new Uri(referrer);
|
||||||
|
_lastExecutedRateLimit[requestType] = DateTime.Now;
|
||||||
|
Log($"Requesting {requestType} {url}");
|
||||||
|
response = Client.Send(requestMessage);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException e)
|
||||||
|
{
|
||||||
|
Log("Exception:\n\t{0}\n\tWaiting {1} before retrying.", e.Message, _rateLimit[requestType] * 2);
|
||||||
|
Thread.Sleep(_rateLimit[requestType] * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
Log($"Request-Error {response.StatusCode}: {response.ReasonPhrase}");
|
||||||
|
return new RequestResult(response.StatusCode, Stream.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request has been redirected to another page. For example, it redirects directly to the results when there is only 1 result
|
||||||
|
if(response.RequestMessage is not null && response.RequestMessage.RequestUri is not null)
|
||||||
|
{
|
||||||
|
return new RequestResult(response.StatusCode, response.Content.ReadAsStream(), true, response.RequestMessage.RequestUri.AbsoluteUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RequestResult(response.StatusCode, response.Content.ReadAsStream());
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RequestResult
|
||||||
|
{
|
||||||
|
public HttpStatusCode statusCode { get; }
|
||||||
|
public Stream result { get; }
|
||||||
|
public bool hasBeenRedirected { get; }
|
||||||
|
public string? redirectedToUrl { get; }
|
||||||
|
|
||||||
|
public RequestResult(HttpStatusCode statusCode, Stream result)
|
||||||
|
{
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.result = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RequestResult(HttpStatusCode statusCode, Stream result, bool hasBeenRedirected, string redirectedTo)
|
||||||
|
: this(statusCode, result)
|
||||||
|
{
|
||||||
|
this.hasBeenRedirected = hasBeenRedirected;
|
||||||
|
redirectedToUrl = redirectedTo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
249
Tranga/MangaConnectors/MangaConnector.cs
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Net;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Tranga.Jobs;
|
||||||
|
using static System.IO.UnixFileMode;
|
||||||
|
|
||||||
|
namespace Tranga.MangaConnectors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base-Class for all Connectors
|
||||||
|
/// Provides some methods to be used by all Connectors, as well as a DownloadClient
|
||||||
|
/// </summary>
|
||||||
|
public abstract class MangaConnector : GlobalBase
|
||||||
|
{
|
||||||
|
internal DownloadClient downloadClient { get; init; } = null!;
|
||||||
|
|
||||||
|
protected MangaConnector(GlobalBase clone) : base(clone)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(settings.coverImageCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract string name { get; } //Name of the Connector (e.g. Website)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all Publications with the given string.
|
||||||
|
/// If the string is empty or null, returns all Publication of the Connector
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="publicationTitle">Search-Query</param>
|
||||||
|
/// <returns>Publications matching the query</returns>
|
||||||
|
public abstract Publication[] GetPublications(string publicationTitle = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all Chapters of the publication in the provided language.
|
||||||
|
/// If the language is empty or null, returns all Chapters in all Languages.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="publication">Publication to get Chapters for</param>
|
||||||
|
/// <param name="language">Language of the Chapters</param>
|
||||||
|
/// <returns>Array of Chapters matching Publication and Language</returns>
|
||||||
|
public abstract Chapter[] GetChapters(Publication publication, string language="en");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the available Chapters of a Publication
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="publication">Publication to check</param>
|
||||||
|
/// <param name="language">Language to receive chapters for</param>
|
||||||
|
/// <returns>List of Chapters that were previously not in collection</returns>
|
||||||
|
public Chapter[] GetNewChapters(Publication publication, string language = "en")
|
||||||
|
{
|
||||||
|
Log($"Getting new Chapters for {publication}");
|
||||||
|
Chapter[] newChapters = this.GetChapters(publication, language);
|
||||||
|
NumberFormatInfo decimalPoint = new (){ NumberDecimalSeparator = "." };
|
||||||
|
Log($"Checking for duplicates {publication}");
|
||||||
|
List<Chapter> newChaptersList = newChapters.Where(nChapter =>
|
||||||
|
float.Parse(nChapter.chapterNumber, decimalPoint) > publication.ignoreChaptersBelow &&
|
||||||
|
!nChapter.CheckChapterIsDownloaded(settings.downloadLocation)).ToList();
|
||||||
|
Log($"{newChaptersList.Count} new chapters. {publication}");
|
||||||
|
|
||||||
|
return newChaptersList.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Chapter[] SelectChapters(Publication publication, string searchTerm, string? language = null)
|
||||||
|
{
|
||||||
|
Chapter[] availableChapters = this.GetChapters(publication, language??"en");
|
||||||
|
Regex volumeRegex = new ("((v(ol)*(olume)*){1} *([0-9]+(-[0-9]+)?){1})", RegexOptions.IgnoreCase);
|
||||||
|
Regex chapterRegex = new ("((c(h)*(hapter)*){1} *([0-9]+(-[0-9]+)?){1})", RegexOptions.IgnoreCase);
|
||||||
|
Regex singleResultRegex = new("([0-9]+)", RegexOptions.IgnoreCase);
|
||||||
|
Regex rangeResultRegex = new("([0-9]+(-[0-9]+))", RegexOptions.IgnoreCase);
|
||||||
|
Regex allRegex = new("a(ll)?", RegexOptions.IgnoreCase);
|
||||||
|
if (volumeRegex.IsMatch(searchTerm) && chapterRegex.IsMatch(searchTerm))
|
||||||
|
{
|
||||||
|
string volume = singleResultRegex.Match(volumeRegex.Match(searchTerm).Value).Value;
|
||||||
|
string chapter = singleResultRegex.Match(chapterRegex.Match(searchTerm).Value).Value;
|
||||||
|
return availableChapters.Where(aCh => aCh.volumeNumber is not null &&
|
||||||
|
aCh.volumeNumber.Equals(volume, StringComparison.InvariantCultureIgnoreCase) &&
|
||||||
|
aCh.chapterNumber.Equals(chapter, StringComparison.InvariantCultureIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
else if (volumeRegex.IsMatch(searchTerm))
|
||||||
|
{
|
||||||
|
string volume = volumeRegex.Match(searchTerm).Value;
|
||||||
|
if (rangeResultRegex.IsMatch(volume))
|
||||||
|
{
|
||||||
|
string range = rangeResultRegex.Match(volume).Value;
|
||||||
|
int start = Convert.ToInt32(range.Split('-')[0]);
|
||||||
|
int end = Convert.ToInt32(range.Split('-')[1]);
|
||||||
|
return availableChapters.Where(aCh => aCh.volumeNumber is not null &&
|
||||||
|
Convert.ToInt32(aCh.volumeNumber) >= start &&
|
||||||
|
Convert.ToInt32(aCh.volumeNumber) <= end).ToArray();
|
||||||
|
}
|
||||||
|
else if (singleResultRegex.IsMatch(volume))
|
||||||
|
{
|
||||||
|
string volumeNumber = singleResultRegex.Match(volume).Value;
|
||||||
|
return availableChapters.Where(aCh =>
|
||||||
|
aCh.volumeNumber is not null &&
|
||||||
|
aCh.volumeNumber.Equals(volumeNumber, StringComparison.InvariantCultureIgnoreCase)).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
else if (chapterRegex.IsMatch(searchTerm))
|
||||||
|
{
|
||||||
|
string chapter = chapterRegex.Match(searchTerm).Value;
|
||||||
|
if (rangeResultRegex.IsMatch(chapter))
|
||||||
|
{
|
||||||
|
string range = rangeResultRegex.Match(chapter).Value;
|
||||||
|
int start = Convert.ToInt32(range.Split('-')[0]);
|
||||||
|
int end = Convert.ToInt32(range.Split('-')[1]);
|
||||||
|
return availableChapters.Where(aCh => Convert.ToInt32(aCh.chapterNumber) >= start &&
|
||||||
|
Convert.ToInt32(aCh.chapterNumber) <= end).ToArray();
|
||||||
|
}
|
||||||
|
else if (singleResultRegex.IsMatch(chapter))
|
||||||
|
{
|
||||||
|
string chapterNumber = singleResultRegex.Match(chapter).Value;
|
||||||
|
return availableChapters.Where(aCh =>
|
||||||
|
aCh.chapterNumber.Equals(chapterNumber, StringComparison.InvariantCultureIgnoreCase)).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (rangeResultRegex.IsMatch(searchTerm))
|
||||||
|
{
|
||||||
|
int start = Convert.ToInt32(searchTerm.Split('-')[0]);
|
||||||
|
int end = Convert.ToInt32(searchTerm.Split('-')[1]);
|
||||||
|
return availableChapters[start..(end + 1)];
|
||||||
|
}
|
||||||
|
else if(singleResultRegex.IsMatch(searchTerm))
|
||||||
|
return new [] { availableChapters[Convert.ToInt32(searchTerm)] };
|
||||||
|
else if (allRegex.IsMatch(searchTerm))
|
||||||
|
return availableChapters;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.Empty<Chapter>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies the already downloaded cover from cache to downloadLocation
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="publication">Publication to retrieve Cover for</param>
|
||||||
|
public void CopyCoverFromCacheToDownloadLocation(Publication publication)
|
||||||
|
{
|
||||||
|
Log($"Copy cover {publication}");
|
||||||
|
//Check if Publication already has a Folder and cover
|
||||||
|
string publicationFolder = publication.CreatePublicationFolder(settings.downloadLocation);
|
||||||
|
DirectoryInfo dirInfo = new (publicationFolder);
|
||||||
|
if (dirInfo.EnumerateFiles().Any(info => info.Name.Contains("cover", StringComparison.InvariantCultureIgnoreCase)))
|
||||||
|
{
|
||||||
|
Log($"Cover exists {publication}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string fileInCache = Path.Join(settings.coverImageCache, publication.coverFileNameInCache);
|
||||||
|
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" );
|
||||||
|
Log($"Cloning cover {fileInCache} -> {newFilePath}");
|
||||||
|
File.Copy(fileInCache, newFilePath, true);
|
||||||
|
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | OtherRead | OtherWrite | UserRead | UserWrite);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads Image from URL and saves it to the given path(incl. fileName)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="imageUrl"></param>
|
||||||
|
/// <param name="fullPath"></param>
|
||||||
|
/// <param name="requestType">RequestType for Rate-Limit</param>
|
||||||
|
/// <param name="referrer">referrer used in html request header</param>
|
||||||
|
private HttpStatusCode DownloadImage(string imageUrl, string fullPath, byte requestType, string? referrer = null)
|
||||||
|
{
|
||||||
|
DownloadClient.RequestResult requestResult = downloadClient.MakeRequest(imageUrl, requestType, referrer);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300 || requestResult.result == Stream.Null)
|
||||||
|
return requestResult.statusCode;
|
||||||
|
byte[] buffer = new byte[requestResult.result.Length];
|
||||||
|
requestResult.result.ReadExactly(buffer, 0, buffer.Length);
|
||||||
|
File.WriteAllBytes(fullPath, buffer);
|
||||||
|
return requestResult.statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected HttpStatusCode DownloadChapterImages(string[] imageUrls, string saveArchiveFilePath, byte requestType, string? comicInfoPath = null, string? referrer = null, ProgressToken? progressToken = null)
|
||||||
|
{
|
||||||
|
if (progressToken?.cancellationRequested ?? false)
|
||||||
|
return HttpStatusCode.RequestTimeout;
|
||||||
|
Log($"Downloading Images for {saveArchiveFilePath}");
|
||||||
|
if(progressToken is not null)
|
||||||
|
progressToken.increments = imageUrls.Length;
|
||||||
|
//Check if Publication Directory already exists
|
||||||
|
string directoryPath = Path.GetDirectoryName(saveArchiveFilePath)!;
|
||||||
|
if (!Directory.Exists(directoryPath))
|
||||||
|
Directory.CreateDirectory(directoryPath);
|
||||||
|
|
||||||
|
if (File.Exists(saveArchiveFilePath)) //Don't download twice.
|
||||||
|
return HttpStatusCode.OK;
|
||||||
|
|
||||||
|
//Create a temporary folder to store images
|
||||||
|
string tempFolder = Directory.CreateTempSubdirectory().FullName;
|
||||||
|
|
||||||
|
int chapter = 0;
|
||||||
|
//Download all Images to temporary Folder
|
||||||
|
foreach (string imageUrl in imageUrls)
|
||||||
|
{
|
||||||
|
string[] split = imageUrl.Split('.');
|
||||||
|
string extension = split[^1];
|
||||||
|
Log($"Downloading image {chapter + 1:000}/{imageUrls.Length:000}"); //TODO
|
||||||
|
HttpStatusCode status = DownloadImage(imageUrl, Path.Join(tempFolder, $"{chapter++}.{extension}"), requestType, referrer);
|
||||||
|
if ((int)status < 200 || (int)status >= 300)
|
||||||
|
{
|
||||||
|
progressToken?.Complete();
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
if (progressToken?.cancellationRequested ?? false)
|
||||||
|
{
|
||||||
|
progressToken?.Complete();
|
||||||
|
return HttpStatusCode.RequestTimeout;
|
||||||
|
}
|
||||||
|
progressToken?.Increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(comicInfoPath is not null)
|
||||||
|
File.Copy(comicInfoPath, Path.Join(tempFolder, "ComicInfo.xml"));
|
||||||
|
|
||||||
|
Log($"Creating archive {saveArchiveFilePath}");
|
||||||
|
//ZIP-it and ship-it
|
||||||
|
ZipFile.CreateFromDirectory(tempFolder, saveArchiveFilePath);
|
||||||
|
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
File.SetUnixFileMode(saveArchiveFilePath, GroupRead | GroupWrite | OtherRead | OtherWrite | UserRead | UserWrite);
|
||||||
|
Directory.Delete(tempFolder, true); //Cleanup
|
||||||
|
|
||||||
|
progressToken?.Complete();
|
||||||
|
return HttpStatusCode.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected string SaveCoverImageToCache(string url, byte requestType)
|
||||||
|
{
|
||||||
|
string[] split = url.Split('/');
|
||||||
|
string filename = split[^1];
|
||||||
|
string saveImagePath = Path.Join(settings.coverImageCache, filename);
|
||||||
|
|
||||||
|
if (File.Exists(saveImagePath))
|
||||||
|
return filename;
|
||||||
|
|
||||||
|
DownloadClient.RequestResult coverResult = downloadClient.MakeRequest(url, requestType);
|
||||||
|
using MemoryStream ms = new();
|
||||||
|
coverResult.result.CopyTo(ms);
|
||||||
|
File.WriteAllBytes(saveImagePath, ms.ToArray());
|
||||||
|
Log($"Saving cover to {saveImagePath}");
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
}
|
@ -2,10 +2,10 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using Logging;
|
using Tranga.Jobs;
|
||||||
|
|
||||||
namespace Tranga.Connectors;
|
namespace Tranga.MangaConnectors;
|
||||||
public class MangaDex : Connector
|
public class MangaDex : MangaConnector
|
||||||
{
|
{
|
||||||
public override string name { get; }
|
public override string name { get; }
|
||||||
|
|
||||||
@ -18,33 +18,34 @@ public class MangaDex : Connector
|
|||||||
Author,
|
Author,
|
||||||
}
|
}
|
||||||
|
|
||||||
public MangaDex(string downloadLocation, string imageCachePath, Logger? logger) : base(downloadLocation, imageCachePath, logger)
|
public MangaDex(GlobalBase clone) : base(clone)
|
||||||
{
|
{
|
||||||
name = "MangaDex";
|
name = "MangaDex";
|
||||||
this.downloadClient = new DownloadClient(new Dictionary<byte, int>()
|
this.downloadClient = new DownloadClient(clone, new Dictionary<byte, int>()
|
||||||
{
|
{
|
||||||
{(byte)RequestType.Manga, 250},
|
{(byte)RequestType.Manga, 250},
|
||||||
{(byte)RequestType.Feed, 250},
|
{(byte)RequestType.Feed, 250},
|
||||||
{(byte)RequestType.AtHomeServer, 40},
|
{(byte)RequestType.AtHomeServer, 40},
|
||||||
{(byte)RequestType.CoverUrl, 250},
|
{(byte)RequestType.CoverUrl, 250},
|
||||||
{(byte)RequestType.Author, 250}
|
{(byte)RequestType.Author, 250}
|
||||||
}, logger);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Publication[] GetPublications(string publicationTitle = "")
|
public override Publication[] GetPublications(string publicationTitle = "")
|
||||||
{
|
{
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Getting Publications (title={publicationTitle})");
|
Log($"Searching Publications. Term=\"{publicationTitle}\"");
|
||||||
const int limit = 100; //How many values we want returned at once
|
const int limit = 100; //How many values we want returned at once
|
||||||
int offset = 0; //"Page"
|
int offset = 0; //"Page"
|
||||||
int total = int.MaxValue; //How many total results are there, is updated on first request
|
int total = int.MaxValue; //How many total results are there, is updated on first request
|
||||||
HashSet<Publication> publications = new();
|
HashSet<Publication> publications = new();
|
||||||
|
int loadedPublicationData = 0;
|
||||||
while (offset < total) //As long as we haven't requested all "Pages"
|
while (offset < total) //As long as we haven't requested all "Pages"
|
||||||
{
|
{
|
||||||
//Request next Page
|
//Request next Page
|
||||||
DownloadClient.RequestResult requestResult =
|
DownloadClient.RequestResult requestResult =
|
||||||
downloadClient.MakeRequest(
|
downloadClient.MakeRequest(
|
||||||
$"https://api.mangadex.org/manga?limit={limit}&title={publicationTitle}&offset={offset}", (byte)RequestType.Manga);
|
$"https://api.mangadex.org/manga?limit={limit}&title={publicationTitle}&offset={offset}", (byte)RequestType.Manga);
|
||||||
if (requestResult.statusCode != HttpStatusCode.OK)
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
break;
|
break;
|
||||||
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
||||||
|
|
||||||
@ -55,10 +56,10 @@ public class MangaDex : Connector
|
|||||||
total = result["total"]!.GetValue<int>(); //Update the total number of Publications
|
total = result["total"]!.GetValue<int>(); //Update the total number of Publications
|
||||||
|
|
||||||
JsonArray mangaInResult = result["data"]!.AsArray(); //Manga-data-Array
|
JsonArray mangaInResult = result["data"]!.AsArray(); //Manga-data-Array
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Getting publication data.");
|
|
||||||
//Loop each Manga and extract information from JSON
|
//Loop each Manga and extract information from JSON
|
||||||
foreach (JsonNode? mangeNode in mangaInResult)
|
foreach (JsonNode? mangeNode in mangaInResult)
|
||||||
{
|
{
|
||||||
|
Log($"Getting publication data. {++loadedPublicationData}/{total}");
|
||||||
JsonObject manga = (JsonObject)mangeNode!;
|
JsonObject manga = (JsonObject)mangeNode!;
|
||||||
JsonObject attributes = manga["attributes"]!.AsObject();
|
JsonObject attributes = manga["attributes"]!.AsObject();
|
||||||
|
|
||||||
@ -91,19 +92,21 @@ public class MangaDex : Connector
|
|||||||
}
|
}
|
||||||
|
|
||||||
string? posterId = null;
|
string? posterId = null;
|
||||||
string? authorId = null;
|
HashSet<string> authorIds = new();
|
||||||
if (manga.ContainsKey("relationships") && manga["relationships"] is not null)
|
if (manga.ContainsKey("relationships") && manga["relationships"] is not null)
|
||||||
{
|
{
|
||||||
JsonArray relationships = manga["relationships"]!.AsArray();
|
JsonArray relationships = manga["relationships"]!.AsArray();
|
||||||
posterId = relationships.FirstOrDefault(relationship => relationship!["type"]!.GetValue<string>() == "cover_art")!["id"]!.GetValue<string>();
|
posterId = relationships.FirstOrDefault(relationship => relationship!["type"]!.GetValue<string>() == "cover_art")!["id"]!.GetValue<string>();
|
||||||
authorId = relationships.FirstOrDefault(relationship => relationship!["type"]!.GetValue<string>() == "author")!["id"]!.GetValue<string>();
|
foreach (JsonNode? node in relationships.Where(relationship =>
|
||||||
|
relationship!["type"]!.GetValue<string>() == "author"))
|
||||||
|
authorIds.Add(node!["id"]!.GetValue<string>());
|
||||||
}
|
}
|
||||||
string? coverUrl = GetCoverUrl(publicationId, posterId);
|
string? coverUrl = GetCoverUrl(publicationId, posterId);
|
||||||
string? coverCacheName = null;
|
string? coverCacheName = null;
|
||||||
if (coverUrl is not null)
|
if (coverUrl is not null)
|
||||||
coverCacheName = SaveImage(coverUrl);
|
coverCacheName = SaveCoverImageToCache(coverUrl, (byte)RequestType.AtHomeServer);
|
||||||
|
|
||||||
string? author = GetAuthor(authorId);
|
List<string> authors = GetAuthors(authorIds);
|
||||||
|
|
||||||
Dictionary<string, string> linksDict = new();
|
Dictionary<string, string> linksDict = new();
|
||||||
if (attributes.ContainsKey("links") && attributes["links"] is not null)
|
if (attributes.ContainsKey("links") && attributes["links"] is not null)
|
||||||
@ -127,7 +130,7 @@ public class MangaDex : Connector
|
|||||||
|
|
||||||
Publication pub = new (
|
Publication pub = new (
|
||||||
title,
|
title,
|
||||||
author,
|
authors,
|
||||||
description,
|
description,
|
||||||
altTitlesDict,
|
altTitlesDict,
|
||||||
tags.ToArray(),
|
tags.ToArray(),
|
||||||
@ -143,13 +146,14 @@ public class MangaDex : Connector
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Done getting publications (title={publicationTitle})");
|
cachedPublications.AddRange(publications);
|
||||||
|
Log($"Retrieved {publications.Count} publications. Term=\"{publicationTitle}\"");
|
||||||
return publications.ToArray();
|
return publications.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Chapter[] GetChapters(Publication publication, string language = "")
|
public override Chapter[] GetChapters(Publication publication, string language="en")
|
||||||
{
|
{
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Getting Chapters for {publication.sortName} {publication.internalId} (language={language})");
|
Log($"Getting chapters {publication}");
|
||||||
const int limit = 100; //How many values we want returned at once
|
const int limit = 100; //How many values we want returned at once
|
||||||
int offset = 0; //"Page"
|
int offset = 0; //"Page"
|
||||||
int total = int.MaxValue; //How many total results are there, is updated on first request
|
int total = int.MaxValue; //How many total results are there, is updated on first request
|
||||||
@ -161,7 +165,7 @@ public class MangaDex : Connector
|
|||||||
DownloadClient.RequestResult requestResult =
|
DownloadClient.RequestResult requestResult =
|
||||||
downloadClient.MakeRequest(
|
downloadClient.MakeRequest(
|
||||||
$"https://api.mangadex.org/manga/{publication.publicationId}/feed?limit={limit}&offset={offset}&translatedLanguage%5B%5D={language}", (byte)RequestType.Feed);
|
$"https://api.mangadex.org/manga/{publication.publicationId}/feed?limit={limit}&offset={offset}&translatedLanguage%5B%5D={language}", (byte)RequestType.Feed);
|
||||||
if (requestResult.statusCode != HttpStatusCode.OK)
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
break;
|
break;
|
||||||
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
||||||
|
|
||||||
@ -186,34 +190,34 @@ public class MangaDex : Connector
|
|||||||
? attributes["volume"]!.GetValue<string>()
|
? attributes["volume"]!.GetValue<string>()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
string? chapterNum = attributes.ContainsKey("chapter") && attributes["chapter"] is not null
|
string chapterNum = attributes.ContainsKey("chapter") && attributes["chapter"] is not null
|
||||||
? attributes["chapter"]!.GetValue<string>()
|
? attributes["chapter"]!.GetValue<string>()
|
||||||
: null;
|
: "null";
|
||||||
|
|
||||||
chapters.Add(new Chapter(title, volume, chapterNum, chapterId));
|
if(chapterNum is not "null")
|
||||||
|
chapters.Add(new Chapter(publication, title, volume, chapterNum, chapterId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//Return Chapters ordered by Chapter-Number
|
//Return Chapters ordered by Chapter-Number
|
||||||
NumberFormatInfo chapterNumberFormatInfo = new()
|
NumberFormatInfo chapterNumberFormatInfo = new() { NumberDecimalSeparator = "." };
|
||||||
{
|
Log($"Got {chapters.Count} chapters. {publication}");
|
||||||
NumberDecimalSeparator = "."
|
|
||||||
};
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Done getting Chapters for {publication.internalId}");
|
|
||||||
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, chapterNumberFormatInfo)).ToArray();
|
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, chapterNumberFormatInfo)).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void DownloadChapter(Publication publication, Chapter chapter)
|
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
|
||||||
{
|
{
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Downloading Chapter-Info {publication.sortName} {publication.internalId} {chapter.volumeNumber}-{chapter.chapterNumber}");
|
if (progressToken?.cancellationRequested ?? false)
|
||||||
|
return HttpStatusCode.RequestTimeout;
|
||||||
|
Log($"Retrieving chapter-info {chapter} {chapter.parentPublication}");
|
||||||
//Request URLs for Chapter-Images
|
//Request URLs for Chapter-Images
|
||||||
DownloadClient.RequestResult requestResult =
|
DownloadClient.RequestResult requestResult =
|
||||||
downloadClient.MakeRequest($"https://api.mangadex.org/at-home/server/{chapter.url}?forcePort443=false'", (byte)RequestType.AtHomeServer);
|
downloadClient.MakeRequest($"https://api.mangadex.org/at-home/server/{chapter.url}?forcePort443=false'", (byte)RequestType.AtHomeServer);
|
||||||
if (requestResult.statusCode != HttpStatusCode.OK)
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
return;
|
return requestResult.statusCode;
|
||||||
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
||||||
if (result is null)
|
if (result is null)
|
||||||
return;
|
return HttpStatusCode.NoContent;
|
||||||
|
|
||||||
string baseUrl = result["baseUrl"]!.GetValue<string>();
|
string baseUrl = result["baseUrl"]!.GetValue<string>();
|
||||||
string hash = result["chapter"]!["hash"]!.GetValue<string>();
|
string hash = result["chapter"]!["hash"]!.GetValue<string>();
|
||||||
@ -224,25 +228,25 @@ public class MangaDex : Connector
|
|||||||
imageUrls.Add($"{baseUrl}/data/{hash}/{image!.GetValue<string>()}");
|
imageUrls.Add($"{baseUrl}/data/{hash}/{image!.GetValue<string>()}");
|
||||||
|
|
||||||
string comicInfoPath = Path.GetTempFileName();
|
string comicInfoPath = Path.GetTempFileName();
|
||||||
File.WriteAllText(comicInfoPath, CreateComicInfo(publication, chapter, logger));
|
File.WriteAllText(comicInfoPath, chapter.GetComicInfoXmlString());
|
||||||
|
|
||||||
//Download Chapter-Images
|
//Download Chapter-Images
|
||||||
DownloadChapterImages(imageUrls.ToArray(), CreateFullFilepath(publication, chapter), downloadClient, (byte)RequestType.AtHomeServer, logger, comicInfoPath);
|
return DownloadChapterImages(imageUrls.ToArray(), chapter.GetArchiveFilePath(settings.downloadLocation), (byte)RequestType.AtHomeServer, comicInfoPath, progressToken:progressToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? GetCoverUrl(string publicationId, string? posterId)
|
private string? GetCoverUrl(string publicationId, string? posterId)
|
||||||
{
|
{
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Getting CoverUrl for {publicationId}");
|
Log($"Getting CoverUrl for Publication {publicationId}");
|
||||||
if (posterId is null)
|
if (posterId is null)
|
||||||
{
|
{
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"No posterId, aborting");
|
Log("No cover.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Request information where to download Cover
|
//Request information where to download Cover
|
||||||
DownloadClient.RequestResult requestResult =
|
DownloadClient.RequestResult requestResult =
|
||||||
downloadClient.MakeRequest($"https://api.mangadex.org/cover/{posterId}", (byte)RequestType.CoverUrl);
|
downloadClient.MakeRequest($"https://api.mangadex.org/cover/{posterId}", (byte)RequestType.CoverUrl);
|
||||||
if (requestResult.statusCode != HttpStatusCode.OK)
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
return null;
|
return null;
|
||||||
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
||||||
if (result is null)
|
if (result is null)
|
||||||
@ -251,63 +255,28 @@ public class MangaDex : Connector
|
|||||||
string fileName = result["data"]!["attributes"]!["fileName"]!.GetValue<string>();
|
string fileName = result["data"]!["attributes"]!["fileName"]!.GetValue<string>();
|
||||||
|
|
||||||
string coverUrl = $"https://uploads.mangadex.org/covers/{publicationId}/{fileName}";
|
string coverUrl = $"https://uploads.mangadex.org/covers/{publicationId}/{fileName}";
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Got Cover-Url for {publicationId} -> {coverUrl}");
|
Log($"Cover-Url {publicationId} -> {coverUrl}");
|
||||||
return coverUrl;
|
return coverUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? GetAuthor(string? authorId)
|
private List<string> GetAuthors(IEnumerable<string> authorIds)
|
||||||
{
|
{
|
||||||
if (authorId is null)
|
Log("Retrieving authors.");
|
||||||
return null;
|
List<string> ret = new();
|
||||||
|
foreach (string authorId in authorIds)
|
||||||
DownloadClient.RequestResult requestResult =
|
|
||||||
downloadClient.MakeRequest($"https://api.mangadex.org/author/{authorId}", (byte)RequestType.Author);
|
|
||||||
if (requestResult.statusCode != HttpStatusCode.OK)
|
|
||||||
return null;
|
|
||||||
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
|
||||||
if (result is null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
string author = result["data"]!["attributes"]!["name"]!.GetValue<string>();
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Got author {authorId} -> {author}");
|
|
||||||
return author;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void CloneCoverFromCache(Publication publication, TrangaSettings settings)
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Cloning cover {publication.sortName}");
|
|
||||||
//Check if Publication already has a Folder and cover
|
|
||||||
string publicationFolder = Path.Join(downloadLocation, publication.folderName);
|
|
||||||
|
|
||||||
if(!Directory.Exists(publicationFolder))
|
|
||||||
Directory.CreateDirectory(publicationFolder);
|
|
||||||
DirectoryInfo dirInfo = new (publicationFolder);
|
|
||||||
if (dirInfo.EnumerateFiles().Any(info => info.Name.Contains("cover.")))
|
|
||||||
{
|
{
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Cover exists {publication.sortName}");
|
DownloadClient.RequestResult requestResult =
|
||||||
return;
|
downloadClient.MakeRequest($"https://api.mangadex.org/author/{authorId}", (byte)RequestType.Author);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return ret;
|
||||||
|
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
|
||||||
|
if (result is null)
|
||||||
|
return ret;
|
||||||
|
|
||||||
|
string authorName = result["data"]!["attributes"]!["name"]!.GetValue<string>();
|
||||||
|
ret.Add(authorName);
|
||||||
|
Log($"Got author {authorId} -> {authorName}");
|
||||||
}
|
}
|
||||||
|
return ret;
|
||||||
string fileInCache = Path.Join(settings.coverImageCache, publication.coverFileNameInCache);
|
|
||||||
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" );
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Cloning cover {fileInCache} -> {newFilePath}");
|
|
||||||
File.Copy(fileInCache, newFilePath, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string SaveImage(string url)
|
|
||||||
{
|
|
||||||
string[] split = url.Split('/');
|
|
||||||
string filename = split[^1];
|
|
||||||
string saveImagePath = Path.Join(imageCachePath, filename);
|
|
||||||
|
|
||||||
if (File.Exists(saveImagePath))
|
|
||||||
return filename;
|
|
||||||
|
|
||||||
DownloadClient.RequestResult coverResult = downloadClient.MakeRequest(url, (byte)RequestType.AtHomeServer);
|
|
||||||
using MemoryStream ms = new();
|
|
||||||
coverResult.result.CopyTo(ms);
|
|
||||||
File.WriteAllBytes(saveImagePath, ms.ToArray());
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Saving image to {saveImagePath}");
|
|
||||||
return filename;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
224
Tranga/MangaConnectors/MangaKatana.cs
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
using Tranga.Jobs;
|
||||||
|
|
||||||
|
namespace Tranga.MangaConnectors;
|
||||||
|
|
||||||
|
public class MangaKatana : MangaConnector
|
||||||
|
{
|
||||||
|
public override string name { get; }
|
||||||
|
|
||||||
|
public MangaKatana(GlobalBase clone) : base(clone)
|
||||||
|
{
|
||||||
|
this.name = "MangaKatana";
|
||||||
|
this.downloadClient = new DownloadClient(clone, new Dictionary<byte, int>()
|
||||||
|
{
|
||||||
|
{1, 60}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Publication[] GetPublications(string publicationTitle = "")
|
||||||
|
{
|
||||||
|
Log($"Searching Publications. Term=\"{publicationTitle}\"");
|
||||||
|
string sanitizedTitle = string.Join('_', Regex.Matches(publicationTitle, "[A-z]*").Where(m => m.Value.Length > 0)).ToLower();
|
||||||
|
string requestUrl = $"https://mangakatana.com/?search={sanitizedTitle}&search_by=book_name";
|
||||||
|
DownloadClient.RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, 1);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return Array.Empty<Publication>();
|
||||||
|
|
||||||
|
// ReSharper disable once MergeIntoPattern
|
||||||
|
// If a single result is found, the user will be redirected to the results directly instead of a result page
|
||||||
|
if(requestResult.hasBeenRedirected
|
||||||
|
&& requestResult.redirectedToUrl is not null
|
||||||
|
&& requestResult.redirectedToUrl.Contains("mangakatana.com/manga"))
|
||||||
|
{
|
||||||
|
return new [] { ParseSinglePublicationFromHtml(requestResult.result, requestResult.redirectedToUrl.Split('/')[^1]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
Publication[] publications = ParsePublicationsFromHtml(requestResult.result);
|
||||||
|
cachedPublications.AddRange(publications);
|
||||||
|
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
|
||||||
|
return publications;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Publication[] ParsePublicationsFromHtml(Stream html)
|
||||||
|
{
|
||||||
|
StreamReader reader = new(html);
|
||||||
|
string htmlString = reader.ReadToEnd();
|
||||||
|
HtmlDocument document = new();
|
||||||
|
document.LoadHtml(htmlString);
|
||||||
|
IEnumerable<HtmlNode> searchResults = document.DocumentNode.SelectNodes("//*[@id='book_list']/div");
|
||||||
|
if (searchResults is null || !searchResults.Any())
|
||||||
|
return Array.Empty<Publication>();
|
||||||
|
List<string> urls = new();
|
||||||
|
foreach (HtmlNode mangaResult in searchResults)
|
||||||
|
{
|
||||||
|
urls.Add(mangaResult.Descendants("a").First().GetAttributes()
|
||||||
|
.First(a => a.Name == "href").Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<Publication> ret = new();
|
||||||
|
foreach (string url in urls)
|
||||||
|
{
|
||||||
|
DownloadClient.RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(url, 1);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return Array.Empty<Publication>();
|
||||||
|
|
||||||
|
ret.Add(ParseSinglePublicationFromHtml(requestResult.result, url.Split('/')[^1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Publication ParseSinglePublicationFromHtml(Stream html, string publicationId)
|
||||||
|
{
|
||||||
|
StreamReader reader = new(html);
|
||||||
|
string htmlString = reader.ReadToEnd();
|
||||||
|
HtmlDocument document = new();
|
||||||
|
document.LoadHtml(htmlString);
|
||||||
|
string status = "";
|
||||||
|
Dictionary<string, string> altTitles = new();
|
||||||
|
Dictionary<string, string>? links = null;
|
||||||
|
HashSet<string> tags = new();
|
||||||
|
string[] authors = Array.Empty<string>();
|
||||||
|
string originalLanguage = "";
|
||||||
|
|
||||||
|
HtmlNode infoNode = document.DocumentNode.SelectSingleNode("//*[@id='single_book']");
|
||||||
|
string sortName = infoNode.Descendants("h1").First(n => n.HasClass("heading")).InnerText;
|
||||||
|
HtmlNode infoTable = infoNode.SelectSingleNode("//*[@id='single_book']/div[2]/div/ul");
|
||||||
|
|
||||||
|
foreach (HtmlNode row in infoTable.Descendants("li"))
|
||||||
|
{
|
||||||
|
string key = row.SelectNodes("div").First().InnerText.ToLower();
|
||||||
|
string value = row.SelectNodes("div").Last().InnerText;
|
||||||
|
string keySanitized = string.Concat(Regex.Matches(key, "[a-z]"));
|
||||||
|
|
||||||
|
switch (keySanitized)
|
||||||
|
{
|
||||||
|
case "altnames":
|
||||||
|
string[] alts = value.Split(" ; ");
|
||||||
|
for (int i = 0; i < alts.Length; i++)
|
||||||
|
altTitles.Add(i.ToString(), alts[i]);
|
||||||
|
break;
|
||||||
|
case "authorsartists":
|
||||||
|
authors = value.Split(',');
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
status = value;
|
||||||
|
break;
|
||||||
|
case "genres":
|
||||||
|
tags = row.SelectNodes("div").Last().Descendants("a").Select(a => a.InnerText).ToHashSet();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string posterUrl = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[1]/div").Descendants("img").First()
|
||||||
|
.GetAttributes().First(a => a.Name == "src").Value;
|
||||||
|
|
||||||
|
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, 1);
|
||||||
|
|
||||||
|
string description = document.DocumentNode.SelectSingleNode("//*[@id='single_book']/div[3]/p").InnerText;
|
||||||
|
while (description.StartsWith('\n'))
|
||||||
|
description = description.Substring(1);
|
||||||
|
|
||||||
|
int year = DateTime.Now.Year;
|
||||||
|
string yearString = infoTable.Descendants("div").First(d => d.HasClass("updateAt"))
|
||||||
|
.InnerText.Split('-')[^1];
|
||||||
|
|
||||||
|
if(yearString.Contains("ago") == false)
|
||||||
|
{
|
||||||
|
year = Convert.ToInt32(yearString);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Publication(sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links,
|
||||||
|
year, originalLanguage, status, publicationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Chapter[] GetChapters(Publication publication, string language="en")
|
||||||
|
{
|
||||||
|
Log($"Getting chapters {publication}");
|
||||||
|
string requestUrl = $"https://mangakatana.com/manga/{publication.publicationId}";
|
||||||
|
// Leaving this in for verification if the page exists
|
||||||
|
DownloadClient.RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, 1);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return Array.Empty<Chapter>();
|
||||||
|
|
||||||
|
//Return Chapters ordered by Chapter-Number
|
||||||
|
NumberFormatInfo chapterNumberFormatInfo = new()
|
||||||
|
{
|
||||||
|
NumberDecimalSeparator = "."
|
||||||
|
};
|
||||||
|
List<Chapter> chapters = ParseChaptersFromHtml(publication, requestUrl);
|
||||||
|
Log($"Got {chapters.Count} chapters. {publication}");
|
||||||
|
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, chapterNumberFormatInfo)).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Chapter> ParseChaptersFromHtml(Publication publication, string mangaUrl)
|
||||||
|
{
|
||||||
|
// Using HtmlWeb will include the chapters since they are loaded with js
|
||||||
|
HtmlWeb web = new();
|
||||||
|
HtmlDocument document = web.Load(mangaUrl);
|
||||||
|
|
||||||
|
List<Chapter> ret = new();
|
||||||
|
|
||||||
|
HtmlNode chapterList = document.DocumentNode.SelectSingleNode("//div[contains(@class, 'chapters')]/table/tbody");
|
||||||
|
|
||||||
|
foreach (HtmlNode chapterInfo in chapterList.Descendants("tr"))
|
||||||
|
{
|
||||||
|
string fullString = chapterInfo.Descendants("a").First().InnerText;
|
||||||
|
|
||||||
|
string? volumeNumber = fullString.Contains("Vol.") ? fullString.Replace("Vol.", "").Split(' ')[0] : null;
|
||||||
|
string chapterNumber = fullString.Split(':')[0].Split("Chapter ")[1].Split(" ")[0].Replace('-', '.');
|
||||||
|
string chapterName = string.Concat(fullString.Split(':')[1..]);
|
||||||
|
string url = chapterInfo.Descendants("a").First()
|
||||||
|
.GetAttributeValue("href", "");
|
||||||
|
ret.Add(new Chapter(publication, chapterName, volumeNumber, chapterNumber, url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
|
||||||
|
{
|
||||||
|
if (progressToken?.cancellationRequested ?? false)
|
||||||
|
return HttpStatusCode.RequestTimeout;
|
||||||
|
Log($"Retrieving chapter-info {chapter} {chapter.parentPublication}");
|
||||||
|
string requestUrl = chapter.url;
|
||||||
|
// Leaving this in to check if the page exists
|
||||||
|
DownloadClient.RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, 1);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return requestResult.statusCode;
|
||||||
|
|
||||||
|
string[] imageUrls = ParseImageUrlsFromHtml(requestUrl);
|
||||||
|
|
||||||
|
string comicInfoPath = Path.GetTempFileName();
|
||||||
|
File.WriteAllText(comicInfoPath, chapter.GetComicInfoXmlString());
|
||||||
|
|
||||||
|
return DownloadChapterImages(imageUrls, chapter.GetArchiveFilePath(settings.downloadLocation), 1, comicInfoPath, "https://mangakatana.com/", progressToken:progressToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] ParseImageUrlsFromHtml(string mangaUrl)
|
||||||
|
{
|
||||||
|
HtmlWeb web = new();
|
||||||
|
HtmlDocument document = web.Load(mangaUrl);
|
||||||
|
|
||||||
|
// Images are loaded dynamically, but the urls are present in a piece of js code on the page
|
||||||
|
string js = document.DocumentNode.SelectSingleNode("//script[contains(., 'data-src')]").InnerText
|
||||||
|
.Replace("\r", "")
|
||||||
|
.Replace("\n", "")
|
||||||
|
.Replace("\t", "");
|
||||||
|
|
||||||
|
// ReSharper disable once StringLiteralTypo
|
||||||
|
string regexPat = @"(var thzq=\[')(.*)(,];function)";
|
||||||
|
var group = Regex.Matches(js, regexPat).First().Groups[2].Value.Replace("'", "");
|
||||||
|
var urls = group.Split(',');
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
}
|
207
Tranga/MangaConnectors/Manganato.cs
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
using Tranga.Jobs;
|
||||||
|
|
||||||
|
namespace Tranga.MangaConnectors;
|
||||||
|
|
||||||
|
public class Manganato : MangaConnector
|
||||||
|
{
|
||||||
|
public override string name { get; }
|
||||||
|
|
||||||
|
public Manganato(GlobalBase clone) : base(clone)
|
||||||
|
{
|
||||||
|
this.name = "Manganato";
|
||||||
|
this.downloadClient = new DownloadClient(clone, new Dictionary<byte, int>()
|
||||||
|
{
|
||||||
|
{1, 60}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Publication[] GetPublications(string publicationTitle = "")
|
||||||
|
{
|
||||||
|
Log($"Searching Publications. Term=\"{publicationTitle}\"");
|
||||||
|
string sanitizedTitle = string.Join('_', Regex.Matches(publicationTitle, "[A-z]*")).ToLower();
|
||||||
|
string requestUrl = $"https://manganato.com/search/story/{sanitizedTitle}";
|
||||||
|
DownloadClient.RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, 1);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return Array.Empty<Publication>();
|
||||||
|
|
||||||
|
Publication[] publications = ParsePublicationsFromHtml(requestResult.result);
|
||||||
|
cachedPublications.AddRange(publications);
|
||||||
|
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
|
||||||
|
return publications;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Publication[] ParsePublicationsFromHtml(Stream html)
|
||||||
|
{
|
||||||
|
StreamReader reader = new (html);
|
||||||
|
string htmlString = reader.ReadToEnd();
|
||||||
|
HtmlDocument document = new ();
|
||||||
|
document.LoadHtml(htmlString);
|
||||||
|
IEnumerable<HtmlNode> searchResults = document.DocumentNode.Descendants("div").Where(n => n.HasClass("search-story-item"));
|
||||||
|
List<string> urls = new();
|
||||||
|
foreach (HtmlNode mangaResult in searchResults)
|
||||||
|
{
|
||||||
|
urls.Add(mangaResult.Descendants("a").First(n => n.HasClass("item-title")).GetAttributes()
|
||||||
|
.First(a => a.Name == "href").Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<Publication> ret = new();
|
||||||
|
foreach (string url in urls)
|
||||||
|
{
|
||||||
|
DownloadClient.RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(url, 1);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return Array.Empty<Publication>();
|
||||||
|
|
||||||
|
ret.Add(ParseSinglePublicationFromHtml(requestResult.result, url.Split('/')[^1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Publication ParseSinglePublicationFromHtml(Stream html, string publicationId)
|
||||||
|
{
|
||||||
|
StreamReader reader = new (html);
|
||||||
|
string htmlString = reader.ReadToEnd();
|
||||||
|
HtmlDocument document = new ();
|
||||||
|
document.LoadHtml(htmlString);
|
||||||
|
string status = "";
|
||||||
|
Dictionary<string, string> altTitles = new();
|
||||||
|
Dictionary<string, string>? links = null;
|
||||||
|
HashSet<string> tags = new();
|
||||||
|
string[] authors = Array.Empty<string>();
|
||||||
|
string originalLanguage = "";
|
||||||
|
|
||||||
|
HtmlNode infoNode = document.DocumentNode.Descendants("div").First(d => d.HasClass("story-info-right"));
|
||||||
|
|
||||||
|
string sortName = infoNode.Descendants("h1").First().InnerText;
|
||||||
|
|
||||||
|
HtmlNode infoTable = infoNode.Descendants().First(d => d.Name == "table");
|
||||||
|
|
||||||
|
foreach (HtmlNode row in infoTable.Descendants("tr"))
|
||||||
|
{
|
||||||
|
string key = row.SelectNodes("td").First().InnerText.ToLower();
|
||||||
|
string value = row.SelectNodes("td").Last().InnerText;
|
||||||
|
string keySanitized = string.Concat(Regex.Matches(key, "[a-z]"));
|
||||||
|
|
||||||
|
switch (keySanitized)
|
||||||
|
{
|
||||||
|
case "alternative":
|
||||||
|
string[] alts = value.Split(" ; ");
|
||||||
|
for(int i = 0; i < alts.Length; i++)
|
||||||
|
altTitles.Add(i.ToString(), alts[i]);
|
||||||
|
break;
|
||||||
|
case "authors":
|
||||||
|
authors = value.Split('-');
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
status = value;
|
||||||
|
break;
|
||||||
|
case "genres":
|
||||||
|
string[] genres = value.Split(" - ");
|
||||||
|
tags = genres.ToHashSet();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string posterUrl = document.DocumentNode.Descendants("span").First(s => s.HasClass("info-image")).Descendants("img").First()
|
||||||
|
.GetAttributes().First(a => a.Name == "src").Value;
|
||||||
|
|
||||||
|
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, 1);
|
||||||
|
|
||||||
|
string description = document.DocumentNode.Descendants("div").First(d => d.HasClass("panel-story-info-description"))
|
||||||
|
.InnerText.Replace("Description :", "");
|
||||||
|
while (description.StartsWith('\n'))
|
||||||
|
description = description.Substring(1);
|
||||||
|
|
||||||
|
string yearString = document.DocumentNode.Descendants("li").Last(li => li.HasClass("a-h")).Descendants("span")
|
||||||
|
.First(s => s.HasClass("chapter-time")).InnerText;
|
||||||
|
int year = Convert.ToInt32(yearString.Split(',')[^1]) + 2000;
|
||||||
|
|
||||||
|
return new Publication(sortName, authors.ToList(), description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links,
|
||||||
|
year, originalLanguage, status, publicationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Chapter[] GetChapters(Publication publication, string language="en")
|
||||||
|
{
|
||||||
|
Log($"Getting chapters {publication}");
|
||||||
|
string requestUrl = $"https://chapmanganato.com/{publication.publicationId}";
|
||||||
|
DownloadClient.RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, 1);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return Array.Empty<Chapter>();
|
||||||
|
|
||||||
|
//Return Chapters ordered by Chapter-Number
|
||||||
|
NumberFormatInfo chapterNumberFormatInfo = new()
|
||||||
|
{
|
||||||
|
NumberDecimalSeparator = "."
|
||||||
|
};
|
||||||
|
List<Chapter> chapters = ParseChaptersFromHtml(publication, requestResult.result);
|
||||||
|
Log($"Got {chapters.Count} chapters. {publication}");
|
||||||
|
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, chapterNumberFormatInfo)).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Chapter> ParseChaptersFromHtml(Publication publication, Stream html)
|
||||||
|
{
|
||||||
|
StreamReader reader = new (html);
|
||||||
|
string htmlString = reader.ReadToEnd();
|
||||||
|
HtmlDocument document = new ();
|
||||||
|
document.LoadHtml(htmlString);
|
||||||
|
List<Chapter> ret = new();
|
||||||
|
|
||||||
|
HtmlNode chapterList = document.DocumentNode.Descendants("ul").First(l => l.HasClass("row-content-chapter"));
|
||||||
|
|
||||||
|
foreach (HtmlNode chapterInfo in chapterList.Descendants("li"))
|
||||||
|
{
|
||||||
|
string fullString = chapterInfo.Descendants("a").First(d => d.HasClass("chapter-name")).InnerText;
|
||||||
|
|
||||||
|
string? volumeNumber = fullString.Contains("Vol.") ? fullString.Replace("Vol.", "").Split(' ')[0] : null;
|
||||||
|
string chapterNumber = fullString.Split(':')[0].Split("Chapter ")[1].Replace('-','.');
|
||||||
|
string chapterName = string.Concat(fullString.Split(':')[1..]);
|
||||||
|
string url = chapterInfo.Descendants("a").First(d => d.HasClass("chapter-name"))
|
||||||
|
.GetAttributeValue("href", "");
|
||||||
|
ret.Add(new Chapter(publication, chapterName, volumeNumber, chapterNumber, url));
|
||||||
|
}
|
||||||
|
ret.Reverse();
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
|
||||||
|
{
|
||||||
|
if (progressToken?.cancellationRequested ?? false)
|
||||||
|
return HttpStatusCode.RequestTimeout;
|
||||||
|
Log($"Retrieving chapter-info {chapter} {chapter.parentPublication}");
|
||||||
|
string requestUrl = chapter.url;
|
||||||
|
DownloadClient.RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, 1);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return requestResult.statusCode;
|
||||||
|
|
||||||
|
string[] imageUrls = ParseImageUrlsFromHtml(requestResult.result);
|
||||||
|
|
||||||
|
string comicInfoPath = Path.GetTempFileName();
|
||||||
|
File.WriteAllText(comicInfoPath, chapter.GetComicInfoXmlString());
|
||||||
|
|
||||||
|
return DownloadChapterImages(imageUrls, chapter.GetArchiveFilePath(settings.downloadLocation), 1, comicInfoPath, "https://chapmanganato.com/", progressToken:progressToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] ParseImageUrlsFromHtml(Stream html)
|
||||||
|
{
|
||||||
|
StreamReader reader = new (html);
|
||||||
|
string htmlString = reader.ReadToEnd();
|
||||||
|
HtmlDocument document = new ();
|
||||||
|
document.LoadHtml(htmlString);
|
||||||
|
List<string> ret = new();
|
||||||
|
|
||||||
|
HtmlNode imageContainer =
|
||||||
|
document.DocumentNode.Descendants("div").First(i => i.HasClass("container-chapter-reader"));
|
||||||
|
foreach(HtmlNode imageNode in imageContainer.Descendants("img"))
|
||||||
|
ret.Add(imageNode.GetAttributeValue("src", ""));
|
||||||
|
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
}
|
277
Tranga/MangaConnectors/Mangasee.cs
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using PuppeteerSharp;
|
||||||
|
using Tranga.Jobs;
|
||||||
|
|
||||||
|
namespace Tranga.MangaConnectors;
|
||||||
|
|
||||||
|
public class Mangasee : MangaConnector
|
||||||
|
{
|
||||||
|
public override string name { get; }
|
||||||
|
private IBrowser? _browser;
|
||||||
|
private const string ChromiumVersion = "1154303";
|
||||||
|
|
||||||
|
public Mangasee(GlobalBase clone) : base(clone)
|
||||||
|
{
|
||||||
|
this.name = "Mangasee";
|
||||||
|
this.downloadClient = new DownloadClient(clone, new Dictionary<byte, int>()
|
||||||
|
{
|
||||||
|
{ 1, 60 }
|
||||||
|
});
|
||||||
|
|
||||||
|
Task d = new Task(DownloadBrowser);
|
||||||
|
d.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void DownloadBrowser()
|
||||||
|
{
|
||||||
|
BrowserFetcher browserFetcher = new BrowserFetcher();
|
||||||
|
foreach(string rev in browserFetcher.LocalRevisions().Where(rev => rev != ChromiumVersion))
|
||||||
|
browserFetcher.Remove(rev);
|
||||||
|
if (!browserFetcher.LocalRevisions().Contains(ChromiumVersion))
|
||||||
|
{
|
||||||
|
Log("Downloading headless browser");
|
||||||
|
DateTime last = DateTime.Now.Subtract(TimeSpan.FromSeconds(5));
|
||||||
|
browserFetcher.DownloadProgressChanged += (_, args) =>
|
||||||
|
{
|
||||||
|
double currentBytes = Convert.ToDouble(args.BytesReceived) / Convert.ToDouble(args.TotalBytesToReceive);
|
||||||
|
if (args.TotalBytesToReceive == args.BytesReceived)
|
||||||
|
Log("Browser downloaded.");
|
||||||
|
else if (DateTime.Now > last.AddSeconds(1))
|
||||||
|
{
|
||||||
|
Log($"Browser download progress: {currentBytes:P2}");
|
||||||
|
last = DateTime.Now;
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
if (!browserFetcher.CanDownloadAsync(ChromiumVersion).Result)
|
||||||
|
{
|
||||||
|
Log($"Can't download browser version {ChromiumVersion}");
|
||||||
|
throw new Exception();
|
||||||
|
}
|
||||||
|
await browserFetcher.DownloadAsync(ChromiumVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log("Starting Browser.");
|
||||||
|
this._browser = await Puppeteer.LaunchAsync(new LaunchOptions
|
||||||
|
{
|
||||||
|
Headless = true,
|
||||||
|
ExecutablePath = browserFetcher.GetExecutablePath(ChromiumVersion),
|
||||||
|
Args = new [] {
|
||||||
|
"--disable-gpu",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-setuid-sandbox",
|
||||||
|
"--no-sandbox"}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Publication[] GetPublications(string publicationTitle = "")
|
||||||
|
{
|
||||||
|
Log($"Searching Publications. Term=\"{publicationTitle}\"");
|
||||||
|
string requestUrl = $"https://mangasee123.com/_search.php";
|
||||||
|
DownloadClient.RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest(requestUrl, 1);
|
||||||
|
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
|
||||||
|
return Array.Empty<Publication>();
|
||||||
|
|
||||||
|
Publication[] publications = ParsePublicationsFromHtml(requestResult.result, publicationTitle);
|
||||||
|
cachedPublications.AddRange(publications);
|
||||||
|
Log($"Retrieved {publications.Length} publications. Term=\"{publicationTitle}\"");
|
||||||
|
return publications;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Publication[] ParsePublicationsFromHtml(Stream html, string publicationTitle)
|
||||||
|
{
|
||||||
|
string jsonString = new StreamReader(html).ReadToEnd();
|
||||||
|
List<SearchResultItem> result = JsonConvert.DeserializeObject<List<SearchResultItem>>(jsonString)!;
|
||||||
|
Dictionary<SearchResultItem, int> queryFiltered = new();
|
||||||
|
foreach (SearchResultItem resultItem in result)
|
||||||
|
{
|
||||||
|
int matches = resultItem.GetMatches(publicationTitle);
|
||||||
|
if (matches > 0)
|
||||||
|
queryFiltered.TryAdd(resultItem, matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryFiltered = queryFiltered.Where(item => item.Value >= publicationTitle.Split(' ').Length - 1)
|
||||||
|
.ToDictionary(item => item.Key, item => item.Value);
|
||||||
|
|
||||||
|
Log($"Retrieved {queryFiltered.Count} publications.");
|
||||||
|
|
||||||
|
HashSet<Publication> ret = new();
|
||||||
|
List<SearchResultItem> orderedFiltered =
|
||||||
|
queryFiltered.OrderBy(item => item.Value).ToDictionary(item => item.Key, item => item.Value).Keys.ToList();
|
||||||
|
|
||||||
|
uint index = 1;
|
||||||
|
foreach (SearchResultItem orderedItem in orderedFiltered)
|
||||||
|
{
|
||||||
|
DownloadClient.RequestResult requestResult =
|
||||||
|
downloadClient.MakeRequest($"https://mangasee123.com/manga/{orderedItem.i}", 1);
|
||||||
|
if ((int)requestResult.statusCode >= 200 || (int)requestResult.statusCode < 300)
|
||||||
|
{
|
||||||
|
Log($"Retrieving Publication info: {orderedItem.s} {index++}/{orderedFiltered.Count}");
|
||||||
|
ret.Add(ParseSinglePublicationFromHtml(requestResult.result, orderedItem.s, orderedItem.i, orderedItem.a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private Publication ParseSinglePublicationFromHtml(Stream html, string sortName, string publicationId, string[] a)
|
||||||
|
{
|
||||||
|
StreamReader reader = new (html);
|
||||||
|
HtmlDocument document = new ();
|
||||||
|
document.LoadHtml(reader.ReadToEnd());
|
||||||
|
|
||||||
|
string originalLanguage = "", status = "";
|
||||||
|
Dictionary<string, string> altTitles = new(), links = new();
|
||||||
|
HashSet<string> tags = new();
|
||||||
|
|
||||||
|
HtmlNode posterNode =
|
||||||
|
document.DocumentNode.Descendants("img").First(img => img.HasClass("img-fluid") && img.HasClass("bottom-5"));
|
||||||
|
string posterUrl = posterNode.GetAttributeValue("src", "");
|
||||||
|
string coverFileNameInCache = SaveCoverImageToCache(posterUrl, 1);
|
||||||
|
|
||||||
|
HtmlNode attributes = document.DocumentNode.Descendants("div")
|
||||||
|
.First(div => div.HasClass("col-md-9") && div.HasClass("col-sm-8") && div.HasClass("top-5"))
|
||||||
|
.Descendants("ul").First();
|
||||||
|
|
||||||
|
HtmlNode[] authorsNodes = attributes.Descendants("li")
|
||||||
|
.First(node => node.InnerText.Contains("author(s):", StringComparison.CurrentCultureIgnoreCase))
|
||||||
|
.Descendants("a").ToArray();
|
||||||
|
List<string> authors = new();
|
||||||
|
foreach(HtmlNode authorNode in authorsNodes)
|
||||||
|
authors.Add(authorNode.InnerText);
|
||||||
|
|
||||||
|
HtmlNode[] genreNodes = attributes.Descendants("li")
|
||||||
|
.First(node => node.InnerText.Contains("genre(s):", StringComparison.CurrentCultureIgnoreCase))
|
||||||
|
.Descendants("a").ToArray();
|
||||||
|
foreach (HtmlNode genreNode in genreNodes)
|
||||||
|
tags.Add(genreNode.InnerText);
|
||||||
|
|
||||||
|
HtmlNode yearNode = attributes.Descendants("li")
|
||||||
|
.First(node => node.InnerText.Contains("released:", StringComparison.CurrentCultureIgnoreCase))
|
||||||
|
.Descendants("a").First();
|
||||||
|
int year = Convert.ToInt32(yearNode.InnerText);
|
||||||
|
|
||||||
|
HtmlNode[] statusNodes = attributes.Descendants("li")
|
||||||
|
.First(node => node.InnerText.Contains("status:", StringComparison.CurrentCultureIgnoreCase))
|
||||||
|
.Descendants("a").ToArray();
|
||||||
|
foreach(HtmlNode statusNode in statusNodes)
|
||||||
|
if (statusNode.InnerText.Contains("publish", StringComparison.CurrentCultureIgnoreCase))
|
||||||
|
status = statusNode.InnerText.Split(' ')[0];
|
||||||
|
|
||||||
|
HtmlNode descriptionNode = attributes.Descendants("li").First(node => node.InnerText.Contains("description:", StringComparison.CurrentCultureIgnoreCase)).Descendants("div").First();
|
||||||
|
string description = descriptionNode.InnerText;
|
||||||
|
|
||||||
|
int i = 0;
|
||||||
|
foreach(string at in a)
|
||||||
|
altTitles.Add((i++).ToString(), at);
|
||||||
|
|
||||||
|
return new Publication(sortName, authors, description, altTitles, tags.ToArray(), posterUrl, coverFileNameInCache, links,
|
||||||
|
year, originalLanguage, status, publicationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReSharper disable once ClassNeverInstantiated.Local Will be instantiated during deserialization
|
||||||
|
private class SearchResultItem
|
||||||
|
{
|
||||||
|
public string i { get; init; }
|
||||||
|
public string s { get; init; }
|
||||||
|
public string[] a { get; init; }
|
||||||
|
|
||||||
|
[JsonConstructor]
|
||||||
|
public SearchResultItem(string i, string s, string[] a)
|
||||||
|
{
|
||||||
|
this.i = i;
|
||||||
|
this.s = s;
|
||||||
|
this.a = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetMatches(string title)
|
||||||
|
{
|
||||||
|
int ret = 0;
|
||||||
|
Regex cleanRex = new("[A-z0-9]*");
|
||||||
|
string[] badWords = { "a", "an", "no", "ni", "so", "as", "and", "the", "of", "that", "in", "is", "for" };
|
||||||
|
|
||||||
|
string[] titleTerms = title.Split(new[] { ' ', '-' }).Where(str => !badWords.Contains(str)).ToArray();
|
||||||
|
|
||||||
|
foreach (Match matchTerm in cleanRex.Matches(this.i))
|
||||||
|
ret += titleTerms.Count(titleTerm =>
|
||||||
|
titleTerm.Equals(matchTerm.Value, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
foreach (Match matchTerm in cleanRex.Matches(this.s))
|
||||||
|
ret += titleTerms.Count(titleTerm =>
|
||||||
|
titleTerm.Equals(matchTerm.Value, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
foreach(string alt in this.a)
|
||||||
|
foreach (Match matchTerm in cleanRex.Matches(alt))
|
||||||
|
ret += titleTerms.Count(titleTerm =>
|
||||||
|
titleTerm.Equals(matchTerm.Value, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Chapter[] GetChapters(Publication publication, string language="en")
|
||||||
|
{
|
||||||
|
Log($"Getting chapters {publication}");
|
||||||
|
XDocument doc = XDocument.Load($"https://mangasee123.com/rss/{publication.publicationId}.xml");
|
||||||
|
XElement[] chapterItems = doc.Descendants("item").ToArray();
|
||||||
|
List<Chapter> chapters = new();
|
||||||
|
foreach (XElement chapter in chapterItems)
|
||||||
|
{
|
||||||
|
string volumeNumber = "1";
|
||||||
|
string chapterName = chapter.Descendants("title").First().Value;
|
||||||
|
string chapterNumber = Regex.Matches(chapterName, "[0-9]+")[^1].ToString();
|
||||||
|
|
||||||
|
string url = chapter.Descendants("link").First().Value;
|
||||||
|
url = url.Replace(Regex.Matches(url,"(-page-[0-9])")[0].ToString(),"");
|
||||||
|
chapters.Add(new Chapter(publication, "", volumeNumber, chapterNumber, url));
|
||||||
|
}
|
||||||
|
|
||||||
|
//Return Chapters ordered by Chapter-Number
|
||||||
|
NumberFormatInfo chapterNumberFormatInfo = new()
|
||||||
|
{
|
||||||
|
NumberDecimalSeparator = "."
|
||||||
|
};
|
||||||
|
Log($"Got {chapters.Count} chapters. {publication}");
|
||||||
|
return chapters.OrderBy(chapter => Convert.ToSingle(chapter.chapterNumber, chapterNumberFormatInfo)).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override HttpStatusCode DownloadChapter(Chapter chapter, ProgressToken? progressToken = null)
|
||||||
|
{
|
||||||
|
if (progressToken?.cancellationRequested ?? false)
|
||||||
|
return HttpStatusCode.RequestTimeout;
|
||||||
|
while (this._browser is null && !(progressToken?.cancellationRequested??false))
|
||||||
|
{
|
||||||
|
Log("Waiting for headless browser to download...");
|
||||||
|
Thread.Sleep(1000);
|
||||||
|
}
|
||||||
|
if (progressToken?.cancellationRequested??false)
|
||||||
|
return HttpStatusCode.RequestTimeout;
|
||||||
|
|
||||||
|
Log($"Retrieving chapter-info {chapter} {chapter.parentPublication}");
|
||||||
|
IPage page = _browser!.NewPageAsync().Result;
|
||||||
|
IResponse response = page.GoToAsync(chapter.url).Result;
|
||||||
|
if (response.Ok)
|
||||||
|
{
|
||||||
|
HtmlDocument document = new ();
|
||||||
|
document.LoadHtml(page.GetContentAsync().Result);
|
||||||
|
|
||||||
|
HtmlNode gallery = document.DocumentNode.Descendants("div").First(div => div.HasClass("ImageGallery"));
|
||||||
|
HtmlNode[] images = gallery.Descendants("img").Where(img => img.HasClass("img-fluid")).ToArray();
|
||||||
|
List<string> urls = new();
|
||||||
|
foreach(HtmlNode galleryImage in images)
|
||||||
|
urls.Add(galleryImage.GetAttributeValue("src", ""));
|
||||||
|
|
||||||
|
string comicInfoPath = Path.GetTempFileName();
|
||||||
|
File.WriteAllText(comicInfoPath, chapter.GetComicInfoXmlString());
|
||||||
|
|
||||||
|
return DownloadChapterImages(urls.ToArray(), chapter.GetArchiveFilePath(settings.downloadLocation), 1, comicInfoPath, progressToken:progressToken);
|
||||||
|
}
|
||||||
|
return response.Status;
|
||||||
|
}
|
||||||
|
}
|
56
Tranga/NotificationConnectors/Gotify.cs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace Tranga.NotificationConnectors;
|
||||||
|
|
||||||
|
public class Gotify : NotificationConnector
|
||||||
|
{
|
||||||
|
public string endpoint { get; }
|
||||||
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
|
public string appToken { get; }
|
||||||
|
private readonly HttpClient _client = new();
|
||||||
|
|
||||||
|
[JsonConstructor]
|
||||||
|
public Gotify(GlobalBase clone, string endpoint, string appToken) : base(clone, NotificationManagerType.Gotify)
|
||||||
|
{
|
||||||
|
this.endpoint = endpoint;
|
||||||
|
this.appToken = appToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"Gotify {endpoint}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SendNotification(string title, string notificationText)
|
||||||
|
{
|
||||||
|
Log($"Sending notification: {title} - {notificationText}");
|
||||||
|
MessageData message = new(title, notificationText);
|
||||||
|
HttpRequestMessage request = new(HttpMethod.Post, $"{endpoint}/message");
|
||||||
|
request.Headers.Add("X-Gotify-Key", this.appToken);
|
||||||
|
request.Content = new StringContent(JsonConvert.SerializeObject(message, Formatting.None), Encoding.UTF8, "application/json");
|
||||||
|
HttpResponseMessage response = _client.Send(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
StreamReader sr = new (response.Content.ReadAsStream());
|
||||||
|
Log($"{response.StatusCode}: {sr.ReadToEnd()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MessageData
|
||||||
|
{
|
||||||
|
// ReSharper disable four times UnusedAutoPropertyAccessor.Local
|
||||||
|
public string message { get; }
|
||||||
|
public long priority { get; }
|
||||||
|
public string title { get; }
|
||||||
|
public Dictionary<string, object> extras { get; }
|
||||||
|
|
||||||
|
public MessageData(string title, string message)
|
||||||
|
{
|
||||||
|
this.title = title;
|
||||||
|
this.message = message;
|
||||||
|
this.extras = new();
|
||||||
|
this.priority = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
49
Tranga/NotificationConnectors/LunaSea.cs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace Tranga.NotificationConnectors;
|
||||||
|
|
||||||
|
public class LunaSea : NotificationConnector
|
||||||
|
{
|
||||||
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
|
public string id { get; init; }
|
||||||
|
private readonly HttpClient _client = new();
|
||||||
|
|
||||||
|
[JsonConstructor]
|
||||||
|
public LunaSea(GlobalBase clone, string id) : base(clone, NotificationManagerType.LunaSea)
|
||||||
|
{
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"LunaSea {id}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SendNotification(string title, string notificationText)
|
||||||
|
{
|
||||||
|
Log($"Sending notification: {title} - {notificationText}");
|
||||||
|
MessageData message = new(title, notificationText);
|
||||||
|
HttpRequestMessage request = new(HttpMethod.Post, $"https://notify.lunasea.app/v1/custom/{id}");
|
||||||
|
request.Content = new StringContent(JsonConvert.SerializeObject(message, Formatting.None), Encoding.UTF8, "application/json");
|
||||||
|
HttpResponseMessage response = _client.Send(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
StreamReader sr = new (response.Content.ReadAsStream());
|
||||||
|
Log($"{response.StatusCode}: {sr.ReadToEnd()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class MessageData
|
||||||
|
{
|
||||||
|
// ReSharper disable twice UnusedAutoPropertyAccessor.Local
|
||||||
|
public string title { get; }
|
||||||
|
public string body { get; }
|
||||||
|
|
||||||
|
public MessageData(string title, string body)
|
||||||
|
{
|
||||||
|
this.title = title;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
15
Tranga/NotificationConnectors/NotificationConnector.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
namespace Tranga.NotificationConnectors;
|
||||||
|
|
||||||
|
public abstract class NotificationConnector : GlobalBase
|
||||||
|
{
|
||||||
|
public readonly NotificationManagerType notificationManagerType;
|
||||||
|
|
||||||
|
protected NotificationConnector(GlobalBase clone, NotificationManagerType notificationManagerType) : base(clone)
|
||||||
|
{
|
||||||
|
this.notificationManagerType = notificationManagerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NotificationManagerType : byte { Gotify = 0, LunaSea = 1 }
|
||||||
|
|
||||||
|
public abstract void SendNotification(string title, string notificationText);
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Tranga.NotificationConnectors;
|
||||||
|
|
||||||
|
public class NotificationManagerJsonConverter : JsonConverter
|
||||||
|
{
|
||||||
|
public override bool CanConvert(Type objectType)
|
||||||
|
{
|
||||||
|
return (objectType == typeof(NotificationConnector));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue,
|
||||||
|
JsonSerializer serializer)
|
||||||
|
{
|
||||||
|
JObject jo = JObject.Load(reader);
|
||||||
|
if (jo["notificationManagerType"]!.Value<byte>() == (byte)NotificationConnector.NotificationManagerType.Gotify)
|
||||||
|
return jo.ToObject<Gotify>(serializer)!;
|
||||||
|
else if (jo["notificationManagerType"]!.Value<byte>() == (byte)NotificationConnector.NotificationManagerType.LunaSea)
|
||||||
|
return jo.ToObject<LunaSea>(serializer)!;
|
||||||
|
|
||||||
|
throw new Exception();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanWrite => false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Don't call this
|
||||||
|
/// </summary>
|
||||||
|
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||||
|
{
|
||||||
|
throw new Exception("Dont call this");
|
||||||
|
}
|
||||||
|
}
|
@ -1,36 +1,45 @@
|
|||||||
using System.Text;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
using static System.IO.UnixFileMode;
|
||||||
|
|
||||||
namespace Tranga;
|
namespace Tranga;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Contains information on a Publication (Manga)
|
/// Contains information on a Publication (Manga)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly struct Publication
|
public struct Publication
|
||||||
{
|
{
|
||||||
public string sortName { get; }
|
public string sortName { get; }
|
||||||
public string? author { get; }
|
public List<string> authors { get; }
|
||||||
|
// ReSharper disable once UnusedAutoPropertyAccessor.Global
|
||||||
public Dictionary<string,string> altTitles { get; }
|
public Dictionary<string,string> altTitles { get; }
|
||||||
// ReSharper disable trice MemberCanBePrivate.Global, trust
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
public string? description { get; }
|
public string? description { get; }
|
||||||
public string[] tags { get; }
|
public string[] tags { get; }
|
||||||
|
// ReSharper disable once UnusedAutoPropertyAccessor.Global
|
||||||
public string? posterUrl { get; }
|
public string? posterUrl { get; }
|
||||||
public string? coverFileNameInCache { get; }
|
public string? coverFileNameInCache { get; }
|
||||||
|
// ReSharper disable once UnusedAutoPropertyAccessor.Global
|
||||||
public Dictionary<string,string> links { get; }
|
public Dictionary<string,string> links { get; }
|
||||||
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
public int? year { get; }
|
public int? year { get; }
|
||||||
public string? originalLanguage { get; }
|
public string? originalLanguage { get; }
|
||||||
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
public string status { get; }
|
public string status { get; }
|
||||||
public string folderName { get; }
|
public string folderName { get; }
|
||||||
public string publicationId { get; }
|
public string publicationId { get; }
|
||||||
public string internalId { get; }
|
public string internalId { get; }
|
||||||
|
public float ignoreChaptersBelow { get; set; }
|
||||||
|
|
||||||
private static readonly Regex LegalCharacters = new Regex(@"([A-z]*[0-9]* *\.*-*,*\]*\[*'*\'*\)*\(*~*!*)*");
|
private static readonly Regex LegalCharacters = new (@"[A-Z]*[a-z]*[0-9]* *\.*-*,*'*\'*\)*\(*~*!*");
|
||||||
|
|
||||||
public Publication(string sortName, string? author, string? description, Dictionary<string,string> altTitles, string[] tags, string? posterUrl, string? coverFileNameInCache, Dictionary<string,string>? links, int? year, string? originalLanguage, string status, string publicationId)
|
[JsonConstructor]
|
||||||
|
public Publication(string sortName, List<string> authors, string? description, Dictionary<string,string> altTitles, string[] tags, string? posterUrl, string? coverFileNameInCache, Dictionary<string,string>? links, int? year, string? originalLanguage, string status, string publicationId, string? folderName = null, float? ignoreChaptersBelow = 0)
|
||||||
{
|
{
|
||||||
this.sortName = sortName;
|
this.sortName = sortName;
|
||||||
this.author = author;
|
this.authors = authors;
|
||||||
this.description = description;
|
this.description = description;
|
||||||
this.altTitles = altTitles;
|
this.altTitles = altTitles;
|
||||||
this.tags = tags;
|
this.tags = tags;
|
||||||
@ -41,15 +50,41 @@ public readonly struct Publication
|
|||||||
this.originalLanguage = originalLanguage;
|
this.originalLanguage = originalLanguage;
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.publicationId = publicationId;
|
this.publicationId = publicationId;
|
||||||
this.folderName = string.Concat(LegalCharacters.Matches(sortName));
|
this.folderName = folderName ?? string.Concat(LegalCharacters.Matches(sortName));
|
||||||
while (this.folderName.EndsWith('.'))
|
while (this.folderName.EndsWith('.'))
|
||||||
this.folderName = this.folderName.Substring(0, this.folderName.Length - 1);
|
this.folderName = this.folderName.Substring(0, this.folderName.Length - 1);
|
||||||
string onlyLowerLetters = string.Concat(this.sortName.ToLower().Where(Char.IsLetter));
|
string onlyLowerLetters = string.Concat(this.sortName.ToLower().Where(Char.IsLetter));
|
||||||
this.internalId = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{onlyLowerLetters}{this.year}"));
|
this.internalId = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{onlyLowerLetters}{this.year}"));
|
||||||
|
this.ignoreChaptersBelow = ignoreChaptersBelow ?? 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return $"Publication {sortName} {internalId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CreatePublicationFolder(string downloadDirectory)
|
||||||
|
{
|
||||||
|
string publicationFolder = Path.Join(downloadDirectory, this.folderName);
|
||||||
|
if(!Directory.Exists(publicationFolder))
|
||||||
|
Directory.CreateDirectory(publicationFolder);
|
||||||
|
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
File.SetUnixFileMode(publicationFolder, GroupRead | GroupWrite | GroupExecute | OtherRead | OtherWrite | OtherExecute | UserRead | UserWrite | UserExecute);
|
||||||
|
return publicationFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveSeriesInfoJson(string downloadDirectory)
|
||||||
|
{
|
||||||
|
string publicationFolder = CreatePublicationFolder(downloadDirectory);
|
||||||
|
string seriesInfoPath = Path.Join(publicationFolder, "series.json");
|
||||||
|
if(!File.Exists(seriesInfoPath))
|
||||||
|
File.WriteAllText(seriesInfoPath,this.GetSeriesInfoJson());
|
||||||
|
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
File.SetUnixFileMode(seriesInfoPath, GroupRead | GroupWrite | OtherRead | OtherWrite | UserRead | UserWrite);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <returns>Serialized JSON String for series.json</returns>
|
/// <returns>Serialized JSON String for series.json</returns>
|
||||||
public string GetSeriesInfoJson()
|
private string GetSeriesInfoJson()
|
||||||
{
|
{
|
||||||
SeriesInfo si = new (new Metadata(this.sortName, this.year.ToString() ?? string.Empty, this.status, this.description ?? ""));
|
SeriesInfo si = new (new Metadata(this.sortName, this.year.ToString() ?? string.Empty, this.status, this.description ?? ""));
|
||||||
return System.Text.Json.JsonSerializer.Serialize(si);
|
return System.Text.Json.JsonSerializer.Serialize(si);
|
||||||
@ -85,9 +120,9 @@ public readonly struct Publication
|
|||||||
{
|
{
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.year = year;
|
this.year = year;
|
||||||
if(status == "ongoing" || status == "hiatus")
|
if(status.ToLower() == "ongoing" || status.ToLower() == "hiatus")
|
||||||
this.status = "Continuing";
|
this.status = "Continuing";
|
||||||
else if (status == "completed" || status == "cancelled")
|
else if (status.ToLower() == "completed" || status.ToLower() == "cancelled" || status.ToLower() == "discontinued")
|
||||||
this.status = "Ended";
|
this.status = "Ended";
|
||||||
else
|
else
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
421
Tranga/Server.cs
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Tranga.Jobs;
|
||||||
|
using Tranga.LibraryConnectors;
|
||||||
|
using Tranga.MangaConnectors;
|
||||||
|
using Tranga.NotificationConnectors;
|
||||||
|
|
||||||
|
namespace Tranga;
|
||||||
|
|
||||||
|
public class Server : GlobalBase
|
||||||
|
{
|
||||||
|
private readonly HttpListener _listener = new ();
|
||||||
|
private readonly Tranga _parent;
|
||||||
|
|
||||||
|
public Server(Tranga parent) : base(parent)
|
||||||
|
{
|
||||||
|
this._parent = parent;
|
||||||
|
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
this._listener.Prefixes.Add($"http://*:{settings.apiPortNumber}/");
|
||||||
|
else
|
||||||
|
this._listener.Prefixes.Add($"http://localhost:{settings.apiPortNumber}/");
|
||||||
|
Thread listenThread = new (Listen);
|
||||||
|
listenThread.Start();
|
||||||
|
Thread watchThread = new(WatchRunning);
|
||||||
|
watchThread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WatchRunning()
|
||||||
|
{
|
||||||
|
while(_parent.keepRunning)
|
||||||
|
Thread.Sleep(1000);
|
||||||
|
this._listener.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Listen()
|
||||||
|
{
|
||||||
|
this._listener.Start();
|
||||||
|
foreach(string prefix in this._listener.Prefixes)
|
||||||
|
Log($"Listening on {prefix}");
|
||||||
|
while (this._listener.IsListening && _parent.keepRunning)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HttpListenerContext context = this._listener.GetContext();
|
||||||
|
Log($"{context.Request.HttpMethod} {context.Request.Url} {context.Request.UserAgent}");
|
||||||
|
Task t = new(() =>
|
||||||
|
{
|
||||||
|
HandleRequest(context);
|
||||||
|
});
|
||||||
|
t.Start();
|
||||||
|
}
|
||||||
|
catch (HttpListenerException e)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleRequest(HttpListenerContext context)
|
||||||
|
{
|
||||||
|
HttpListenerRequest request = context.Request;
|
||||||
|
HttpListenerResponse response = context.Response;
|
||||||
|
if(request.HttpMethod == "OPTIONS")
|
||||||
|
SendResponse(HttpStatusCode.OK, context.Response);
|
||||||
|
if(request.Url!.LocalPath.Contains("favicon"))
|
||||||
|
SendResponse(HttpStatusCode.NoContent, response);
|
||||||
|
|
||||||
|
switch (request.HttpMethod)
|
||||||
|
{
|
||||||
|
case "GET":
|
||||||
|
HandleGet(request, response);
|
||||||
|
break;
|
||||||
|
case "POST":
|
||||||
|
HandlePost(request, response);
|
||||||
|
break;
|
||||||
|
case "DELETE":
|
||||||
|
HandleDelete(request, response);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, string> GetRequestVariables(string query)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> ret = new();
|
||||||
|
Regex queryRex = new (@"\?{1}&?([A-z0-9-=]+=[A-z0-9-=]+)+(&[A-z0-9-=]+=[A-z0-9-=]+)*");
|
||||||
|
if (!queryRex.IsMatch(query))
|
||||||
|
return ret;
|
||||||
|
query = query.Substring(1);
|
||||||
|
foreach (string keyValuePair in query.Split('&').Where(str => str.Length >= 3))
|
||||||
|
{
|
||||||
|
string var = keyValuePair.Split('=')[0];
|
||||||
|
string val = Regex.Replace(keyValuePair.Substring(var.Length + 1), "%20", " ");
|
||||||
|
val = Regex.Replace(val, "%[0-9]{2}", "");
|
||||||
|
ret.Add(var, val);
|
||||||
|
}
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleGet(HttpListenerRequest request, HttpListenerResponse response)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> requestVariables = GetRequestVariables(request.Url!.Query);
|
||||||
|
string? connectorName, jobId;
|
||||||
|
MangaConnector connector;
|
||||||
|
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 "Publications/FromConnector":
|
||||||
|
if (!requestVariables.TryGetValue("connector", out connectorName) ||
|
||||||
|
!requestVariables.TryGetValue("title", out string? title) ||
|
||||||
|
_parent.GetConnector(connectorName) is null)
|
||||||
|
{
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
connector = _parent.GetConnector(connectorName)!;
|
||||||
|
SendResponse(HttpStatusCode.OK, response, connector.GetPublications(title));
|
||||||
|
break;
|
||||||
|
case "Publications/Chapters":
|
||||||
|
if(!requestVariables.TryGetValue("connector", out connectorName) ||
|
||||||
|
!requestVariables.TryGetValue("internalId", out string? internalId) ||
|
||||||
|
_parent.GetConnector(connectorName) is null ||
|
||||||
|
_parent.GetPublicationById(internalId) is null)
|
||||||
|
{
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
connector = _parent.GetConnector(connectorName)!;
|
||||||
|
Publication publication = (Publication)_parent.GetPublicationById(internalId)!;
|
||||||
|
SendResponse(HttpStatusCode.OK, response, connector.GetChapters(publication));
|
||||||
|
break;
|
||||||
|
case "Tasks":
|
||||||
|
if (!requestVariables.TryGetValue("jobId", out jobId))
|
||||||
|
{
|
||||||
|
if(!_parent._jobBoss.jobs.Any(jjob => jjob.id == jobId))
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
else
|
||||||
|
SendResponse(HttpStatusCode.OK, response, _parent._jobBoss.jobs.First(jjob => jjob.id == jobId));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
SendResponse(HttpStatusCode.OK, response, _parent._jobBoss.jobs);
|
||||||
|
break;
|
||||||
|
case "Tasks/Progress":
|
||||||
|
if (!requestVariables.TryGetValue("jobId", out jobId))
|
||||||
|
{
|
||||||
|
if(!_parent._jobBoss.jobs.Any(jjob => jjob.id == jobId))
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
else
|
||||||
|
SendResponse(HttpStatusCode.OK, response, _parent._jobBoss.jobs.First(jjob => jjob.id == jobId).progressToken);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
SendResponse(HttpStatusCode.OK, response, _parent._jobBoss.jobs.Select(jjob => jjob.progressToken));
|
||||||
|
break;
|
||||||
|
case "Tasks/Running":
|
||||||
|
SendResponse(HttpStatusCode.OK, response, _parent._jobBoss.jobs.Where(jjob => jjob.progressToken.state is ProgressToken.State.Running));
|
||||||
|
break;
|
||||||
|
case "Tasks/Waiting":
|
||||||
|
SendResponse(HttpStatusCode.OK, response, _parent._jobBoss.jobs.Where(jjob => jjob.progressToken.state is ProgressToken.State.Standby));
|
||||||
|
break;
|
||||||
|
case "Settings":
|
||||||
|
SendResponse(HttpStatusCode.OK, response, settings);
|
||||||
|
break;
|
||||||
|
case "NotificationConnectors":
|
||||||
|
SendResponse(HttpStatusCode.OK, response, notificationConnectors);
|
||||||
|
break;
|
||||||
|
case "NotificationsConnectors/Types":
|
||||||
|
SendResponse(HttpStatusCode.OK, response, Enum.GetNames(typeof(NotificationConnector.NotificationManagerType)));
|
||||||
|
break;
|
||||||
|
case "LibraryConnectors":
|
||||||
|
SendResponse(HttpStatusCode.OK, response, libraryConnectors);
|
||||||
|
break;
|
||||||
|
case "LibraryConnectors/Types":
|
||||||
|
SendResponse(HttpStatusCode.OK, response, Enum.GetNames(typeof(LibraryConnector.LibraryType)));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandlePost(HttpListenerRequest request, HttpListenerResponse response)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> requestVariables = GetRequestVariables(request.Url!.Query);
|
||||||
|
string? connectorName, internalId;
|
||||||
|
MangaConnector connector;
|
||||||
|
Publication publication;
|
||||||
|
string path = Regex.Match(request.Url!.LocalPath, @"[A-z0-9]+(\/[A-z0-9]+)*").Value;
|
||||||
|
switch (path)
|
||||||
|
{
|
||||||
|
case "Tasks/MonitorManga":
|
||||||
|
if(!requestVariables.TryGetValue("connector", out connectorName) ||
|
||||||
|
!requestVariables.TryGetValue("internalId", out internalId) ||
|
||||||
|
!requestVariables.TryGetValue("interval", out string? intervalStr) ||
|
||||||
|
_parent.GetConnector(connectorName) is null ||
|
||||||
|
_parent.GetPublicationById(internalId) is null ||
|
||||||
|
!TimeSpan.TryParse(intervalStr, out TimeSpan interval))
|
||||||
|
{
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
connector = _parent.GetConnector(connectorName)!;
|
||||||
|
publication = (Publication)_parent.GetPublicationById(internalId)!;
|
||||||
|
_parent._jobBoss.AddJob(new DownloadNewChapters(this, connector, publication, true, interval));
|
||||||
|
SendResponse(HttpStatusCode.Accepted, response);
|
||||||
|
break;
|
||||||
|
case "Tasks/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)!;
|
||||||
|
publication = (Publication)_parent.GetPublicationById(internalId)!;
|
||||||
|
_parent._jobBoss.AddJob(new DownloadNewChapters(this, connector, publication, false));
|
||||||
|
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.NotificationManagerType notificationManagerType))
|
||||||
|
{
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notificationManagerType is NotificationConnector.NotificationManagerType.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 (notificationManagerType is NotificationConnector.NotificationManagerType.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 "LibraryManagers/Update":
|
||||||
|
if (!requestVariables.TryGetValue("libraryManager", out string? libraryManagerStr) ||
|
||||||
|
!Enum.TryParse(libraryManagerStr,
|
||||||
|
out LibraryConnector.LibraryType libraryManagerType))
|
||||||
|
{
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraryManagerType 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 (libraryManagerType 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;
|
||||||
|
default:
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleDelete(HttpListenerRequest request, HttpListenerResponse response)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> requestVariables = GetRequestVariables(request.Url!.Query);
|
||||||
|
string? connectorName, internalId;
|
||||||
|
MangaConnector connector;
|
||||||
|
Publication publication;
|
||||||
|
string path = Regex.Match(request.Url!.LocalPath, @"[A-z0-9]+(\/[A-z0-9]+)*").Value;
|
||||||
|
switch (path)
|
||||||
|
{
|
||||||
|
case "Tasks/DownloadChapter":
|
||||||
|
if(!requestVariables.TryGetValue("connector", out connectorName) ||
|
||||||
|
!requestVariables.TryGetValue("internalId", out internalId) ||
|
||||||
|
!requestVariables.TryGetValue("chapterNumber", out string? chapterNumber) ||
|
||||||
|
_parent.GetConnector(connectorName) is null ||
|
||||||
|
_parent.GetPublicationById(internalId) is null)
|
||||||
|
{
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_parent._jobBoss.RemoveJobs(_parent._jobBoss.GetJobsLike(connectorName, internalId, chapterNumber));
|
||||||
|
SendResponse(HttpStatusCode.Accepted, response);
|
||||||
|
break;
|
||||||
|
case "Tasks/MonitorManga":
|
||||||
|
if(!requestVariables.TryGetValue("connector", out connectorName) ||
|
||||||
|
!requestVariables.TryGetValue("internalId", out internalId) ||
|
||||||
|
_parent.GetConnector(connectorName) is null ||
|
||||||
|
_parent.GetPublicationById(internalId) is null)
|
||||||
|
{
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
connector = _parent.GetConnector(connectorName)!;
|
||||||
|
publication = (Publication)_parent.GetPublicationById(internalId)!;
|
||||||
|
_parent._jobBoss.RemoveJobs(_parent._jobBoss.GetJobsLike(connector, publication));
|
||||||
|
SendResponse(HttpStatusCode.Accepted, response);
|
||||||
|
break;
|
||||||
|
case "Tasks/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)!;
|
||||||
|
publication = (Publication)_parent.GetPublicationById(internalId)!;
|
||||||
|
_parent._jobBoss.RemoveJobs(_parent._jobBoss.GetJobsLike(connector, publication));
|
||||||
|
SendResponse(HttpStatusCode.Accepted, response);
|
||||||
|
break;
|
||||||
|
case "NotificationConnectors":
|
||||||
|
if (!requestVariables.TryGetValue("notificationConnector", out string? notificationConnectorStr) ||
|
||||||
|
!Enum.TryParse(notificationConnectorStr, out NotificationConnector.NotificationManagerType notificationManagerType))
|
||||||
|
{
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
DeleteNotificationConnector(notificationManagerType);
|
||||||
|
SendResponse(HttpStatusCode.Accepted, response);
|
||||||
|
break;
|
||||||
|
case "LibraryManagers":
|
||||||
|
if (!requestVariables.TryGetValue("libraryManager", out string? libraryManagerStr) ||
|
||||||
|
!Enum.TryParse(libraryManagerStr,
|
||||||
|
out LibraryConnector.LibraryType libraryManagerType))
|
||||||
|
{
|
||||||
|
SendResponse(HttpStatusCode.BadRequest, response);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
DeleteLibraryConnector(libraryManagerType);
|
||||||
|
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", "*");
|
||||||
|
response.ContentType = "application/json";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response.OutputStream.Write(content is not null
|
||||||
|
? Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(content))
|
||||||
|
: Array.Empty<byte>());
|
||||||
|
response.OutputStream.Close();
|
||||||
|
}
|
||||||
|
catch (HttpListenerException e)
|
||||||
|
{
|
||||||
|
Log(e.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,129 +0,0 @@
|
|||||||
using Logging;
|
|
||||||
|
|
||||||
namespace Tranga;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Executes TrangaTasks
|
|
||||||
/// Based on the TrangaTask.Task a method is called.
|
|
||||||
/// The chapterCollection is updated with new Publications/Chapters.
|
|
||||||
/// </summary>
|
|
||||||
public static class TaskExecutor
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Executes TrangaTask.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="taskManager">Parent</param>
|
|
||||||
/// <param name="trangaTask">Task to execute</param>
|
|
||||||
/// <param name="chapterCollection">Current chapterCollection to update</param>
|
|
||||||
/// <param name="logger"></param>
|
|
||||||
/// <exception cref="ArgumentException">Is thrown when there is no Connector available with the name of the TrangaTask.connectorName</exception>
|
|
||||||
public static void Execute(TaskManager taskManager, TrangaTask trangaTask, Logger? logger)
|
|
||||||
{
|
|
||||||
//Only execute task if it is not already being executed.
|
|
||||||
if (trangaTask.state == TrangaTask.ExecutionState.Running)
|
|
||||||
{
|
|
||||||
logger?.WriteLine("TaskExecutor", $"Task already running {trangaTask}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
trangaTask.state = TrangaTask.ExecutionState.Running;
|
|
||||||
logger?.WriteLine("TaskExecutor", $"Starting Task {trangaTask}");
|
|
||||||
|
|
||||||
//Connector is not needed for all tasks
|
|
||||||
Connector? connector = null;
|
|
||||||
if (trangaTask.task != TrangaTask.Task.UpdateKomgaLibrary)
|
|
||||||
connector = taskManager.GetConnector(trangaTask.connectorName!);
|
|
||||||
|
|
||||||
//Call appropriate Method based on TrangaTask.Task
|
|
||||||
switch (trangaTask.task)
|
|
||||||
{
|
|
||||||
case TrangaTask.Task.DownloadNewChapters:
|
|
||||||
DownloadNewChapters(connector!, (Publication)trangaTask.publication!, trangaTask.language, ref taskManager._chapterCollection, taskManager.settings);
|
|
||||||
break;
|
|
||||||
case TrangaTask.Task.UpdateChapters:
|
|
||||||
UpdateChapters(connector!, (Publication)trangaTask.publication!, trangaTask.language, ref taskManager._chapterCollection);
|
|
||||||
break;
|
|
||||||
case TrangaTask.Task.UpdatePublications:
|
|
||||||
UpdatePublications(connector!, ref taskManager._chapterCollection);
|
|
||||||
break;
|
|
||||||
case TrangaTask.Task.UpdateKomgaLibrary:
|
|
||||||
UpdateKomgaLibrary(taskManager);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger?.WriteLine("TaskExecutor", $"Task finished! {trangaTask}");
|
|
||||||
trangaTask.lastExecuted = DateTime.Now;
|
|
||||||
trangaTask.state = TrangaTask.ExecutionState.Waiting;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates all Komga-Libraries
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="taskManager">Parent</param>
|
|
||||||
private static void UpdateKomgaLibrary(TaskManager taskManager)
|
|
||||||
{
|
|
||||||
if (taskManager.komga is null)
|
|
||||||
return;
|
|
||||||
Komga komga = taskManager.komga;
|
|
||||||
|
|
||||||
Komga.KomgaLibrary[] allLibraries = komga.GetLibraries();
|
|
||||||
foreach (Komga.KomgaLibrary lib in allLibraries)
|
|
||||||
komga.UpdateLibrary(lib.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the available Publications from a Connector (all of them)
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="connector">Connector to receive Publications from</param>
|
|
||||||
/// <param name="chapterCollection"></param>
|
|
||||||
private static void UpdatePublications(Connector connector, ref Dictionary<Publication, List<Chapter>> chapterCollection)
|
|
||||||
{
|
|
||||||
Publication[] publications = connector.GetPublications();
|
|
||||||
foreach (Publication publication in publications)
|
|
||||||
chapterCollection.TryAdd(publication, new List<Chapter>());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks for new Chapters and Downloads new ones.
|
|
||||||
/// If no Chapters had been downloaded previously, download also cover and create series.json
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="connector">Connector to use</param>
|
|
||||||
/// <param name="publication">Publication to check</param>
|
|
||||||
/// <param name="language">Language to receive chapters for</param>
|
|
||||||
/// <param name="chapterCollection"></param>
|
|
||||||
private static void DownloadNewChapters(Connector connector, Publication publication, string language, ref Dictionary<Publication, List<Chapter>> chapterCollection, TrangaSettings settings)
|
|
||||||
{
|
|
||||||
//Check if Publication already has a Folder
|
|
||||||
string publicationFolder = Path.Join(connector.downloadLocation, publication.folderName);
|
|
||||||
if(!Directory.Exists(publicationFolder))
|
|
||||||
Directory.CreateDirectory(publicationFolder);
|
|
||||||
List<Chapter> newChapters = UpdateChapters(connector, publication, language, ref chapterCollection);
|
|
||||||
|
|
||||||
connector.CloneCoverFromCache(publication, settings);
|
|
||||||
|
|
||||||
string seriesInfoPath = Path.Join(publicationFolder, "series.json");
|
|
||||||
if(!File.Exists(seriesInfoPath))
|
|
||||||
File.WriteAllText(seriesInfoPath,publication.GetSeriesInfoJson());
|
|
||||||
|
|
||||||
foreach(Chapter newChapter in newChapters)
|
|
||||||
connector.DownloadChapter(publication, newChapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates the available Chapters of a Publication
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="connector">Connector to use</param>
|
|
||||||
/// <param name="publication">Publication to check</param>
|
|
||||||
/// <param name="language">Language to receive chapters for</param>
|
|
||||||
/// <param name="chapterCollection"></param>
|
|
||||||
/// <returns>List of Chapters that were previously not in collection</returns>
|
|
||||||
private static List<Chapter> UpdateChapters(Connector connector, Publication publication, string language, ref Dictionary<Publication, List<Chapter>> chapterCollection)
|
|
||||||
{
|
|
||||||
List<Chapter> newChaptersList = new();
|
|
||||||
chapterCollection.TryAdd(publication, newChaptersList); //To ensure publication is actually in collection
|
|
||||||
|
|
||||||
Chapter[] newChapters = connector.GetChapters(publication, language);
|
|
||||||
newChaptersList = newChapters.Where(nChapter => !connector.ChapterIsDownloaded(publication, nChapter)).ToList();
|
|
||||||
|
|
||||||
return newChaptersList;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,339 +0,0 @@
|
|||||||
using Logging;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
using Tranga.Connectors;
|
|
||||||
|
|
||||||
namespace Tranga;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Manages all TrangaTasks.
|
|
||||||
/// Provides a Threaded environment to execute Tasks, and still manage the Task-Collection
|
|
||||||
/// </summary>
|
|
||||||
public class TaskManager
|
|
||||||
{
|
|
||||||
public Dictionary<Publication, List<Chapter>> _chapterCollection = new();
|
|
||||||
private HashSet<TrangaTask> _allTasks;
|
|
||||||
private bool _continueRunning = true;
|
|
||||||
private readonly Connector[] _connectors;
|
|
||||||
private readonly Dictionary<Connector, List<TrangaTask>> _taskQueue = new();
|
|
||||||
public TrangaSettings settings { get; }
|
|
||||||
private Logger? logger { get; }
|
|
||||||
public Komga? komga => settings.komga;
|
|
||||||
|
|
||||||
/// <param name="downloadFolderPath">Local path to save data (Manga) to</param>
|
|
||||||
/// <param name="workingDirectory">Path to the working directory</param>
|
|
||||||
/// <param name="imageCachePath">Path to the cover-image cache</param>
|
|
||||||
/// <param name="komgaBaseUrl">The Url of the Komga-instance that you want to update</param>
|
|
||||||
/// <param name="komgaUsername">The Komga username</param>
|
|
||||||
/// <param name="komgaPassword">The Komga password</param>
|
|
||||||
/// <param name="logger"></param>
|
|
||||||
public TaskManager(string downloadFolderPath, string workingDirectory, string imageCachePath, string? komgaBaseUrl = null, string? komgaUsername = null, string? komgaPassword = null, Logger? logger = null)
|
|
||||||
{
|
|
||||||
this.logger = logger;
|
|
||||||
_allTasks = new HashSet<TrangaTask>();
|
|
||||||
|
|
||||||
Komga? newKomga = null;
|
|
||||||
if (komgaBaseUrl != null && komgaUsername != null && komgaPassword != null)
|
|
||||||
newKomga = new Komga(komgaBaseUrl, komgaUsername, komgaPassword, logger);
|
|
||||||
|
|
||||||
this.settings = new TrangaSettings(downloadFolderPath, workingDirectory, newKomga);
|
|
||||||
ExportData();
|
|
||||||
|
|
||||||
this._connectors = new Connector[]{ new MangaDex(downloadFolderPath, imageCachePath, logger) };
|
|
||||||
foreach(Connector cConnector in this._connectors)
|
|
||||||
_taskQueue.Add(cConnector, new List<TrangaTask>());
|
|
||||||
|
|
||||||
Thread taskChecker = new(TaskCheckerThread);
|
|
||||||
taskChecker.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateSettings(string? downloadLocation, string? komgaUrl, string? komgaAuth)
|
|
||||||
{
|
|
||||||
Komga? komga = null;
|
|
||||||
if (komgaUrl is not null && komgaAuth is not null)
|
|
||||||
komga = new Komga(komgaUrl, komgaAuth, null);
|
|
||||||
settings.downloadLocation = downloadLocation ?? settings.downloadLocation;
|
|
||||||
settings.komga = komga ?? komga;
|
|
||||||
ExportData();
|
|
||||||
}
|
|
||||||
|
|
||||||
public TaskManager(TrangaSettings settings, Logger? logger = null)
|
|
||||||
{
|
|
||||||
this.logger = logger;
|
|
||||||
this._connectors = new Connector[]{ new MangaDex(settings.downloadLocation, settings.coverImageCache, logger) };
|
|
||||||
foreach(Connector cConnector in this._connectors)
|
|
||||||
_taskQueue.Add(cConnector, new List<TrangaTask>());
|
|
||||||
_allTasks = new HashSet<TrangaTask>();
|
|
||||||
|
|
||||||
this.settings = settings;
|
|
||||||
ImportData();
|
|
||||||
ExportData();
|
|
||||||
Thread taskChecker = new(TaskCheckerThread);
|
|
||||||
taskChecker.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Runs continuously until shutdown.
|
|
||||||
/// Checks if tasks have to be executed (time elapsed)
|
|
||||||
/// </summary>
|
|
||||||
private void TaskCheckerThread()
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), "Starting TaskCheckerThread.");
|
|
||||||
while (_continueRunning)
|
|
||||||
{
|
|
||||||
//Check if previous tasks have finished and execute new tasks
|
|
||||||
foreach (KeyValuePair<Connector, List<TrangaTask>> connectorTaskQueue in _taskQueue)
|
|
||||||
{
|
|
||||||
if(connectorTaskQueue.Value.RemoveAll(task => task.state == TrangaTask.ExecutionState.Waiting) > 0)
|
|
||||||
ExportData();
|
|
||||||
|
|
||||||
if (connectorTaskQueue.Value.Count > 0 && connectorTaskQueue.Value.All(task => task.state is TrangaTask.ExecutionState.Enqueued))
|
|
||||||
ExecuteTaskNow(connectorTaskQueue.Value.First());
|
|
||||||
}
|
|
||||||
|
|
||||||
//Check if task should be executed
|
|
||||||
//Depending on type execute immediately or enqueue
|
|
||||||
foreach (TrangaTask task in _allTasks.Where(aTask => aTask.ShouldExecute()))
|
|
||||||
{
|
|
||||||
task.state = TrangaTask.ExecutionState.Enqueued;
|
|
||||||
if(task.connectorName is null)
|
|
||||||
ExecuteTaskNow(task);
|
|
||||||
else
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Task due: {task}");
|
|
||||||
_taskQueue[GetConnector(task.connectorName!)].Add(task);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Thread.Sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Forces the execution of a given task
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="task">Task to execute</param>
|
|
||||||
public void ExecuteTaskNow(TrangaTask task)
|
|
||||||
{
|
|
||||||
if (!this._allTasks.Contains(task))
|
|
||||||
return;
|
|
||||||
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Forcing Execution: {task}");
|
|
||||||
Task t = new Task(() =>
|
|
||||||
{
|
|
||||||
TaskExecutor.Execute(this, task, logger);
|
|
||||||
});
|
|
||||||
t.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates and adds a new Task to the task-Collection
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="task">TrangaTask.Task to later execute</param>
|
|
||||||
/// <param name="connectorName">Name of the connector to use</param>
|
|
||||||
/// <param name="publication">Publication to execute Task on, can be null in case of unrelated Task</param>
|
|
||||||
/// <param name="reoccurrence">Time-Interval between Executions</param>
|
|
||||||
/// <param name="language">language, should Task require parameter. Can be empty</param>
|
|
||||||
/// <exception cref="ArgumentException">Is thrown when connectorName is not a available Connector</exception>
|
|
||||||
public TrangaTask AddTask(TrangaTask.Task task, string? connectorName, Publication? publication, TimeSpan reoccurrence,
|
|
||||||
string language = "")
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Adding new Task {task} {connectorName} {publication?.sortName}");
|
|
||||||
|
|
||||||
TrangaTask newTask;
|
|
||||||
if (task == TrangaTask.Task.UpdateKomgaLibrary)
|
|
||||||
{
|
|
||||||
newTask = new TrangaTask(task, null, null, reoccurrence);
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Removing old {task}-Task.");
|
|
||||||
//Only one UpdateKomgaLibrary Task
|
|
||||||
_allTasks.RemoveWhere(trangaTask => trangaTask.task is TrangaTask.Task.UpdateKomgaLibrary);
|
|
||||||
_allTasks.Add(newTask);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if(connectorName is null)
|
|
||||||
throw new ArgumentException($"connectorName can not be null for task {task}");
|
|
||||||
|
|
||||||
//Get appropriate Connector from available Connectors for TrangaTask
|
|
||||||
Connector? connector = _connectors.FirstOrDefault(c => c.name == connectorName);
|
|
||||||
if (connector is null)
|
|
||||||
throw new ArgumentException($"Connector {connectorName} is not a known connector.");
|
|
||||||
|
|
||||||
newTask = new TrangaTask(task, connector.name, publication, reoccurrence, language);
|
|
||||||
|
|
||||||
//Check if same task already exists
|
|
||||||
if (!_allTasks.Any(trangaTask => trangaTask.task == task && trangaTask.connectorName == connector.name &&
|
|
||||||
trangaTask.publication?.internalId == publication?.internalId))
|
|
||||||
{
|
|
||||||
if(task != TrangaTask.Task.UpdatePublications)
|
|
||||||
_chapterCollection.TryAdd((Publication)publication!, new List<Chapter>());
|
|
||||||
_allTasks.Add(newTask);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Publication already exists {publication?.internalId}");
|
|
||||||
}
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Added new Task {newTask.ToString()}");
|
|
||||||
ExportData();
|
|
||||||
|
|
||||||
return newTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes Task from task-collection
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="task">TrangaTask.Task type</param>
|
|
||||||
/// <param name="connectorName">Name of Connector that was used</param>
|
|
||||||
/// <param name="publication">Publication that was used</param>
|
|
||||||
public void DeleteTask(TrangaTask.Task task, string? connectorName, Publication? publication)
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Removing Task {task} {publication?.sortName}");
|
|
||||||
if (task == TrangaTask.Task.UpdateKomgaLibrary)
|
|
||||||
{
|
|
||||||
_allTasks.RemoveWhere(uTask => uTask.task == TrangaTask.Task.UpdateKomgaLibrary);
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Removed Task {task} from all Tasks.");
|
|
||||||
}
|
|
||||||
else if (connectorName is null)
|
|
||||||
throw new ArgumentException($"connectorName can not be null for Task {task}");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
foreach (List<TrangaTask> taskQueue in this._taskQueue.Values)
|
|
||||||
if(taskQueue.RemoveAll(trangaTask =>
|
|
||||||
trangaTask.task == task && trangaTask.connectorName == connectorName &&
|
|
||||||
trangaTask.publication?.internalId == publication?.internalId) > 0)
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Removed Task {task} {publication?.sortName} {publication?.internalId} from Queue.");
|
|
||||||
else
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Task {task} {publication?.sortName} {publication?.internalId} was not in Queue.");
|
|
||||||
if(_allTasks.RemoveWhere(trangaTask =>
|
|
||||||
trangaTask.task == task && trangaTask.connectorName == connectorName &&
|
|
||||||
trangaTask.publication?.internalId == publication?.internalId) > 0)
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Removed Task {task} {publication?.sortName} {publication?.internalId} from all Tasks.");
|
|
||||||
else
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"No Task {task} {publication?.sortName} {publication?.internalId} could be found.");
|
|
||||||
}
|
|
||||||
ExportData();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes a Task from the queue
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="task"></param>
|
|
||||||
public void RemoveTaskFromQueue(TrangaTask task)
|
|
||||||
{
|
|
||||||
task.lastExecuted = DateTime.Now;
|
|
||||||
foreach (List<TrangaTask> taskList in this._taskQueue.Values)
|
|
||||||
taskList.Remove(task);
|
|
||||||
task.state = TrangaTask.ExecutionState.Waiting;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sets last execution time to start of time
|
|
||||||
/// Let taskManager handle enqueuing
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="task"></param>
|
|
||||||
public void AddTaskToQueue(TrangaTask task)
|
|
||||||
{
|
|
||||||
task.lastExecuted = DateTime.UnixEpoch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <returns>All available Connectors</returns>
|
|
||||||
public Dictionary<string, Connector> GetAvailableConnectors()
|
|
||||||
{
|
|
||||||
return this._connectors.ToDictionary(connector => connector.name, connector => connector);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <returns>All TrangaTasks in task-collection</returns>
|
|
||||||
public TrangaTask[] GetAllTasks()
|
|
||||||
{
|
|
||||||
TrangaTask[] ret = new TrangaTask[_allTasks.Count];
|
|
||||||
_allTasks.CopyTo(ret);
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Publication[] GetPublicationsFromConnector(Connector connector, string? title = null)
|
|
||||||
{
|
|
||||||
Publication[] ret = connector.GetPublications(title ?? "");
|
|
||||||
foreach (Publication publication in ret)
|
|
||||||
{
|
|
||||||
if(!_chapterCollection.Any(pub => pub.Key.sortName == publication.sortName))
|
|
||||||
this._chapterCollection.TryAdd(publication, new List<Chapter>());
|
|
||||||
}
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <returns>All added Publications</returns>
|
|
||||||
public Publication[] GetAllPublications()
|
|
||||||
{
|
|
||||||
return this._chapterCollection.Keys.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Return Connector with given Name
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="connectorName">Connector-name (exact)</param>
|
|
||||||
/// <exception cref="Exception">If Connector is not available</exception>
|
|
||||||
public Connector GetConnector(string? connectorName)
|
|
||||||
{
|
|
||||||
if(connectorName is null)
|
|
||||||
throw new Exception($"connectorName can not be null");
|
|
||||||
Connector? ret = this._connectors.FirstOrDefault(connector => connector.name == connectorName);
|
|
||||||
if (ret is null)
|
|
||||||
throw new Exception($"Connector {connectorName} is not an available Connector.");
|
|
||||||
return (Connector)ret!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shuts down the taskManager.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="force">If force is true, tasks are aborted.</param>
|
|
||||||
public void Shutdown(bool force = false)
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Shutting down (forced={force})");
|
|
||||||
_continueRunning = false;
|
|
||||||
ExportData();
|
|
||||||
|
|
||||||
if(force)
|
|
||||||
Environment.Exit(_allTasks.Count(task => task.state is TrangaTask.ExecutionState.Enqueued or TrangaTask.ExecutionState.Running));
|
|
||||||
|
|
||||||
//Wait for tasks to finish
|
|
||||||
while(_allTasks.Any(task => task.state is TrangaTask.ExecutionState.Running or TrangaTask.ExecutionState.Enqueued))
|
|
||||||
Thread.Sleep(10);
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), "Tasks finished. Bye!");
|
|
||||||
Environment.Exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ImportData()
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), "Importing Data");
|
|
||||||
string buffer;
|
|
||||||
if (File.Exists(settings.tasksFilePath))
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Importing tasks from {settings.tasksFilePath}");
|
|
||||||
buffer = File.ReadAllText(settings.tasksFilePath);
|
|
||||||
this._allTasks = JsonConvert.DeserializeObject<HashSet<TrangaTask>>(buffer)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File.Exists(settings.knownPublicationsPath))
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Importing known publications from {settings.knownPublicationsPath}");
|
|
||||||
buffer = File.ReadAllText(settings.knownPublicationsPath);
|
|
||||||
Publication[] publications = JsonConvert.DeserializeObject<Publication[]>(buffer)!;
|
|
||||||
foreach (Publication publication in publications)
|
|
||||||
this._chapterCollection.TryAdd(publication, new List<Chapter>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exports data (settings, tasks) to file
|
|
||||||
/// </summary>
|
|
||||||
private void ExportData()
|
|
||||||
{
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Exporting settings to {settings.settingsFilePath}");
|
|
||||||
File.WriteAllText(settings.settingsFilePath, JsonConvert.SerializeObject(settings));
|
|
||||||
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Exporting tasks to {settings.tasksFilePath}");
|
|
||||||
File.WriteAllText(settings.tasksFilePath, JsonConvert.SerializeObject(this._allTasks));
|
|
||||||
|
|
||||||
logger?.WriteLine(this.GetType().ToString(), $"Exporting known publications to {settings.knownPublicationsPath}");
|
|
||||||
File.WriteAllText(settings.knownPublicationsPath, JsonConvert.SerializeObject(this._chapterCollection.Keys.ToArray()));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
61
Tranga/Tranga.cs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
using Logging;
|
||||||
|
using Tranga.Jobs;
|
||||||
|
using Tranga.MangaConnectors;
|
||||||
|
|
||||||
|
namespace Tranga;
|
||||||
|
|
||||||
|
public partial class Tranga : GlobalBase
|
||||||
|
{
|
||||||
|
public bool keepRunning;
|
||||||
|
public JobBoss _jobBoss;
|
||||||
|
private Server server;
|
||||||
|
private HashSet<MangaConnector> connectors;
|
||||||
|
|
||||||
|
public Tranga(Logger? logger, TrangaSettings settings) : base(logger, settings)
|
||||||
|
{
|
||||||
|
keepRunning = true;
|
||||||
|
_jobBoss = new(this);
|
||||||
|
connectors = new HashSet<MangaConnector>()
|
||||||
|
{
|
||||||
|
new Manganato(this),
|
||||||
|
new Mangasee(this),
|
||||||
|
new MangaDex(this),
|
||||||
|
new MangaKatana(this)
|
||||||
|
};
|
||||||
|
StartJobBoss();
|
||||||
|
this.server = new Server(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MangaConnector? GetConnector(string name)
|
||||||
|
{
|
||||||
|
foreach(MangaConnector mc in connectors)
|
||||||
|
if (mc.name.Equals(name, StringComparison.InvariantCultureIgnoreCase))
|
||||||
|
return mc;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<MangaConnector> GetConnectors()
|
||||||
|
{
|
||||||
|
return connectors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Publication? GetPublicationById(string internalId)
|
||||||
|
{
|
||||||
|
if (cachedPublications.Exists(publication => publication.internalId == internalId))
|
||||||
|
return cachedPublications.First(publication => publication.internalId == internalId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartJobBoss()
|
||||||
|
{
|
||||||
|
Thread t = new (() =>
|
||||||
|
{
|
||||||
|
while (keepRunning)
|
||||||
|
{
|
||||||
|
_jobBoss.CheckJobs();
|
||||||
|
Thread.Sleep(1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
t.Start();
|
||||||
|
}
|
||||||
|
}
|
@ -4,14 +4,24 @@
|
|||||||
<TargetFramework>net7.0</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="HtmlAgilityPack" Version="1.11.46" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
<PackageReference Include="PuppeteerSharp" Version="10.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Logging\Logging.csproj" />
|
<ProjectReference Include="..\Logging\Logging.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\.dockerignore">
|
||||||
|
<Link>.dockerignore</Link>
|
||||||
|
<DependentUpon>Dockerfile</DependentUpon>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
136
Tranga/TrangaArgs.cs
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
using Logging;
|
||||||
|
|
||||||
|
namespace Tranga;
|
||||||
|
|
||||||
|
public partial class Tranga : GlobalBase
|
||||||
|
{
|
||||||
|
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
string[]? help = GetArg(args, ArgEnum.Help);
|
||||||
|
if (help is not null)
|
||||||
|
{
|
||||||
|
PrintHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[]? consoleLogger = GetArg(args, ArgEnum.ConsoleLogger);
|
||||||
|
string[]? fileLogger = GetArg(args, ArgEnum.FileLogger);
|
||||||
|
string? filePath = fileLogger?[0];//TODO validate path
|
||||||
|
|
||||||
|
List<Logger.LoggerType> enabledLoggers = new();
|
||||||
|
if(consoleLogger is not null)
|
||||||
|
enabledLoggers.Add(Logger.LoggerType.ConsoleLogger);
|
||||||
|
if (fileLogger is not null)
|
||||||
|
enabledLoggers.Add(Logger.LoggerType.FileLogger);
|
||||||
|
Logger logger = new(enabledLoggers.ToArray(), Console.Out, Console.OutputEncoding, filePath);
|
||||||
|
|
||||||
|
TrangaSettings? settings = null;
|
||||||
|
string[]? downloadLocationPath = GetArg(args, ArgEnum.DownloadLocation);
|
||||||
|
string[]? workingDirectory = GetArg(args, ArgEnum.WorkingDirectory);
|
||||||
|
|
||||||
|
if (downloadLocationPath is not null && workingDirectory is not null)
|
||||||
|
{
|
||||||
|
settings = new TrangaSettings(downloadLocationPath[0], workingDirectory[0]);
|
||||||
|
}else if (downloadLocationPath is not null)
|
||||||
|
{
|
||||||
|
if (settings is null)
|
||||||
|
settings = new TrangaSettings(downloadLocation: downloadLocationPath[0]);
|
||||||
|
else
|
||||||
|
settings = new TrangaSettings(downloadLocation: downloadLocationPath[0], settings.workingDirectory);
|
||||||
|
}else if (workingDirectory is not null)
|
||||||
|
{
|
||||||
|
if (settings is null)
|
||||||
|
settings = new TrangaSettings(downloadLocation: workingDirectory[0]);
|
||||||
|
else
|
||||||
|
settings = new TrangaSettings(settings.downloadLocation, workingDirectory[0]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
settings = new TrangaSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(settings.downloadLocation);//TODO validate path
|
||||||
|
Directory.CreateDirectory(settings.workingDirectory);//TODO validate path
|
||||||
|
|
||||||
|
Tranga _ = new (logger, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PrintHelp()
|
||||||
|
{
|
||||||
|
Console.WriteLine("Tranga-Help:");
|
||||||
|
foreach (Argument argument in arguments.Values)
|
||||||
|
{
|
||||||
|
foreach(string name in argument.names)
|
||||||
|
Console.Write("{0} ", name);
|
||||||
|
if(argument.parameterCount > 0)
|
||||||
|
Console.Write($"<{argument.parameterCount}>");
|
||||||
|
Console.Write("\r\n {0}\r\n", argument.helpText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns an array containing the parameters for the argument.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="args">List of argument-strings</param>
|
||||||
|
/// <param name="arg">Requested parameter</param>
|
||||||
|
/// <returns>
|
||||||
|
/// If there are no parameters for an argument, returns an empty array.
|
||||||
|
/// If the argument is not found returns null.
|
||||||
|
/// </returns>
|
||||||
|
private static string[]? GetArg(string[] args, ArgEnum arg)
|
||||||
|
{
|
||||||
|
List<string> argsList = args.ToList();
|
||||||
|
List<string> ret = new();
|
||||||
|
foreach (string name in arguments[arg].names)
|
||||||
|
{
|
||||||
|
int argIndex = argsList.IndexOf(name);
|
||||||
|
if (argIndex != -1)
|
||||||
|
{
|
||||||
|
if (arguments[arg].parameterCount == 0)
|
||||||
|
return ret.ToArray();
|
||||||
|
for (int parameterIndex = 1; parameterIndex <= arguments[arg].parameterCount; parameterIndex++)
|
||||||
|
{
|
||||||
|
if(argIndex + parameterIndex >= argsList.Count || args[argIndex + parameterIndex].Contains('-'))//End of arguments, or no parameter provided, when one is required
|
||||||
|
Console.WriteLine($"No parameter provided for argument {name}. -h for help.");
|
||||||
|
ret.Add(args[argIndex + parameterIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret.Any() ? ret.ToArray() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<ArgEnum, Argument> arguments = new()
|
||||||
|
{
|
||||||
|
{ ArgEnum.DownloadLocation, new(new []{"-d", "--downloadLocation"}, 1, "Directory to which downloaded Manga are saved") },
|
||||||
|
{ ArgEnum.WorkingDirectory, new(new []{"-w", "--workingDirectory"}, 1, "Directory in which application-data is saved") },
|
||||||
|
{ ArgEnum.ConsoleLogger, new(new []{"-c", "--consoleLogger"}, 0, "Enables the consoleLogger") },
|
||||||
|
{ ArgEnum.FileLogger, new(new []{"-f", "--fileLogger"}, 1, "Enables the fileLogger, Directory where logfiles are saved") },
|
||||||
|
{ ArgEnum.Help, new(new []{"-h", "--help"}, 0, "Print this") }
|
||||||
|
//{ ArgEnum., new(new []{""}, 1, "") }
|
||||||
|
};
|
||||||
|
|
||||||
|
internal enum ArgEnum
|
||||||
|
{
|
||||||
|
TrangaSettings,
|
||||||
|
DownloadLocation,
|
||||||
|
WorkingDirectory,
|
||||||
|
ConsoleLogger,
|
||||||
|
FileLogger,
|
||||||
|
Help
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Argument
|
||||||
|
{
|
||||||
|
public string[] names { get; }
|
||||||
|
public byte parameterCount { get; }
|
||||||
|
public string helpText { get; }
|
||||||
|
|
||||||
|
public Argument(string[] names, byte parameterCount, string helpText)
|
||||||
|
{
|
||||||
|
this.names = names;
|
||||||
|
this.parameterCount = parameterCount;
|
||||||
|
this.helpText = helpText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,32 +1,129 @@
|
|||||||
using Newtonsoft.Json;
|
using System.Runtime.InteropServices;
|
||||||
|
using Logging;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Tranga.LibraryConnectors;
|
||||||
|
using Tranga.NotificationConnectors;
|
||||||
|
using static System.IO.UnixFileMode;
|
||||||
|
|
||||||
namespace Tranga;
|
namespace Tranga;
|
||||||
|
|
||||||
public class TrangaSettings
|
public class TrangaSettings
|
||||||
{
|
{
|
||||||
public string downloadLocation { get; set; }
|
public string downloadLocation { get; private set; }
|
||||||
public string workingDirectory { get; set; }
|
public string workingDirectory { get; private set; }
|
||||||
[JsonIgnore]public string settingsFilePath => Path.Join(workingDirectory, "settings.json");
|
public int apiPortNumber { get; init; }
|
||||||
[JsonIgnore]public string tasksFilePath => Path.Join(workingDirectory, "tasks.json");
|
[JsonIgnore] public string settingsFilePath => Path.Join(workingDirectory, "settings.json");
|
||||||
[JsonIgnore]public string knownPublicationsPath => Path.Join(workingDirectory, "knownPublications.json");
|
[JsonIgnore] public string libraryConnectorsFilePath => Path.Join(workingDirectory, "libraryConnectors.json");
|
||||||
[JsonIgnore] public string coverImageCache => Path.Join(workingDirectory, "imageCache");
|
|
||||||
public Komga? komga { get; set; }
|
|
||||||
|
|
||||||
public TrangaSettings(string downloadLocation, string workingDirectory, Komga? komga)
|
[JsonIgnore] public string notificationConnectorsFilePath => Path.Join(workingDirectory, "notificationConnectors.json");
|
||||||
|
[JsonIgnore] public string tasksFilePath => Path.Join(workingDirectory, "tasks.json");
|
||||||
|
[JsonIgnore] public string coverImageCache => Path.Join(workingDirectory, "imageCache");
|
||||||
|
public ushort? version { get; set; }
|
||||||
|
|
||||||
|
public TrangaSettings(string? downloadLocation = null, string? workingDirectory = null, int? apiPortNumber = null)
|
||||||
{
|
{
|
||||||
this.workingDirectory = workingDirectory;
|
string lockFilePath = $"{settingsFilePath}.lock";
|
||||||
this.downloadLocation = downloadLocation;
|
if (File.Exists(settingsFilePath) && !File.Exists(lockFilePath))
|
||||||
this.komga = komga;
|
{//Load from settings file
|
||||||
|
FileStream lockFile = File.Create(lockFilePath,0, FileOptions.DeleteOnClose);
|
||||||
|
string settingsStr = File.ReadAllText(settingsFilePath);
|
||||||
|
TrangaSettings settings = JsonConvert.DeserializeObject<TrangaSettings>(settingsStr)!;
|
||||||
|
this.downloadLocation = downloadLocation ?? settings.downloadLocation;
|
||||||
|
this.workingDirectory = workingDirectory ?? settings.workingDirectory;
|
||||||
|
this.apiPortNumber = apiPortNumber ?? settings.apiPortNumber;
|
||||||
|
lockFile.Close();
|
||||||
|
}
|
||||||
|
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 ?? Path.Join(Directory.GetCurrentDirectory(), "Downloads");
|
||||||
|
this.workingDirectory = workingDirectory ?? Directory.GetCurrentDirectory();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{//Settingsfile is locked
|
||||||
|
this.apiPortNumber = apiPortNumber!.Value;
|
||||||
|
this.downloadLocation = downloadLocation!;
|
||||||
|
this.workingDirectory = workingDirectory!;
|
||||||
|
}
|
||||||
|
UpdateDownloadLocation(this.downloadLocation!, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TrangaSettings LoadSettings(string importFilePath)
|
public HashSet<LibraryConnector> LoadLibraryConnectors()
|
||||||
{
|
{
|
||||||
if (!File.Exists(importFilePath))
|
if (!File.Exists(libraryConnectorsFilePath))
|
||||||
return new TrangaSettings(Path.Join(Directory.GetCurrentDirectory(), "Downloads"), Directory.GetCurrentDirectory(), null);
|
return new HashSet<LibraryConnector>();
|
||||||
|
return JsonConvert.DeserializeObject<HashSet<LibraryConnector>>(File.ReadAllText(libraryConnectorsFilePath),
|
||||||
|
new JsonSerializerSettings()
|
||||||
|
{
|
||||||
|
Converters =
|
||||||
|
{
|
||||||
|
new LibraryManagerJsonConverter()
|
||||||
|
}
|
||||||
|
})!;
|
||||||
|
}
|
||||||
|
|
||||||
string toRead = File.ReadAllText(importFilePath);
|
public HashSet<NotificationConnector> LoadNotificationConnectors()
|
||||||
TrangaSettings settings = JsonConvert.DeserializeObject<TrangaSettings>(toRead)!;
|
{
|
||||||
|
if (!File.Exists(notificationConnectorsFilePath))
|
||||||
|
return new HashSet<NotificationConnector>();
|
||||||
|
return JsonConvert.DeserializeObject<HashSet<NotificationConnector>>(File.ReadAllText(libraryConnectorsFilePath),
|
||||||
|
new JsonSerializerSettings()
|
||||||
|
{
|
||||||
|
Converters =
|
||||||
|
{
|
||||||
|
new NotificationManagerJsonConverter()
|
||||||
|
}
|
||||||
|
})!;
|
||||||
|
}
|
||||||
|
|
||||||
return settings;
|
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()
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
Thread.Sleep(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
File.WriteAllText(settingsFilePath, JsonConvert.SerializeObject(this));
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,61 +0,0 @@
|
|||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace Tranga;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stores information on Task
|
|
||||||
/// </summary>
|
|
||||||
public class TrangaTask
|
|
||||||
{
|
|
||||||
// ReSharper disable once CommentTypo ...Tell me why!
|
|
||||||
// ReSharper disable once MemberCanBePrivate.Global I want it thaaat way
|
|
||||||
public TimeSpan reoccurrence { get; }
|
|
||||||
public DateTime lastExecuted { get; set; }
|
|
||||||
public string? connectorName { get; }
|
|
||||||
public Task task { get; }
|
|
||||||
public Publication? publication { get; }
|
|
||||||
public string language { get; }
|
|
||||||
[JsonIgnore]public ExecutionState state { get; set; }
|
|
||||||
|
|
||||||
public enum ExecutionState
|
|
||||||
{
|
|
||||||
Waiting,
|
|
||||||
Enqueued,
|
|
||||||
Running
|
|
||||||
};
|
|
||||||
|
|
||||||
public TrangaTask(Task task, string? connectorName, Publication? publication, TimeSpan reoccurrence, string language = "")
|
|
||||||
{
|
|
||||||
if(task != Task.UpdateKomgaLibrary && connectorName is null)
|
|
||||||
throw new ArgumentException($"connectorName can not be null for task {task}");
|
|
||||||
|
|
||||||
if (publication is null && task != Task.UpdatePublications && task != Task.UpdateKomgaLibrary)
|
|
||||||
throw new ArgumentException($"Publication can not be null for task {task}");
|
|
||||||
|
|
||||||
this.publication = publication;
|
|
||||||
this.reoccurrence = reoccurrence;
|
|
||||||
this.lastExecuted = DateTime.Now.Subtract(reoccurrence);
|
|
||||||
this.connectorName = connectorName;
|
|
||||||
this.task = task;
|
|
||||||
this.language = language;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <returns>True if elapsed time since last execution is greater than set interval</returns>
|
|
||||||
public bool ShouldExecute()
|
|
||||||
{
|
|
||||||
return DateTime.Now.Subtract(this.lastExecuted) > reoccurrence && state is ExecutionState.Waiting;
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum Task
|
|
||||||
{
|
|
||||||
UpdatePublications,
|
|
||||||
UpdateChapters,
|
|
||||||
DownloadNewChapters,
|
|
||||||
UpdateKomgaLibrary
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString()
|
|
||||||
{
|
|
||||||
return $"{task}, {lastExecuted}, {reoccurrence}, {state} {(connectorName is not null ? $", {connectorName}" : "" )} {(publication is not null ? $", {publication?.sortName}": "")}";
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +0,0 @@
|
|||||||
FROM nginx:alpine3.17-slim
|
|
||||||
COPY . /usr/share/nginx/html
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
@ -1,126 +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 + "/Tranga/GetAvailableControllers";
|
|
||||||
let json = await GetData(uri);
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function GetPublication(connectorName, title){
|
|
||||||
var uri = apiUri + `/Tranga/GetPublicationsFromConnector?connectorName=${connectorName}&title=${title}`;
|
|
||||||
let json = await GetData(uri);
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function GetKnownPublications(){
|
|
||||||
var uri = apiUri + "/Tranga/GetKnownPublications";
|
|
||||||
let json = await GetData(uri);
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function GetTaskTypes(){
|
|
||||||
var uri = apiUri + "/Tasks/GetTaskTypes";
|
|
||||||
let json = await GetData(uri);
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
async function GetRunningTasks(){
|
|
||||||
var uri = apiUri + "/Tasks/GetRunningTasks";
|
|
||||||
let json = await GetData(uri);
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function GetDownloadTasks(){
|
|
||||||
var uri = apiUri + "/Tasks/Get?taskType=DownloadNewChapters";
|
|
||||||
let json = await GetData(uri);
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function GetSettings(){
|
|
||||||
var uri = apiUri + "/Settings/Get";
|
|
||||||
let json = await GetData(uri);
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function GetKomgaTask(){
|
|
||||||
var uri = apiUri + "/Tasks/Get?taskType=UpdateKomgaLibrary";
|
|
||||||
let json = await GetData(uri);
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateTask(taskType, reoccurrence, connectorName, publicationId, language){
|
|
||||||
var uri = apiUri + `/Tasks/Create?taskType=${taskType}&connectorName=${connectorName}&publicationId=${publicationId}&reoccurrenceTime=${reoccurrence}&language=${language}`;
|
|
||||||
PostData(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StartTask(taskType, connectorName, publicationId){
|
|
||||||
var uri = apiUri + `/Tasks/Start?taskType=${taskType}&connectorName=${connectorName}&publicationId=${publicationId}`;
|
|
||||||
PostData(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EnqueueTask(taskType, connectorName, publicationId){
|
|
||||||
var uri = apiUri + `/Queue/Enqueue?taskType=${taskType}&connectorName=${connectorName}&publicationId=${publicationId}`;
|
|
||||||
PostData(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UpdateSettings(downloadLocation, komgaUrl, komgaAuth){
|
|
||||||
var uri = apiUri + `/Settings/Update?downloadLocation=${downloadLocation}&komgaUrl=${komgaUrl}&komgaAuth=${komgaAuth}`;
|
|
||||||
PostData(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeleteTask(taskType, connectorName, publicationId){
|
|
||||||
var uri = apiUri + `/Tasks/Delete?taskType=${taskType}&connectorName=${connectorName}&publicationId=${publicationId}`;
|
|
||||||
DeleteData(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DequeueTask(taskType, connectorName, publicationId){
|
|
||||||
var uri = apiUri + `/Queue/Dequeue?taskType=${taskType}&connectorName=${connectorName}&publicationId=${publicationId}`;
|
|
||||||
DeleteData(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function GetQueue(){
|
|
||||||
var uri = apiUri + "/Queue/GetList";
|
|
||||||
let json = await GetData(uri);
|
|
||||||
return json;
|
|
||||||
}
|
|
Before Width: | Height: | Size: 66 KiB |
@ -1,123 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Tranga</title>
|
|
||||||
<link rel="stylesheet" href="style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<wrapper>
|
|
||||||
<topbar>
|
|
||||||
<titlebox>
|
|
||||||
<img src="media/blahaj.png">
|
|
||||||
<span>Tranga</span>
|
|
||||||
</titlebox>
|
|
||||||
<spacer></spacer>
|
|
||||||
<searchdiv>
|
|
||||||
<input id="searchbox" placeholder="Filter" type="text">
|
|
||||||
</searchdiv>
|
|
||||||
<img id="settingscog" src="media/settings-cogwheel.svg" height="100%" alt="settingscog">
|
|
||||||
</topbar>
|
|
||||||
<viewport>
|
|
||||||
<content>
|
|
||||||
<div id="addPublication">
|
|
||||||
<p>+</p>
|
|
||||||
</div>
|
|
||||||
<publication>
|
|
||||||
<img src="media/cover.jpg">
|
|
||||||
<publication-information>
|
|
||||||
<connector-name class="pill">MangaDex</connector-name>
|
|
||||||
<publication-name>Tensei Pandemic</publication-name>
|
|
||||||
</publication-information>
|
|
||||||
</publication>
|
|
||||||
</content>
|
|
||||||
|
|
||||||
<popup id="addTaskPopup">
|
|
||||||
<blur-background id="blurBackgroundTaskPopup"></blur-background>
|
|
||||||
<addtask-window>
|
|
||||||
<window-titlebar>
|
|
||||||
<p>Add Task</p>
|
|
||||||
<img id="closePopupImg" src="media/close-x.svg" alt="Close">
|
|
||||||
</window-titlebar>
|
|
||||||
<window-content>
|
|
||||||
<addtask-settings>
|
|
||||||
<addtask-setting><label for="selectReccurrence">Recurrence</label><input id="selectReccurrence" type="time" value="01:00:00" step="3600"></addtask-setting>
|
|
||||||
<addtask-setting><label for="connectors">Connector</label>
|
|
||||||
<select id="connectors">
|
|
||||||
<option value=""></option>
|
|
||||||
</select>
|
|
||||||
</addtask-setting>
|
|
||||||
<addtask-setting><label for="searchPublicationQuery">Search Title</label><input id="searchPublicationQuery" type="text"></addtask-setting>
|
|
||||||
<input type="submit" value="Search" onclick="NewSearch();">
|
|
||||||
</addtask-settings>
|
|
||||||
<div id="taskSelectOutput"></div>
|
|
||||||
</window-content>
|
|
||||||
</addtask-window>
|
|
||||||
</popup>
|
|
||||||
<popup id="publicationViewerPopup">
|
|
||||||
<blur-background id="blurBackgroundPublicationPopup"></blur-background>
|
|
||||||
<publication-viewer>
|
|
||||||
<img id="pubviewcover" src="media/cover.jpg" alt="cover">
|
|
||||||
<publication-information>
|
|
||||||
<publication-name id="publicationViewerName">Tensei Pandemic</publication-name>
|
|
||||||
<publication-author id="publicationViewerAuthor">Imamura Hinata</publication-author>
|
|
||||||
<publication-description id="publicationViewerDescription">Imamura Hinata is a high school boy with a cute appearance.
|
|
||||||
Since his trauma with the first love, he wanted to be more manly than anybody else. But one day he woke up to something different…
|
|
||||||
The total opposite of his ideal male body!
|
|
||||||
Pandemic love comedy!
|
|
||||||
</publication-description>
|
|
||||||
<publication-interactions>
|
|
||||||
<publication-starttask>Start Task ▶️</publication-starttask>
|
|
||||||
<publication-delete>Delete Task ❌</publication-delete>
|
|
||||||
<publication-add>Add Task ➕</publication-add>
|
|
||||||
</publication-interactions>
|
|
||||||
</publication-information>
|
|
||||||
</publication-viewer>
|
|
||||||
</popup>
|
|
||||||
<popup id="settingsPopup">
|
|
||||||
<blur-background id="blurBackgroundSettingsPopup"></blur-background>
|
|
||||||
<settings>
|
|
||||||
<span style="font-weight: bold; text-align: center; font-size: 16pt;">Settings</span>
|
|
||||||
<div>
|
|
||||||
<p class="title">Download Location:</p>
|
|
||||||
<span id="downloadLocation"></span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="title">API-URI</p>
|
|
||||||
<label for="settingApiUri"></label><input placeholder="https://" type="text" id="settingApiUri">
|
|
||||||
</div>
|
|
||||||
<komga-settings>
|
|
||||||
<span class="title">Komga</span>
|
|
||||||
<div>Configured: <span id="komgaConfigured">✅❌</span></div>
|
|
||||||
<label for="komgaUrl"></label><input placeholder="URL" id="komgaUrl" type="text">
|
|
||||||
<label for="komgaUsername"></label><input placeholder="Username" id="komgaUsername" type="text">
|
|
||||||
<label for="komgaPassword"></label><input placeholder="Password" id="komgaPassword" type="password">
|
|
||||||
<label for="komgaUpdateTime" style="margin-right: 5px;">Update Time</label><input id="komgaUpdateTime" type="time" value="00:01:00" step="10">
|
|
||||||
<input type="submit" value="Update" onclick="UpdateKomgaSettings()">
|
|
||||||
</komga-settings>
|
|
||||||
</settings>
|
|
||||||
</popup>
|
|
||||||
</viewport>
|
|
||||||
<footer>
|
|
||||||
<div>
|
|
||||||
<img src="media/running.svg" alt="running"><div id="tasksRunningTag">0</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<img src="media/queue.svg" alt="queue"><div id="tasksQueuedTag">0</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<img src="media/tasks.svg" alt="queue"><div id="totalTasksTag">0</div>
|
|
||||||
</div>
|
|
||||||
<p id="madeWith">Made with Blåhaj 🦈</p>
|
|
||||||
</footer>
|
|
||||||
</wrapper>
|
|
||||||
<footer-tag-popup>
|
|
||||||
<footer-tag-content>
|
|
||||||
<footer-tag-task-name>Test</footer-tag-task-name>
|
|
||||||
</footer-tag-content>
|
|
||||||
</footer-tag-popup>
|
|
||||||
|
|
||||||
<script src="apiConnector.js"></script>
|
|
||||||
<script src="interaction.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,383 +0,0 @@
|
|||||||
let publications = [];
|
|
||||||
let tasks = [];
|
|
||||||
let toEditId;
|
|
||||||
|
|
||||||
const searchPublicationQuery = document.querySelector("#searchPublicationQuery");
|
|
||||||
const selectPublication = document.querySelector("#taskSelectOutput");
|
|
||||||
const connectorSelect = document.querySelector("#connectors");
|
|
||||||
const settingsPopup = document.querySelector("#settingsPopup");
|
|
||||||
const settingsCog = document.querySelector("#settingscog");
|
|
||||||
const selectRecurrence = document.querySelector("#selectReccurrence");
|
|
||||||
const tasksContent = document.querySelector("content");
|
|
||||||
const addTaskPopup = document.querySelector("#addTaskPopup");
|
|
||||||
const publicationViewerPopup = document.querySelector("#publicationViewerPopup");
|
|
||||||
const publicationViewerWindow = document.querySelector("publication-viewer");
|
|
||||||
const publicationViewerDescription = document.querySelector("#publicationViewerDescription");
|
|
||||||
const publicationViewerName = document.querySelector("#publicationViewerName");
|
|
||||||
const publicationViewerAuthor = document.querySelector("#publicationViewerAuthor");
|
|
||||||
const pubviewcover = document.querySelector("#pubviewcover");
|
|
||||||
const publicationDelete = document.querySelector("publication-delete");
|
|
||||||
const publicationAdd = document.querySelector("publication-add");
|
|
||||||
const publicationTaskStart = document.querySelector("publication-starttask");
|
|
||||||
const closetaskpopup = document.querySelector("#closePopupImg");
|
|
||||||
const settingDownloadLocation = document.querySelector("#downloadLocation");
|
|
||||||
const settingKomgaUrl = document.querySelector("#komgaUrl");
|
|
||||||
const settingKomgaUser = document.querySelector("#komgaUsername");
|
|
||||||
const settingKomgaPass = document.querySelector("#komgaPassword");
|
|
||||||
const settingKomgaTime = document.querySelector("#komgaUpdateTime");
|
|
||||||
const settingKomgaConfigured = document.querySelector("#komgaConfigured");
|
|
||||||
const settingApiUri = document.querySelector("#settingApiUri");
|
|
||||||
const tagTasksRunning = document.querySelector("#tasksRunningTag");
|
|
||||||
const tagTasksQueued = document.querySelector("#tasksQueuedTag");
|
|
||||||
const tagTasksTotal = document.querySelector("#totalTasksTag");
|
|
||||||
const tagTaskPopup = document.querySelector("footer-tag-popup");
|
|
||||||
const tagTasksPopupContent = document.querySelector("footer-tag-content");
|
|
||||||
|
|
||||||
settingsCog.addEventListener("click", () => OpenSettings());
|
|
||||||
document.querySelector("#blurBackgroundSettingsPopup").addEventListener("click", () => HideSettings());
|
|
||||||
closetaskpopup.addEventListener("click", () => HideAddTaskPopup());
|
|
||||||
document.querySelector("#blurBackgroundTaskPopup").addEventListener("click", () => HideAddTaskPopup());
|
|
||||||
document.querySelector("#blurBackgroundPublicationPopup").addEventListener("click", () => HidePublicationPopup());
|
|
||||||
publicationDelete.addEventListener("click", () => DeleteTaskClick());
|
|
||||||
publicationAdd.addEventListener("click", () => AddTaskClick());
|
|
||||||
publicationTaskStart.addEventListener("click", () => StartTaskClick());
|
|
||||||
settingApiUri.addEventListener("keypress", (event) => {
|
|
||||||
if(event.key === "Enter"){
|
|
||||||
apiUri = settingApiUri.value;
|
|
||||||
setTimeout(() => GetSettingsClick(), 100);
|
|
||||||
document.cookie = `apiUri=${apiUri};`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
searchPublicationQuery.addEventListener("keypress", (event) => {
|
|
||||||
if(event.key === "Enter"){
|
|
||||||
NewSearch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
tagTasksRunning.addEventListener("mouseover", (event) => ShowRunningTasks(event));
|
|
||||||
tagTasksRunning.addEventListener("mouseout", () => CloseTasksPopup());
|
|
||||||
tagTasksQueued.addEventListener("mouseover", (event) => ShowQueuedTasks(event));
|
|
||||||
tagTasksQueued.addEventListener("mouseout", () => CloseTasksPopup());
|
|
||||||
tagTasksTotal.addEventListener("mouseover", (event) => ShowAllTasks(event));
|
|
||||||
tagTasksTotal.addEventListener("mouseout", () => CloseTasksPopup());
|
|
||||||
|
|
||||||
let availableConnectors;
|
|
||||||
GetAvailableControllers()
|
|
||||||
.then(json => availableConnectors = json)
|
|
||||||
.then(json =>
|
|
||||||
json.forEach(connector => {
|
|
||||||
var option = document.createElement('option');
|
|
||||||
option.value = connector;
|
|
||||||
option.innerText = connector;
|
|
||||||
connectorSelect.appendChild(option);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
function NewSearch(){
|
|
||||||
//Disable inputs
|
|
||||||
selectRecurrence.disabled = true;
|
|
||||||
connectorSelect.disabled = true;
|
|
||||||
searchPublicationQuery.disabled = true;
|
|
||||||
|
|
||||||
//Empty previous results
|
|
||||||
selectPublication.replaceChildren();
|
|
||||||
GetPublication(connectorSelect.value, searchPublicationQuery.value)
|
|
||||||
.then(json =>
|
|
||||||
json.forEach(publication => {
|
|
||||||
var option = CreatePublication(publication, connectorSelect.value);
|
|
||||||
option.addEventListener("click", (mouseEvent) => {
|
|
||||||
ShowPublicationViewerWindow(publication.internalId, mouseEvent, true);
|
|
||||||
});
|
|
||||||
selectPublication.appendChild(option);
|
|
||||||
}
|
|
||||||
))
|
|
||||||
.then(() => {
|
|
||||||
//Re-enable inputs
|
|
||||||
selectRecurrence.disabled = false;
|
|
||||||
connectorSelect.disabled = false;
|
|
||||||
searchPublicationQuery.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
//Returns a new "Publication" Item to display in the tasks section
|
|
||||||
function CreatePublication(publication, connector){
|
|
||||||
var publicationElement = document.createElement('publication');
|
|
||||||
publicationElement.setAttribute("id", publication.internalId);
|
|
||||||
var img = document.createElement('img');
|
|
||||||
img.src = `imageCache/${publication.coverFileNameInCache}`;
|
|
||||||
publicationElement.appendChild(img);
|
|
||||||
var info = document.createElement('publication-information');
|
|
||||||
var connectorName = document.createElement('connector-name');
|
|
||||||
connectorName.innerText = connector;
|
|
||||||
connectorName.className = "pill";
|
|
||||||
info.appendChild(connectorName);
|
|
||||||
var publicationName = document.createElement('publication-name');
|
|
||||||
publicationName.innerText = publication.sortName;
|
|
||||||
info.appendChild(publicationName);
|
|
||||||
publicationElement.appendChild(info);
|
|
||||||
if(publications.filter(pub => pub.internalId === publication.internalId) < 1)
|
|
||||||
publications.push(publication);
|
|
||||||
return publicationElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeleteTaskClick(){
|
|
||||||
taskToDelete = tasks.filter(tTask => tTask.publication.internalId === toEditId)[0];
|
|
||||||
DeleteTask("DownloadNewChapters", taskToDelete.connectorName, toEditId);
|
|
||||||
HidePublicationPopup();
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddTaskClick(){
|
|
||||||
CreateTask("DownloadNewChapters", selectRecurrence.value, connectorSelect.value, toEditId, "en")
|
|
||||||
HideAddTaskPopup();
|
|
||||||
HidePublicationPopup();
|
|
||||||
}
|
|
||||||
|
|
||||||
function StartTaskClick(){
|
|
||||||
var toEditTask = tasks.filter(task => task.publication.internalId == toEditId)[0];
|
|
||||||
StartTask("DownloadNewChapters", toEditTask.connectorName, toEditId);
|
|
||||||
HidePublicationPopup();
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResetContent(){
|
|
||||||
//Delete everything
|
|
||||||
tasksContent.replaceChildren();
|
|
||||||
|
|
||||||
//Add "Add new Task" Button
|
|
||||||
var add = document.createElement("div");
|
|
||||||
add.setAttribute("id", "addPublication")
|
|
||||||
var plus = document.createElement("p");
|
|
||||||
plus.innerText = "+";
|
|
||||||
add.appendChild(plus);
|
|
||||||
add.addEventListener("click", () => ShowNewTaskWindow());
|
|
||||||
tasksContent.appendChild(add);
|
|
||||||
}
|
|
||||||
function ShowPublicationViewerWindow(publicationId, event, add){
|
|
||||||
//Show popup
|
|
||||||
publicationViewerPopup.style.display = "block";
|
|
||||||
|
|
||||||
//Set position to mouse-position
|
|
||||||
if(event.clientY < window.innerHeight - publicationViewerWindow.offsetHeight)
|
|
||||||
publicationViewerWindow.style.top = `${event.clientY}px`;
|
|
||||||
else
|
|
||||||
publicationViewerWindow.style.top = `${event.clientY - publicationViewerWindow.offsetHeight}px`;
|
|
||||||
|
|
||||||
if(event.clientX < window.innerWidth - publicationViewerWindow.offsetWidth)
|
|
||||||
publicationViewerWindow.style.left = `${event.clientX}px`;
|
|
||||||
else
|
|
||||||
publicationViewerWindow.style.left = `${event.clientX - publicationViewerWindow.offsetWidth}px`;
|
|
||||||
|
|
||||||
//Edit information inside the window
|
|
||||||
var publication = publications.filter(pub => pub.internalId === publicationId)[0];
|
|
||||||
publicationViewerName.innerText = publication.sortName;
|
|
||||||
publicationViewerDescription.innerText = publication.description;
|
|
||||||
publicationViewerAuthor.innerText = publication.author;
|
|
||||||
pubviewcover.src = `imageCache/${publication.coverFileNameInCache}`;
|
|
||||||
toEditId = publicationId;
|
|
||||||
|
|
||||||
//Check what action should be listed
|
|
||||||
if(add){
|
|
||||||
publicationAdd.style.display = "initial";
|
|
||||||
publicationDelete.style.display = "none";
|
|
||||||
publicationTaskStart.style.display = "none";
|
|
||||||
}
|
|
||||||
else{
|
|
||||||
publicationAdd.style.display = "none";
|
|
||||||
publicationDelete.style.display = "initial";
|
|
||||||
publicationTaskStart.style.display = "initial";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function HidePublicationPopup(){
|
|
||||||
publicationViewerPopup.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShowNewTaskWindow(){
|
|
||||||
selectPublication.replaceChildren();
|
|
||||||
addTaskPopup.style.display = "block";
|
|
||||||
}
|
|
||||||
function HideAddTaskPopup(){
|
|
||||||
addTaskPopup.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const fadeIn = [
|
|
||||||
{ opacity: "0" },
|
|
||||||
{ opacity: "1" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const fadeInTiming = {
|
|
||||||
duration: 50,
|
|
||||||
iterations: 1,
|
|
||||||
fill: "forwards"
|
|
||||||
}
|
|
||||||
|
|
||||||
function OpenSettings(){
|
|
||||||
GetSettingsClick();
|
|
||||||
settingsPopup.style.display = "flex";
|
|
||||||
}
|
|
||||||
|
|
||||||
function HideSettings(){
|
|
||||||
settingsPopup.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
function GetSettingsClick(){
|
|
||||||
settingApiUri.value = "";
|
|
||||||
settingKomgaUrl.value = "";
|
|
||||||
settingKomgaUser.value = "";
|
|
||||||
settingKomgaPass.value = "";
|
|
||||||
|
|
||||||
settingApiUri.placeholder = apiUri;
|
|
||||||
|
|
||||||
GetSettings().then(json => {
|
|
||||||
settingDownloadLocation.innerText = json.downloadLocation;
|
|
||||||
if(json.komga != null)
|
|
||||||
settingKomgaUrl.placeholder = json.komga.baseUrl;
|
|
||||||
});
|
|
||||||
|
|
||||||
GetKomgaTask().then(json => {
|
|
||||||
settingKomgaTime.value = json[0].reoccurrence;
|
|
||||||
if(json.length > 0)
|
|
||||||
settingKomgaConfigured.innerText = "✅";
|
|
||||||
else
|
|
||||||
settingKomgaConfigured.innerText = "❌";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function UpdateKomgaSettings(){
|
|
||||||
if(settingKomgaUser.value != "" && settingKomgaPass != ""){
|
|
||||||
var auth = utf8_to_b64(`${settingKomgaUser.value}:${settingKomgaPass.value}`);
|
|
||||||
console.log(auth);
|
|
||||||
|
|
||||||
if(settingKomgaUrl.value != "")
|
|
||||||
UpdateSettings("", settingKomgaUrl.value, auth);
|
|
||||||
else
|
|
||||||
UpdateSettings("", settingKomgaUrl.placeholder, auth);
|
|
||||||
}
|
|
||||||
CreateTask("UpdateKomgaLibrary", settingKomgaTime.value, "","","");
|
|
||||||
setTimeout(() => GetSettingsClick(), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function utf8_to_b64( str ) {
|
|
||||||
return window.btoa(unescape(encodeURIComponent( str )));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function ShowRunningTasks(event){
|
|
||||||
GetRunningTasks()
|
|
||||||
.then(json => {
|
|
||||||
tagTasksPopupContent.replaceChildren();
|
|
||||||
json.forEach(task => {
|
|
||||||
console.log(task);
|
|
||||||
if(task.publication != null){
|
|
||||||
var taskname = document.createElement("footer-tag-task-name");
|
|
||||||
taskname.innerText = task.publication.sortName;
|
|
||||||
tagTasksPopupContent.appendChild(taskname);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if(tagTasksPopupContent.children.length > 0){
|
|
||||||
tagTaskPopup.style.display = "block";
|
|
||||||
tagTaskPopup.style.left = `${tagTasksRunning.offsetLeft - 20}px`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShowQueuedTasks(event){
|
|
||||||
GetQueue()
|
|
||||||
.then(json => {
|
|
||||||
tagTasksPopupContent.replaceChildren();
|
|
||||||
json.forEach(task => {
|
|
||||||
var taskname = document.createElement("footer-tag-task-name");
|
|
||||||
taskname.innerText = task.publication.sortName;
|
|
||||||
tagTasksPopupContent.appendChild(taskname);
|
|
||||||
});
|
|
||||||
if(json.length > 0){
|
|
||||||
tagTaskPopup.style.display = "block";
|
|
||||||
tagTaskPopup.style.left = `${tagTasksQueued.offsetLeft- 20}px`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShowAllTasks(event){
|
|
||||||
GetDownloadTasks()
|
|
||||||
.then(json => {
|
|
||||||
tagTasksPopupContent.replaceChildren();
|
|
||||||
json.forEach(task => {
|
|
||||||
var taskname = document.createElement("footer-tag-task-name");
|
|
||||||
taskname.innerText = task.publication.sortName;
|
|
||||||
tagTasksPopupContent.appendChild(taskname);
|
|
||||||
});
|
|
||||||
if(json.length > 0){
|
|
||||||
tagTaskPopup.style.display = "block";
|
|
||||||
tagTaskPopup.style.left = `${tagTasksTotal.offsetLeft - 20}px`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function CloseTasksPopup(){
|
|
||||||
tagTaskPopup.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
//Resets the tasks shown
|
|
||||||
ResetContent();
|
|
||||||
//Get Tasks and show them
|
|
||||||
GetDownloadTasks()
|
|
||||||
.then(json => json.forEach(task => {
|
|
||||||
var publication = CreatePublication(task.publication, task.connectorName);
|
|
||||||
publication.addEventListener("click", (event) => ShowPublicationViewerWindow(task.publication.internalId, event, false));
|
|
||||||
tasksContent.appendChild(publication);
|
|
||||||
tasks.push(task);
|
|
||||||
}));
|
|
||||||
|
|
||||||
GetRunningTasks()
|
|
||||||
.then(json => {
|
|
||||||
tagTasksRunning.innerText = json.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
GetDownloadTasks()
|
|
||||||
.then(json => {
|
|
||||||
tagTasksTotal.innerText = json.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
GetQueue()
|
|
||||||
.then(json => {
|
|
||||||
tagTasksQueued.innerText = json.length;
|
|
||||||
})
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
//Tasks from API
|
|
||||||
var cTasks = [];
|
|
||||||
GetDownloadTasks()
|
|
||||||
.then(json => json.forEach(task => cTasks.push(task)))
|
|
||||||
.then(() => {
|
|
||||||
//Only update view if tasks-amount has changed
|
|
||||||
if(tasks.length != cTasks.length) {
|
|
||||||
//Resets the tasks shown
|
|
||||||
ResetContent();
|
|
||||||
//Add all currenttasks to view
|
|
||||||
cTasks.forEach(task => {
|
|
||||||
var publication = CreatePublication(task.publication, task.connectorName);
|
|
||||||
publication.addEventListener("click", (event) => ShowPublicationViewerWindow(task.publication.internalId, event, false));
|
|
||||||
tasksContent.appendChild(publication);
|
|
||||||
})
|
|
||||||
|
|
||||||
tasks = cTasks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
GetRunningTasks()
|
|
||||||
.then(json => {
|
|
||||||
tagTasksRunning.innerText = json.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
GetDownloadTasks()
|
|
||||||
.then(json => {
|
|
||||||
tagTasksTotal.innerText = json.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
GetQueue()
|
|
||||||
.then(json => {
|
|
||||||
tagTasksQueued.innerText = json.length;
|
|
||||||
})
|
|
||||||
|
|
||||||
}, 1000);
|
|
Before Width: | Height: | Size: 124 KiB |
@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
|
||||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.29289 5.29289C5.68342 4.90237 6.31658 4.90237 6.70711 5.29289L12 10.5858L17.2929 5.29289C17.6834 4.90237 18.3166 4.90237 18.7071 5.29289C19.0976 5.68342 19.0976 6.31658 18.7071 6.70711L13.4142 12L18.7071 17.2929C19.0976 17.6834 19.0976 18.3166 18.7071 18.7071C18.3166 19.0976 17.6834 19.0976 17.2929 18.7071L12 13.4142L6.70711 18.7071C6.31658 19.0976 5.68342 19.0976 5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929L10.5858 12L5.29289 6.70711C4.90237 6.31658 4.90237 5.68342 5.29289 5.29289Z" fill="#0F1729"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 804 B |
@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
|
|
||||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
|
||||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
|
|
||||||
|
|
||||||
<g fill="#000000">
|
|
||||||
|
|
Before Width: | Height: | Size: 545 B |
@ -1,53 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
|
||||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
width="800px" height="800px" viewBox="0 0 235.504 235.504"
|
|
||||||
xml:space="preserve">
|
|
||||||
<g>
|
|
||||||
<g>
|
|
||||||
<path d="M195.209,81.456l-49.227-0.15c0.737-0.886,1.351-1.868,2.284-2.583c3.282-2.497,3.911-7.166,1.427-10.438
|
|
||||||
c-2.501-3.266-7.161-3.919-10.443-1.423c-4.873,3.715-8.388,8.704-10.255,14.389l-22.191-0.064
|
|
||||||
c-9.508,0-19.588,7.398-22.938,16.851l-16.877,47.479c-1.775,5.013-1.338,9.966,1.207,13.568
|
|
||||||
c2.412,3.427,6.384,5.318,11.187,5.358l45.126,0.136c-1.509,5.186-4.701,9.622-9.352,12.424
|
|
||||||
c-4.891,2.957-10.636,3.814-16.172,2.444c-3.994-0.998-8.031,1.442-9.027,5.418c-0.99,4.012,1.445,8.035,5.432,9.032
|
|
||||||
c2.927,0.738,5.879,1.091,8.808,1.091c6.516,0,12.93-1.788,18.645-5.23c8.312-5.013,14.172-12.979,16.484-22.409
|
|
||||||
c0.232-0.905,0.232-1.823,0.124-2.713l28.296,0.092h0.049c2.925,0,5.854-0.89,8.684-2.147c0.2,0.493,0.32,1.014,0.661,1.471
|
|
||||||
c3.335,4.677,4.629,10.343,3.688,15.993c-0.95,5.627-4.028,10.536-8.688,13.862c-3.351,2.376-4.14,7.037-1.755,10.379
|
|
||||||
c1.466,2.04,3.751,3.122,6.062,3.122c1.491,0,3.006-0.429,4.312-1.367c7.919-5.61,13.16-13.966,14.771-23.52
|
|
||||||
c1.603-9.565-0.613-19.203-6.28-27.122c-0.48-0.693-1.134-1.19-1.779-1.659c1.318-1.831,2.501-3.763,3.238-5.854l16.863-47.464
|
|
||||||
c1.795-5.018,1.351-9.969-1.194-13.58C203.954,83.387,200.015,81.47,195.209,81.456z M201.979,98.405l-16.868,47.464
|
|
||||||
c-0.981,2.757-2.941,5.214-5.213,7.329c-0.337,0.16-0.706,0.229-1.026,0.465c-0.673,0.485-1.182,1.122-1.639,1.747
|
|
||||||
c-2.962,1.996-6.288,3.339-9.434,3.339v2.989l-0.044-2.989l-33.194-0.101c-0.232-0.076-0.424-0.261-0.661-0.324
|
|
||||||
c-1.435-0.353-2.805-0.145-4.095,0.309l-29.768-0.101l1.192-3.358c0.549-1.547-0.269-3.25-1.813-3.795
|
|
||||||
c-1.521-0.553-3.25,0.24-3.799,1.804l-1.899,5.334l-14.318-0.044c-2.805,0-5.063-0.998-6.336-2.813
|
|
||||||
c-1.437-2.032-1.603-4.921-0.463-8.144l16.877-47.478c2.48-6.979,10.417-12.868,17.356-12.868l12.217,0.038l-1.963,5.536
|
|
||||||
c-0.555,1.549,0.262,3.25,1.805,3.797c0.331,0.12,0.661,0.174,0.998,0.174c1.227,0,2.372-0.768,2.793-1.986l2.497-7.019
|
|
||||||
c0.064-0.164-0.048-0.322-0.016-0.487h2.512c-0.905,7.758,1.163,15.42,5.947,21.638c5.903,7.687,14.852,11.726,23.873,11.726
|
|
||||||
c6.371,0,12.771-2.001,18.186-6.129c3.266-2.488,3.911-7.167,1.426-10.441c-2.508-3.267-7.161-3.901-10.455-1.415
|
|
||||||
c-6.612,5.056-16.146,3.775-21.223-2.809c-2.445-3.194-3.487-7.133-2.958-11.117c0.061-0.503,0.353-0.916,0.481-1.402
|
|
||||||
l52.216,0.156c2.806,0,5.054,1.004,6.324,2.811C202.928,92.241,203.105,95.223,201.979,98.405z"/>
|
|
||||||
<path d="M107.997,127.194c-1.531-0.553-3.248,0.244-3.799,1.791l-4.302,12.099c-0.551,1.543,0.265,3.242,1.813,3.795
|
|
||||||
c0.331,0.116,0.659,0.16,0.998,0.16c1.214,0,2.372-0.765,2.801-1.976l4.294-12.099
|
|
||||||
C110.369,129.446,109.551,127.728,107.997,127.194z"/>
|
|
||||||
<path d="M116.6,103.014c-1.529-0.541-3.25,0.252-3.805,1.805l-4.298,12.088c-0.547,1.547,0.261,3.252,1.799,3.799
|
|
||||||
c0.329,0.12,0.659,0.172,1,0.172c1.222,0,2.368-0.769,2.809-1.983l4.294-12.09C118.955,105.268,118.139,103.555,116.6,103.014z"/>
|
|
||||||
<path d="M232.527,90.428l-14.896-0.038l0,0c-1.639,0-2.974,1.327-2.997,2.976c0,1.639,1.342,2.981,2.981,2.989l14.896,0.042l0,0
|
|
||||||
c1.643,0,2.978-1.331,2.993-2.979C235.504,91.763,234.17,90.436,232.527,90.428z"/>
|
|
||||||
<path d="M220.333,80.436c0.629,0,1.242-0.188,1.771-0.583l11.994-8.83c1.326-0.974,1.611-2.842,0.645-4.168
|
|
||||||
c-0.965-1.327-2.845-1.611-4.163-0.637l-11.998,8.833c-1.323,0.974-1.607,2.841-0.642,4.167
|
|
||||||
C218.513,80.003,219.418,80.436,220.333,80.436z"/>
|
|
||||||
<path d="M209.152,56.279c-1.547-0.549-3.25,0.269-3.787,1.805l-4.997,14.036c-0.537,1.547,0.26,3.252,1.803,3.807
|
|
||||||
c0.337,0.12,0.674,0.172,0.994,0.172c1.242,0,2.385-0.757,2.821-1.986l4.985-14.036C211.516,58.541,210.695,56.846,209.152,56.279
|
|
||||||
z"/>
|
|
||||||
<path d="M17.587,100.894h55.208c1.641,0,2.976-1.343,2.976-2.981c0-1.641-1.334-2.988-2.976-2.988H17.587
|
|
||||||
c-1.641,0-2.988,1.338-2.988,2.988C14.599,99.559,15.946,100.894,17.587,100.894z"/>
|
|
||||||
<path d="M68.471,119.328c0-1.641-1.345-2.987-2.986-2.987H10.283c-1.639,0-2.981,1.338-2.981,2.987
|
|
||||||
c0,1.639,1.342,2.974,2.981,2.974h55.202C67.119,122.301,68.471,120.967,68.471,119.328z"/>
|
|
||||||
<path d="M58.188,137.758H2.974c-1.641,0-2.974,1.335-2.974,2.989c0,1.64,1.333,2.974,2.974,2.974h55.214
|
|
||||||
c1.639,0,2.981-1.334,2.981-2.974C61.162,139.093,59.827,137.758,58.188,137.758z"/>
|
|
||||||
<path d="M169.611,28.097c11.821,0,21.403,9.584,21.403,21.41c0,11.82-9.582,21.408-21.403,21.408
|
|
||||||
c-11.822,0-21.412-9.588-21.412-21.408C148.199,37.681,157.789,28.097,169.611,28.097z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 4.6 KiB |
@ -1,21 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
|
||||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
width="800px" height="800px" viewBox="0 0 93.5 93.5" xml:space="preserve">
|
|
||||||
<g>
|
|
||||||
<g>
|
|
||||||
<path d="M93.5,40.899c0-2.453-1.995-4.447-4.448-4.447H81.98c-0.74-2.545-1.756-5.001-3.035-7.331l4.998-5
|
|
||||||
c0.826-0.827,1.303-1.973,1.303-3.146c0-1.19-0.462-2.306-1.303-3.146L75.67,9.555c-1.613-1.615-4.673-1.618-6.29,0l-5,5
|
|
||||||
c-2.327-1.28-4.786-2.296-7.332-3.037v-7.07C57.048,1.995,55.053,0,52.602,0H40.899c-2.453,0-4.447,1.995-4.447,4.448v7.071
|
|
||||||
c-2.546,0.741-5.005,1.757-7.333,3.037l-5-5c-1.68-1.679-4.609-1.679-6.288,0L9.555,17.83c-1.734,1.734-1.734,4.555,0,6.289
|
|
||||||
l4.999,5c-1.279,2.33-2.295,4.788-3.036,7.333h-7.07C1.995,36.452,0,38.447,0,40.899V52.6c0,2.453,1.995,4.447,4.448,4.447h7.071
|
|
||||||
c0.74,2.545,1.757,5.003,3.036,7.332l-4.998,4.999c-0.827,0.827-1.303,1.974-1.303,3.146c0,1.189,0.462,2.307,1.302,3.146
|
|
||||||
l8.274,8.273c1.614,1.615,4.674,1.619,6.29,0l5-5c2.328,1.279,4.786,2.297,7.333,3.037v7.071c0,2.453,1.995,4.448,4.447,4.448
|
|
||||||
h11.702c2.453,0,4.446-1.995,4.446-4.448V81.98c2.546-0.74,5.005-1.756,7.332-3.037l5,5c1.681,1.68,4.608,1.68,6.288,0
|
|
||||||
l8.275-8.273c1.734-1.734,1.734-4.555,0-6.289l-4.998-5.001c1.279-2.329,2.295-4.787,3.035-7.332h7.071
|
|
||||||
c2.453,0,4.448-1.995,4.448-4.446V40.899z M62.947,46.75c0,8.932-7.266,16.197-16.197,16.197c-8.931,0-16.197-7.266-16.197-16.197
|
|
||||||
c0-8.931,7.266-16.197,16.197-16.197C55.682,30.553,62.947,37.819,62.947,46.75z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.7 KiB |
@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
|
|
||||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
|
||||||
<svg fill="#000000" height="800px" width="800px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
|
||||||
<g id="task">
|
|
||||||
<path d="M4,23.4l-3.7-3.7l1.4-1.4L4,20.6l4.3-4.3l1.4,1.4L4,23.4z M24,21H12v-2h12V21z M4,15.4l-3.7-3.7l1.4-1.4L4,12.6l4.3-4.3
|
|
||||||
l1.4,1.4L4,15.4z M24,13H12v-2h12V13z M4,7.4L0.3,3.7l1.4-1.4L4,4.6l4.3-4.3l1.4,1.4L4,7.4z M24,5H12V3h12V5z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 603 B |
@ -1,500 +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{
|
|
||||||
font-size: 24pt;
|
|
||||||
font-weight: bold;
|
|
||||||
background: linear-gradient(150deg, var(--primary-color), var(--accent-color));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
margin-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
titlebox img {
|
|
||||||
height: 100%;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
spacer{
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
searchdiv{
|
|
||||||
display: block;
|
|
||||||
margin: 0 10px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#searchbox {
|
|
||||||
padding: 3px 10px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14pt;
|
|
||||||
width: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#settingscog {
|
|
||||||
cursor: pointer;
|
|
||||||
margin: 0px 30px;
|
|
||||||
height: 50%;
|
|
||||||
filter: invert(100%) sepia(0%) saturate(7465%) hue-rotate(115deg) brightness(116%) contrast(101%);
|
|
||||||
}
|
|
||||||
|
|
||||||
viewport {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-flow: row;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
flex-grow: 1;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
width: 100%;
|
|
||||||
height: 40px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
align-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer > div {
|
|
||||||
height: 100%;
|
|
||||||
margin: 0 30px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer > div > *{
|
|
||||||
height: 40%;
|
|
||||||
margin: 0 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#madeWith {
|
|
||||||
flex-grow: 1;
|
|
||||||
text-align: right;
|
|
||||||
margin-right: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
content {
|
|
||||||
position: relative;
|
|
||||||
flex-grow: 1;
|
|
||||||
border-radius: 5px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: start;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
width: 50%;
|
|
||||||
background-color: var(--accent-color);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
z-index: 10;
|
|
||||||
position: absolute;
|
|
||||||
left: 25%;
|
|
||||||
top: 25%;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#settingsPopup{
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
settings > * {
|
|
||||||
margin: 0 20%;
|
|
||||||
}
|
|
||||||
|
|
||||||
settings input {
|
|
||||||
margin: 3px 0;
|
|
||||||
padding: 3px;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: 1px solid rgba(0,0,0,0.2);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
settings .title {
|
|
||||||
font-weight: bolder;
|
|
||||||
font-size: 14pt;
|
|
||||||
margin: 15px 0 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
komga-settings {
|
|
||||||
margin-top: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
#addPublication {
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var(--secondary-color);
|
|
||||||
width: 180px;
|
|
||||||
height: 300px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin: 10px 10px;
|
|
||||||
padding: 15px 20px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
#addPublication p{
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 150pt;
|
|
||||||
vertical-align: middle;
|
|
||||||
line-height: 300px;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
flex-grow: 0;
|
|
||||||
height: 14pt;
|
|
||||||
font-size: 12pt;
|
|
||||||
border-radius: 9pt;
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
padding: 2pt 17px;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication{
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var(--secondary-color);
|
|
||||||
width: 180px;
|
|
||||||
height: 300px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin: 10px 10px;
|
|
||||||
padding: 15px 20px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication::after{
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 0; top: 0;
|
|
||||||
border-radius: 5px;
|
|
||||||
width: 100%; height: 100%;
|
|
||||||
background: linear-gradient(rgba(0,0,0,0.8), rgba(0, 0, 0, 0.7),rgba(0, 0, 0, 0.2));
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-information {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-information * {
|
|
||||||
z-index: 1;
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
connector-name{
|
|
||||||
width: fit-content;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-name{
|
|
||||||
width: fit-content;
|
|
||||||
font-size: 16pt;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication img {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
z-index: 0;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
popup{
|
|
||||||
display: none;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
position: fixed;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
blur-background {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
background-color: black;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
addtask-window {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
position: absolute;
|
|
||||||
left: 12.5%;
|
|
||||||
top: 15%;
|
|
||||||
width: 75%;
|
|
||||||
min-height: 70%;
|
|
||||||
max-height: 80%;
|
|
||||||
padding: 0;
|
|
||||||
background-color: var(--accent-color);
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
window-titlebar {
|
|
||||||
width: 100%;
|
|
||||||
height: 60px;
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
border-radius: 5px 5px 0 0;
|
|
||||||
color: var(--accent-color);
|
|
||||||
display: flex block;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
window-titlebar p {
|
|
||||||
margin: 0 30px;
|
|
||||||
font-size: 14pt;
|
|
||||||
font-weight: bolder;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
window-titlebar #closePopupImg {
|
|
||||||
height: 70%;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: 20px;
|
|
||||||
filter: invert(100%) sepia(0%) saturate(100%) hue-rotate(115deg) brightness(116%) contrast(101%);
|
|
||||||
}
|
|
||||||
|
|
||||||
window-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 20px 5%;
|
|
||||||
overflow-x: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
addtask-settings{
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
addtask-settings select, addtask-settings input{
|
|
||||||
padding: 5px;
|
|
||||||
font-size: 10pt;
|
|
||||||
border: 1px solid rgba(0,0,0,0.2);
|
|
||||||
border-radius: 3px;
|
|
||||||
background-color: transparent;
|
|
||||||
margin: 10px 0;
|
|
||||||
width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
addtask-settings label {
|
|
||||||
font-weight: bolder;
|
|
||||||
margin: 0 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
addtask-settings addtask-setting{
|
|
||||||
margin: 0 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#taskSelectOutput{
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: start;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
#publicationViewerPopup{
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-viewer{
|
|
||||||
display: block;
|
|
||||||
width: 450px;
|
|
||||||
height: 300px;
|
|
||||||
position: absolute;
|
|
||||||
top: 200px;
|
|
||||||
left: 400px;
|
|
||||||
background-color: var(--accent-color);
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-viewer::after{
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 0; top: 0;
|
|
||||||
border-radius: 5px;
|
|
||||||
width: 100%; height: 100%;
|
|
||||||
background: rgba(0,0,0,0.8);
|
|
||||||
backdrop-filter: blur(3px);
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-viewer img {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 5px;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-viewer publication-information publication-name{
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-viewer publication-information publication-author {
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-viewer publication-information publication-author::before {
|
|
||||||
content: "Author: ";
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-viewer publication-information publication-description::before {
|
|
||||||
content: "Description";
|
|
||||||
display: block;
|
|
||||||
font-weight: bolder;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-viewer publication-information publication-description {
|
|
||||||
font-size: 12pt;
|
|
||||||
margin: 5px 0;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-x: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-viewer publication-information publication-interactions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
position: absolute;
|
|
||||||
justify-content: end;
|
|
||||||
align-items: start;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-viewer publication-information publication-interactions > * {
|
|
||||||
margin: 0 10px 10px 10px;
|
|
||||||
font-size: 16pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-viewer publication-information publication-interactions publication-starttask {
|
|
||||||
color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-viewer publication-information publication-interactions publication-delete {
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
|
|
||||||
publication-viewer publication-information publication-interactions publication-add {
|
|
||||||
color: limegreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer-tag-popup {
|
|
||||||
display: none;
|
|
||||||
padding: 2px 4px;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 58px;
|
|
||||||
left: 20px;
|
|
||||||
background-color: var(--second-background-color);
|
|
||||||
z-index: 8;
|
|
||||||
border-radius: 5px;
|
|
||||||
max-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer-tag-content{
|
|
||||||
position: relative;
|
|
||||||
max-height: 400px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer-tag-content > * {
|
|
||||||
margin: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer-tag-popup::before{
|
|
||||||
content: "";
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
position: absolute;
|
|
||||||
border-right: 10px solid var(--second-background-color);
|
|
||||||
border-left: 10px solid transparent;
|
|
||||||
border-top: 10px solid var(--second-background-color);
|
|
||||||
border-bottom: 10px solid transparent;
|
|
||||||
left: 0px;
|
|
||||||
bottom: -17px;
|
|
||||||
border-radius: 0 0 0 5px;
|
|
||||||
}
|
|
@ -1,4 +1,5 @@
|
|||||||
services:
|
version: '3'
|
||||||
|
services:
|
||||||
tranga-api:
|
tranga-api:
|
||||||
image: glax/tranga-api:latest
|
image: glax/tranga-api:latest
|
||||||
container_name: tranga-api
|
container_name: tranga-api
|
||||||
@ -6,7 +7,7 @@
|
|||||||
- ./tranga:/usr/share/Tranga-API #1 when replacing ./tranga replace #2 with same value
|
- ./tranga:/usr/share/Tranga-API #1 when replacing ./tranga replace #2 with same value
|
||||||
- ./Manga:/Manga
|
- ./Manga:/Manga
|
||||||
ports:
|
ports:
|
||||||
- 6531:80
|
- "6531:6531"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
tranga-website:
|
tranga-website:
|
||||||
image: glax/tranga-website:latest
|
image: glax/tranga-website:latest
|
||||||
@ -14,6 +15,7 @@
|
|||||||
volumes:
|
volumes:
|
||||||
- ./tranga/imageCache:/usr/share/nginx/html/imageCache:ro #2 when replacing Point to same value as #1/imageCache
|
- ./tranga/imageCache:/usr/share/nginx/html/imageCache:ro #2 when replacing Point to same value as #1/imageCache
|
||||||
ports:
|
ports:
|
||||||
- 9555:80
|
- "9555:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- tranga-api
|
- tranga-api
|
||||||
|
restart: unless-stopped
|
Before Width: | Height: | Size: 1.0 MiB |
Before Width: | Height: | Size: 2.6 MiB |
Before Width: | Height: | Size: 2.2 MiB |
Before Width: | Height: | Size: 1.7 MiB |