commit a4403ce17b5e8b4191e5eb4d3eaeff590b2685f3 Author: Cameron Date: Fri Aug 23 23:52:36 2024 -0500 API mostly working, starting to work on webapp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a30d25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,398 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml diff --git a/Background/ComicAnalyzer.cs b/Background/ComicAnalyzer.cs new file mode 100644 index 0000000..6c5820b --- /dev/null +++ b/Background/ComicAnalyzer.cs @@ -0,0 +1,211 @@ +using Microsoft.AspNetCore.Routing.Constraints; +using Microsoft.AspNetCore.StaticFiles; +using SharpCompress.Archives; +using SharpCompress.Archives.Rar; +using SharpCompress.Archives.SevenZip; +using System.Diagnostics; +using System.IO.Compression; +using System.IO.Hashing; +using System.Linq; + +namespace ComiServ.Background +{ + public record class ComicAnalysis + ( + long FileSizeBytes, + int PageCount, + Int64 Xxhash + ); + public record class ComicPage + ( + string Filename, + string Mime, + byte[] Data + ); + public interface IComicAnalyzer + { + public static readonly IReadOnlyList ZIP_EXTS = [".cbz", ".zip"]; + public static readonly IReadOnlyList RAR_EXTS = [".cbr", ".rar"]; + public static readonly IReadOnlyList ZIP7_EXTS = [".cb7", ".7z"]; + //returns null on invalid filetype, throws on analysis error + public ComicAnalysis? AnalyzeComic(string filename); + public Task AnalyzeComicAsync(string filename); + //returns null if out of range, throws for file error + public ComicPage? GetComicPage(string filepath, int page); + //based purely on filename, doesn't try to open file + //returns null for ALL UNRECOGNIZED OR NON-IMAGES + public static string? GetImageMime(string filename) + { + if (new FileExtensionContentTypeProvider().TryGetContentType(filename, out string _mime)) + { + if (_mime.StartsWith("image")) + return _mime; + } + return null; + } + } + //async methods actually just block + public class SynchronousComicAnalyzer(ILogger? logger) + : IComicAnalyzer + { + private readonly ILogger? _logger = logger; + public ComicAnalysis? AnalyzeComic(string filepath) + { + _logger?.LogTrace($"Analyzing comic: {filepath}"); + var ext = new FileInfo(filepath).Extension.ToLower(); + if (IComicAnalyzer.ZIP_EXTS.Contains(ext)) + return ZipAnalyze(filepath); + else if (IComicAnalyzer.RAR_EXTS.Contains(ext)) + return RarAnalyze(filepath); + else if (IComicAnalyzer.ZIP7_EXTS.Contains(ext)) + return Zip7Analyze(filepath); + else + //throw new ArgumentException("Cannot analyze this file type"); + return null; + } +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async Task AnalyzeComicAsync(string filename) +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + { + return AnalyzeComic(filename); + } + protected ComicAnalysis ZipAnalyze(string filepath) + { + var filedata = File.ReadAllBytes(filepath); + var hash = ComputeHash(filedata); + using var stream = new MemoryStream(filedata); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, false); + return new + ( + FileSizeBytes: filedata.LongLength, + PageCount: archive.Entries.Count, + Xxhash: hash + ); + } + protected ComicAnalysis RarAnalyze(string filepath) + { + var filedata = File.ReadAllBytes(filepath); + var hash = ComputeHash(filedata); + using var stream = new MemoryStream(filedata); + using var rar = RarArchive.Open(stream, new SharpCompress.Readers.ReaderOptions() + { + LeaveStreamOpen = false + }); + return new + ( + FileSizeBytes: filedata.LongLength, + PageCount: rar.Entries.Count, + Xxhash: hash + ); + } + protected ComicAnalysis Zip7Analyze(string filepath) + { + var filedata = File.ReadAllBytes(filepath); + var hash = ComputeHash(filedata); + using var stream = new MemoryStream(filedata); + using var zip7 = SevenZipArchive.Open(stream, new SharpCompress.Readers.ReaderOptions() + { + LeaveStreamOpen = false + }); + return new + ( + FileSizeBytes: filedata.LongLength, + PageCount: zip7.Entries.Count, + Xxhash: hash + ); + } + protected static Int64 ComputeHash(ReadOnlySpan data) + => unchecked((Int64)XxHash64.HashToUInt64(data)); + + public ComicPage? GetComicPage(string filepath, int page) + { + var fi = new FileInfo(filepath); + var ext = fi.Extension; + if (IComicAnalyzer.ZIP_EXTS.Contains(ext)) + return GetPageZip(filepath, page); + else if (IComicAnalyzer.RAR_EXTS.Contains(ext)) + return GetPageRar(filepath, page); + else if (IComicAnalyzer.ZIP7_EXTS.Contains(ext)) + return GetPage7Zip(filepath, page); + else return null; + } + protected ComicPage? GetPageZip(string filepath, int page) + { + Debug.Assert(page >= 1, "Page number must be positive"); + try + { + using var fileStream = new FileStream(filepath, FileMode.Open); + using var arc = new ZipArchive(fileStream, ZipArchiveMode.Read, false); + (var entry, var mime) = arc.Entries + .Select((ZipArchiveEntry e) => (e, IComicAnalyzer.GetImageMime(e.Name))) + .Where(static pair => pair.Item2 is not null) + .OrderBy(static pair => pair.Item1.FullName) + .Skip(page - 1) + .FirstOrDefault(); + if (entry is null || mime is null) + return null; + using var pageStream = entry.Open(); + using var pageStream2 = new MemoryStream(); + pageStream.CopyTo(pageStream2); + pageStream2.Seek(0, SeekOrigin.Begin); + var pageData = pageStream2.ToArray(); + return new + ( + Filename: entry.Name, + Mime: mime, + Data: pageData + ); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + } + protected ComicPage? GetPageRar(string filepath, int page) + { + using var rar = RarArchive.Open(filepath); + (var entry, var mime) = rar.Entries + .Select((RarArchiveEntry e) => (e, IComicAnalyzer.GetImageMime(e.Key))) + .Where(static pair => pair.Item2 is not null) + .OrderBy(static pair => pair.Item1.Key) + .Skip(page - 1) + .FirstOrDefault(); + if (entry is null || mime is null) + return null; + using var stream = new MemoryStream(); + entry.WriteTo(stream); + var pageData = stream.ToArray(); + return new + ( + Filename: entry.Key ?? "", + Mime: mime, + Data: pageData + ); + } + protected ComicPage? GetPage7Zip(string filepath, int page) + { + using var zip7 = SevenZipArchive.Open(filepath); + (var entry, var mime) = zip7.Entries + .Select((SevenZipArchiveEntry e) => (e, IComicAnalyzer.GetImageMime(e.Key))) + .Where(static pair => pair.Item2 is not null) + .OrderBy(static pair => pair.Item1.Key) + .Skip(page - 1) + .FirstOrDefault(); + if (entry is null || mime is null) + return null; + using var stream = new MemoryStream(); + entry.WriteTo(stream); + var pageData = stream.ToArray(); + return new + ( + Filename: entry.Key ?? "", + Mime: mime, + Data: pageData + ); + } + } +} diff --git a/Background/ComicScanner.cs b/Background/ComicScanner.cs new file mode 100644 index 0000000..727bd87 --- /dev/null +++ b/Background/ComicScanner.cs @@ -0,0 +1,168 @@ +using System.Collections.Generic; +using System.Runtime.InteropServices; +using ComiServ.Controllers; +using ComiServ.Entities; +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) + ))); + 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; + context.Covers.Add(new() + { + FileXxhash64 = hash, + Filename = page.Filename, + CoverFile = page.Data + }); + context.SaveChanges(); + } + public void Dispose() + { + RepeatedLibraryScanTokenSource?.Dispose(); + } + } +} diff --git a/Background/TaskManager.cs b/Background/TaskManager.cs new file mode 100644 index 0000000..dcbdf50 --- /dev/null +++ b/Background/TaskManager.cs @@ -0,0 +1,98 @@ +using System.Collections.Concurrent; + +namespace ComiServ.Background +{ + public enum TaskTypes + { + Scan, + GetCover, + } + //task needs to use the token parameter rather than its own token, because it gets merged with the master token + public class TaskItem(TaskTypes type, string name, Action action, CancellationToken? token = null) + { + public readonly TaskTypes Type = type; + public readonly string Name = name; + public readonly Action Action = action; + public readonly CancellationToken Token = token ?? CancellationToken.None; + } + public interface ITaskManager : IDisposable + { + public void StartTask(TaskItem taskItem); + public void ScheduleTask(TaskItem taskItem, TimeSpan interval); + public string[] GetTasks(int limit); + public void CancelAll(); + } + public class TaskManager(ILogger? logger) + : ITaskManager + { + private readonly ConcurrentDictionary ActiveTasks = []; + private readonly CancellationTokenSource MasterToken = new(); + private readonly ILogger? _logger = logger; + private readonly ConcurrentDictionary Scheduled = []; + public void StartTask(TaskItem taskItem) + { + _logger?.LogTrace($"Start Task: {taskItem.Name}"); + var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token); + var newTask = Task.Run(() => taskItem.Action(tokenSource.Token), + tokenSource.Token); + if (!ActiveTasks.TryAdd(newTask, taskItem)) + { + //TODO better exception + throw new Exception("failed to add task"); + } + //TODO should master token actually cancel followup? + newTask.ContinueWith(ManageFinishedTasks, MasterToken.Token); + } + public void ScheduleTask(TaskItem taskItem, TimeSpan interval) + { + //var timer = new Timer((_) => StartTask(taskItem), null, dueTime, period ?? Timeout.InfiniteTimeSpan); + var timer = new System.Timers.Timer(interval); + var token = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token); + Scheduled.TryAdd(timer, taskItem); + token.Token.Register(() => + { + timer.Stop(); + Scheduled.TryRemove(timer, out var _); + }); + timer.Elapsed += (_, _) => taskItem.Action(token.Token); + timer.Start(); + } + public string[] GetTasks(int limit) + { + return ActiveTasks.Select(p => p.Value.Name).Take(limit).ToArray(); + } + + public void CancelAll() + { + MasterToken.Cancel(); + } + public void ManageFinishedTasks() + { + ManageFinishedTasks(null); + } + private readonly object _TaskCleanupLock = new(); + protected void ManageFinishedTasks(Task? cause = null) + { + //there shouldn't really be concerns with running multiple simultaneously but might as well + lock (_TaskCleanupLock) + { + //cache first because we're modifying the dictionary + foreach (var pair in ActiveTasks.ToArray()) + { + if (pair.Key.IsCompleted) + { + bool taskRemoved = ActiveTasks.TryRemove(pair.Key, out _); + if (taskRemoved) + { + _logger?.LogTrace($"Removed Task: {pair.Value.Name}"); + } + } + } + } + } + public void Dispose() + { + MasterToken?.Dispose(); + } + } +} diff --git a/ComiServ.csproj b/ComiServ.csproj new file mode 100644 index 0000000..ea03ccb --- /dev/null +++ b/ComiServ.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/ComicsContext.cs b/ComicsContext.cs new file mode 100644 index 0000000..2022f11 --- /dev/null +++ b/ComicsContext.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore; +using ComiServ.Entities; + +namespace ComiServ +{ + public class ComicsContext : DbContext + { + //TODO is this the best place for this to live? + public const int HANDLE_LENGTH = 12; + //relies on low probability of repeat handles in a short period of time + //duplicate handles could be created before either of them are commited + public string CreateHandle() + { + char ToChar(int i) + { + if (i < 10) + return (char)('0' + i); + if (i - 10 + 'A' < 'O') + return (char)('A' + i - 10); + else + //skip 'O' + return (char)('A' + i - 9); + } + string handle = ""; + do + { + handle = string.Join("", Enumerable.Repeat(0, HANDLE_LENGTH) + .Select(_ => ToChar(Random.Shared.Next(0, 35)))); + } while (Comics.Any(c => c.Handle == handle)); + return handle; + } + public DbSet Comics { get; set; } + public DbSet ComicTags { get; set; } + public DbSet Tags { get; set; } + public DbSet ComicAuthors { get; set; } + public DbSet Authors { get; set; } + public DbSet Covers { get; set; } + public ComicsContext(DbContextOptions options) + : base(options) + { + + } + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().ToTable("Comics"); + modelBuilder.Entity().ToTable("ComicTags"); + modelBuilder.Entity().ToTable("Tags"); + modelBuilder.Entity().ToTable("ComicAuthors"); + modelBuilder.Entity().ToTable("Authors"); + modelBuilder.Entity().ToTable("Covers"); + } + /// + /// puts a user-provided handle into the proper form + /// + /// + /// formatted handle or null if invalid + public static string? CleanValidateHandle(string? handle) + { + if (handle is null) + return null; + handle = handle.Trim(); + if (handle.Length != HANDLE_LENGTH) + return null; + return handle.ToUpper(); + } + } +} diff --git a/ConfigService.cs b/ConfigService.cs new file mode 100644 index 0000000..2b8c65b --- /dev/null +++ b/ConfigService.cs @@ -0,0 +1,30 @@ +using System.Text.Json; + +namespace ComiServ +{ + public class Configuration + { + public string LibraryRoot { get; set; } + public string DatabaseFile { get; set; } + public Configuration Copy() + => MemberwiseClone() as Configuration + //this really shouldn't be possible + ?? throw new Exception("Failed to clone configuration"); + } + public interface IConfigService + { + public Configuration Config { get; } + } + public class ConfigService : IConfigService + { + public Configuration _Config; + //protect original + public Configuration Config => _Config.Copy(); + public ConfigService(string filepath) + { + using var fileStream = File.OpenRead(filepath); + _Config = JsonSerializer.Deserialize(fileStream) + ?? throw new ArgumentException("Failed to parse config file"); + } + } +} diff --git a/Controllers/ComicController.cs b/Controllers/ComicController.cs new file mode 100644 index 0000000..3bae5eb --- /dev/null +++ b/Controllers/ComicController.cs @@ -0,0 +1,284 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using ComiServ.Models; +using System.Linq; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; +using ComiServ.Entities; +using ComiServ.Background; +using System.ComponentModel; + +namespace ComiServ.Controllers +{ + [Route("api/v1/comics")] + [ApiController] + public class ComicController(ComicsContext context, ILogger logger, IConfigService config, IComicAnalyzer analyzer) + : ControllerBase + { + private readonly ComicsContext _context = context; + private readonly ILogger _logger = logger; + private readonly Configuration _config = config.Config; + private readonly IComicAnalyzer _analyzer = analyzer; + //TODO search parameters + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + public IActionResult SearchComics( + [FromQuery(Name = "TitleSearch")] + string? titleSearch, + [FromQuery(Name = "DescriptionSearch")] + string? descSearch, + [FromQuery] + string[] authors, + [FromQuery] + string[] tags, + [FromQuery] + string? pages, + [FromQuery] + string? xxhash64Hex, + [FromQuery] + bool? exists, + [FromQuery] + [DefaultValue(0)] + int page, + [FromQuery] + [DefaultValue(20)] + int pageSize + ) + { + //throw new NotImplementedException(); + var results = _context.Comics + .Include("ComicAuthors.Author") + .Include("ComicTags.Tag"); + if (exists is not null) + { + results = results.Where(c => c.Exists == exists); + } + foreach (var author in authors) + { + results = results.Where(c => c.ComicAuthors.Any(ca => EF.Functions.Like(ca.Author.Name, author))); + } + foreach (var tag in tags) + { + results = results.Where(c => c.ComicTags.Any(ct => EF.Functions.Like(ct.Tag.Name, tag))); + } + if (pages is not null) + { + pages = pages.Trim(); + if (pages.StartsWith("<=")) + { + var pageMax = int.Parse(pages.Substring(2)); + results = results.Where(c => c.PageCount <= pageMax); + } + else if (pages.StartsWith('<')) + { + var pageMax = int.Parse(pages.Substring(1)); + results = results.Where(c => c.PageCount < pageMax); + } + else if (pages.StartsWith(">=")) + { + var pageMin = int.Parse(pages.Substring(2)); + results = results.Where(c => c.PageCount >= pageMin); + } + else if (pages.StartsWith('>')) + { + var pageMin = int.Parse(pages.Substring(1)); + results = results.Where(c => c.PageCount > pageMin); + } + else + { + if (pages.StartsWith('=')) + pages = pages.Substring(1); + var pageExact = int.Parse(pages); + results = results.Where(c => c.PageCount == pageExact); + } + } + if (xxhash64Hex is not null) + { + xxhash64Hex = xxhash64Hex.Trim().ToUpper(); + if (!xxhash64Hex.All(c => (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) + return BadRequest(); + Int64 hash = 0; + foreach (char c in xxhash64Hex) + { + if (c >= '0' && c <= '9') + hash = hash * 16 + (c - '0'); + else if (c >= 'A' && c <= 'F') + hash = hash * 16 + (c - 'A' + 10); + else + throw new ArgumentException("Invalid hex character bypassed filter"); + } + results = results.Where(c => c.FileXxhash64 == hash); + } + if (titleSearch is not null) + { + //results = results.Where(c => EF.Functions.Like(c.Title, $"*{titleSearch}*")); + results = results.Where(c => c.Title.Contains(titleSearch)); + } + if (descSearch is not null) + { + //results = results.Where(c => EF.Functions.Like(c.Description, $"*{descSearch}*")); + results = results.Where(c => c.Description.Contains(descSearch)); + } + int offset = page * pageSize; + return Ok(new Paginated(pageSize, page, results.Skip(offset) + .Select(c => new ComicData(c)))); + } + [HttpDelete] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult DeleteComicsThatDontExist() + { + var search = _context.Comics.Where(c => !c.Exists); + var nonExtant = search.ToList(); + search.ExecuteDelete(); + _context.SaveChanges(); + return Ok(search.Select(c => new ComicData(c))); + } + [HttpGet("{handle}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public IActionResult GetSingleComicInfo(string handle) + { + _logger.LogInformation("GetSingleComicInfo: {handle}", handle); + handle = handle.Trim().ToUpper(); + if (handle.Length != ComicsContext.HANDLE_LENGTH) + return BadRequest(RequestError.InvalidHandle); + var comic = _context.Comics + .Include("ComicAuthors.Author") + .Include("ComicTags.Tag") + .SingleOrDefault(c => c.Handle == handle); + if (comic is Comic actualComic) + return Ok(new ComicData(comic)); + else + return NotFound(RequestError.ComicNotFound); + } + [HttpPatch("{handle}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult UpdateComicMetadata(string handle, [FromBody] ComicMetadataUpdate metadata) + { + //throw new NotImplementedException(); + if (handle.Length != ComicsContext.HANDLE_LENGTH) + return BadRequest(RequestError.InvalidHandle); + //using var transaction = _context.Database.BeginTransaction(); + var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle); + if (comic is Comic actualComic) + { + if (metadata.Title != null) + actualComic.Title = metadata.Title; + if (metadata.Authors is List authors) + { + //make sure all authors exist, without changing Id of pre-existing authors + //TODO try to batch these + authors.ForEach(author => _context.Database.ExecuteSql( + $"INSERT OR IGNORE INTO [Authors] (Name) VALUES ({author})")); + //get the Id of needed authors + var authorEntities = _context.Authors.Where(a => authors.Contains(a.Name)).ToList(); + //delete existing author mappings + _context.ComicAuthors.RemoveRange(_context.ComicAuthors.Where(ca => ca.Comic.Id == comic.Id)); + //add all author mappings + _context.ComicAuthors.AddRange(authorEntities.Select(a => new ComicAuthor { Comic = comic, Author = a })); + } + if (metadata.Tags is List tags) + { + //make sure all tags exist, without changing Id of pre-existing tags + //TODO try to batch these + tags.ForEach(tag => _context.Database.ExecuteSql( + $"INSERT OR IGNORE INTO [Tags] (Name) VALUES ({tag})")); + //get the needed tags + var tagEntities = _context.Tags.Where(t => tags.Contains(t.Name)).ToList(); + //delete existing tag mappings + _context.ComicTags.RemoveRange(_context.ComicTags.Where(ta => ta.Comic.Id == comic.Id)); + //add all tag mappings + _context.ComicTags.AddRange(tagEntities.Select(t => new ComicTag { Comic = comic, Tag = t })); + } + _context.SaveChanges(); + return Ok(); + } + else + return NotFound(RequestError.ComicNotFound); + } + //[HttpDelete("{handle}")] + //public IActionResult DeleteComic(string handle) + //{ + // throw new NotImplementedException(); + //} + [HttpGet("{handle}/file")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult GetComicFile(string handle) + { + _logger.LogInformation($"{nameof(GetComicFile)}: {handle}"); + handle = handle.Trim().ToUpper(); + if (handle.Length != ComicsContext.HANDLE_LENGTH) + return BadRequest(RequestError.InvalidHandle); + var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle); + if (comic is null) + return NotFound(RequestError.ComicNotFound); + var data = System.IO.File.ReadAllBytes(Path.Join(_config.LibraryRoot, comic.Filepath)); + return File(data, "application/octet-stream", new FileInfo(comic.Filepath).Name); + } + [HttpGet("{handle}/cover")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult GetComicCover(string handle) + { + _logger.LogInformation($"{nameof(GetComicCover)}: {handle}"); + var validated = ComicsContext.CleanValidateHandle(handle); + if (validated is null) + return BadRequest(RequestError.InvalidHandle); + var comic = _context.Comics + .SingleOrDefault(c => c.Handle == validated); + if (comic is null) + return NotFound(RequestError.ComicNotFound); + var cover = _context.Covers + .SingleOrDefault(cov => cov.FileXxhash64 == comic.FileXxhash64); + if (cover is null) + return NotFound(RequestError.CoverNotFound); + var mime = IComicAnalyzer.GetImageMime(cover.Filename); + if (mime is null) + return File(cover.CoverFile, "application/octet-stream", cover.Filename); + return File(cover.CoverFile, mime); + } + [HttpGet("{handle}/page/{page}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public IActionResult GetComicPage(string handle, int page) + { + _logger.LogInformation($"{nameof(GetComicPage)}: {handle} {page}"); + var validated = ComicsContext.CleanValidateHandle(handle); + if (validated is null) + return BadRequest(RequestError.InvalidHandle); + var comic = _context.Comics.SingleOrDefault(c => c.Handle == validated); + if (comic is null) + return NotFound(RequestError.ComicNotFound); + var comicPage = _analyzer.GetComicPage(Path.Join(_config.LibraryRoot, comic.Filepath), page); + if (comicPage is null) + //TODO rethink error code + return NotFound(RequestError.PageNotFound); + return File(comicPage.Data, comicPage.Mime); + } + [HttpPost("cleandb")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult CleanUnusedTagAuthors() + { + //Since ComicAuthors uses foreign keys + _context.Authors + .Include("ComicAuthors") + .Where(a => a.ComicAuthors.Count == 0) + .ExecuteDelete(); + _context.Tags + .Include("ComicTags") + .Where(a => a.ComicTags.Count == 0) + .ExecuteDelete(); + //ExecuteDelete doesn't wait for SaveChanges + //_context.SaveChanges(); + return Ok(); + } + } +} diff --git a/Controllers/TaskController.cs b/Controllers/TaskController.cs new file mode 100644 index 0000000..c199396 --- /dev/null +++ b/Controllers/TaskController.cs @@ -0,0 +1,41 @@ +using ComiServ.Background; +using ComiServ.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.ComponentModel; + +namespace ComiServ.Controllers +{ + [Route("api/v1/tasks")] + [ApiController] + public class TaskController( + ComicsContext context + ,ITaskManager manager + ,IComicScanner scanner + ,ILogger logger + ) : ControllerBase + { + private readonly ComicsContext _context = context; + private readonly ITaskManager _manager = manager; + private readonly IComicScanner _scanner = scanner; + private readonly ILogger _logger = logger; + private readonly CancellationTokenSource cancellationToken = new(); + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + public IActionResult GetTasks( + [FromQuery] + [DefaultValue(20)] + int limit + ) + { + return Ok(new Truncated(limit, _manager.GetTasks(limit+1))); + } + [HttpPost("scan")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult StartScan() + { + _scanner.TriggerLibraryScan(); + return Ok(); + } + } +} diff --git a/Controllers/WebappController.cs b/Controllers/WebappController.cs new file mode 100644 index 0000000..8073ea3 --- /dev/null +++ b/Controllers/WebappController.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc; + +namespace ComiServ.Controllers +{ + [Route("app")] + [Controller] + public class WebappController + : ControllerBase + { + + } +} diff --git a/Entities/Author.cs b/Entities/Author.cs new file mode 100644 index 0000000..73d3d25 --- /dev/null +++ b/Entities/Author.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Swashbuckle.AspNetCore.Annotations; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +//using System.ComponentModel.DataAnnotations.Schema; + +namespace ComiServ.Entities +{ + [Index(nameof(Name), IsUnique = true)] + public class Author + { + public int Id { get; set; } + [Required] + public string Name { get; set; } = null!; + public ICollection ComicAuthors = null!; + } +} diff --git a/Entities/Comic.cs b/Entities/Comic.cs new file mode 100644 index 0000000..258bc29 --- /dev/null +++ b/Entities/Comic.cs @@ -0,0 +1,32 @@ +using ComiServ.Controllers; +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace ComiServ.Entities +{ + [Index(nameof(Handle), IsUnique = true)] + [Index(nameof(Filepath), IsUnique = true)] + public class Comic + { + public int Id { get; set; } + public bool Exists { get; set; } + //id exposed through the API + [Required] + [StringLength(ComicsContext.HANDLE_LENGTH)] + public string Handle { get; set; } = null!; + [Required] + public string Filepath { get; set; } = null!; + [Required] + public string Title { get; set; } = null!; + [Required] + public string Description { get; set; } = null!; + public int PageCount { get; set; } + public long SizeBytes { get; set; } + public long FileXxhash64 { get; set; } + [InverseProperty("Comic")] + public ICollection ComicTags { get; set; } = []; + [InverseProperty("Comic")] + public ICollection ComicAuthors { get; set; } = []; + } +} diff --git a/Entities/ComicAuthor.cs b/Entities/ComicAuthor.cs new file mode 100644 index 0000000..5813b97 --- /dev/null +++ b/Entities/ComicAuthor.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace ComiServ.Entities +{ + [PrimaryKey("ComicId", "AuthorId")] + [Index("ComicId")] + [Index("AuthorId")] + public class ComicAuthor + { + [ForeignKey(nameof(Comic))] + public int ComicId { get; set; } + [Required] + public Comic Comic { get; set; } = null!; + [ForeignKey(nameof(Author))] + public int AuthorId { get; set; } + [Required] + public Author Author { get; set; } = null!; + } +} diff --git a/Entities/ComicTag.cs b/Entities/ComicTag.cs new file mode 100644 index 0000000..69022f4 --- /dev/null +++ b/Entities/ComicTag.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace ComiServ.Entities +{ + [PrimaryKey("ComicId", "TagId")] + [Index("ComicId")] + [Index("TagId")] + public class ComicTag + { + public int ComicId { get; set; } + public Comic Comic { get; set; } = null!; + public int TagId { get; set; } + public Tag Tag { get; set; } = null!; + } +} diff --git a/Entities/Cover.cs b/Entities/Cover.cs new file mode 100644 index 0000000..7dc43f2 --- /dev/null +++ b/Entities/Cover.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore; + +namespace ComiServ.Entities +{ + [PrimaryKey("FileXxhash64")] + public class Cover + { + public long FileXxhash64 { get; set; } + public string Filename { get; set; } = null!; + public byte[] CoverFile { get; set; } = null!; + } +} diff --git a/Entities/EntitySwaggerFilter.cs b/Entities/EntitySwaggerFilter.cs new file mode 100644 index 0000000..319de27 --- /dev/null +++ b/Entities/EntitySwaggerFilter.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace ComiServ.Entities +{ + /// + /// This was originally made to remove Entity types that were being added to the Swagger schema. + /// I found that there was a bug a `ProducesResponseTypeAttribute` that caused it, and this is + /// no longer necessary. I changed Apply to a nop but am keeping this around as an example and + /// in case I actually need something like this in the future. + /// + public class EntitySwaggerFilter : ISchemaFilter + { + public readonly static string[] FILTER = [ + nameof(Author), + nameof(Comic), + nameof(ComicAuthor), + nameof(ComicTag), + nameof(Cover), + nameof(Tag) + ]; + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + return; + foreach (var item in context.SchemaRepository.Schemas.Keys) + { + if (FILTER.Contains(item)) + { + context.SchemaRepository.Schemas.Remove(item); + } + } + } + } +} diff --git a/Entities/Tag.cs b/Entities/Tag.cs new file mode 100644 index 0000000..4b52368 --- /dev/null +++ b/Entities/Tag.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace ComiServ.Entities +{ + [Index(nameof(Name), IsUnique = true)] + public class Tag + { + //[DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + [Required] + public string Name { get; set; } = null!; + public ICollection ComicTags = null!; + } +} diff --git a/Logging/Events.cs b/Logging/Events.cs new file mode 100644 index 0000000..140b3ea --- /dev/null +++ b/Logging/Events.cs @@ -0,0 +1,7 @@ +namespace ComiServ.Logging +{ + public static class Events + { + + } +} diff --git a/Models/ComicData.cs b/Models/ComicData.cs new file mode 100644 index 0000000..28e2fc2 --- /dev/null +++ b/Models/ComicData.cs @@ -0,0 +1,40 @@ +using ComiServ.Entities; + +namespace ComiServ.Models +{ + public class ComicData + { + public string Handle { get; set; } + public bool Exists { get; set; } + public string Filepath { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public int PageCount { get; set; } + public long SizeBytes { get; set; } + public string FileXxhash64 { get; set; } + public List Authors { get; set; } + public List Tags { get; set; } + public ComicData(Comic comic) + { + Handle = comic.Handle; + Exists = comic.Exists; + Filepath = comic.Filepath; + Title = comic.Title; + PageCount = comic.PageCount; + SizeBytes = comic.SizeBytes; + FileXxhash64 = ""; + var unsigned = (UInt64)comic.FileXxhash64; + for (int i = 0; i < 8; i++) + { + var c = unsigned % 16; + if (c < 10) + FileXxhash64 += ((char)('0' + c)).ToString(); + else + FileXxhash64 += ((char)('A' + c - 10)).ToString(); + unsigned /= 16; + } + Authors = comic.ComicAuthors.Select(a => a.Author.Name).ToList(); + Tags = comic.ComicTags.Select(a => a.Tag.Name).ToList(); + } + } +} diff --git a/Models/ComicMetadataUpdate.cs b/Models/ComicMetadataUpdate.cs new file mode 100644 index 0000000..83bbe73 --- /dev/null +++ b/Models/ComicMetadataUpdate.cs @@ -0,0 +1,10 @@ +namespace ComiServ.Models +{ + public class ComicMetadataUpdate + { + public string? Title { get; set; } + public string? Description { get; set; } + public List? Tags { get; set; } + public List? Authors { get; set; } + } +} diff --git a/Models/Paginated.cs b/Models/Paginated.cs new file mode 100644 index 0000000..92a08f1 --- /dev/null +++ b/Models/Paginated.cs @@ -0,0 +1,35 @@ +namespace ComiServ.Models +{ + public class Paginated + { + public int Max { get; } + public int Page { get;} + public bool Last { get; } + public int Count { get; } + public List Items { get; } + public Paginated(int max, int page, IEnumerable iter) + { + Max = max; + Page = page; + if (max <= 0) + { + throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0"); + } + if (page < 0) + { + throw new ArgumentOutOfRangeException(nameof(page), page, "must be greater than or equal to 0"); + } + Items = iter.Take(max + 1).ToList(); + if (Items.Count > max) + { + Last = false; + Items.RemoveAt(max); + } + else + { + Last = true; + } + Count = Items.Count; + } + } +} diff --git a/Models/RequestError.cs b/Models/RequestError.cs new file mode 100644 index 0000000..2256a50 --- /dev/null +++ b/Models/RequestError.cs @@ -0,0 +1,34 @@ +namespace ComiServ.Models +{ + public class RequestError + { + + public static RequestError InvalidHandle => new("Invalid handle"); + public static RequestError ComicNotFound => new("Comic not found"); + public static RequestError CoverNotFound => new("Cover not found"); + public static RequestError PageNotFound => new("Page not found"); + public static RequestError FileNotFound => new("File not found"); + public string[] Errors { get; } + public RequestError(string ErrorMessage) + { + Errors = [ErrorMessage]; + } + public RequestError(IEnumerable ErrorMessages) + { + Errors = ErrorMessages.ToArray(); + } + public RequestError And(RequestError other) + { + return new RequestError(Errors.Concat(other.Errors)); + } + public RequestError And(string other) + { + return new RequestError(Errors.Append(other)); + } + public RequestError And(IEnumerable other) + { + return new RequestError(Errors.Concat(other)) + ; + } + } +} diff --git a/Models/Truncated.cs b/Models/Truncated.cs new file mode 100644 index 0000000..c99c674 --- /dev/null +++ b/Models/Truncated.cs @@ -0,0 +1,32 @@ +using System.Reflection.PortableExecutable; + +namespace ComiServ.Models +{ + public class Truncated + { + public int Max { get; } + public int Count { get; } + public bool Complete { get; } + public List Items { get; } + public Truncated(int max, IEnumerable items) + { + if (max <= 0) + { + throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0"); + } + Max = max; + Items = items.Take(max+1).ToList(); + if (Items.Count <= max) + { + Complete = true; + if (Items.Count > 0) + Items.RemoveAt(max); + } + else + { + Complete = false; + } + Count = Items.Count; + } + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..1aec2f2 --- /dev/null +++ b/Program.cs @@ -0,0 +1,75 @@ +using ComiServ; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ComiServ.Background; +using Swashbuckle.AspNetCore.SwaggerGen; +using ComiServ.Entities; + +var builder = WebApplication.CreateBuilder(args); + +var CONFIG_FILEPATH = "config.json"; +var configService = new ConfigService(CONFIG_FILEPATH); +var config = configService.Config; +var ConnectionString = $"Data Source={config.DatabaseFile};Mode=ReadWriteCreate"; +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SchemaFilter(); +}); +builder.Services.AddSingleton(configService); +builder.Services.AddDbContext(options => + options.UseSqlite(ConnectionString)); +builder.Services.AddDatabaseDeveloperPageExceptionFilter(); +builder.Services.AddSingleton(sp => + new TaskManager(sp.GetService>())); +builder.Services.AddSingleton(sp => + new SynchronousComicAnalyzer( + logger: sp.GetRequiredService>())); +builder.Services.AddSingleton(sp => + new ComicScanner(provider: sp)); +builder.Services.AddHttpLogging(o => { }); +//builder.Services.AddRazorPages().AddRazorPagesOptions(o => +//{ +// o.RootDirectory = "/Pages"; +//}); +builder.Services.AddLogging(config => +{ + config.AddConsole(); + config.AddDebug(); +}); +var app = builder.Build(); +app.UseHttpLogging(); +app.UseHttpsRedirection(); +app.UseStaticFiles(); +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); + app.UseDeveloperExceptionPage(); + app.UseMigrationsEndPoint(); +} + +using (var scope = app.Services.CreateScope()) +{ + var services = scope.ServiceProvider; + using var context = services.GetRequiredService(); + context.Database.EnsureCreated(); +} + +var scanner = app.Services.GetRequiredService(); +scanner.TriggerLibraryScan(); +scanner.ScheduleRepeatedLibraryScans(TimeSpan.FromDays(1)); + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..ccd0225 --- /dev/null +++ b/config.json @@ -0,0 +1,4 @@ +{ + "LibraryRoot": "./Library", + "DatabaseFile": "ComiServ.db" +} \ No newline at end of file