using System.IO.Compression;
using System.Net;
using System.Xml.Linq;
using Logging;
namespace Tranga;
///
/// Base-Class for all Connectors
/// Provides some methods to be used by all Connectors, as well as a DownloadClient
///
public abstract class Connector
{
internal string downloadLocation { get; } //Location of local files
protected DownloadClient downloadClient { get; init; }
protected Logger? logger;
protected Connector(string downloadLocation, Logger? logger)
{
this.downloadLocation = downloadLocation;
this.logger = logger;
}
public abstract string name { get; } //Name of the Connector (e.g. Website)
///
/// Returns all Publications with the given string.
/// If the string is empty or null, returns all Publication of the Connector
///
/// Search-Query
/// Publications matching the query
public abstract Publication[] GetPublications(string publicationTitle = "");
///
/// Returns all Chapters of the publication in the provided language.
/// If the language is empty or null, returns all Chapters in all Languages.
///
/// Publication to get Chapters for
/// Language of the Chapters
/// Array of Chapters matching Publication and Language
public abstract Chapter[] GetChapters(Publication publication, string language = "");
///
/// Retrieves the Chapter (+Images) from the website.
/// Should later call DownloadChapterImages to retrieve the individual Images of the Chapter.
///
/// Publication that contains Chapter
/// Chapter with Images to retrieve
public abstract void DownloadChapter(Publication publication, Chapter chapter);
///
/// Retrieves the Cover from the Website
///
/// Publication to retrieve Cover for
public abstract void DownloadCover(Publication publication);
///
/// Saves the series-info to series.json in the Publication Folder
///
/// Publication to save series.json for
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());
}
///
/// Creates a string containing XML of publication and chapter.
/// See ComicInfo.xml
///
/// XML-string
protected static string CreateComicInfo(Publication publication, Chapter chapter, Logger? logger)
{
logger?.WriteLine("Connector", $"Creating ComicInfo.Xml for {publication.sortName} Chapter {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();
}
///
/// Checks if a chapter-archive is already present
///
/// true if chapter is present
public bool ChapterIsDownloaded(Publication publication, Chapter chapter)
{
return File.Exists(CreateFullFilepath(publication, chapter));
}
///
/// Creates full file path of chapter-archive
///
/// Filepath
protected string CreateFullFilepath(Publication publication, Chapter chapter)
{
return Path.Join(downloadLocation, publication.folderName, $"{chapter.fileName}.cbz");
}
///
/// Downloads Image from URL and saves it to the given path(incl. fileName)
///
///
///
/// DownloadClient of the connector
/// Requesttype for ratelimit
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);
}
///
/// Downloads all Images from URLs, Compresses to zip(cbz) and saves.
///
/// List of URLs to download Images from
/// Full path to save archive to (without file ending .cbz)
/// DownloadClient of the connector
/// Path of the generate Chapter ComicInfo.xml, if it was generated
/// RequestType for RateLimits
protected static void DownloadChapterImages(string[] imageUrls, string saveArchiveFilePath, DownloadClient downloadClient, byte requestType, Logger? logger, string? comicInfoPath = null)
{
logger?.WriteLine("Connector", "Downloading Images");
//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];
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");
//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 _lastExecutedRateLimit;
private readonly Dictionary _rateLimit;
private Logger? logger;
///
/// Creates a httpClient
///
/// minimum delay between requests (to avoid spam)
/// Rate limits for requests. byte is RequestType, int maximum requests per minute for RequestType
public DownloadClient(Dictionary rateLimitRequestsPerMinute, Logger? logger)
{
this.logger = logger;
_lastExecutedRateLimit = new();
_rateLimit = new();
foreach(KeyValuePair limit in rateLimitRequestsPerMinute)
_rateLimit.Add(limit.Key, TimeSpan.FromMinutes(1).Divide(limit.Value));
}
///
/// Request Webpage
///
///
/// For RateLimits: Same Endpoints use same type
/// RequestResult with StatusCode and Stream of received data
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]));
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)
{
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;
}
}
}
}