using System.Collections.Generic; using System.Runtime.InteropServices; using ComiServ.Controllers; using ComiServ.Entities; using ComiServ.Extensions; using ComiServ.Services; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration.Ini; using Microsoft.OpenApi.Writers; namespace ComiServ.Background { public record class ComicScanItem ( string Filepath, long FileSizeBytes, Int64 Xxhash, int PageCount ); public interface IComicScanner : IDisposable { //TODO should be configurable public static readonly IReadOnlyList COMIC_EXTENSIONS = [ "cbz", "zip", "cbr", "rar", "cb7", "7zip", ]; public void TriggerLibraryScan(); public void ScheduleRepeatedLibraryScans(TimeSpan period); public IDictionary PerfomLibraryScan(CancellationToken? token = null); } public class ComicScanner( IServiceProvider provider ) : IComicScanner { //private readonly ComicsContext _context = context; private readonly ITaskManager _manager = provider.GetRequiredService(); private readonly Configuration _config = provider.GetRequiredService().Config; private readonly IComicAnalyzer _analyzer = provider.GetRequiredService(); private readonly IServiceProvider _provider = provider; public IDictionary PerfomLibraryScan(CancellationToken? token = null) { return new DirectoryInfo(_config.LibraryRoot).EnumerateFiles("*", SearchOption.AllDirectories) .Select(fi => { token?.ThrowIfCancellationRequested(); var path = Path.GetRelativePath(_config.LibraryRoot, fi.FullName); var analysis = _analyzer.AnalyzeComic(fi.FullName); if (analysis is null) //null will be filtered return (path, null); return (path, new ComicScanItem ( Filepath: path, FileSizeBytes: analysis.FileSizeBytes, Xxhash: analysis.Xxhash, PageCount: analysis.PageCount )); }) //ignore files of the wrong extension .Where(p => p.Item2 is not null) .ToDictionary(); } public void TriggerLibraryScan() { TaskItem ti = new( TaskTypes.Scan, "Library Scan", token => { var items = PerfomLibraryScan(token); token?.ThrowIfCancellationRequested(); UpdateDatabaseWithScanResults(items); }, null); _manager.StartTask(ti); } private CancellationTokenSource? RepeatedLibraryScanTokenSource = null; public void ScheduleRepeatedLibraryScans(TimeSpan interval) { RepeatedLibraryScanTokenSource?.Cancel(); RepeatedLibraryScanTokenSource?.Dispose(); RepeatedLibraryScanTokenSource = new(); TaskItem ti = new( TaskTypes.Scan, "Scheduled Library Scan", token => { var items = PerfomLibraryScan(token); token?.ThrowIfCancellationRequested(); UpdateDatabaseWithScanResults(items); }, RepeatedLibraryScanTokenSource.Token); _manager.ScheduleTask(ti, interval); } public void UpdateDatabaseWithScanResults(IDictionary items) { using var scope = _provider.CreateScope(); var services = scope.ServiceProvider; using var context = services.GetRequiredService(); //not an ideal algorithm //need to go through every comic in the database to update `Exists` //also need to go through every discovered comic to add new ones //and should make sure not to double up on the overlaps //there should be a faster method than using ExceptBy but I don't it's urgent //TODO profile on large database SortedSet alreadyExistingFiles = []; foreach (var comic in context.Comics) { ComicScanItem info; if (items.TryGetValue(comic.Filepath, out info)) { comic.FileXxhash64 = info.Xxhash; comic.Exists = true; comic.PageCount = info.PageCount; comic.SizeBytes = info.FileSizeBytes; alreadyExistingFiles.Add(comic.Filepath); } else { comic.Exists = false; } } var newComics = items.ExceptBy(alreadyExistingFiles, p => p.Key).Select(p => new Comic() { Handle = context.CreateHandle(), Exists = true, Filepath = p.Value.Filepath, Title = new FileInfo(p.Value.Filepath).Name, Description = "", SizeBytes = p.Value.FileSizeBytes, FileXxhash64 = p.Value.Xxhash, PageCount = p.Value.PageCount }).ToList(); newComics.ForEach(c => _manager.StartTask(new( TaskTypes.GetCover, $"Get Cover: {c.Title}", token => InsertCover(Path.Join(_config.LibraryRoot, c.Filepath), c.FileXxhash64) ))); newComics.ForEach(c => _manager.StartTask(new( TaskTypes.MakeThumbnail, $"Make Thumbnail: {c.Title}", token => InsertThumbnail(c.Handle, Path.Join(_config.LibraryRoot, c.Filepath), 1) ))); context.Comics.AddRange(newComics); context.SaveChanges(); } protected void InsertCover(string filepath, long hash) { using var scope = _provider.CreateScope(); var services = scope.ServiceProvider; using var context = services.GetRequiredService(); var existing = context.Covers.SingleOrDefault(c => c.FileXxhash64 == hash); //assuming no hash overlap //if you already have a cover, assume it's correct if (existing is not null) return; var page = _analyzer.GetComicPage(filepath, 1); if (page is null) return; Cover cover = new() { FileXxhash64 = hash, Filename = page.Filename, CoverFile = page.Data }; context.InsertOrIgnore(cover, true); } protected void InsertThumbnail(string handle, string filepath, int page = 1) { using var scope = _provider.CreateScope(); var services = scope.ServiceProvider; using var context = services.GetRequiredService(); var comic = context.Comics.SingleOrDefault(c => c.Handle == handle); if (comic?.ThumbnailWebp is null) return; var comicPage = _analyzer.GetComicPage(filepath, page); if (comicPage is null) return; var converter = services.GetRequiredService(); using var inStream = new MemoryStream(comicPage.Data); var outStream = converter.MakeThumbnail(inStream); comic.ThumbnailWebp = outStream.ReadAllBytes(); } public void Dispose() { RepeatedLibraryScanTokenSource?.Dispose(); } } }