This commit is contained in:
2025-07-02 02:26:02 +02:00
parent 07880fedb5
commit 57bb87120a
96 changed files with 814 additions and 13216 deletions

62
API/Workers/BaseWorker.cs Normal file
View File

@ -0,0 +1,62 @@
using API.Schema;
using log4net;
namespace API.Workers;
public abstract class BaseWorker : Identifiable
{
public BaseWorker[] DependsOn { get; init; }
public IEnumerable<BaseWorker> AllDependencies => DependsOn.Select(d => d.AllDependencies).SelectMany(x => x);
public IEnumerable<BaseWorker> DependenciesAndSelf => AllDependencies.Append(this);
public IEnumerable<BaseWorker> MissingDependencies => DependsOn.Where(d => d.State < WorkerExecutionState.Completed);
public bool DependenciesFulfilled => DependsOn.All(d => d.State >= WorkerExecutionState.Completed);
internal WorkerExecutionState State { get; set; }
private static readonly CancellationTokenSource CancellationTokenSource = new(TimeSpan.FromMinutes(10));
protected ILog Log { get; init; }
public void Cancel() => CancellationTokenSource.Cancel();
protected void Fail() => this.State = WorkerExecutionState.Failed;
public BaseWorker(IEnumerable<BaseWorker>? dependsOn = null)
{
this.DependsOn = dependsOn?.ToArray() ?? [];
this.Log = LogManager.GetLogger(GetType());
}
public Task<BaseWorker[]> DoWork()
{
this.State = WorkerExecutionState.Waiting;
BaseWorker[] missingDependenciesThatNeedStarting = MissingDependencies.Where(d => d.State < WorkerExecutionState.Waiting).ToArray();
if(missingDependenciesThatNeedStarting.Any())
return new Task<BaseWorker[]>(() => missingDependenciesThatNeedStarting);
if (MissingDependencies.Any())
return new Task<BaseWorker[]>(WaitForDependencies);
Task<BaseWorker[]> task = new (DoWorkInternal, CancellationTokenSource.Token);
task.GetAwaiter().OnCompleted(() => this.State = WorkerExecutionState.Completed);
task.Start();
this.State = WorkerExecutionState.Running;
return task;
}
protected abstract BaseWorker[] DoWorkInternal();
private BaseWorker[] WaitForDependencies()
{
while (CancellationTokenSource.IsCancellationRequested == false && MissingDependencies.Any())
{
Thread.Sleep(TrangaSettings.workCycleTimeout);
}
return [this];
}
}
public enum WorkerExecutionState
{
Failed = 0,
Created = 64,
Waiting = 96,
Running = 128,
Completed = 192
}

View File

@ -0,0 +1,8 @@
using Microsoft.EntityFrameworkCore;
namespace API.Workers;
public abstract class BaseWorkerWithContext<T>(IServiceScope scope, IEnumerable<BaseWorker>? dependsOn = null) : BaseWorker(dependsOn) where T : DbContext
{
protected T DbContext { get; init; } = scope.ServiceProvider.GetRequiredService<T>();
}

9
API/Workers/IPeriodic.cs Normal file
View File

@ -0,0 +1,9 @@
namespace API.Workers;
public interface IPeriodic<T> where T : BaseWorker
{
protected DateTime LastExecution { get; set; }
protected TimeSpan Interval { get; set; }
public DateTime NextExecution => LastExecution.Add(Interval);
}

View File

@ -0,0 +1,28 @@
using API.Schema.MangaContext;
namespace API.Workers.MaintenanceWorkers;
public class CleanupMangaCoversWorker(IServiceScope scope, IEnumerable<BaseWorker>? dependsOn = null) : BaseWorkerWithContext<MangaContext>(scope, dependsOn), IPeriodic<CleanupMangaCoversWorker>
{
public DateTime LastExecution { get; set; } = DateTime.UtcNow;
public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(60);
protected override BaseWorker[] DoWorkInternal()
{
Log.Info("Removing stale files...");
if (!Directory.Exists(TrangaSettings.coverImageCache))
return [];
string[] usedFiles = DbContext.Mangas.Select(m => m.CoverFileNameInCache).Where(s => s != null).ToArray()!;
string[] extraneousFiles = new DirectoryInfo(TrangaSettings.coverImageCache).GetFiles()
.Where(f => usedFiles.Contains(f.FullName) == false)
.Select(f => f.FullName)
.ToArray();
foreach (string path in extraneousFiles)
{
Log.Info($"Deleting {path}");
File.Delete(path);
}
return [];
}
}

View File

@ -0,0 +1,28 @@
using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
namespace API.Workers;
public class UpdateChaptersDownloadedWorker(Manga manga, IServiceScope scope, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(scope, dependsOn), IPeriodic<UpdateChaptersDownloadedWorker>
{
public DateTime LastExecution { get; set; } = DateTime.UtcNow;
public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(60);
protected override BaseWorker[] DoWorkInternal()
{
foreach (Chapter mangaChapter in manga.Chapters)
{
mangaChapter.Downloaded = mangaChapter.CheckDownloaded();
}
try
{
DbContext.SaveChanges();
}
catch (DbUpdateException e)
{
Log.Error(e);
}
return [];
}
}

View File

@ -0,0 +1,180 @@
using System.IO.Compression;
using System.Runtime.InteropServices;
using API.MangaDownloadClients;
using API.Schema.MangaContext;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Binarization;
using static System.IO.UnixFileMode;
namespace API.Workers;
public class DownloadChapterFromMangaconnectorWorker(Chapter chapter, IServiceScope scope, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(scope, dependsOn)
{
protected override BaseWorker[] DoWorkInternal()
{
if (chapter.Downloaded)
{
Log.Info("Chapter was already downloaded.");
return [];
}
//TODO MangaConnector Selection
MangaConnectorId<Chapter> mcId = chapter.MangaConnectorIds.First();
string[] imageUrls = mcId.MangaConnector.GetChapterImageUrls(mcId);
if (imageUrls.Length < 1)
{
Log.Info($"No imageUrls for chapter {chapter}");
return [];
}
string saveArchiveFilePath = chapter.FullArchiveFilePath;
Log.Debug($"Chapter path: {saveArchiveFilePath}");
//Check if Publication Directory already exists
string? directoryPath = Path.GetDirectoryName(saveArchiveFilePath);
if (directoryPath is null)
{
Log.Error($"Directory path could not be found: {saveArchiveFilePath}");
this.Fail();
return [];
}
if (!Directory.Exists(directoryPath))
{
Log.Info($"Creating publication Directory: {directoryPath}");
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
Directory.CreateDirectory(directoryPath,
UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute );
else
Directory.CreateDirectory(directoryPath);
}
if (File.Exists(saveArchiveFilePath)) //Don't download twice. Redownload
{
Log.Info($"Archive {saveArchiveFilePath} already existed, but deleting and re-downloading.");
File.Delete(saveArchiveFilePath);
}
//Create a temporary folder to store images
string tempFolder = Directory.CreateTempSubdirectory("trangatemp").FullName;
Log.Debug($"Created temp folder: {tempFolder}");
Log.Info($"Downloading images: {chapter}");
int chapterNum = 0;
//Download all Images to temporary Folder
foreach (string imageUrl in imageUrls)
{
string extension = imageUrl.Split('.')[^1].Split('?')[0];
string imagePath = Path.Join(tempFolder, $"{chapterNum++}.{extension}");
bool status = DownloadImage(imageUrl, imagePath);
if (status is false)
{
Log.Error($"Failed to download image: {imageUrl}");
return [];
}
}
CopyCoverFromCacheToDownloadLocation(chapter.ParentManga);
Log.Debug($"Creating ComicInfo.xml {chapter}");
File.WriteAllText(Path.Join(tempFolder, "ComicInfo.xml"), chapter.GetComicInfoXmlString());
Log.Debug($"Packaging images to archive {chapter}");
//ZIP-it and ship-it
ZipFile.CreateFromDirectory(tempFolder, saveArchiveFilePath);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(saveArchiveFilePath, UserRead | UserWrite | UserExecute | GroupRead | GroupWrite | GroupExecute | OtherRead | OtherExecute);
Directory.Delete(tempFolder, true); //Cleanup
chapter.Downloaded = true;
DbContext.SaveChanges();
return [];
}
private void ProcessImage(string imagePath)
{
if (!TrangaSettings.bwImages && TrangaSettings.compression == 100)
{
Log.Debug("No processing requested for image");
return;
}
Log.Debug($"Processing image: {imagePath}");
try
{
using Image image = Image.Load(imagePath);
if (TrangaSettings.bwImages)
image.Mutate(i => i.ApplyProcessor(new AdaptiveThresholdProcessor()));
File.Delete(imagePath);
image.SaveAsJpeg(imagePath, new JpegEncoder()
{
Quality = TrangaSettings.compression
});
}
catch (Exception e)
{
if (e is UnknownImageFormatException or NotSupportedException)
{
//If the Image-Format is not processable by ImageSharp, we can't modify it.
Log.Debug($"Unable to process {imagePath}: Not supported image format");
}else if (e is InvalidImageContentException)
{
Log.Debug($"Unable to process {imagePath}: Invalid Content");
}
else
{
Log.Error(e);
}
}
}
private void CopyCoverFromCacheToDownloadLocation(Manga manga)
{
//Check if Publication already has a Folder and cover
string publicationFolder = manga.CreatePublicationFolder();
DirectoryInfo dirInfo = new (publicationFolder);
if (dirInfo.EnumerateFiles().Any(info => info.Name.Contains("cover", StringComparison.InvariantCultureIgnoreCase)))
{
Log.Debug($"Cover already exists at {publicationFolder}");
return;
}
//TODO MangaConnector Selection
MangaConnectorId<Manga> mcId = manga.MangaConnectorIds.First();
Log.Info($"Copying cover to {publicationFolder}");
string? fileInCache = manga.CoverFileNameInCache ?? mcId.MangaConnector.SaveCoverImageToCache(mcId);
if (fileInCache is null)
{
Log.Error($"File {fileInCache} does not exist");
return;
}
string newFilePath = Path.Join(publicationFolder, $"cover.{Path.GetFileName(fileInCache).Split('.')[^1]}" );
File.Copy(fileInCache, newFilePath, true);
if(RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
File.SetUnixFileMode(newFilePath, GroupRead | GroupWrite | UserRead | UserWrite | OtherRead | OtherWrite);
Log.Debug($"Copied cover from {fileInCache} to {newFilePath}");
}
private bool DownloadImage(string imageUrl, string savePath)
{
HttpDownloadClient downloadClient = new();
RequestResult requestResult = downloadClient.MakeRequest(imageUrl, RequestType.MangaImage);
if ((int)requestResult.statusCode < 200 || (int)requestResult.statusCode >= 300)
return false;
if (requestResult.result == Stream.Null)
return false;
FileStream fs = new (savePath, FileMode.Create, FileAccess.Write, FileShare.None);
requestResult.result.CopyTo(fs);
fs.Close();
ProcessImage(savePath);
return true;
}
}

View File

@ -0,0 +1,26 @@
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
using Microsoft.EntityFrameworkCore;
namespace API.Workers;
public class DownloadCoverFromMangaconnectorWorker(MangaConnectorId<Manga> mcId, IServiceScope scope, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(scope, dependsOn)
{
public MangaConnectorId<Manga> MangaConnectorId { get; init; } = mcId;
protected override BaseWorker[] DoWorkInternal()
{
MangaConnector mangaConnector = MangaConnectorId.MangaConnector;
Manga manga = MangaConnectorId.Obj;
try
{
manga.CoverFileNameInCache = mangaConnector.SaveCoverImageToCache(MangaConnectorId);
DbContext.SaveChanges();
}
catch (DbUpdateException e)
{
Log.Error(e);
}
return [];
}
}

View File

@ -0,0 +1,39 @@
using API.Schema.MangaContext;
using API.Schema.MangaContext.MangaConnectors;
using Microsoft.EntityFrameworkCore;
namespace API.Workers;
public class RetrieveMangaChaptersFromMangaconnectorWorker(MangaConnectorId<Manga> mcId, string language, IServiceScope scope, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(scope, dependsOn)
{
public MangaConnectorId<Manga> MangaConnectorId { get; init; } = mcId;
protected override BaseWorker[] DoWorkInternal()
{
MangaConnector mangaConnector = MangaConnectorId.MangaConnector;
Manga manga = MangaConnectorId.Obj;
// This gets all chapters that are not downloaded
(Chapter, MangaConnectorId<Chapter>)[] allChapters =
mangaConnector.GetChapters(MangaConnectorId, language).DistinctBy(c => c.Item1.Key).ToArray();
(Chapter, MangaConnectorId<Chapter>)[] newChapters = allChapters.Where(chapter =>
manga.Chapters.Any(ch => chapter.Item1.Key == ch.Key && ch.Downloaded) == false).ToArray();
Log.Info($"{manga.Chapters.Count} existing + {newChapters.Length} new chapters.");
try
{
foreach ((Chapter chapter, MangaConnectorId<Chapter> mcId) newChapter in newChapters)
{
manga.Chapters.Add(newChapter.chapter);
DbContext.MangaConnectorToChapter.Add(newChapter.mcId);
}
DbContext.SaveChanges();
}
catch (DbUpdateException e)
{
Log.Error(e);
}
return [];
}
}

View File

@ -0,0 +1,47 @@
namespace API.Workers;
public class MoveFileOrFolderWorker(string toLocation, string fromLocation, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorker(dependsOn)
{
public readonly string FromLocation = fromLocation;
public readonly string ToLocation = toLocation;
protected override BaseWorker[] DoWorkInternal()
{
try
{
FileInfo fi = new (FromLocation);
if (!fi.Exists)
{
Log.Error($"File does not exist at {FromLocation}");
return [];
}
if (File.Exists(ToLocation))//Do not override existing
{
Log.Error($"File already exists at {ToLocation}");
return [];
}
if(fi.Attributes.HasFlag(FileAttributes.Directory))
MoveDirectory(fi, ToLocation);
else
MoveFile(fi, ToLocation);
}
catch (Exception e)
{
Log.Error(e);
}
return [];
}
private void MoveDirectory(FileInfo from, string toLocation)
{
Directory.Move(from.FullName, toLocation);
}
private void MoveFile(FileInfo from, string toLocation)
{
File.Move(from.FullName, toLocation);
}
}

View File

@ -0,0 +1,25 @@
using API.Schema.MangaContext;
using Microsoft.EntityFrameworkCore;
namespace API.Workers;
public class MoveMangaLibraryWorker(Manga manga, FileLibrary toLibrary, IServiceScope scope, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<MangaContext>(scope, dependsOn)
{
protected override BaseWorker[] DoWorkInternal()
{
Dictionary<Chapter, string> oldPath = manga.Chapters.ToDictionary(c => c, c => c.FullArchiveFilePath);
manga.Library = toLibrary;
try
{
DbContext.SaveChanges();
}
catch (DbUpdateException e)
{
Log.Error(e);
return [];
}
return manga.Chapters.Select(c => new MoveFileOrFolderWorker(c.FullArchiveFilePath, oldPath[c])).ToArray<BaseWorker>();
}
}

View File

@ -0,0 +1,15 @@
using API.Schema.NotificationsContext;
namespace API.Workers;
public class SendNotificationsWorker(IServiceScope scope, IEnumerable<BaseWorker>? dependsOn = null)
: BaseWorkerWithContext<NotificationsContext>(scope, dependsOn), IPeriodic<SendNotificationsWorker>
{
public DateTime LastExecution { get; set; } = DateTime.UtcNow;
public TimeSpan Interval { get; set; } = TimeSpan.FromMinutes(1);
protected override BaseWorker[] DoWorkInternal()
{
throw new NotImplementedException();
}
}