API mostly working, starting to work on webapp
This commit is contained in:
398
.gitignore
vendored
Normal file
398
.gitignore
vendored
Normal file
@@ -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
|
||||
211
Background/ComicAnalyzer.cs
Normal file
211
Background/ComicAnalyzer.cs
Normal file
@@ -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<string> ZIP_EXTS = [".cbz", ".zip"];
|
||||
public static readonly IReadOnlyList<string> RAR_EXTS = [".cbr", ".rar"];
|
||||
public static readonly IReadOnlyList<string> ZIP7_EXTS = [".cb7", ".7z"];
|
||||
//returns null on invalid filetype, throws on analysis error
|
||||
public ComicAnalysis? AnalyzeComic(string filename);
|
||||
public Task<ComicAnalysis?> 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<IComicAnalyzer>? logger)
|
||||
: IComicAnalyzer
|
||||
{
|
||||
private readonly ILogger<IComicAnalyzer>? _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<ComicAnalysis?> 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<byte> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
168
Background/ComicScanner.cs
Normal file
168
Background/ComicScanner.cs
Normal file
@@ -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<string> COMIC_EXTENSIONS = [
|
||||
"cbz", "zip",
|
||||
"cbr", "rar",
|
||||
"cb7", "7zip",
|
||||
];
|
||||
public void TriggerLibraryScan();
|
||||
public void ScheduleRepeatedLibraryScans(TimeSpan period);
|
||||
public IDictionary<string, ComicScanItem> PerfomLibraryScan(CancellationToken? token = null);
|
||||
}
|
||||
public class ComicScanner(
|
||||
IServiceProvider provider
|
||||
) : IComicScanner
|
||||
{
|
||||
//private readonly ComicsContext _context = context;
|
||||
private readonly ITaskManager _manager = provider.GetRequiredService<ITaskManager>();
|
||||
private readonly Configuration _config = provider.GetRequiredService<IConfigService>().Config;
|
||||
private readonly IComicAnalyzer _analyzer = provider.GetRequiredService<IComicAnalyzer>();
|
||||
private readonly IServiceProvider _provider = provider;
|
||||
|
||||
public IDictionary<string, ComicScanItem> 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<string, ComicScanItem> items)
|
||||
{
|
||||
using var scope = _provider.CreateScope();
|
||||
var services = scope.ServiceProvider;
|
||||
using var context = services.GetRequiredService<ComicsContext>();
|
||||
//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<string> 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<ComicsContext>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
98
Background/TaskManager.cs
Normal file
98
Background/TaskManager.cs
Normal file
@@ -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<CancellationToken?> action, CancellationToken? token = null)
|
||||
{
|
||||
public readonly TaskTypes Type = type;
|
||||
public readonly string Name = name;
|
||||
public readonly Action<CancellationToken?> 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<ITaskManager>? logger)
|
||||
: ITaskManager
|
||||
{
|
||||
private readonly ConcurrentDictionary<Task, TaskItem> ActiveTasks = [];
|
||||
private readonly CancellationTokenSource MasterToken = new();
|
||||
private readonly ILogger<ITaskManager>? _logger = logger;
|
||||
private readonly ConcurrentDictionary<System.Timers.Timer,TaskItem> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
23
ComiServ.csproj
Normal file
23
ComiServ.csproj
Normal file
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.4" />
|
||||
<PackageReference Include="SharpCompress" Version="0.37.2" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.1" />
|
||||
<PackageReference Include="System.IO.Hashing" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
67
ComicsContext.cs
Normal file
67
ComicsContext.cs
Normal file
@@ -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<Comic> Comics { get; set; }
|
||||
public DbSet<ComicTag> ComicTags { get; set; }
|
||||
public DbSet<Tag> Tags { get; set; }
|
||||
public DbSet<ComicAuthor> ComicAuthors { get; set; }
|
||||
public DbSet<Author> Authors { get; set; }
|
||||
public DbSet<Cover> Covers { get; set; }
|
||||
public ComicsContext(DbContextOptions<ComicsContext> options)
|
||||
: base(options)
|
||||
{
|
||||
|
||||
}
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Comic>().ToTable("Comics");
|
||||
modelBuilder.Entity<ComicTag>().ToTable("ComicTags");
|
||||
modelBuilder.Entity<Tag>().ToTable("Tags");
|
||||
modelBuilder.Entity<ComicAuthor>().ToTable("ComicAuthors");
|
||||
modelBuilder.Entity<Author>().ToTable("Authors");
|
||||
modelBuilder.Entity<Cover>().ToTable("Covers");
|
||||
}
|
||||
/// <summary>
|
||||
/// puts a user-provided handle into the proper form
|
||||
/// </summary>
|
||||
/// <param name="handle"></param>
|
||||
/// <returns>formatted handle or null if invalid</returns>
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
30
ConfigService.cs
Normal file
30
ConfigService.cs
Normal file
@@ -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<Configuration>(fileStream)
|
||||
?? throw new ArgumentException("Failed to parse config file");
|
||||
}
|
||||
}
|
||||
}
|
||||
284
Controllers/ComicController.cs
Normal file
284
Controllers/ComicController.cs
Normal file
@@ -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<ComicController> logger, IConfigService config, IComicAnalyzer analyzer)
|
||||
: ControllerBase
|
||||
{
|
||||
private readonly ComicsContext _context = context;
|
||||
private readonly ILogger<ComicController> _logger = logger;
|
||||
private readonly Configuration _config = config.Config;
|
||||
private readonly IComicAnalyzer _analyzer = analyzer;
|
||||
//TODO search parameters
|
||||
[HttpGet]
|
||||
[ProducesResponseType<Paginated<ComicData>>(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<ComicData>(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<ComicData>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType<RequestError>(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<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(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<string> 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<string> 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<byte[]>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(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<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(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<RequestError>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<RequestError>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Controllers/TaskController.cs
Normal file
41
Controllers/TaskController.cs
Normal file
@@ -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<TaskController> logger
|
||||
) : ControllerBase
|
||||
{
|
||||
private readonly ComicsContext _context = context;
|
||||
private readonly ITaskManager _manager = manager;
|
||||
private readonly IComicScanner _scanner = scanner;
|
||||
private readonly ILogger<TaskController> _logger = logger;
|
||||
private readonly CancellationTokenSource cancellationToken = new();
|
||||
[HttpGet]
|
||||
[ProducesResponseType<Truncated<string>>(StatusCodes.Status200OK)]
|
||||
public IActionResult GetTasks(
|
||||
[FromQuery]
|
||||
[DefaultValue(20)]
|
||||
int limit
|
||||
)
|
||||
{
|
||||
return Ok(new Truncated<string>(limit, _manager.GetTasks(limit+1)));
|
||||
}
|
||||
[HttpPost("scan")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult StartScan()
|
||||
{
|
||||
_scanner.TriggerLibraryScan();
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Controllers/WebappController.cs
Normal file
12
Controllers/WebappController.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ComiServ.Controllers
|
||||
{
|
||||
[Route("app")]
|
||||
[Controller]
|
||||
public class WebappController
|
||||
: ControllerBase
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
17
Entities/Author.cs
Normal file
17
Entities/Author.cs
Normal file
@@ -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<ComicAuthor> ComicAuthors = null!;
|
||||
}
|
||||
}
|
||||
32
Entities/Comic.cs
Normal file
32
Entities/Comic.cs
Normal file
@@ -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<ComicTag> ComicTags { get; set; } = [];
|
||||
[InverseProperty("Comic")]
|
||||
public ICollection<ComicAuthor> ComicAuthors { get; set; } = [];
|
||||
}
|
||||
}
|
||||
21
Entities/ComicAuthor.cs
Normal file
21
Entities/ComicAuthor.cs
Normal file
@@ -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!;
|
||||
}
|
||||
}
|
||||
15
Entities/ComicTag.cs
Normal file
15
Entities/ComicTag.cs
Normal file
@@ -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!;
|
||||
}
|
||||
}
|
||||
12
Entities/Cover.cs
Normal file
12
Entities/Cover.cs
Normal file
@@ -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!;
|
||||
}
|
||||
}
|
||||
35
Entities/EntitySwaggerFilter.cs
Normal file
35
Entities/EntitySwaggerFilter.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Internal;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace ComiServ.Entities
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
Entities/Tag.cs
Normal file
16
Entities/Tag.cs
Normal file
@@ -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<ComicTag> ComicTags = null!;
|
||||
}
|
||||
}
|
||||
7
Logging/Events.cs
Normal file
7
Logging/Events.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ComiServ.Logging
|
||||
{
|
||||
public static class Events
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
40
Models/ComicData.cs
Normal file
40
Models/ComicData.cs
Normal file
@@ -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<string> Authors { get; set; }
|
||||
public List<string> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Models/ComicMetadataUpdate.cs
Normal file
10
Models/ComicMetadataUpdate.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
public class ComicMetadataUpdate
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public List<string>? Tags { get; set; }
|
||||
public List<string>? Authors { get; set; }
|
||||
}
|
||||
}
|
||||
35
Models/Paginated.cs
Normal file
35
Models/Paginated.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
public class Paginated<T>
|
||||
{
|
||||
public int Max { get; }
|
||||
public int Page { get;}
|
||||
public bool Last { get; }
|
||||
public int Count { get; }
|
||||
public List<T> Items { get; }
|
||||
public Paginated(int max, int page, IEnumerable<T> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
Models/RequestError.cs
Normal file
34
Models/RequestError.cs
Normal file
@@ -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<string> 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<string> other)
|
||||
{
|
||||
return new RequestError(Errors.Concat(other))
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
Models/Truncated.cs
Normal file
32
Models/Truncated.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.Reflection.PortableExecutable;
|
||||
|
||||
namespace ComiServ.Models
|
||||
{
|
||||
public class Truncated<T>
|
||||
{
|
||||
public int Max { get; }
|
||||
public int Count { get; }
|
||||
public bool Complete { get; }
|
||||
public List<T> Items { get; }
|
||||
public Truncated(int max, IEnumerable<T> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
75
Program.cs
Normal file
75
Program.cs
Normal file
@@ -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<EntitySwaggerFilter>();
|
||||
});
|
||||
builder.Services.AddSingleton<IConfigService>(configService);
|
||||
builder.Services.AddDbContext<ComicsContext>(options =>
|
||||
options.UseSqlite(ConnectionString));
|
||||
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
||||
builder.Services.AddSingleton<ITaskManager>(sp =>
|
||||
new TaskManager(sp.GetService<ILogger<ITaskManager>>()));
|
||||
builder.Services.AddSingleton<IComicAnalyzer>(sp =>
|
||||
new SynchronousComicAnalyzer(
|
||||
logger: sp.GetRequiredService<ILogger<IComicAnalyzer>>()));
|
||||
builder.Services.AddSingleton<IComicScanner>(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<ComicsContext>();
|
||||
context.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
var scanner = app.Services.GetRequiredService<IComicScanner>();
|
||||
scanner.TriggerLibraryScan();
|
||||
scanner.ScheduleRepeatedLibraryScans(TimeSpan.FromDays(1));
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
8
appsettings.Development.json
Normal file
8
appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
4
config.json
Normal file
4
config.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"LibraryRoot": "./Library",
|
||||
"DatabaseFile": "ComiServ.db"
|
||||
}
|
||||
Reference in New Issue
Block a user