mirror of
https://github.com/C9Glax/tranga.git
synced 2025-07-03 17:34:17 +02:00
WIP
This commit is contained in:
62
API/Workers/BaseWorker.cs
Normal file
62
API/Workers/BaseWorker.cs
Normal 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
|
||||
}
|
8
API/Workers/BaseWorkerWithContext.cs
Normal file
8
API/Workers/BaseWorkerWithContext.cs
Normal 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
9
API/Workers/IPeriodic.cs
Normal 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);
|
||||
}
|
28
API/Workers/MaintenanceWorkers/CleanupMangaCoversWorker.cs
Normal file
28
API/Workers/MaintenanceWorkers/CleanupMangaCoversWorker.cs
Normal 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 [];
|
||||
}
|
||||
}
|
@ -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 [];
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 [];
|
||||
}
|
||||
}
|
@ -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 [];
|
||||
}
|
||||
}
|
47
API/Workers/MoveFileOrFolderWorker.cs
Normal file
47
API/Workers/MoveFileOrFolderWorker.cs
Normal 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);
|
||||
}
|
||||
}
|
25
API/Workers/MoveMangaLibraryWorker.cs
Normal file
25
API/Workers/MoveMangaLibraryWorker.cs
Normal 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>();
|
||||
}
|
||||
}
|
15
API/Workers/SendNotificationsWorker.cs
Normal file
15
API/Workers/SendNotificationsWorker.cs
Normal 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();
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user