49 Commits
v0.1 ... 0.3.1

Author SHA1 Message Date
3effc7aeb6 Check later 2023-05-20 01:35:19 +02:00
621468f498 Added InvalidFileNameCharacters to list of replaced Characters in folder-names 2023-05-20 01:31:06 +02:00
2c8e647a04 Simplification 2023-05-20 01:30:34 +02:00
9d583b284a Created Method to check wether file is already downloaded.
Using this method when running TaskExecutor.UpdateChapters to get a list of all chapters that have not yet been downloaded.
2023-05-20 01:30:23 +02:00
08e0fe7c71 We happy? We happy. Thanks ReSharper 2023-05-20 01:06:12 +02:00
9d104b25f8 Renamed some variables,
changed some access-types to protected/readonly
Made Resharper a bit happier
2023-05-20 01:06:00 +02:00
2550beb621 non-english titles can now also be listed. 2023-05-20 00:46:25 +02:00
2b18dc9d4f Added TrangaTask.ToString 2023-05-20 00:37:31 +02:00
247c06872e Formatting 2023-05-20 00:37:18 +02:00
854bb71771 Print the created task 2023-05-20 00:37:10 +02:00
3f72e527fa AddTask returns the created Task 2023-05-20 00:36:52 +02:00
3c1865de31 Added Menu options:
List Running Tasks
Search Task (by name)
2023-05-20 00:30:24 +02:00
84542640dc Renamed Method GetSeriesInfo to GetSeriesInfoJson to avoid confusion with xml 2023-05-20 00:19:40 +02:00
a3520dfd77 Now adding ComicInfo.xml to chapterse 2023-05-20 00:19:04 +02:00
68b40e087e rage 2023-05-19 23:02:08 +02:00
1674d70995 Moved SaveSeriesInfo to 6 lines of code... 2023-05-19 23:01:34 +02:00
ccbe8a95f8 Proper naming 2023-05-19 23:01:04 +02:00
78d8deb9de Properly create directory, not file, ya doofus 2023-05-19 23:00:45 +02:00
1d0883cbab strings 2023-05-19 23:00:04 +02:00
7726259d19 resharper 2023-05-19 22:59:37 +02:00
dc97774587 series.json is an abomination 2023-05-19 22:59:16 +02:00
26ef59ab42 Check if directory exists before creating 2023-05-19 22:58:59 +02:00
1b59475254 Number Format 2023-05-19 22:58:04 +02:00
28218b6dab New lines 2023-05-19 21:06:29 +02:00
5bfd6bc196 Delete Tempfolder even with files in it. 2023-05-19 20:55:19 +02:00
bc99735f76 Download Cover and Create Series Info before Chapters.
Create Publication Directory when calling SaveSeriesInfo and DownloadCover
2023-05-19 20:55:04 +02:00
c9602d5f67 Bug where Value was not returned 2023-05-19 20:35:51 +02:00
b040419e12 Fix bug where if no tasks were available, the program could not continue. 2023-05-19 20:34:34 +02:00
204ec203d5 Changed some strings 2023-05-19 20:34:09 +02:00
8fcee6ca22 Store last selected Folder-Path 2023-05-19 20:33:53 +02:00
e499062fd5 Add more documentation 2023-05-19 20:22:13 +02:00
a988d54619 Cleanup temp-dir after download 2023-05-19 20:14:21 +02:00
124c644db1 Added summary for TaskExecutor, TaskManager, TrangaTask 2023-05-19 20:03:17 +02:00
c1a3532a6c Execute now checks if Task is actually in collection 2023-05-19 20:02:58 +02:00
21b8c7e071 Added summary for Publication 2023-05-19 19:53:59 +02:00
ea6026101b Added Summaries to Chapter and Connector
Made some methods static
2023-05-19 19:52:24 +02:00
95eca6e1da Moved _downloadClient initialization from inherited Connector Classes to Connector-Main class. 2023-05-19 19:50:26 +02:00
881caafd43 Moved DownloadImage Method to Connector. 2023-05-19 19:44:59 +02:00
bf20676994 Removed field Publication from Chapter (Since Chapter is always Part of Publication) 2023-05-19 19:32:47 +02:00
553a77320d Strings 2023-05-19 19:23:37 +02:00
68e877298a Added option to immediately execute a task 2023-05-19 19:23:12 +02:00
58fef5c307 Added force shutdown functionality and option to select wether we want to force or not 2023-05-19 19:21:54 +02:00
c8654dbb85 Changed access types of Import and Export Tasks to private 2023-05-19 19:21:15 +02:00
133b3146b5 Added option to immediately execute a task 2023-05-19 19:20:52 +02:00
312672a05c Check if task is already being executed before running again. 2023-05-19 19:20:06 +02:00
d659a26987 Fixed bug when adding task, that Publication was not added to known publications.
Fixed issue, that chapters would be re-downloaded
2023-05-19 18:20:26 +02:00
8c6c95d07d Fixed issue where unsuccessfull resquests would crash the program. 2023-05-19 18:11:14 +02:00
c4949936cd Changed string 2023-05-19 18:10:47 +02:00
3ca96cea78 Add README 2023-05-19 17:50:56 +02:00
10 changed files with 507 additions and 121 deletions

5
README.md Normal file
View File

@ -0,0 +1,5 @@
Has a interactive CLI-Version as well as API-Version (no documentation of API yet).
Only one Connector so far: MangaDex.org (Timeout between requests 750ms)
Can automatically download new Chapters every given time-period.

View File

@ -12,7 +12,7 @@ app.MapGet("/GetPublications", (string connectorName, string? title) =>
{
Connector? connector = taskManager.GetAvailableConnectors().FirstOrDefault(c => c.Key == connectorName).Value;
if (connector is null)
JsonSerializer.Serialize($"Connector {connectorName} is not a known connector.");
return JsonSerializer.Serialize($"Connector {connectorName} is not a known connector.");
Publication[] publications;
if (title is not null)

View File

@ -8,12 +8,18 @@ public static class Tranga_Cli
{
public static void Main(string[] args)
{
Console.WriteLine("Output folder path [standard D:]:");
string? folderPath = Console.ReadLine();
while(folderPath is null )
folderPath = Console.ReadLine();
if (folderPath.Length < 1)
folderPath = "D:";
string folderPath = Directory.GetCurrentDirectory();
string settingsPath = Path.Join(Directory.GetCurrentDirectory(), "lastPath.setting");
if (File.Exists(settingsPath))
folderPath = File.ReadAllText(settingsPath);
Console.WriteLine($"Output folder path [{folderPath}]:");
string? tmpPath = Console.ReadLine();
while(tmpPath is null)
tmpPath = Console.ReadLine();
if(tmpPath.Length > 0)
folderPath = tmpPath;
File.WriteAllText(settingsPath, folderPath);
Console.Write("Mode (D: Interactive only, T: TaskManager):");
ConsoleKeyInfo mode = Console.ReadKey();
@ -37,7 +43,7 @@ public static class Tranga_Cli
switch (menu)
{
case 1:
PrintTasks(taskManager);
PrintTasks(taskManager.GetAllTasks());
Console.WriteLine("Press any key.");
Console.ReadKey();
menu = 0;
@ -49,8 +55,8 @@ public static class Tranga_Cli
if(task != TrangaTask.Task.UpdatePublications)
publication = SelectPublication(connector);
TimeSpan reoccurrence = SelectReoccurrence();
taskManager.AddTask(task, connector.name, publication, reoccurrence, "en");
Console.WriteLine($"{task} - {connector.name} - {publication?.sortName}");
TrangaTask newTask = taskManager.AddTask(task, connector.name, publication, reoccurrence, "en");
Console.WriteLine(newTask);
Console.WriteLine("Press any key.");
Console.ReadKey();
menu = 0;
@ -61,8 +67,32 @@ public static class Tranga_Cli
Console.ReadKey();
menu = 0;
break;
case 4:
ExecuteTaskNow(taskManager);
Console.WriteLine("Press any key.");
Console.ReadKey();
menu = 0;
break;
case 5:
Console.WriteLine("Search-Query (Name):");
string? query = Console.ReadLine();
while (query is null || query.Length < 1)
query = Console.ReadLine();
PrintTasks(taskManager.GetAllTasks().Where(qTask =>
((Publication)qTask.publication!).sortName.ToLower()
.Contains(query, StringComparison.OrdinalIgnoreCase)).ToArray());
Console.WriteLine("Press any key.");
Console.ReadKey();
menu = 0;
break;
case 6:
PrintTasks(taskManager.GetAllTasks().Where(eTask => eTask.isBeingExecuted).ToArray());
Console.WriteLine("Press any key.");
Console.ReadKey();
menu = 0;
break;
default:
selection = Menu(folderPath);
selection = Menu(taskManager, folderPath);
switch (selection)
{
case ConsoleKey.L:
@ -74,6 +104,18 @@ public static class Tranga_Cli
case ConsoleKey.D:
menu = 3;
break;
case ConsoleKey.E:
menu = 4;
break;
case ConsoleKey.U:
menu = 0;
break;
case ConsoleKey.S:
menu = 5;
break;
case ConsoleKey.R:
menu = 6;
break;
default:
menu = 0;
break;
@ -81,40 +123,80 @@ public static class Tranga_Cli
break;
}
}
taskManager.Shutdown();
if (taskManager.GetAllTasks().Any(task => task.isBeingExecuted))
{
Console.WriteLine("Force quit (Even with running tasks?) y/N");
selection = Console.ReadKey().Key;
taskManager.Shutdown(selection == ConsoleKey.Y);
}else
// ReSharper disable once RedundantArgumentDefaultValue Better readability
taskManager.Shutdown(false);
}
private static ConsoleKey Menu(string folderPath)
private static ConsoleKey Menu(TaskManager taskManager, string folderPath)
{
int taskCount = taskManager.GetAllTasks().Length;
int taskRunningCount = taskManager.GetAllTasks().Count(task => task.isBeingExecuted);
Console.Clear();
Console.WriteLine($"Download Folder: {folderPath}");
Console.WriteLine("Select Option:");
Console.WriteLine($"Download Folder: {folderPath} Tasks (Running/Total): {taskRunningCount}/{taskCount}");
Console.WriteLine("U: Update this Screen");
Console.WriteLine("L: List tasks");
Console.WriteLine("C: Create Task");
Console.WriteLine("D: Delete Task");
Console.WriteLine("Q: Exit with saving");
Console.WriteLine("E: Execute Task now");
Console.WriteLine("S: Search Task");
Console.WriteLine("R: Running Tasks");
Console.WriteLine("Q: Exit");
ConsoleKey selection = Console.ReadKey().Key;
Console.WriteLine();
return selection;
}
private static int PrintTasks(TaskManager taskManager)
private static void PrintTasks(TrangaTask[] tasks)
{
int taskCount = tasks.Length;
int taskRunningCount = tasks.Count(task => task.isBeingExecuted);
Console.Clear();
TrangaTask[] tasks = taskManager.GetAllTasks();
int tIndex = 0;
Console.WriteLine("Tasks:");
Console.WriteLine($"Tasks (Running/Total): {taskRunningCount}/{taskCount}");
foreach(TrangaTask trangaTask in tasks)
Console.WriteLine($"{tIndex++}: {trangaTask.task} - {trangaTask.reoccurrence} - {trangaTask.publication?.sortName} - {trangaTask.connectorName}");
return tasks.Length;
Console.WriteLine($"{tIndex++:000}: {trangaTask}");
}
private static void ExecuteTaskNow(TaskManager taskManager)
{
TrangaTask[] tasks = taskManager.GetAllTasks();
if (tasks.Length < 1)
{
Console.Clear();
Console.WriteLine("There are no available Tasks.");
return;
}
PrintTasks(tasks);
Console.WriteLine($"Select Task (0-{tasks.Length - 1}):");
string? selectedTask = Console.ReadLine();
while(selectedTask is null || selectedTask.Length < 1)
selectedTask = Console.ReadLine();
int selectedTaskIndex = Convert.ToInt32(selectedTask);
taskManager.ExecuteTaskNow(tasks[selectedTaskIndex]);
}
private static void RemoveTask(TaskManager taskManager)
{
int length = PrintTasks(taskManager);
TrangaTask[] tasks = taskManager.GetAllTasks();
Console.WriteLine($"Select Task (0-{length - 1}):");
if (tasks.Length < 1)
{
Console.Clear();
Console.WriteLine("There are no available Tasks.");
return;
}
PrintTasks(tasks);
Console.WriteLine($"Select Task (0-{tasks.Length - 1}):");
string? selectedTask = Console.ReadLine();
while(selectedTask is null || selectedTask.Length < 1)
@ -234,7 +316,7 @@ public static class Tranga_Cli
selected = Console.ReadLine();
int start = 0;
int end = 0;
int end;
if (selected == "a")
end = chapters.Length - 1;
else if (selected.Contains('-'))

View File

@ -2,19 +2,20 @@
namespace Tranga;
/// <summary>
/// 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.
/// </summary>
public struct Chapter
{
public Publication publication { get; }
public string? name { get; }
public string? volumeNumber { get; }
public string? chapterNumber { get; }
public string url { get; }
public string fileName { get; }
public Chapter(Publication publication, string? name, string? volumeNumber, string? chapterNumber, string url)
public Chapter(string? name, string? volumeNumber, string? chapterNumber, string url)
{
this.publication = publication;
this.name = name;
this.volumeNumber = volumeNumber;
this.chapterNumber = chapterNumber;
@ -22,6 +23,6 @@ public struct Chapter
string chapterName = string.Concat((name ?? "").Split(Path.GetInvalidFileNameChars()));
double multiplied = Convert.ToDouble(chapterNumber, new NumberFormatInfo() { NumberDecimalSeparator = "." }) *
Convert.ToInt32(volumeNumber);
this.fileName = $"{chapterName} - V{volumeNumber}C{chapterNumber} - {multiplied}";
this.fileName = $"{chapterName} - V{volumeNumber}C{chapterNumber} - {multiplied.ToString(new NumberFormatInfo() { NumberDecimalSeparator = "." })}";
}
}

View File

@ -1,66 +1,167 @@
using System.IO.Compression;
using System.Net;
using System.Xml.Linq;
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
{
public Connector(string downloadLocation)
internal string downloadLocation { get; } //Location of local files
protected DownloadClient downloadClient { get; }
protected Connector(string downloadLocation, uint downloadDelay)
{
this.downloadLocation = downloadLocation;
this.downloadClient = new DownloadClient(downloadDelay);
}
internal string downloadLocation { get; }
public abstract string name { get; }
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 = "");
public abstract void DownloadChapter(Publication publication, Chapter chapter); //where to?
protected abstract void DownloadImage(string url, string savePath);
/// <summary>
/// Retrieves the Chapter (+Images) from the website.
/// Should later call DownloadChapterImages to retrieve the individual Images of the Chapter.
/// </summary>
/// <param name="publication">Publication that contains Chapter</param>
/// <param name="chapter">Chapter with Images to retrieve</param>
public abstract void DownloadChapter(Publication publication, Chapter chapter);
/// <summary>
/// Retrieves the Cover from the Website
/// </summary>
/// <param name="publication">Publication to retrieve Cover for</param>
public abstract void DownloadCover(Publication publication);
protected void DownloadChapter(string[] imageUrls, string saveArchiveFilePath)
/// <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)
{
string tempFolder = Path.GetTempFileName();
File.Delete(tempFolder);
Directory.CreateDirectory(tempFolder);
//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);
int chapter = 0;
foreach (string imageUrl in imageUrls)
{
string[] split = imageUrl.Split('.');
string extension = split[split.Length - 1];
DownloadImage(imageUrl, Path.Join(tempFolder, $"{chapter++}.{extension}"));
}
string seriesInfoPath = Path.Join(publicationFolder, "series.json");
if(!File.Exists(seriesInfoPath))
File.WriteAllText(seriesInfoPath,publication.GetSeriesInfoJson());
}
protected static string CreateComicInfo(Publication publication, Chapter chapter)
{
XElement comicInfo = new XElement("ComicInfo",
new XElement("Tags", string.Join(',',publication.tags)),
new XElement("LanguageISO", publication.originalLanguage),
new XElement("Title", chapter.name)
);
return comicInfo.ToString();
}
public bool ChapterIsDownloaded(Publication publication, Chapter chapter)
{
return File.Exists(CreateFullFilepath(publication, chapter));
}
protected string CreateFullFilepath(Publication publication, Chapter chapter)
{
return Path.Join(downloadLocation, publication.folderName, chapter.fileName);
}
/// <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>
protected static void DownloadImage(string imageUrl, string fullPath, DownloadClient downloadClient)
{
DownloadClient.RequestResult requestResult = downloadClient.MakeRequest(imageUrl);
byte[] buffer = new byte[requestResult.result.Length];
requestResult.result.ReadExactly(buffer, 0, buffer.Length);
File.WriteAllBytes(fullPath, buffer);
}
/// <summary>
/// Downloads all Images from URLs, Compresses to zip(cbz) and saves.
/// </summary>
/// <param name="imageUrls">List of URLs to download Images from</param>
/// <param name="saveArchiveFilePath">Full path to save archive to (without file ending .cbz)</param>
/// <param name="downloadClient">DownloadClient of the connector</param>
/// <param name="comicInfoPath">Path of the generate Chapter ComicInfo.xml, if it was generated</param>
protected static void DownloadChapterImages(string[] imageUrls, string saveArchiveFilePath, DownloadClient downloadClient, string? comicInfoPath = null)
{
//Check if Publication Directory already exists
string[] splitPath = saveArchiveFilePath.Split(Path.DirectorySeparatorChar);
string directoryPath = Path.Combine(splitPath.Take(splitPath.Length - 1).ToArray());
if (!Directory.Exists(directoryPath))
Directory.CreateDirectory(directoryPath);
string fullPath = $"{saveArchiveFilePath}.cbz";
File.Delete(fullPath);
if (File.Exists(fullPath)) //Don't download twice.
return;
//Create a temporary folder to store images
string tempFolder = Directory.CreateTempSubdirectory().FullName;
int chapter = 0;
//Download all Images to temporary Folder
foreach (string imageUrl in imageUrls)
{
string[] split = imageUrl.Split('.');
string extension = split[^1];
DownloadImage(imageUrl, Path.Join(tempFolder, $"{chapter++}.{extension}"), downloadClient);
}
if(comicInfoPath is not null)
File.Copy(comicInfoPath, Path.Join(tempFolder, "ComicInfo.xml"));
//ZIP-it and ship-it
ZipFile.CreateFromDirectory(tempFolder, fullPath);
Directory.Delete(tempFolder, true); //Cleanup
}
public void SaveSeriesInfo(Publication publication)
{
string seriesInfoPath = Path.Join(downloadLocation, publication.folderName, "series.json");
if(!File.Exists(seriesInfoPath))
File.WriteAllText(seriesInfoPath,publication.GetSeriesInfo());
}
internal class DownloadClient
protected class DownloadClient
{
private readonly TimeSpan _requestSpeed;
private DateTime _lastRequest;
private static readonly HttpClient Client = new();
/// <summary>
/// Creates a httpClient
/// </summary>
/// <param name="delay">minimum delay between requests (to avoid spam)</param>
public DownloadClient(uint delay)
{
_requestSpeed = TimeSpan.FromMilliseconds(delay);
_lastRequest = DateTime.Now.Subtract(_requestSpeed);
}
/// <summary>
/// Request Webpage
/// </summary>
/// <param name="url"></param>
/// <returns>RequestResult with StatusCode and Stream of received data</returns>
public RequestResult MakeRequest(string url)
{
while((DateTime.Now - _lastRequest) < _requestSpeed)

View File

@ -1,4 +1,5 @@
using System.Globalization;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
@ -6,29 +7,41 @@ namespace Tranga.Connectors;
public class MangaDex : Connector
{
public override string name { get; }
private readonly DownloadClient _downloadClient = new (750);
public MangaDex(string downloadLocation) : base(downloadLocation)
public MangaDex(string downloadLocation, uint downloadDelay) : base(downloadLocation, downloadDelay)
{
name = "MangaDex";
}
public MangaDex(string downloadLocation) : base(downloadLocation, 750)
{
name = "MangaDex";
}
public override Publication[] GetPublications(string publicationTitle = "")
{
const int limit = 100;
int offset = 0;
int total = int.MaxValue;
const int limit = 100; //How many values we want returned at once
int offset = 0; //"Page"
int total = int.MaxValue; //How many total results are there, is updated on first request
HashSet<Publication> publications = new();
while (offset < total)
while (offset < total) //As long as we haven't requested all "Pages"
{
DownloadClient.RequestResult requestResult = _downloadClient.MakeRequest($"https://api.mangadex.org/manga?limit={limit}&title={publicationTitle}&offset={offset}");
//Request next Page
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest(
$"https://api.mangadex.org/manga?limit={limit}&title={publicationTitle}&offset={offset}");
if (requestResult.statusCode != HttpStatusCode.OK)
break;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
offset += limit;
if (result is null)
break;
total = result["total"]!.GetValue<int>();
JsonArray mangaInResult = result["data"]!.AsArray();
total = result["total"]!.GetValue<int>(); //Update the total number of Publications
JsonArray mangaInResult = result["data"]!.AsArray(); //Manga-data-Array
//Loop each Manga and extract information from JSON
foreach (JsonNode? mangeNode in mangaInResult)
{
JsonObject manga = (JsonObject)mangeNode!;
@ -36,7 +49,7 @@ public class MangaDex : Connector
string title = attributes["title"]!.AsObject().ContainsKey("en") && attributes["title"]!["en"] is not null
? attributes["title"]!["en"]!.GetValue<string>()
: "";
: attributes["title"]![((IDictionary<string, JsonNode?>)attributes["title"]!.AsObject()).Keys.First()]!.GetValue<string>();
string? description = attributes["description"]!.AsObject().ContainsKey("en") && attributes["description"]!["en"] is not null
? attributes["description"]!["en"]!.GetValue<string?>()
@ -104,7 +117,7 @@ public class MangaDex : Connector
status,
manga["id"]!.GetValue<string>()
);
publications.Add(pub);
publications.Add(pub); //Add Publication (Manga) to result
}
}
@ -113,15 +126,19 @@ public class MangaDex : Connector
public override Chapter[] GetChapters(Publication publication, string language = "")
{
const int limit = 100;
int offset = 0;
string id = publication.downloadUrl;
int total = int.MaxValue;
const int limit = 100; //How many values we want returned at once
int offset = 0; //"Page"
int total = int.MaxValue; //How many total results are there, is updated on first request
List<Chapter> chapters = new();
//As long as we haven't requested all "Pages"
while (offset < total)
{
//Request next "Page"
DownloadClient.RequestResult requestResult =
_downloadClient.MakeRequest($"https://api.mangadex.org/manga/{id}/feed?limit={limit}&offset={offset}&translatedLanguage%5B%5D={language}");
downloadClient.MakeRequest(
$"https://api.mangadex.org/manga/{publication.downloadUrl}/feed?limit={limit}&offset={offset}&translatedLanguage%5B%5D={language}");
if (requestResult.statusCode != HttpStatusCode.OK)
break;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
offset += limit;
@ -130,6 +147,7 @@ public class MangaDex : Connector
total = result["total"]!.GetValue<int>();
JsonArray chaptersInResult = result["data"]!.AsArray();
//Loop through all Chapters in result and extract information from JSON
foreach (JsonNode? jsonNode in chaptersInResult)
{
JsonObject chapter = (JsonObject)jsonNode!;
@ -148,10 +166,11 @@ public class MangaDex : Connector
? attributes["chapter"]!.GetValue<string>()
: null;
chapters.Add(new Chapter(publication, title, volume, chapterNum, chapterId));
chapters.Add(new Chapter(title, volume, chapterNum, chapterId));
}
}
//Return Chapters ordered by Chapter-Number
NumberFormatInfo chapterNumberFormatInfo = new()
{
NumberDecimalSeparator = "."
@ -161,8 +180,11 @@ public class MangaDex : Connector
public override void DownloadChapter(Publication publication, Chapter chapter)
{
//Request URLs for Chapter-Images
DownloadClient.RequestResult requestResult =
_downloadClient.MakeRequest($"https://api.mangadex.org/at-home/server/{chapter.url}?forcePort443=false'");
downloadClient.MakeRequest($"https://api.mangadex.org/at-home/server/{chapter.url}?forcePort443=false'");
if (requestResult.statusCode != HttpStatusCode.OK)
return;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
if (result is null)
return;
@ -170,42 +192,50 @@ public class MangaDex : Connector
string baseUrl = result["baseUrl"]!.GetValue<string>();
string hash = result["chapter"]!["hash"]!.GetValue<string>();
JsonArray imageFileNames = result["chapter"]!["data"]!.AsArray();
//Loop through all imageNames and construct urls (imageUrl)
HashSet<string> imageUrls = new();
foreach (JsonNode? image in imageFileNames)
imageUrls.Add($"{baseUrl}/data/{hash}/{image!.GetValue<string>()}");
DownloadChapter(imageUrls.ToArray(), Path.Join(downloadLocation, publication.folderName, chapter.fileName));
}
string comicInfoPath = Path.GetTempFileName();
File.WriteAllText(comicInfoPath, CreateComicInfo(publication, chapter));
protected override void DownloadImage(string url, string savePath)
{
DownloadClient.RequestResult requestResult = _downloadClient.MakeRequest(url);
byte[] buffer = new byte[requestResult.result.Length];
requestResult.result.ReadExactly(buffer, 0, buffer.Length);
File.WriteAllBytes(savePath, buffer);
//Download Chapter-Images
DownloadChapterImages(imageUrls.ToArray(), CreateFullFilepath(publication, chapter), downloadClient, comicInfoPath);
}
public override void DownloadCover(Publication publication)
{
string publicationPath = Path.Join(downloadLocation, publication.folderName);
DirectoryInfo dirInfo = new DirectoryInfo(publicationPath);
//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);
foreach(FileInfo fileInfo in dirInfo.EnumerateFiles())
if (fileInfo.Name.Contains("cover."))
return;
DownloadClient.RequestResult requestResult = _downloadClient.MakeRequest($"https://api.mangadex.org/cover/{publication.posterUrl}");
//Request information where to download Cover
DownloadClient.RequestResult requestResult =
downloadClient.MakeRequest($"https://api.mangadex.org/cover/{publication.posterUrl}");
if (requestResult.statusCode != HttpStatusCode.OK)
return;
JsonObject? result = JsonSerializer.Deserialize<JsonObject>(requestResult.result);
if (result is null)
return;
string fileName = result!["data"]!["attributes"]!["fileName"]!.GetValue<string>();
string fileName = result["data"]!["attributes"]!["fileName"]!.GetValue<string>();
string coverUrl = $"https://uploads.mangadex.org/covers/{publication.downloadUrl}/{fileName}";
//Get file-extension (jpg, png)
string[] split = coverUrl.Split('.');
string extension = split[split.Length - 1];
string extension = split[^1];
string outFolderPath = Path.Join(downloadLocation, publication.folderName);
Directory.CreateDirectory(outFolderPath);
DownloadImage(coverUrl, Path.Join(downloadLocation, publication.folderName, $"cover.{extension}"));
//Download cover-Image
DownloadImage(coverUrl, Path.Join(downloadLocation, publication.folderName, $"cover.{extension}"), this.downloadClient);
}
}

View File

@ -2,10 +2,15 @@
namespace Tranga;
public struct Publication
/// <summary>
/// Contains information on a Publication (Manga)
/// </summary>
public readonly struct Publication
{
public string sortName { get; }
// ReSharper disable UnusedAutoPropertyAccessor.Global we need it, trust
[JsonIgnore]public string[,] altTitles { get; }
// ReSharper disable trice MemberCanBePrivate.Global, trust
public string? description { get; }
public string[] tags { get; }
public string? posterUrl { get; }
@ -28,35 +33,65 @@ public struct Publication
this.originalLanguage = originalLanguage;
this.status = status;
this.downloadUrl = downloadUrl;
this.folderName = string.Concat(sortName.Split(Path.GetInvalidPathChars()));
this.folderName = string.Concat(sortName.Split(Path.GetInvalidPathChars().Concat(Path.GetInvalidFileNameChars()).ToArray()));
}
public string GetSeriesInfo()
/// <summary>
///
/// </summary>
/// <returns>Serialized JSON String for series.json</returns>
public string GetSeriesInfoJson()
{
SeriesInfo si = new (new Metadata(this.sortName, this.year.ToString() ?? string.Empty, this.status, this.description ?? ""));
return System.Text.Json.JsonSerializer.Serialize(si);
}
internal struct SeriesInfo
//Only for series.json
private struct SeriesInfo
{
// ReSharper disable once UnusedAutoPropertyAccessor.Local we need it, trust
[JsonRequired]public Metadata metadata { get; }
public SeriesInfo(Metadata metadata) => this.metadata = metadata;
}
internal struct Metadata
//Only for series.json what an abomination, why are all the fields not-null????
private struct Metadata
{
// ReSharper disable UnusedAutoPropertyAccessor.Local we need it, trust
[JsonRequired] public string type { get; }
[JsonRequired] public string publisher { get; }
// ReSharper disable twice IdentifierTypo
[JsonRequired] public int comicid { get; }
[JsonRequired] public string booktype { get; }
// ReSharper disable InconsistentNaming
[JsonRequired] public string ComicImage { get; }
[JsonRequired] public int total_issues { get; }
[JsonRequired] public string publication_run { get; }
[JsonRequired]public string name { get; }
[JsonRequired]public string year { get; }
[JsonRequired]public string status { get; }
// ReSharper disable twice InconsistentNaming
[JsonRequired]public string description_text { get; }
public Metadata(string name, string year, string status, string description_text)
{
this.name = name;
this.year = year;
this.status = status;
if(status == "ongoing" || status == "hiatus")
this.status = "Continuing";
else if (status == "completed" || status == "cancelled")
this.status = "Ended";
else
this.status = status;
this.description_text = description_text;
//kill it with fire
type = "Manga";
publisher = "";
comicid = 0;
booktype = "";
ComicImage = "";
total_issues = 0;
publication_run = "";
}
}
}

View File

@ -1,14 +1,33 @@
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="connectors">List of all available Connectors</param>
/// <param name="trangaTask">Task to execute</param>
/// <param name="chapterCollection">Current chapterCollection to update</param>
/// <exception cref="ArgumentException">Is thrown when there is no Connector available with the name of the TrangaTask.connectorName</exception>
public static void Execute(Connector[] connectors, TrangaTask trangaTask, Dictionary<Publication, List<Chapter>> chapterCollection)
{
//Get Connector from list of available Connectors and the required Connector of the TrangaTask
Connector? connector = connectors.FirstOrDefault(c => c.name == trangaTask.connectorName);
if (connector is null)
throw new ArgumentException($"Connector {trangaTask.connectorName} is not a known connector.");
if (trangaTask.isBeingExecuted)
return;
trangaTask.isBeingExecuted = true;
trangaTask.lastExecuted = DateTime.Now;
//Call appropriate Method based on TrangaTask.Task
switch (trangaTask.task)
{
case TrangaTask.Task.DownloadNewChapters:
@ -21,8 +40,15 @@ public static class TaskExecutor
UpdatePublications(connector, chapterCollection);
break;
}
trangaTask.isBeingExecuted = false;
}
/// <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, Dictionary<Publication, List<Chapter>> chapterCollection)
{
Publication[] publications = connector.GetPublications();
@ -30,26 +56,48 @@ public static class TaskExecutor
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, Dictionary<Publication, List<Chapter>> chapterCollection)
{
List<Chapter> newChapters = UpdateChapters(connector, publication, language, chapterCollection);
connector.DownloadCover(publication);
//Check if Publication already has a Folder and a series.json
string publicationFolder = Path.Join(connector.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());
foreach(Chapter newChapter in newChapters)
connector.DownloadChapter(publication, newChapter);
connector.DownloadCover(publication);
connector.SaveSeriesInfo(publication);
}
/// <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, Dictionary<Publication, List<Chapter>> chapterCollection)
{
List<Chapter> newChaptersList = new();
if (!chapterCollection.ContainsKey(publication))
return newChaptersList;
chapterCollection.TryAdd(publication, newChaptersList); //To ensure publication is actually in collection
List<Chapter> currentChapters = chapterCollection[publication];
Chapter[] newChapters = connector.GetChapters(publication, language);
newChaptersList = newChapters.Where(nChapter => !connector.ChapterIsDownloaded(publication, nChapter)).ToList();
newChaptersList = newChapters.ToList()
.ExceptBy(currentChapters.Select(cChapter => cChapter.url), nChapter => nChapter.url).ToList();
return newChaptersList;
}
}

View File

@ -3,18 +3,24 @@ 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
{
private readonly Dictionary<Publication, List<Chapter>> _chapterCollection;
private readonly HashSet<TrangaTask> _allTasks;
private bool _continueRunning = true;
private readonly Connector[] connectors;
private readonly string folderPath;
private readonly Connector[] _connectors;
/// <summary>
///
/// </summary>
/// <param name="folderPath">Local path to save data (Manga) to</param>
public TaskManager(string folderPath)
{
this.folderPath = folderPath;
this.connectors = new Connector[]{ new MangaDex(folderPath) };
this._connectors = new Connector[]{ new MangaDex(folderPath) };
_chapterCollection = new();
_allTasks = ImportTasks(Directory.GetCurrentDirectory());
Thread taskChecker = new(TaskCheckerThread);
@ -28,27 +34,64 @@ public class TaskManager
foreach (TrangaTask task in _allTasks)
{
if(task.ShouldExecute())
TaskExecutor.Execute(this.connectors, task, this._chapterCollection);
TaskExecutor.Execute(this._connectors, task, this._chapterCollection); //Might crash here, when adding new Task while another Task is running. Check later
}
Thread.Sleep(1000);
}
}
public void AddTask(TrangaTask.Task task, string connectorName, Publication? publication, TimeSpan reoccurrence,
/// <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;
Task t = new Task(() =>
{
TaskExecutor.Execute(this._connectors, task, this._chapterCollection);
});
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 = "")
{
Connector? connector = connectors.FirstOrDefault(c => c.name == connectorName);
//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.");
TrangaTask newTask = new TrangaTask(connector.name, task, publication, reoccurrence, language);
//Check if same task already exists
if (!_allTasks.Any(trangaTask => trangaTask.task != task && trangaTask.connectorName != connector.name &&
trangaTask.publication?.downloadUrl != publication?.downloadUrl))
{
_allTasks.Add(new TrangaTask(connector.name, task, publication, reoccurrence, language));
if(task != TrangaTask.Task.UpdatePublications)
_chapterCollection.Add((Publication)publication!, new List<Chapter>());
_allTasks.Add(newTask);
ExportTasks(Directory.GetCurrentDirectory());
}
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 RemoveTask(TrangaTask.Task task, string connectorName, Publication? publication)
{
_allTasks.RemoveWhere(trangaTask =>
@ -57,11 +100,19 @@ public class TaskManager
ExportTasks(Directory.GetCurrentDirectory());
}
/// <summary>
///
/// </summary>
/// <returns>All available Connectors</returns>
public Dictionary<string, Connector> GetAvailableConnectors()
{
return this.connectors.ToDictionary(connector => connector.name, connector => connector);
return this._connectors.ToDictionary(connector => connector.name, connector => connector);
}
/// <summary>
///
/// </summary>
/// <returns>All TrangaTasks in task-collection</returns>
public TrangaTask[] GetAllTasks()
{
TrangaTask[] ret = new TrangaTask[_allTasks.Count];
@ -69,18 +120,34 @@ public class TaskManager
return ret;
}
/// <summary>
///
/// </summary>
/// <returns>All added Publications</returns>
public Publication[] GetAllPublications()
{
return this._chapterCollection.Keys.ToArray();
}
public void Shutdown()
/// <summary>
/// Shuts down the taskManager.
/// </summary>
/// <param name="force">If force is true, tasks are aborted.</param>
public void Shutdown(bool force = false)
{
_continueRunning = false;
ExportTasks(Directory.GetCurrentDirectory());
if(force)
Environment.Exit(_allTasks.Count(task => task.isBeingExecuted));
//Wait for tasks to finish
while(_allTasks.Any(task => task.isBeingExecuted))
Thread.Sleep(10);
Environment.Exit(0);
}
public HashSet<TrangaTask> ImportTasks(string importFolderPath)
private HashSet<TrangaTask> ImportTasks(string importFolderPath)
{
string filePath = Path.Join(importFolderPath, "tasks.json");
if (!File.Exists(filePath))
@ -96,7 +163,7 @@ public class TaskManager
return importTasks.ToHashSet();
}
public void ExportTasks(string exportFolderPath)
private void ExportTasks(string exportFolderPath)
{
string filePath = Path.Join(exportFolderPath, "tasks.json");
string toWrite = JsonConvert.SerializeObject(_allTasks.ToArray());

View File

@ -1,13 +1,21 @@
namespace Tranga;
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 bool isBeingExecuted { get; set; }
public TrangaTask(string connectorName, Task task, Publication? publication, TimeSpan reoccurrence, string language = "")
{
@ -21,6 +29,10 @@ public class TrangaTask
this.language = language;
}
/// <summary>
///
/// </summary>
/// <returns>True if elapsed time since last execution is greater than set interval</returns>
public bool ShouldExecute()
{
return DateTime.Now.Subtract(this.lastExecuted) > reoccurrence;
@ -32,4 +44,9 @@ public class TrangaTask
UpdateChapters,
DownloadNewChapters
}
public override string ToString()
{
return $"{task}\t{lastExecuted}\t{reoccurrence}\t{(isBeingExecuted ? "running" : "waiting")}\t{connectorName}\t{publication?.sortName}";
}
}