updated controllers and some services to use async/await

This commit is contained in:
Cameron
2024-08-29 05:36:01 -05:00
parent 18de6d599b
commit 8302e3ea61
36 changed files with 1981 additions and 1809 deletions

View File

@@ -1,8 +1,8 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ComiServ.Entities; using ComiServ.Entities;
namespace ComiServ namespace ComiServ;
{
public class ComicsContext : DbContext public class ComicsContext : DbContext
{ {
//TODO is this the best place for this to live? //TODO is this the best place for this to live?
@@ -78,4 +78,3 @@ namespace ComiServ
return handle.ToUpper(); return handle.ToUpper();
} }
} }
}

View File

@@ -12,10 +12,11 @@ using ComiServ.Extensions;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using ComiServ.Services; using ComiServ.Services;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using System.Data;
using SQLitePCL; using SQLitePCL;
namespace ComiServ.Controllers namespace ComiServ.Controllers;
{
[Route(ROUTE)] [Route(ROUTE)]
[ApiController] [ApiController]
public class ComicController(ComicsContext context, ILogger<ComicController> logger, IConfigService config, IComicAnalyzer analyzer, IPictureConverter converter, IAuthenticationService _auth) public class ComicController(ComicsContext context, ILogger<ComicController> logger, IConfigService config, IComicAnalyzer analyzer, IPictureConverter converter, IAuthenticationService _auth)
@@ -31,7 +32,7 @@ namespace ComiServ.Controllers
//TODO search parameters //TODO search parameters
[HttpGet] [HttpGet]
[ProducesResponseType<Paginated<ComicData>>(StatusCodes.Status200OK)] [ProducesResponseType<Paginated<ComicData>>(StatusCodes.Status200OK)]
public IActionResult SearchComics( public async Task<IActionResult> SearchComics(
[FromQuery(Name = "TitleSearch")] [FromQuery(Name = "TitleSearch")]
string? titleSearch, string? titleSearch,
[FromQuery(Name = "DescriptionSearch")] [FromQuery(Name = "DescriptionSearch")]
@@ -143,16 +144,16 @@ namespace ComiServ.Controllers
results = results.Where(c => EF.Functions.Like(c.Description, $"%{descSearch}%")); results = results.Where(c => EF.Functions.Like(c.Description, $"%{descSearch}%"));
} }
int offset = page * pageSize; int offset = page * pageSize;
return Ok(new Paginated<ComicData>(pageSize, page, results return Ok(await Paginated<ComicData>.CreateAsync(pageSize, page, results
.OrderBy(c => c.Id) .OrderBy(c => c.Id)
.Select(c => new ComicData(c)))); .Select(c => new ComicData(c))));
} }
[HttpDelete] [HttpDelete]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult DeleteComicsThatDontExist() public async Task<IActionResult> DeleteComicsThatDontExist()
{ {
var search = _context.Comics.Where(c => !c.Exists); var search = _context.Comics.Where(c => !c.Exists);
var nonExtant = search.ToList(); var nonExtant = await search.ToListAsync();
search.ExecuteDelete(); search.ExecuteDelete();
_context.SaveChanges(); _context.SaveChanges();
return Ok(search.Select(c => new ComicData(c))); return Ok(search.Select(c => new ComicData(c)));
@@ -161,16 +162,16 @@ namespace ComiServ.Controllers
[ProducesResponseType<ComicData>(StatusCodes.Status200OK)] [ProducesResponseType<ComicData>(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)] [ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)] [ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public IActionResult GetSingleComicInfo(string handle) public async Task<IActionResult> GetSingleComicInfo(string handle)
{ {
//_logger.LogInformation("GetSingleComicInfo: {handle}", handle); //_logger.LogInformation("GetSingleComicInfo: {handle}", handle);
var validated = ComicsContext.CleanValidateHandle(handle); var validated = ComicsContext.CleanValidateHandle(handle);
if (validated is null) if (validated is null)
return BadRequest(RequestError.InvalidHandle); return BadRequest(RequestError.InvalidHandle);
var comic = _context.Comics var comic = await _context.Comics
.Include("ComicAuthors.Author") .Include("ComicAuthors.Author")
.Include("ComicTags.Tag") .Include("ComicTags.Tag")
.SingleOrDefault(c => c.Handle == handle); .SingleOrDefaultAsync(c => c.Handle == handle);
if (comic is Comic actualComic) if (comic is Comic actualComic)
return Ok(new ComicData(comic)); return Ok(new ComicData(comic));
else else
@@ -182,11 +183,11 @@ namespace ComiServ.Controllers
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)] [ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)] [ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public IActionResult UpdateComicMetadata(string handle, [FromBody] ComicMetadataUpdateRequest metadata) public async Task<IActionResult> UpdateComicMetadata(string handle, [FromBody] ComicMetadataUpdateRequest metadata)
{ {
if (handle.Length != ComicsContext.HANDLE_LENGTH) if (handle.Length != ComicsContext.HANDLE_LENGTH)
return BadRequest(RequestError.InvalidHandle); return BadRequest(RequestError.InvalidHandle);
var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle); var comic = await _context.Comics.SingleOrDefaultAsync(c => c.Handle == handle);
if (comic is Comic actualComic) if (comic is Comic actualComic)
{ {
if (metadata.Title != null) if (metadata.Title != null)
@@ -196,7 +197,7 @@ namespace ComiServ.Controllers
//make sure all authors exist, without changing Id of pre-existing authors //make sure all authors exist, without changing Id of pre-existing authors
_context.InsertOrIgnore(authors.Select(author => new Author() { Name = author }), ignorePrimaryKey: true); _context.InsertOrIgnore(authors.Select(author => new Author() { Name = author }), ignorePrimaryKey: true);
//get the Id of needed authors //get the Id of needed authors
var authorEntities = _context.Authors.Where(a => authors.Contains(a.Name)).ToList(); var authorEntities = await _context.Authors.Where(a => authors.Contains(a.Name)).ToListAsync();
//delete existing author mappings //delete existing author mappings
_context.ComicAuthors.RemoveRange(_context.ComicAuthors.Where(ca => ca.Comic.Id == comic.Id)); _context.ComicAuthors.RemoveRange(_context.ComicAuthors.Where(ca => ca.Comic.Id == comic.Id));
//add all author mappings //add all author mappings
@@ -207,13 +208,13 @@ namespace ComiServ.Controllers
//make sure all tags exist, without changing Id of pre-existing tags //make sure all tags exist, without changing Id of pre-existing tags
_context.InsertOrIgnore(tags.Select(t => new Tag() { Name = t }), ignorePrimaryKey: true); _context.InsertOrIgnore(tags.Select(t => new Tag() { Name = t }), ignorePrimaryKey: true);
//get the needed tags //get the needed tags
var tagEntities = _context.Tags.Where(t => tags.Contains(t.Name)).ToList(); var tagEntities = await _context.Tags.Where(t => tags.Contains(t.Name)).ToListAsync();
//delete existing tag mappings //delete existing tag mappings
_context.ComicTags.RemoveRange(_context.ComicTags.Where(ta => ta.Comic.Id == comic.Id)); _context.ComicTags.RemoveRange(_context.ComicTags.Where(ta => ta.Comic.Id == comic.Id));
//add all tag mappings //add all tag mappings
_context.ComicTags.AddRange(tagEntities.Select(t => new ComicTag { Comic = comic, Tag = t })); _context.ComicTags.AddRange(tagEntities.Select(t => new ComicTag { Comic = comic, Tag = t }));
} }
_context.SaveChanges(); await _context.SaveChangesAsync();
return Ok(); return Ok();
} }
else else
@@ -223,14 +224,14 @@ namespace ComiServ.Controllers
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)] [ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)] [ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
public IActionResult MarkComicAsRead( public async Task<IActionResult> MarkComicAsRead(
ComicsContext context, ComicsContext context,
string handle) string handle)
{ {
var validated = ComicsContext.CleanValidateHandle(handle); var validated = ComicsContext.CleanValidateHandle(handle);
if (validated is null) if (validated is null)
return BadRequest(RequestError.InvalidHandle); return BadRequest(RequestError.InvalidHandle);
var comic = context.Comics.SingleOrDefault(c => c.Handle == validated); var comic = await context.Comics.SingleOrDefaultAsync(c => c.Handle == validated);
if (comic is null) if (comic is null)
return NotFound(RequestError.ComicNotFound); return NotFound(RequestError.ComicNotFound);
if (_auth.User is null) if (_auth.User is null)
@@ -242,32 +243,32 @@ namespace ComiServ.Controllers
ComicId = comic.Id ComicId = comic.Id
}; };
context.InsertOrIgnore(comicRead, ignorePrimaryKey: false); context.InsertOrIgnore(comicRead, ignorePrimaryKey: false);
context.SaveChanges(); await context.SaveChangesAsync();
return Ok(); return Ok();
} }
[HttpPatch("{handle}/markunread")] [HttpPatch("{handle}/markunread")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)] [ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)] [ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
public IActionResult MarkComicAsUnread( public async Task<IActionResult> MarkComicAsUnread(
ComicsContext context, ComicsContext context,
string handle) string handle)
{ {
var validated = ComicsContext.CleanValidateHandle(handle); var validated = ComicsContext.CleanValidateHandle(handle);
if (validated is null) if (validated is null)
return BadRequest(RequestError.InvalidHandle); return BadRequest(RequestError.InvalidHandle);
var comic = context.Comics.SingleOrDefault(c => c.Handle == validated); var comic = await context.Comics.SingleOrDefaultAsync(c => c.Handle == validated);
if (comic is null) if (comic is null)
return NotFound(RequestError.ComicNotFound); return NotFound(RequestError.ComicNotFound);
if (_auth.User is null) if (_auth.User is null)
//user shouldn't have passed authentication if username doesn't match //user shouldn't have passed authentication if username doesn't match
return StatusCode(StatusCodes.Status500InternalServerError); return StatusCode(StatusCodes.Status500InternalServerError);
var comicRead = context.ComicsRead.SingleOrDefault(cr => var comicRead = await context.ComicsRead.SingleOrDefaultAsync(cr =>
cr.ComicId == comic.Id && cr.UserId == _auth.User.Id); cr.ComicId == comic.Id && cr.UserId == _auth.User.Id);
if (comicRead is null) if (comicRead is null)
return Ok(); return Ok();
context.ComicsRead.Remove(comicRead); context.ComicsRead.Remove(comicRead);
context.SaveChanges(); await context.SaveChangesAsync();
return Ok(); return Ok();
} }
[HttpDelete("{handle}")] [HttpDelete("{handle}")]
@@ -276,7 +277,7 @@ namespace ComiServ.Controllers
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)] [ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)] [ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public IActionResult DeleteComic( public async Task<IActionResult> DeleteComic(
string handle, string handle,
[FromBody] [FromBody]
ComicDeleteRequest req) ComicDeleteRequest req)
@@ -288,14 +289,14 @@ namespace ComiServ.Controllers
} }
if (_auth.User.UserTypeId != UserTypeEnum.Administrator) if (_auth.User.UserTypeId != UserTypeEnum.Administrator)
return Forbid(); return Forbid();
var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle); var comic = await _context.Comics.SingleOrDefaultAsync(c => c.Handle == handle);
if (comic is null) if (comic is null)
return NotFound(RequestError.ComicNotFound); return NotFound(RequestError.ComicNotFound);
comic.Exists = _analyzer.ComicFileExists(string.Join(config.Config.LibraryRoot, comic.Filepath)); comic.Exists = _analyzer.ComicFileExists(string.Join(config.Config.LibraryRoot, comic.Filepath));
if (comic.Exists && !req.DeleteIfFileExists) if (comic.Exists && !req.DeleteIfFileExists)
return BadRequest(RequestError.ComicFileExists); return BadRequest(RequestError.ComicFileExists);
_context.Comics.Remove(comic); _context.Comics.Remove(comic);
_context.SaveChanges(); await _context.SaveChangesAsync();
_analyzer.DeleteComicFile(string.Join(config.Config.LibraryRoot, comic.Filepath)); _analyzer.DeleteComicFile(string.Join(config.Config.LibraryRoot, comic.Filepath));
return Ok(); return Ok();
} }
@@ -303,34 +304,34 @@ namespace ComiServ.Controllers
[ProducesResponseType<byte[]>(StatusCodes.Status200OK)] [ProducesResponseType<byte[]>(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)] [ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)] [ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public IActionResult GetComicFile(string handle) public async Task<IActionResult> GetComicFile(string handle)
{ {
//_logger.LogInformation(nameof(GetComicFile) + ": {handle}", handle); //_logger.LogInformation(nameof(GetComicFile) + ": {handle}", handle);
var validated = ComicsContext.CleanValidateHandle(handle); var validated = ComicsContext.CleanValidateHandle(handle);
if (validated is null) if (validated is null)
return BadRequest(RequestError.InvalidHandle); return BadRequest(RequestError.InvalidHandle);
var comic = _context.Comics.SingleOrDefault(c => c.Handle == handle); var comic = await _context.Comics.SingleOrDefaultAsync(c => c.Handle == handle);
if (comic is null) if (comic is null)
return NotFound(RequestError.ComicNotFound); return NotFound(RequestError.ComicNotFound);
var data = System.IO.File.ReadAllBytes(Path.Join(_config.LibraryRoot, comic.Filepath)); var data = await System.IO.File.ReadAllBytesAsync(Path.Join(_config.LibraryRoot, comic.Filepath));
return File(data, "application/octet-stream", new FileInfo(comic.Filepath).Name); return File(data, "application/octet-stream", new FileInfo(comic.Filepath).Name);
} }
[HttpGet("{handle}/cover")] [HttpGet("{handle}/cover")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)] [ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)] [ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public IActionResult GetComicCover(string handle) public async Task<IActionResult> GetComicCover(string handle)
{ {
//_logger.LogInformation(nameof(GetComicCover) + ": {handle}", handle); //_logger.LogInformation(nameof(GetComicCover) + ": {handle}", handle);
var validated = ComicsContext.CleanValidateHandle(handle); var validated = ComicsContext.CleanValidateHandle(handle);
if (validated is null) if (validated is null)
return BadRequest(RequestError.InvalidHandle); return BadRequest(RequestError.InvalidHandle);
var comic = _context.Comics var comic = await _context.Comics
.SingleOrDefault(c => c.Handle == validated); .SingleOrDefaultAsync(c => c.Handle == validated);
if (comic is null) if (comic is null)
return NotFound(RequestError.ComicNotFound); return NotFound(RequestError.ComicNotFound);
var cover = _context.Covers var cover = await _context.Covers
.SingleOrDefault(cov => cov.FileXxhash64 == comic.FileXxhash64); .SingleOrDefaultAsync(cov => cov.FileXxhash64 == comic.FileXxhash64);
if (cover is null) if (cover is null)
return NotFound(RequestError.CoverNotFound); return NotFound(RequestError.CoverNotFound);
var mime = IComicAnalyzer.GetImageMime(cover.Filename); var mime = IComicAnalyzer.GetImageMime(cover.Filename);
@@ -342,7 +343,7 @@ namespace ComiServ.Controllers
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)] [ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)] [ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public IActionResult GetComicPage(string handle, int page, public async Task<IActionResult> GetComicPage(string handle, int page,
[FromQuery] [FromQuery]
[DefaultValue(0)] [DefaultValue(0)]
int? maxWidth, int? maxWidth,
@@ -357,10 +358,10 @@ namespace ComiServ.Controllers
var validated = ComicsContext.CleanValidateHandle(handle); var validated = ComicsContext.CleanValidateHandle(handle);
if (validated is null) if (validated is null)
return BadRequest(RequestError.InvalidHandle); return BadRequest(RequestError.InvalidHandle);
var comic = _context.Comics.SingleOrDefault(c => c.Handle == validated); var comic = await _context.Comics.SingleOrDefaultAsync(c => c.Handle == validated);
if (comic is null) if (comic is null)
return NotFound(RequestError.ComicNotFound); return NotFound(RequestError.ComicNotFound);
var comicPage = _analyzer.GetComicPage(Path.Join(_config.LibraryRoot, comic.Filepath), page); var comicPage = await _analyzer.GetComicPageAsync(Path.Join(_config.LibraryRoot, comic.Filepath), page);
if (comicPage is null) if (comicPage is null)
//TODO rethink error code //TODO rethink error code
return NotFound(RequestError.PageNotFound); return NotFound(RequestError.PageNotFound);
@@ -382,7 +383,8 @@ namespace ComiServ.Controllers
null => comicPage.Mime, null => comicPage.Mime,
}; };
//TODO using the stream directly throws but I think it should be valid, need to debug //TODO using the stream directly throws but I think it should be valid, need to debug
var arr = _converter.ResizeIfBigger(stream, limit, format).ReadAllBytes(); using var resizedStream = await _converter.ResizeIfBigger(stream, limit, format);
var arr = await resizedStream.ReadAllBytesAsync();
return File(arr, mime); return File(arr, mime);
} }
else else
@@ -392,7 +394,7 @@ namespace ComiServ.Controllers
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)] [ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)] [ProducesResponseType<RequestError>(StatusCodes.Status404NotFound)]
public IActionResult GetComicThumbnail( public async Task<IActionResult> GetComicThumbnail(
string handle, string handle,
[FromQuery] [FromQuery]
[DefaultValue(false)] [DefaultValue(false)]
@@ -403,7 +405,7 @@ namespace ComiServ.Controllers
var validated = ComicsContext.CleanValidateHandle(handle); var validated = ComicsContext.CleanValidateHandle(handle);
if (validated is null) if (validated is null)
return BadRequest(RequestError.InvalidHandle); return BadRequest(RequestError.InvalidHandle);
var comic = _context.Comics.SingleOrDefault(c => c.Handle == validated); var comic = await _context.Comics.SingleOrDefaultAsync(c => c.Handle == validated);
if (comic is null) if (comic is null)
return NotFound(RequestError.ComicNotFound); return NotFound(RequestError.ComicNotFound);
if (comic.ThumbnailWebp is byte[] img) if (comic.ThumbnailWebp is byte[] img)
@@ -412,7 +414,7 @@ namespace ComiServ.Controllers
} }
if (fallbackToCover) if (fallbackToCover)
{ {
var cover = _context.Covers.SingleOrDefault(c => c.FileXxhash64 == comic.FileXxhash64); var cover = await _context.Covers.SingleOrDefaultAsync(c => c.FileXxhash64 == comic.FileXxhash64);
if (cover is not null) if (cover is not null)
{ {
//TODO should this convert to a thumbnail on the fly? //TODO should this convert to a thumbnail on the fly?
@@ -424,23 +426,23 @@ namespace ComiServ.Controllers
} }
[HttpPost("cleandb")] [HttpPost("cleandb")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult CleanUnusedTagAuthors() public async Task<IActionResult> CleanUnusedTagAuthors()
{ {
_context.Authors await _context.Authors
.Include(a => a.ComicAuthors) .Include(a => a.ComicAuthors)
.Where(a => a.ComicAuthors.Count == 0) .Where(a => a.ComicAuthors.Count == 0)
.ExecuteDelete(); .ExecuteDeleteAsync();
_context.Tags await _context.Tags
.Include(a => a.ComicTags) .Include(a => a.ComicTags)
.Where(a => a.ComicTags.Count == 0) .Where(a => a.ComicTags.Count == 0)
.ExecuteDelete(); .ExecuteDeleteAsync();
//ExecuteDelete doesn't wait for SaveChanges //ExecuteDelete doesn't wait for SaveChanges
//_context.SaveChanges(); //_context.SaveChanges();
return Ok(); return Ok();
} }
[HttpGet("duplicates")] [HttpGet("duplicates")]
[ProducesResponseType<Paginated<ComicDuplicateList>>(StatusCodes.Status200OK)] [ProducesResponseType<Paginated<ComicDuplicateList>>(StatusCodes.Status200OK)]
public IActionResult GetDuplicateFiles( public async Task<IActionResult> GetDuplicateFiles(
[FromQuery] [FromQuery]
[DefaultValue(0)] [DefaultValue(0)]
int page, int page,
@@ -454,7 +456,7 @@ namespace ComiServ.Controllers
.GroupBy(c => c.FileXxhash64) .GroupBy(c => c.FileXxhash64)
.Where(g => g.Count() > 1) .Where(g => g.Count() > 1)
.OrderBy(g => g.Key); .OrderBy(g => g.Key);
var ret = new Paginated<ComicDuplicateList>(pageSize, page, var ret = await Paginated<ComicDuplicateList>.CreateAsync(pageSize, page,
groups.Select(g => groups.Select(g =>
new ComicDuplicateList(g.Key, g.Select(g => g)) new ComicDuplicateList(g.Key, g.Select(g => g))
)); ));
@@ -464,12 +466,11 @@ namespace ComiServ.Controllers
[ProducesResponseType<LibraryResponse>(StatusCodes.Status200OK)] [ProducesResponseType<LibraryResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)] [ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public IActionResult GetLibraryStats() public async Task<IActionResult> GetLibraryStats()
{ {
return Ok(new LibraryResponse( return Ok(new LibraryResponse(
_context.Comics.Count(), await _context.Comics.CountAsync(),
_context.Comics.Select(c => c.FileXxhash64).Distinct().Count() await _context.Comics.Select(c => c.FileXxhash64).Distinct().CountAsync()
)); ));
} }
} }
}

View File

@@ -6,8 +6,8 @@ using ComiServ.Background;
using ComiServ.Models; using ComiServ.Models;
using System.ComponentModel; using System.ComponentModel;
namespace ComiServ.Controllers namespace ComiServ.Controllers;
{
[Route(ROUTE)] [Route(ROUTE)]
[ApiController] [ApiController]
public class MiscController(ComicsContext context, ILogger<MiscController> logger, IConfigService config, IAuthenticationService auth) public class MiscController(ComicsContext context, ILogger<MiscController> logger, IConfigService config, IAuthenticationService auth)
@@ -22,7 +22,7 @@ namespace ComiServ.Controllers
[ProducesResponseType<Paginated<AuthorResponse>>(StatusCodes.Status200OK)] [ProducesResponseType<Paginated<AuthorResponse>>(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)] [ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public IActionResult GetAuthors( public async Task<IActionResult> GetAuthors(
[FromQuery] [FromQuery]
[DefaultValue(0)] [DefaultValue(0)]
int page, int page,
@@ -38,16 +38,16 @@ namespace ComiServ.Controllers
} }
if (_auth.User.UserTypeId != UserTypeEnum.Administrator) if (_auth.User.UserTypeId != UserTypeEnum.Administrator)
return Forbid(); return Forbid();
var items = context.Authors var items = _context.Authors
.OrderBy(a => a.ComicAuthors.Count()) .OrderBy(a => a.ComicAuthors.Count())
.Select(a => new AuthorResponse(a.Name, a.ComicAuthors.Count())); .Select(a => new AuthorResponse(a.Name, a.ComicAuthors.Count()));
return Ok(new Paginated<AuthorResponse>(pageSize, page, items)); return Ok(await Paginated<AuthorResponse>.CreateAsync(pageSize, page, items));
} }
[HttpGet("tags")] [HttpGet("tags")]
[ProducesResponseType<Paginated<TagResponse>>(StatusCodes.Status200OK)] [ProducesResponseType<Paginated<TagResponse>>(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)] [ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public IActionResult GetTags( public async Task<IActionResult> GetTags(
[FromQuery] [FromQuery]
[DefaultValue(0)] [DefaultValue(0)]
int page, int page,
@@ -63,10 +63,9 @@ namespace ComiServ.Controllers
} }
if (_auth.User.UserTypeId != UserTypeEnum.Administrator) if (_auth.User.UserTypeId != UserTypeEnum.Administrator)
return Forbid(); return Forbid();
var items = context.Tags var items = _context.Tags
.OrderBy(t => t.ComicTags.Count()) .OrderBy(t => t.ComicTags.Count())
.Select(t => new TagResponse(t.Name, t.ComicTags.Count())); .Select(t => new TagResponse(t.Name, t.ComicTags.Count()));
return Ok(new Paginated<TagResponse>(pageSize, page, items)); return Ok(await Paginated<TagResponse>.CreateAsync(pageSize, page, items));
}
} }
} }

View File

@@ -7,8 +7,8 @@ using Microsoft.AspNetCore.Mvc;
using System.ComponentModel; using System.ComponentModel;
using System.Security.Policy; using System.Security.Policy;
namespace ComiServ.Controllers namespace ComiServ.Controllers;
{
[Route(ROUTE)] [Route(ROUTE)]
[ApiController] [ApiController]
public class TaskController( public class TaskController(
@@ -26,7 +26,7 @@ namespace ComiServ.Controllers
private readonly CancellationTokenSource cancellationToken = new(); private readonly CancellationTokenSource cancellationToken = new();
[HttpGet] [HttpGet]
[ProducesResponseType<Truncated<string>>(StatusCodes.Status200OK)] [ProducesResponseType<Truncated<string>>(StatusCodes.Status200OK)]
public IActionResult GetTasks( public Task<IActionResult> GetTasks(
[FromQuery] [FromQuery]
[DefaultValue(20)] [DefaultValue(20)]
int limit int limit
@@ -58,4 +58,3 @@ namespace ComiServ.Controllers
return Ok(); return Ok();
} }
} }
}

View File

@@ -7,8 +7,8 @@ using Microsoft.EntityFrameworkCore;
using System.ComponentModel; using System.ComponentModel;
using System.Text; using System.Text;
namespace ComiServ.Controllers namespace ComiServ.Controllers;
{
[Route(ROUTE)] [Route(ROUTE)]
[ApiController] [ApiController]
public class UserController public class UserController
@@ -19,7 +19,7 @@ namespace ComiServ.Controllers
[ProducesResponseType<Paginated<UserDescription>>(StatusCodes.Status200OK)] [ProducesResponseType<Paginated<UserDescription>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public IActionResult GetUsers(IAuthenticationService auth, public async Task<IActionResult> GetUsers(IAuthenticationService auth,
ComicsContext context, ComicsContext context,
[FromQuery] [FromQuery]
[DefaultValue(null)] [DefaultValue(null)]
@@ -46,7 +46,7 @@ namespace ComiServ.Controllers
users = users.Where(u => u.UserTypeId == t); users = users.Where(u => u.UserTypeId == t);
if (!string.IsNullOrWhiteSpace(search)) if (!string.IsNullOrWhiteSpace(search))
users = users.Where(u => EF.Functions.Like(u.Username, $"%{search}%")); users = users.Where(u => EF.Functions.Like(u.Username, $"%{search}%"));
return Ok(new Paginated<UserDescription>(pageSize, page, users return Ok(await Paginated<UserDescription>.CreateAsync(pageSize, page, users
.Include(u => u.UserType) .Include(u => u.UserType)
.Select(u => new UserDescription(u.Username, u.UserType.Name)))); .Select(u => new UserDescription(u.Username, u.UserType.Name))));
} }
@@ -54,7 +54,7 @@ namespace ComiServ.Controllers
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)] [ProducesResponseType<RequestError>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public IActionResult AddUser(IAuthenticationService auth, public async Task<IActionResult> AddUser(IAuthenticationService auth,
ComicsContext context, ComicsContext context,
[FromBody] [FromBody]
UserCreateRequest req) UserCreateRequest req)
@@ -76,7 +76,7 @@ namespace ComiServ.Controllers
UserTypeId = req.UserType UserTypeId = req.UserType
}; };
context.Users.Add(newUser); context.Users.Add(newUser);
context.SaveChanges(); await context.SaveChangesAsync();
return Ok(); return Ok();
} }
[HttpDelete("delete")] [HttpDelete("delete")]
@@ -84,7 +84,7 @@ namespace ComiServ.Controllers
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public IActionResult DeleteUser(IAuthenticationService auth, public async Task<IActionResult> DeleteUser(IAuthenticationService auth,
ComicsContext context, ComicsContext context,
[FromBody] [FromBody]
string username) string username)
@@ -97,17 +97,18 @@ namespace ComiServ.Controllers
if (auth.User.UserTypeId != UserTypeEnum.Administrator) if (auth.User.UserTypeId != UserTypeEnum.Administrator)
return Forbid(); return Forbid();
username = username.Trim(); username = username.Trim();
var user = context.Users.SingleOrDefault(u => EF.Functions.Like(u.Username, $"{username}")); var user = await context.Users.SingleOrDefaultAsync(u => EF.Functions.Like(u.Username, $"{username}"));
if (user is null) if (user is null)
return BadRequest(); return BadRequest();
context.Users.Remove(user); context.Users.Remove(user);
await context.SaveChangesAsync();
return Ok(); return Ok();
} }
[HttpPost("modify")] [HttpPost("modify")]
[ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)] [ProducesResponseType<RequestError>(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
public IActionResult ModifyUser(IAuthenticationService auth, public async Task<IActionResult> ModifyUser(IAuthenticationService auth,
ComicsContext context, ComicsContext context,
[FromBody] [FromBody]
UserModifyRequest req) UserModifyRequest req)
@@ -131,8 +132,8 @@ namespace ComiServ.Controllers
{ {
return Forbid(); return Forbid();
} }
var user = context.Users var user = await context.Users
.SingleOrDefault(u => EF.Functions.Like(u.Username, req.Username)); .SingleOrDefaultAsync(u => EF.Functions.Like(u.Username, req.Username));
if (user is null) if (user is null)
{ {
return BadRequest(RequestError.UserNotFound); return BadRequest(RequestError.UserNotFound);
@@ -145,8 +146,7 @@ namespace ComiServ.Controllers
{ {
user.UserTypeId = nut; user.UserTypeId = nut;
} }
context.SaveChanges(); await context.SaveChangesAsync();
return Ok(); return Ok();
} }
} }
}

View File

@@ -4,8 +4,8 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
//using System.ComponentModel.DataAnnotations.Schema; //using System.ComponentModel.DataAnnotations.Schema;
namespace ComiServ.Entities namespace ComiServ.Entities;
{
[Index(nameof(Name), IsUnique = true)] [Index(nameof(Name), IsUnique = true)]
public class Author public class Author
{ {
@@ -14,4 +14,3 @@ namespace ComiServ.Entities
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
public ICollection<ComicAuthor> ComicAuthors { get; set; } = null!; public ICollection<ComicAuthor> ComicAuthors { get; set; } = null!;
} }
}

View File

@@ -3,8 +3,8 @@ using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
namespace ComiServ.Entities namespace ComiServ.Entities;
{
[Index(nameof(Handle), IsUnique = true)] [Index(nameof(Handle), IsUnique = true)]
[Index(nameof(Filepath), IsUnique = true)] [Index(nameof(Filepath), IsUnique = true)]
public class Comic public class Comic
@@ -32,4 +32,3 @@ namespace ComiServ.Entities
[InverseProperty("Comic")] [InverseProperty("Comic")]
public ICollection<ComicRead> ReadBy { get; set; } = []; public ICollection<ComicRead> ReadBy { get; set; } = [];
} }
}

View File

@@ -2,8 +2,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
namespace ComiServ.Entities namespace ComiServ.Entities;
{
[PrimaryKey("ComicId", "AuthorId")] [PrimaryKey("ComicId", "AuthorId")]
[Index("ComicId")] [Index("ComicId")]
[Index("AuthorId")] [Index("AuthorId")]
@@ -18,4 +18,3 @@ namespace ComiServ.Entities
[Required] [Required]
public Author Author { get; set; } = null!; public Author Author { get; set; } = null!;
} }
}

View File

@@ -1,8 +1,8 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
namespace ComiServ.Entities namespace ComiServ.Entities;
{
[PrimaryKey(nameof(UserId), nameof(ComicId))] [PrimaryKey(nameof(UserId), nameof(ComicId))]
[Index(nameof(UserId))] [Index(nameof(UserId))]
[Index(nameof(ComicId))] [Index(nameof(ComicId))]
@@ -13,4 +13,3 @@ namespace ComiServ.Entities
public int ComicId { get; set; } public int ComicId { get; set; }
public Comic Comic { get; set; } public Comic Comic { get; set; }
} }
}

View File

@@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ComiServ.Entities namespace ComiServ.Entities;
{
[PrimaryKey("ComicId", "TagId")] [PrimaryKey("ComicId", "TagId")]
[Index("ComicId")] [Index("ComicId")]
[Index("TagId")] [Index("TagId")]
@@ -12,4 +12,3 @@ namespace ComiServ.Entities
public int TagId { get; set; } public int TagId { get; set; }
public Tag Tag { get; set; } = null!; public Tag Tag { get; set; } = null!;
} }
}

View File

@@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ComiServ.Entities namespace ComiServ.Entities;
{
[PrimaryKey("FileXxhash64")] [PrimaryKey("FileXxhash64")]
public class Cover public class Cover
{ {
@@ -9,4 +9,3 @@ namespace ComiServ.Entities
public string Filename { get; set; } = null!; public string Filename { get; set; } = null!;
public byte[] CoverFile { get; set; } = null!; public byte[] CoverFile { get; set; } = null!;
} }
}

View File

@@ -2,8 +2,8 @@
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.SwaggerGen;
namespace ComiServ.Entities namespace ComiServ.Entities;
{
/// <summary> /// <summary>
/// This was originally made to remove Entity types that were being added to the Swagger schema. /// This was originally made to remove Entity types that were being added to the Swagger schema.
/// I found that there was a bug in `ProducesResponseTypeAttribute` that caused it, and this is /// I found that there was a bug in `ProducesResponseTypeAttribute` that caused it, and this is
@@ -25,13 +25,12 @@ namespace ComiServ.Entities
public void Apply(OpenApiSchema schema, SchemaFilterContext context) public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{ {
return; return;
foreach (var item in context.SchemaRepository.Schemas.Keys) //foreach (var item in context.SchemaRepository.Schemas.Keys)
{ //{
if (FILTER.Contains(item)) // if (FILTER.Contains(item))
{ // {
context.SchemaRepository.Schemas.Remove(item); // context.SchemaRepository.Schemas.Remove(item);
} // }
} //}
}
} }
} }

View File

@@ -2,8 +2,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
namespace ComiServ.Entities namespace ComiServ.Entities;
{
[Index(nameof(Name), IsUnique = true)] [Index(nameof(Name), IsUnique = true)]
public class Tag public class Tag
{ {
@@ -13,4 +13,3 @@ namespace ComiServ.Entities
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
public ICollection<ComicTag> ComicTags { get; set; } = null!; public ICollection<ComicTag> ComicTags { get; set; } = null!;
} }
}

View File

@@ -3,8 +3,8 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Security.Cryptography; using System.Security.Cryptography;
namespace ComiServ.Entities namespace ComiServ.Entities;
{
[PrimaryKey(nameof(Id))] [PrimaryKey(nameof(Id))]
[Index(nameof(Username), IsUnique = true)] [Index(nameof(Username), IsUnique = true)]
public class User public class User
@@ -35,4 +35,3 @@ namespace ComiServ.Entities
return SHA512.HashData(salted); return SHA512.HashData(salted);
} }
} }
}

View File

@@ -1,8 +1,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace ComiServ.Entities namespace ComiServ.Entities;
{
[JsonConverter(typeof(JsonStringEnumConverter))] [JsonConverter(typeof(JsonStringEnumConverter))]
public enum UserTypeEnum public enum UserTypeEnum
{ {
@@ -25,4 +25,3 @@ namespace ComiServ.Entities
public string Name { get; set; } public string Name { get; set; }
public ICollection<User> Users { get; set; } public ICollection<User> Users { get; set; }
} }
}

View File

@@ -4,8 +4,8 @@ using System.Runtime.CompilerServices;
//https://stackoverflow.com/a/42467710/25956209 //https://stackoverflow.com/a/42467710/25956209
//https://archive.ph/RvjOy //https://archive.ph/RvjOy
namespace ComiServ.Extensions namespace ComiServ.Extensions;
{
public static class DatabaseExtensions public static class DatabaseExtensions
{ {
//with a compound primary key, `ignorePrimaryKey` will ignore all of them //with a compound primary key, `ignorePrimaryKey` will ignore all of them
@@ -59,4 +59,3 @@ namespace ComiServ.Extensions
return context.Database.ExecuteSql(formattable); return context.Database.ExecuteSql(formattable);
} }
} }
}

View File

@@ -1,5 +1,5 @@
namespace ComiServ.Extensions namespace ComiServ.Extensions;
{
public static class StreamExtensions public static class StreamExtensions
{ {
//https://stackoverflow.com/questions/1080442/how-do-i-convert-a-stream-into-a-byte-in-c //https://stackoverflow.com/questions/1080442/how-do-i-convert-a-stream-into-a-byte-in-c
@@ -9,11 +9,17 @@
if (instream is MemoryStream) if (instream is MemoryStream)
return ((MemoryStream)instream).ToArray(); return ((MemoryStream)instream).ToArray();
using (var memoryStream = new MemoryStream()) using var memoryStream = new MemoryStream();
{
instream.CopyTo(memoryStream); instream.CopyTo(memoryStream);
return memoryStream.ToArray(); return memoryStream.ToArray();
} }
} public static async Task<byte[]> ReadAllBytesAsync(this Stream instream)
{
if (instream is MemoryStream)
return ((MemoryStream)instream).ToArray();
using var memoryStream = new MemoryStream();
await instream.CopyToAsync(memoryStream);
return memoryStream.ToArray();
} }
} }

View File

@@ -1,7 +1,6 @@
namespace ComiServ.Logging namespace ComiServ.Logging;
{
public static class Events public static class Events
{ {
} }
}

View File

@@ -1,7 +1,6 @@
namespace ComiServ.Models namespace ComiServ.Models;
{
public record class AuthorResponse(string Name, int WorkCount) public record class AuthorResponse(string Name, int WorkCount)
{ {
} }
}

View File

@@ -1,8 +1,7 @@
namespace ComiServ.Models namespace ComiServ.Models;
{
//handle is taken from URL //handle is taken from URL
public record class ComicDeleteRequest public record class ComicDeleteRequest
( (
bool DeleteIfFileExists bool DeleteIfFileExists
); );
}

View File

@@ -1,7 +1,7 @@
using ComiServ.Entities; using ComiServ.Entities;
namespace ComiServ.Models namespace ComiServ.Models;
{
public class ComicDuplicateList public class ComicDuplicateList
{ {
public long Hash { get; set; } public long Hash { get; set; }
@@ -20,4 +20,3 @@ namespace ComiServ.Models
Count = Comics.Count; Count = Comics.Count;
} }
} }
}

View File

@@ -1,5 +1,5 @@
namespace ComiServ.Models namespace ComiServ.Models;
{
public class ComicMetadataUpdateRequest public class ComicMetadataUpdateRequest
{ {
public string? Title { get; set; } public string? Title { get; set; }
@@ -7,4 +7,3 @@
public List<string>? Tags { get; set; } public List<string>? Tags { get; set; }
public List<string>? Authors { get; set; } public List<string>? Authors { get; set; }
} }
}

View File

@@ -1,6 +1,5 @@
namespace ComiServ.Models namespace ComiServ.Models;
{
public record class LibraryResponse(int ComicCount, int UniqueFiles) public record class LibraryResponse(int ComicCount, int UniqueFiles)
{ {
} }
}

View File

@@ -1,5 +1,8 @@
namespace ComiServ.Models using Microsoft.AspNetCore.Mvc.RazorPages;
{ using Microsoft.CodeAnalysis.CSharp;
using Microsoft.EntityFrameworkCore;
namespace ComiServ.Models;
public class Paginated<T> public class Paginated<T>
{ {
public int Max { get; } public int Max { get; }
@@ -9,8 +12,6 @@
public List<T> Items { get; } public List<T> Items { get; }
public Paginated(int max, int page, IEnumerable<T> iter) public Paginated(int max, int page, IEnumerable<T> iter)
{ {
Max = max;
Page = page;
if (max <= 0) if (max <= 0)
{ {
throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0"); throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0");
@@ -19,6 +20,8 @@
{ {
throw new ArgumentOutOfRangeException(nameof(page), page, "must be greater than or equal to 0"); throw new ArgumentOutOfRangeException(nameof(page), page, "must be greater than or equal to 0");
} }
Max = max;
Page = page;
Items = iter.Skip(max * page).Take(max + 1).ToList(); Items = iter.Skip(max * page).Take(max + 1).ToList();
if (Items.Count > max) if (Items.Count > max)
{ {
@@ -31,5 +34,62 @@
} }
Count = Items.Count; Count = Items.Count;
} }
private Paginated(int max, int page, bool last, List<T> items)
{
Max = max;
Page = page;
Last = last;
Items = items;
Count = Items.Count;
}
public static async Task<Paginated<T>> CreateAsync(int max, int page, IQueryable<T> iter)
{
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");
}
var items = await iter.Skip(max * page).Take(max + 1).ToListAsync();
bool last = true;
if (items.Count > max)
{
last = false;
items.RemoveAt(max);
}
return new(max, page, last, items);
}
public static async Task<Paginated<T>> CreateAsync(int max, int page, IAsyncEnumerable<T> iter)
{
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");
}
List<T> items = [];
var skip = max * page;
await foreach (T item in iter)
{
if (skip > 0)
{
skip--;
continue;
}
items.Add(item);
if (items.Count >= max + 1)
break;
}
var last = true;
if (items.Count > max)
{
last = false;
items.RemoveAt(max);
}
return new(max, page, last, items);
} }
} }

View File

@@ -1,7 +1,7 @@
using System.Collections; using System.Collections;
namespace ComiServ.Models namespace ComiServ.Models;
{
public class RequestError : IEnumerable<string> public class RequestError : IEnumerable<string>
{ {
public static RequestError InvalidHandle => new("Invalid handle"); public static RequestError InvalidHandle => new("Invalid handle");
@@ -49,4 +49,3 @@ namespace ComiServ.Models
return GetEnumerator(); return GetEnumerator();
} }
} }
}

View File

@@ -1,7 +1,6 @@
namespace ComiServ.Models namespace ComiServ.Models;
{
public record class TagResponse(string Name, int WorkCount) public record class TagResponse(string Name, int WorkCount)
{ {
} }
}

View File

@@ -1,21 +1,23 @@
using System.Reflection.PortableExecutable; using Microsoft.CodeAnalysis.CSharp;
using Microsoft.EntityFrameworkCore;
using System.Reflection.PortableExecutable;
namespace ComiServ.Models;
namespace ComiServ.Models
{
public class Truncated<T> public class Truncated<T>
{ {
public int Max { get; } public int Max { get; }
public int Count { get; } public int Count { get; }
public bool Complete { get; } public bool Complete { get; }
public List<T> Items { get; } public List<T> Items { get; }
public Truncated(int max, IEnumerable<T> items) public Truncated(int max, IEnumerable<T> iter)
{ {
if (max <= 0) if (max <= 0)
{ {
throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0"); throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0");
} }
Max = max; Max = max;
Items = items.Take(max+1).ToList(); Items = iter.Take(max+1).ToList();
if (Items.Count <= max) if (Items.Count <= max)
{ {
Complete = true; Complete = true;
@@ -27,5 +29,47 @@ namespace ComiServ.Models
} }
Count = Items.Count; Count = Items.Count;
} }
private Truncated(int max, bool complete, List<T> items)
{
Max = max;
Complete = complete;
Count = items.Count;
Items = items;
}
public static async Task<Truncated<T>> CreateAsync(int max, IQueryable<T> iter)
{
if (max <= 0)
{
throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0");
}
var items = await iter.Take(max+1).ToListAsync();
var complete = true;
if (items.Count < max)
{
items.RemoveAt(max);
complete = false;
}
return new(max, complete, items);
}
public static async Task<Truncated<T>> CreateAsync(int max, IAsyncEnumerable<T> iter)
{
if (max <= 0)
{
throw new ArgumentOutOfRangeException(nameof(max), max, "must be greater than 0");
}
List<T> items = [];
await foreach (T item in iter)
{
items.Add(item);
if (items.Count > max)
break;
}
var complete = true;
if (items.Count <= max)
{
items.RemoveAt(max);
complete = false;
}
return new Truncated<T>(max, complete, items);
} }
} }

View File

@@ -1,7 +1,7 @@
using ComiServ.Entities; using ComiServ.Entities;
namespace ComiServ.Models namespace ComiServ.Models;
{
public class UserCreateRequest public class UserCreateRequest
{ {
public string Username { get; set; } public string Username { get; set; }
@@ -9,4 +9,3 @@ namespace ComiServ.Models
//NOT HASHED do not persist this object //NOT HASHED do not persist this object
public string Password { get; set; } public string Password { get; set; }
} }
}

View File

@@ -1,4 +1,6 @@
namespace ComiServ.Models namespace ComiServ.Models;
public record class UserDescription(string Username, string Usertype)
{ {
public record class UserDescription(string Username, string Usertype);
} }

View File

@@ -1,11 +1,10 @@
using ComiServ.Entities; using ComiServ.Entities;
namespace ComiServ.Models namespace ComiServ.Models;
{
public class UserModifyRequest public class UserModifyRequest
{ {
public string Username { get; set; } public string Username { get; set; }
public string? NewUsername { get; set; } public string? NewUsername { get; set; }
public UserTypeEnum? NewUserType { get; set; } public UserTypeEnum? NewUserType { get; set; }
} }
}

View File

@@ -1,7 +1,7 @@
using ComiServ.Entities; using ComiServ.Entities;
namespace ComiServ.Services namespace ComiServ.Services;
{
public interface IAuthenticationService public interface IAuthenticationService
{ {
public bool Tested { get; } public bool Tested { get; }
@@ -30,4 +30,3 @@ namespace ComiServ.Services
Tested = true; Tested = true;
} }
} }
}

View File

@@ -8,8 +8,8 @@ using System.IO.Compression;
using System.IO.Hashing; using System.IO.Hashing;
using System.Linq; using System.Linq;
namespace ComiServ.Background namespace ComiServ.Background;
{
public record class ComicAnalysis public record class ComicAnalysis
( (
long FileSizeBytes, long FileSizeBytes,
@@ -34,6 +34,7 @@ namespace ComiServ.Background
public Task<ComicAnalysis?> AnalyzeComicAsync(string filename); public Task<ComicAnalysis?> AnalyzeComicAsync(string filename);
//returns null if out of range, throws for file error //returns null if out of range, throws for file error
public ComicPage? GetComicPage(string filepath, int page); public ComicPage? GetComicPage(string filepath, int page);
public Task<ComicPage?> GetComicPageAsync(string filepath, int page);
//based purely on filename, doesn't try to open file //based purely on filename, doesn't try to open file
//returns null for ALL UNRECOGNIZED OR NON-IMAGES //returns null for ALL UNRECOGNIZED OR NON-IMAGES
public static string? GetImageMime(string filename) public static string? GetImageMime(string filename)
@@ -146,6 +147,12 @@ namespace ComiServ.Background
return GetPage7Zip(filepath, page); return GetPage7Zip(filepath, page);
else return null; else return null;
} }
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
public async Task<ComicPage?> GetComicPageAsync(string filepath, int page)
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
{
return GetComicPage(filepath, page);
}
protected ComicPage? GetPageZip(string filepath, int page) protected ComicPage? GetPageZip(string filepath, int page)
{ {
Debug.Assert(page >= 1, "Page number must be positive"); Debug.Assert(page >= 1, "Page number must be positive");
@@ -225,4 +232,3 @@ namespace ComiServ.Background
); );
} }
} }
}

View File

@@ -9,8 +9,8 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration.Ini; using Microsoft.Extensions.Configuration.Ini;
using Microsoft.OpenApi.Writers; using Microsoft.OpenApi.Writers;
namespace ComiServ.Background namespace ComiServ.Background;
{
public record class ComicScanItem public record class ComicScanItem
( (
string Filepath, string Filepath,
@@ -65,14 +65,14 @@ namespace ComiServ.Background
} }
public void TriggerLibraryScan() public void TriggerLibraryScan()
{ {
TaskItem ti = new( SyncTaskItem ti = new(
TaskTypes.Scan, TaskTypes.Scan,
"Library Scan", "Library Scan",
token => async token =>
{ {
var items = PerfomLibraryScan(token); var items = PerfomLibraryScan(token);
token?.ThrowIfCancellationRequested(); token?.ThrowIfCancellationRequested();
UpdateDatabaseWithScanResults(items); await UpdateDatabaseWithScanResults(items);
}, },
null); null);
_manager.StartTask(ti); _manager.StartTask(ti);
@@ -83,19 +83,19 @@ namespace ComiServ.Background
RepeatedLibraryScanTokenSource?.Cancel(); RepeatedLibraryScanTokenSource?.Cancel();
RepeatedLibraryScanTokenSource?.Dispose(); RepeatedLibraryScanTokenSource?.Dispose();
RepeatedLibraryScanTokenSource = new(); RepeatedLibraryScanTokenSource = new();
TaskItem ti = new( AsyncTaskItem ti = new(
TaskTypes.Scan, TaskTypes.Scan,
"Scheduled Library Scan", "Scheduled Library Scan",
token => async token =>
{ {
var items = PerfomLibraryScan(token); var items = PerfomLibraryScan(token);
token?.ThrowIfCancellationRequested(); token?.ThrowIfCancellationRequested();
UpdateDatabaseWithScanResults(items); await UpdateDatabaseWithScanResults(items);
}, },
RepeatedLibraryScanTokenSource.Token); RepeatedLibraryScanTokenSource.Token);
_manager.ScheduleTask(ti, interval); _manager.ScheduleTask(ti, interval);
} }
public void UpdateDatabaseWithScanResults(IDictionary<string, ComicScanItem> items) public async Task UpdateDatabaseWithScanResults(IDictionary<string, ComicScanItem> items)
{ {
using var scope = _provider.CreateScope(); using var scope = _provider.CreateScope();
var services = scope.ServiceProvider; var services = scope.ServiceProvider;
@@ -135,30 +135,46 @@ namespace ComiServ.Background
FileXxhash64 = p.Value.Xxhash, FileXxhash64 = p.Value.Xxhash,
PageCount = p.Value.PageCount PageCount = p.Value.PageCount
}).ToList(); }).ToList();
newComics.ForEach(c => _manager.StartTask(new( //newComics.ForEach(c => _manager.StartTask(new(
// TaskTypes.GetCover,
// $"Get Cover: {c.Title}",
// token => InsertCover(Path.Join(_config.LibraryRoot, c.Filepath), c.FileXxhash64)
// )));
foreach (var comic in newComics)
{
_manager.StartTask((AsyncTaskItem)new(
TaskTypes.GetCover, TaskTypes.GetCover,
$"Get Cover: {c.Title}", $"Get Cover: {comic.Title}",
token => InsertCover(Path.Join(_config.LibraryRoot, c.Filepath), c.FileXxhash64) token => InsertCover(Path.Join(_config.LibraryRoot, comic.Filepath), comic.FileXxhash64)
))); ));
newComics.ForEach(c => _manager.StartTask(new(
TaskTypes.MakeThumbnail,
$"Make Thumbnail: {c.Title}",
token => InsertThumbnail(c.Handle, Path.Join(_config.LibraryRoot, c.Filepath), 1)
)));
context.Comics.AddRange(newComics);
context.SaveChanges();
} }
protected void InsertCover(string filepath, long hash) //newComics.ForEach(c => _manager.StartTask(new(
// TaskTypes.MakeThumbnail,
// $"Make Thumbnail: {c.Title}",
// token => InsertThumbnail(c.Handle, Path.Join(_config.LibraryRoot, c.Filepath), 1)
// )));
foreach (var comic in newComics)
{
_manager.StartTask((AsyncTaskItem)new(
TaskTypes.MakeThumbnail,
$"Make Thumbnail: {comic.Title}",
token => InsertThumbnail(comic.Handle, Path.Join(_config.LibraryRoot, comic.Filepath), 1)
));
}
context.Comics.AddRange(newComics);
await context.SaveChangesAsync();
}
protected async Task InsertCover(string filepath, long hash)
{ {
using var scope = _provider.CreateScope(); using var scope = _provider.CreateScope();
var services = scope.ServiceProvider; var services = scope.ServiceProvider;
using var context = services.GetRequiredService<ComicsContext>(); using var context = services.GetRequiredService<ComicsContext>();
var existing = context.Covers.SingleOrDefault(c => c.FileXxhash64 == hash); var existing = await context.Covers.SingleOrDefaultAsync(c => c.FileXxhash64 == hash);
//assuming no hash overlap //assuming no hash overlap
//if you already have a cover, assume it's correct //if you already have a cover, assume it's correct
if (existing is not null) if (existing is not null)
return; return;
var page = _analyzer.GetComicPage(filepath, 1); var page = await _analyzer.GetComicPageAsync(filepath, 1);
if (page is null) if (page is null)
return; return;
Cover cover = new() Cover cover = new()
@@ -169,12 +185,12 @@ namespace ComiServ.Background
}; };
context.InsertOrIgnore(cover, true); context.InsertOrIgnore(cover, true);
} }
protected void InsertThumbnail(string handle, string filepath, int page = 1) protected async Task InsertThumbnail(string handle, string filepath, int page = 1)
{ {
using var scope = _provider.CreateScope(); using var scope = _provider.CreateScope();
var services = scope.ServiceProvider; var services = scope.ServiceProvider;
using var context = services.GetRequiredService<ComicsContext>(); using var context = services.GetRequiredService<ComicsContext>();
var comic = context.Comics.SingleOrDefault(c => c.Handle == handle); var comic = await context.Comics.SingleOrDefaultAsync(c => c.Handle == handle);
if (comic?.ThumbnailWebp is null) if (comic?.ThumbnailWebp is null)
return; return;
var comicPage = _analyzer.GetComicPage(filepath, page); var comicPage = _analyzer.GetComicPage(filepath, page);
@@ -182,12 +198,12 @@ namespace ComiServ.Background
return; return;
var converter = services.GetRequiredService<IPictureConverter>(); var converter = services.GetRequiredService<IPictureConverter>();
using var inStream = new MemoryStream(comicPage.Data); using var inStream = new MemoryStream(comicPage.Data);
var outStream = converter.MakeThumbnail(inStream); var outStream = await converter.MakeThumbnail(inStream);
comic.ThumbnailWebp = outStream.ReadAllBytes(); comic.ThumbnailWebp = outStream.ReadAllBytes();
} }
public void Dispose() public void Dispose()
{ {
RepeatedLibraryScanTokenSource?.Dispose(); RepeatedLibraryScanTokenSource?.Dispose();
} GC.SuppressFinalize(this);
} }
} }

View File

@@ -1,7 +1,7 @@
using System.Text.Json; using System.Text.Json;
namespace ComiServ.Services namespace ComiServ.Services;
{
public class Configuration public class Configuration
{ {
public string LibraryRoot { get; set; } public string LibraryRoot { get; set; }
@@ -28,4 +28,3 @@ namespace ComiServ.Services
?? throw new ArgumentException("Failed to parse config file"); ?? throw new ArgumentException("Failed to parse config file");
} }
} }
}

View File

@@ -11,8 +11,8 @@ using SixLabors.ImageSharp.Formats.Bmp;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
namespace ComiServ.Background namespace ComiServ.Background;
{
[JsonConverter(typeof(JsonStringEnumConverter))] [JsonConverter(typeof(JsonStringEnumConverter))]
public enum PictureFormats public enum PictureFormats
{ {
@@ -29,9 +29,9 @@ namespace ComiServ.Background
public static PictureFormats ThumbnailFormat => PictureFormats.Webp; public static PictureFormats ThumbnailFormat => PictureFormats.Webp;
//keeps aspect ratio, crops to horizontally to center, vertically to top //keeps aspect ratio, crops to horizontally to center, vertically to top
//uses System.Drawing.Size so interface isn't dependant on ImageSharp //uses System.Drawing.Size so interface isn't dependant on ImageSharp
public Stream Resize(Stream image, System.Drawing.Size newSize, PictureFormats? newFormat = null); public Task<Stream> Resize(Stream image, System.Drawing.Size newSize, PictureFormats? newFormat = null);
public Stream ResizeIfBigger(Stream image, System.Drawing.Size maxSize, PictureFormats? newFormat = null); public Task<Stream> ResizeIfBigger(Stream image, System.Drawing.Size maxSize, PictureFormats? newFormat = null);
public Stream MakeThumbnail(Stream image); public Task<Stream> MakeThumbnail(Stream image);
public static string GetMime(PictureFormats format) public static string GetMime(PictureFormats format)
{ {
switch (format) switch (format)
@@ -73,7 +73,7 @@ namespace ComiServ.Background
} }
} }
public bool WebpLossless { get; } = webpLossless; public bool WebpLossless { get; } = webpLossless;
public Stream Resize(Stream image, System.Drawing.Size newSize, PictureFormats? newFormat = null) public async Task<Stream> Resize(Stream image, System.Drawing.Size newSize, PictureFormats? newFormat = null)
{ {
using var img = Image.Load(image); using var img = Image.Load(image);
IImageFormat format; IImageFormat format;
@@ -110,15 +110,15 @@ namespace ComiServ.Background
{ {
FileFormat = WebpLossless ? WebpFileFormatType.Lossless : WebpFileFormatType.Lossy FileFormat = WebpLossless ? WebpFileFormatType.Lossless : WebpFileFormatType.Lossy
}; };
img.Save(outStream, enc); await img.SaveAsync(outStream, enc);
} }
else else
{ {
img.Save(outStream, format); await img.SaveAsync(outStream, format);
} }
return outStream; return outStream;
} }
public Stream ResizeIfBigger(Stream image, System.Drawing.Size maxSize, PictureFormats? newFormat = null) public async Task<Stream> ResizeIfBigger(Stream image, System.Drawing.Size maxSize, PictureFormats? newFormat = null)
{ {
using Image img = Image.Load(image); using Image img = Image.Load(image);
IImageFormat format; IImageFormat format;
@@ -146,17 +146,16 @@ namespace ComiServ.Background
{ {
FileFormat = WebpLossless ? WebpFileFormatType.Lossless : WebpFileFormatType.Lossy FileFormat = WebpLossless ? WebpFileFormatType.Lossless : WebpFileFormatType.Lossy
}; };
img.Save(outStream, enc); await img.SaveAsync(outStream, enc);
} }
else else
{ {
img.Save(outStream, format); await img.SaveAsync(outStream, format);
} }
return outStream; return outStream;
} }
public Stream MakeThumbnail(Stream image) public async Task<Stream> MakeThumbnail(Stream image)
{ {
return Resize(image, IPictureConverter.ThumbnailResolution, IPictureConverter.ThumbnailFormat); return await Resize(image, IPictureConverter.ThumbnailResolution, IPictureConverter.ThumbnailFormat);
}
} }
} }

View File

@@ -1,38 +1,68 @@
using System.Collections.Concurrent; using NuGet.Common;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Xml.Linq;
namespace ComiServ.Services;
namespace ComiServ.Background
{
public enum TaskTypes public enum TaskTypes
{ {
Scan, Scan,
GetCover, GetCover,
MakeThumbnail, MakeThumbnail,
} }
//task needs to use the token parameter rather than its own token, because it gets merged with the master token public abstract class BaseTaskItem
public class TaskItem(TaskTypes type, string name, Action<CancellationToken?> action, CancellationToken? token = null)
{ {
public readonly TaskTypes Type = type; public readonly TaskTypes Type;
public readonly string Name = name; public readonly string Name;
public readonly Action<CancellationToken?> Action = action; public readonly CancellationToken Token;
public readonly CancellationToken Token = token ?? CancellationToken.None; protected BaseTaskItem(TaskTypes type, string name, CancellationToken? token = null)
{
Type = type;
Name = name;
Token = token ?? CancellationToken.None;
}
}
//task needs to use the token parameter rather than its own token, because it gets merged with the master token
public class SyncTaskItem
: BaseTaskItem
{
public readonly Action<CancellationToken?> Action;
public SyncTaskItem(TaskTypes type, string name, Action<CancellationToken?> action, CancellationToken? token = null)
: base(type, name, token)
{
Action = action;
}
}
public class AsyncTaskItem
: BaseTaskItem
{
public readonly Func<CancellationToken?, Task?> AsyncAction;
public AsyncTaskItem(TaskTypes type, string name, Func<CancellationToken?, Task?> asyncAction, CancellationToken? token = null)
: base(type, name, token)
{
AsyncAction = asyncAction;
}
} }
public interface ITaskManager : IDisposable public interface ITaskManager : IDisposable
{ {
public void StartTask(TaskItem taskItem); public void StartTask(SyncTaskItem taskItem);
public void ScheduleTask(TaskItem taskItem, TimeSpan interval); public void StartTask(AsyncTaskItem taskItem);
public void ScheduleTask(BaseTaskItem taskItem, TimeSpan interval);
public string[] GetTasks(int limit); public string[] GetTasks(int limit);
public void CancelAll(); public void CancelAll();
} }
public class TaskManager(ILogger<ITaskManager>? logger) public class TaskManager(ILogger<ITaskManager>? logger)
: ITaskManager : ITaskManager
{ {
private readonly ConcurrentDictionary<Task, TaskItem> ActiveTasks = []; private readonly ConcurrentDictionary<Task, BaseTaskItem> ActiveTasks = [];
private CancellationTokenSource MasterToken { get; set; } = new(); private CancellationTokenSource MasterToken { get; set; } = new();
private readonly ILogger<ITaskManager>? _logger = logger; private readonly ILogger<ITaskManager>? _logger = logger;
private readonly ConcurrentDictionary<System.Timers.Timer,TaskItem> Scheduled = []; private readonly ConcurrentDictionary<System.Timers.Timer, BaseTaskItem> Scheduled = [];
public void StartTask(TaskItem taskItem) public void StartTask(SyncTaskItem taskItem)
{ {
_logger?.LogTrace($"Start Task: {taskItem.Name}"); //_logger?.LogTrace($"Start Task: {taskItem.Name}");
var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token); var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token);
var newTask = Task.Run(() => taskItem.Action(tokenSource.Token), var newTask = Task.Run(() => taskItem.Action(tokenSource.Token),
tokenSource.Token); tokenSource.Token);
@@ -44,7 +74,20 @@ namespace ComiServ.Background
//TODO should master token actually cancel followup? //TODO should master token actually cancel followup?
newTask.ContinueWith(ManageFinishedTasks, MasterToken.Token); newTask.ContinueWith(ManageFinishedTasks, MasterToken.Token);
} }
public void ScheduleTask(TaskItem taskItem, TimeSpan interval) public void StartTask(AsyncTaskItem taskItem)
{
var tokenSource = CancellationTokenSource.CreateLinkedTokenSource(MasterToken.Token, taskItem.Token);
var newTask = Task.Run(() => taskItem.AsyncAction(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(SyncTaskItem taskItem, TimeSpan interval)
{ {
//var timer = new Timer((_) => StartTask(taskItem), null, dueTime, period ?? Timeout.InfiniteTimeSpan); //var timer = new Timer((_) => StartTask(taskItem), null, dueTime, period ?? Timeout.InfiniteTimeSpan);
var timer = new System.Timers.Timer(interval); var timer = new System.Timers.Timer(interval);
@@ -58,6 +101,27 @@ namespace ComiServ.Background
timer.Elapsed += (_, _) => taskItem.Action(token.Token); timer.Elapsed += (_, _) => taskItem.Action(token.Token);
timer.Start(); timer.Start();
} }
public void ScheduleTask(BaseTaskItem taskItem, TimeSpan interval)
{
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 _);
});
if (taskItem is AsyncTaskItem ati)
timer.Elapsed += async (_, _) =>
{
var task = ati.AsyncAction(token.Token);
if (task != null)
await task;
};
else if (taskItem is SyncTaskItem sti)
timer.Elapsed += (_, _) => sti.Action(token.Token);
timer.Start();
}
public string[] GetTasks(int limit) public string[] GetTasks(int limit)
{ {
return ActiveTasks.Select(p => p.Value.Name).Take(limit).ToArray(); return ActiveTasks.Select(p => p.Value.Name).Take(limit).ToArray();
@@ -87,7 +151,7 @@ namespace ComiServ.Background
bool taskRemoved = ActiveTasks.TryRemove(pair.Key, out _); bool taskRemoved = ActiveTasks.TryRemove(pair.Key, out _);
if (taskRemoved) if (taskRemoved)
{ {
_logger?.LogTrace($"Removed Task: {pair.Value.Name}"); _logger?.LogTrace("Removed Task: {TaskName}", pair.Value.Name);
} }
} }
} }
@@ -96,6 +160,6 @@ namespace ComiServ.Background
public void Dispose() public void Dispose()
{ {
MasterToken?.Dispose(); MasterToken?.Dispose();
} GC.SuppressFinalize(this);
} }
} }