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,10 +1,10 @@
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?
public const int HANDLE_LENGTH = 12; public const int HANDLE_LENGTH = 12;
//relies on low probability of repeat handles in a short period of time //relies on low probability of repeat handles in a short period of time
@@ -77,5 +77,4 @@ namespace ComiServ
return null; return null;
return handle.ToUpper(); return handle.ToUpper();
} }
}
} }

View File

@@ -12,15 +12,16 @@ 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)
: ControllerBase : ControllerBase
{ {
public const string ROUTE = "/api/v1/comics"; public const string ROUTE = "/api/v1/comics";
private readonly ComicsContext _context = context; private readonly ComicsContext _context = context;
private readonly ILogger<ComicController> _logger = logger; private readonly ILogger<ComicController> _logger = logger;
@@ -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,13 +6,13 @@ 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)
: ControllerBase : ControllerBase
{ {
public const string ROUTE = "/api/v1/"; public const string ROUTE = "/api/v1/";
ComicsContext _context = context; ComicsContext _context = context;
ILogger<MiscController> _logger = logger; ILogger<MiscController> _logger = logger;
@@ -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,17 +7,17 @@ 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(
ComicsContext context ComicsContext context
,ITaskManager manager ,ITaskManager manager
,IComicScanner scanner ,IComicScanner scanner
,ILogger<TaskController> logger ,ILogger<TaskController> logger
) : ControllerBase ) : ControllerBase
{ {
public const string ROUTE = "/api/v1/tasks"; public const string ROUTE = "/api/v1/tasks";
private readonly ComicsContext _context = context; private readonly ComicsContext _context = context;
private readonly ITaskManager _manager = manager; private readonly ITaskManager _manager = manager;
@@ -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
@@ -57,5 +57,4 @@ namespace ComiServ.Controllers
manager.CancelAll(); manager.CancelAll();
return Ok(); return Ok();
} }
}
} }

View File

@@ -7,19 +7,19 @@ 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
: ControllerBase : ControllerBase
{ {
public const string ROUTE = "/api/v1/users"; public const string ROUTE = "/api/v1/users";
[HttpGet] [HttpGet]
[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,14 +4,13 @@ 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)]
public class Author
{ {
[Index(nameof(Name), IsUnique = true)]
public class Author
{
public int Id { get; set; } public int Id { get; set; }
[Required] [Required]
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,12 +3,12 @@ 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(Filepath), IsUnique = true)]
public class Comic
{ {
[Index(nameof(Handle), IsUnique = true)]
[Index(nameof(Filepath), IsUnique = true)]
public class Comic
{
public int Id { get; set; } public int Id { get; set; }
public bool Exists { get; set; } public bool Exists { get; set; }
//id exposed through the API //id exposed through the API
@@ -31,5 +31,4 @@ namespace ComiServ.Entities
public ICollection<ComicAuthor> ComicAuthors { get; set; } = []; public ICollection<ComicAuthor> ComicAuthors { get; set; } = [];
[InverseProperty("Comic")] [InverseProperty("Comic")]
public ICollection<ComicRead> ReadBy { get; set; } = []; public ICollection<ComicRead> ReadBy { get; set; } = [];
}
} }

View File

@@ -2,13 +2,13 @@
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")]
[Index("ComicId")]
[Index("AuthorId")]
public class ComicAuthor
{ {
[PrimaryKey("ComicId", "AuthorId")]
[Index("ComicId")]
[Index("AuthorId")]
public class ComicAuthor
{
[ForeignKey(nameof(Comic))] [ForeignKey(nameof(Comic))]
public int ComicId { get; set; } public int ComicId { get; set; }
[Required] [Required]
@@ -17,5 +17,4 @@ namespace ComiServ.Entities
public int AuthorId { get; set; } public int AuthorId { get; set; }
[Required] [Required]
public Author Author { get; set; } = null!; public Author Author { get; set; } = null!;
}
} }

View File

@@ -1,16 +1,15 @@
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))]
[Index(nameof(UserId))]
[Index(nameof(ComicId))]
public class ComicRead
{ {
[PrimaryKey(nameof(UserId), nameof(ComicId))]
[Index(nameof(UserId))]
[Index(nameof(ComicId))]
public class ComicRead
{
public int UserId { get; set; } public int UserId { get; set; }
public User User { get; set; } public User User { get; set; }
public int ComicId { get; set; } public int ComicId { get; set; }
public Comic Comic { get; set; } public Comic Comic { get; set; }
}
} }

View File

@@ -1,15 +1,14 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ComiServ.Entities namespace ComiServ.Entities;
[PrimaryKey("ComicId", "TagId")]
[Index("ComicId")]
[Index("TagId")]
public class ComicTag
{ {
[PrimaryKey("ComicId", "TagId")]
[Index("ComicId")]
[Index("TagId")]
public class ComicTag
{
public int ComicId { get; set; } public int ComicId { get; set; }
public Comic Comic { get; set; } = null!; public Comic Comic { get; set; } = null!;
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,12 +1,11 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ComiServ.Entities namespace ComiServ.Entities;
[PrimaryKey("FileXxhash64")]
public class Cover
{ {
[PrimaryKey("FileXxhash64")]
public class Cover
{
public long FileXxhash64 { get; set; } public long FileXxhash64 { get; set; }
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,16 +2,16 @@
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.SwaggerGen;
namespace ComiServ.Entities 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 in `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
{ {
/// <summary>
/// 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
/// 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 = [ public readonly static string[] FILTER = [
nameof(Author), nameof(Author),
nameof(Comic), nameof(Comic),
@@ -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,15 +2,14 @@
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)]
public class Tag
{ {
[Index(nameof(Name), IsUnique = true)]
public class Tag
{
//[DatabaseGenerated(DatabaseGeneratedOption.Identity)] //[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; } public int Id { get; set; }
[Required] [Required]
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,12 +3,12 @@ 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))]
[Index(nameof(Username), IsUnique = true)]
public class User
{ {
[PrimaryKey(nameof(Id))]
[Index(nameof(Username), IsUnique = true)]
public class User
{
public const int HashLengthBytes = 512 / 8; public const int HashLengthBytes = 512 / 8;
public const int SaltLengthBytes = HashLengthBytes; public const int SaltLengthBytes = HashLengthBytes;
public int Id { get; set; } public int Id { get; set; }
@@ -34,5 +34,4 @@ namespace ComiServ.Entities
var salted = salt.Append((byte)':').Concat(password).ToArray(); var salted = salt.Append((byte)':').Concat(password).ToArray();
return SHA512.HashData(salted); return SHA512.HashData(salted);
} }
}
} }

View File

@@ -1,11 +1,11 @@
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))]
public enum UserTypeEnum
{ {
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum UserTypeEnum
{
//important that this is 0 as a safety precaution, //important that this is 0 as a safety precaution,
//in case it's accidentally left as default //in case it's accidentally left as default
Invalid = 0, Invalid = 0,
@@ -17,12 +17,11 @@ namespace ComiServ.Entities
Restricted = 3, Restricted = 3,
//refuses to authenticate but maintains records //refuses to authenticate but maintains records
Disabled = 4, Disabled = 4,
} }
public class UserType public class UserType
{ {
public UserTypeEnum Id { get; set; } public UserTypeEnum Id { get; set; }
[MaxLength(26)] [MaxLength(26)]
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,10 +4,10 @@ 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
public static int InsertOrIgnore<T>(this DbContext context, T item, bool ignorePrimaryKey = false) public static int InsertOrIgnore<T>(this DbContext context, T item, bool ignorePrimaryKey = false)
{ {
@@ -58,5 +58,4 @@ namespace ComiServ.Extensions
var formattable = FormattableStringFactory.Create(query, args); var formattable = FormattableStringFactory.Create(query, args);
return context.Database.ExecuteSql(formattable); return context.Database.ExecuteSql(formattable);
} }
}
} }

View File

@@ -1,7 +1,7 @@
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
//https://archive.ph/QUKys //https://archive.ph/QUKys
public static byte[] ReadAllBytes(this Stream instream) public static byte[] ReadAllBytes(this Stream instream)
@@ -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,9 +1,9 @@
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; }
public int Count { get; set; } public int Count { get; set; }
public List<ComicData> Comics { get; set; } public List<ComicData> Comics { get; set; }
@@ -19,5 +19,4 @@ namespace ComiServ.Models
Comics = comics.ToList(); Comics = comics.ToList();
Count = Comics.Count; Count = Comics.Count;
} }
}
} }

View File

@@ -1,10 +1,9 @@
namespace ComiServ.Models namespace ComiServ.Models;
public class ComicMetadataUpdateRequest
{ {
public class ComicMetadataUpdateRequest
{
public string? Title { get; set; } public string? Title { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
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,16 +1,17 @@
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; }
public int Page { get;} public int Page { get; }
public bool Last { get; } public bool Last { get; }
public int Count { get; } public int Count { get; }
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,9 +1,9 @@
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");
public static RequestError ComicNotFound => new("Comic not found"); public static RequestError ComicNotFound => new("Comic not found");
public static RequestError CoverNotFound => new("Cover not found"); public static RequestError CoverNotFound => new("Cover not found");
@@ -48,5 +48,4 @@ 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,12 +1,11 @@
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; }
public UserTypeEnum UserType { get; set; } public UserTypeEnum UserType { get; set; }
//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,17 +1,17 @@
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; }
public User? User { get; } public User? User { get; }
public void Authenticate(User user); public void Authenticate(User user);
public void FailAuth(); public void FailAuth();
} }
//acts as a per-request container of authentication info //acts as a per-request container of authentication info
public class AuthenticationService : IAuthenticationService public class AuthenticationService : IAuthenticationService
{ {
public bool Tested { get; private set; } = false; public bool Tested { get; private set; } = false;
public User? User { get; private set; } public User? User { get; private set; }
@@ -29,5 +29,4 @@ namespace ComiServ.Services
User = null; User = null;
Tested = true; Tested = true;
} }
}
} }

View File

@@ -8,22 +8,22 @@ 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,
int PageCount, int PageCount,
Int64 Xxhash Int64 Xxhash
); );
public record class ComicPage public record class ComicPage
( (
string Filename, string Filename,
string Mime, string Mime,
byte[] Data byte[] Data
); );
public interface IComicAnalyzer public interface IComicAnalyzer
{ {
public static readonly IReadOnlyList<string> ZIP_EXTS = [".cbz", ".zip"]; public static readonly IReadOnlyList<string> ZIP_EXTS = [".cbz", ".zip"];
public static readonly IReadOnlyList<string> RAR_EXTS = [".cbr", ".rar"]; public static readonly IReadOnlyList<string> RAR_EXTS = [".cbr", ".rar"];
public static readonly IReadOnlyList<string> ZIP7_EXTS = [".cb7", ".7z"]; public static readonly IReadOnlyList<string> ZIP7_EXTS = [".cb7", ".7z"];
@@ -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)
@@ -45,11 +46,11 @@ namespace ComiServ.Background
} }
return null; return null;
} }
} }
//async methods actually just block //async methods actually just block
public class SynchronousComicAnalyzer(ILogger<IComicAnalyzer>? logger) public class SynchronousComicAnalyzer(ILogger<IComicAnalyzer>? logger)
: IComicAnalyzer : IComicAnalyzer
{ {
private readonly ILogger<IComicAnalyzer>? _logger = logger; private readonly ILogger<IComicAnalyzer>? _logger = logger;
public bool ComicFileExists(string filename) public bool ComicFileExists(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");
@@ -224,5 +231,4 @@ namespace ComiServ.Background
Data: pageData Data: pageData
); );
} }
}
} }

View File

@@ -9,17 +9,17 @@ 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,
long FileSizeBytes, long FileSizeBytes,
Int64 Xxhash, Int64 Xxhash,
int PageCount int PageCount
); );
public interface IComicScanner : IDisposable public interface IComicScanner : IDisposable
{ {
//TODO should be configurable //TODO should be configurable
public static readonly IReadOnlyList<string> COMIC_EXTENSIONS = [ public static readonly IReadOnlyList<string> COMIC_EXTENSIONS = [
"cbz", "zip", "cbz", "zip",
@@ -29,11 +29,11 @@ namespace ComiServ.Background
public void TriggerLibraryScan(); public void TriggerLibraryScan();
public void ScheduleRepeatedLibraryScans(TimeSpan period); public void ScheduleRepeatedLibraryScans(TimeSpan period);
public IDictionary<string, ComicScanItem> PerfomLibraryScan(CancellationToken? token = null); public IDictionary<string, ComicScanItem> PerfomLibraryScan(CancellationToken? token = null);
} }
public class ComicScanner( public class ComicScanner(
IServiceProvider provider IServiceProvider provider
) : IComicScanner ) : IComicScanner
{ {
//private readonly ComicsContext _context = context; //private readonly ComicsContext _context = context;
private readonly ITaskManager _manager = provider.GetRequiredService<ITaskManager>(); private readonly ITaskManager _manager = provider.GetRequiredService<ITaskManager>();
private readonly Configuration _config = provider.GetRequiredService<IConfigService>().Config; private readonly Configuration _config = provider.GetRequiredService<IConfigService>().Config;
@@ -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,9 +1,9 @@
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; }
public string DatabaseFile { get; set; } public string DatabaseFile { get; set; }
public double AutoScanPeriodHours { get; set; } public double AutoScanPeriodHours { get; set; }
@@ -11,13 +11,13 @@ namespace ComiServ.Services
=> MemberwiseClone() as Configuration => MemberwiseClone() as Configuration
//this really shouldn't be possible //this really shouldn't be possible
?? throw new Exception("Failed to clone configuration"); ?? throw new Exception("Failed to clone configuration");
} }
public interface IConfigService public interface IConfigService
{ {
public Configuration Config { get; } public Configuration Config { get; }
} }
public class JsonConfigService : IConfigService public class JsonConfigService : IConfigService
{ {
public Configuration _Config; public Configuration _Config;
//protect original //protect original
public Configuration Config => _Config.Copy(); public Configuration Config => _Config.Copy();
@@ -27,5 +27,4 @@ namespace ComiServ.Services
_Config = JsonSerializer.Deserialize<Configuration>(fileStream) _Config = JsonSerializer.Deserialize<Configuration>(fileStream)
?? throw new ArgumentException("Failed to parse config file"); ?? throw new ArgumentException("Failed to parse config file");
} }
}
} }

View File

@@ -11,27 +11,27 @@ 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))]
public enum PictureFormats
{ {
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PictureFormats
{
Webp, Webp,
Jpg, Jpg,
Png, Png,
Gif, Gif,
Bmp, Bmp,
} }
//never closes stream! //never closes stream!
public interface IPictureConverter public interface IPictureConverter
{ {
public static System.Drawing.Size ThumbnailResolution => new(200, 320); public static System.Drawing.Size ThumbnailResolution => new(200, 320);
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)
@@ -50,10 +50,10 @@ namespace ComiServ.Background
throw new ArgumentException("Cannot handle this format", nameof(format)); throw new ArgumentException("Cannot handle this format", nameof(format));
} }
} }
} }
public class ResharperPictureConverter(bool webpLossless = false) public class ResharperPictureConverter(bool webpLossless = false)
: IPictureConverter : IPictureConverter
{ {
public static IImageFormat ConvertFormatEnum(PictureFormats format) public static IImageFormat ConvertFormatEnum(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.Background namespace ComiServ.Services;
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;
public readonly string Name;
public readonly CancellationToken Token;
protected BaseTaskItem(TaskTypes type, string name, CancellationToken? token = null)
{ {
public readonly TaskTypes Type = type; Type = type;
public readonly string Name = name; Name = name;
public readonly Action<CancellationToken?> Action = action; Token = token ?? CancellationToken.None;
public readonly CancellationToken Token = token ?? CancellationToken.None;
} }
public interface ITaskManager : IDisposable }
//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)
{ {
public void StartTask(TaskItem taskItem); Action = action;
public void ScheduleTask(TaskItem taskItem, TimeSpan interval); }
}
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 void StartTask(SyncTaskItem taskItem);
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);
} }
} }