Reader Bugs + New Features (#1536)

* Updated a typo in manage tasks of Reoccuring -> Recurring

* Fixed a bug in MinimumNumberFromRange where a regex wasn't properly constructed which could skew results.

* Fixed a bug where Volume numbers that were a float wouldn't render correctly in the manga reader menu.

* Added the ability to double click on the image to bookmark it. Optimized the bookmark and unbookmark flows to remove 2 DB calls and reworked some flow of calls to speed it up.

Fixed some logic where when using double (manga) flow, both of the images wouldn't show the bookmark effect, despite both of them being saved. Likewise, fixed a bug where both images weren't updating UI state, so switching from double (manga) to single, the second image wouldn't show as bookmarked without a refresh.

* Double click works perfectly for bookmarking

* Collection cover image chooser will now prompt with all series covers by default.

Reset button is now moved up to the first slot if applicable.

* When a Completed series is fully read by a user, a nightly task will now remove that series from their Want to Read list.

* Added ability to trigger Want to Read cleanup from Tasks page.

* Moved the brightness readout to the label line and fixed a bootstrap migration bug where small buttons weren't actually small.

* Implemented ability to filter against release year (min or max or both).

* Fixed a log message that wasn't properly formatted when scan finished an no files changes.

* Cleaned up some code and merged some methods

* Implemented sort by Release year metadata filter.

* Fixed the code that finds ComicInfo.xml inside archives to only check the root and check explicitly for casing, so it must be ComicInfo.xml.

* Dependency updates

* Refactored some strings into consts and used TriggerJob rather than just enqueuing

* Fixed the prefetcher which wasn't properly loading in the correct order as it was designed.

* Cleaned up all traces of CircularArray from MangaReader

* Removed a debug code

* Fixed a bug with webtoon reader in fullscreen mode where continuous reader wouldn't trigger

* When cleaning up series from users' want to read lists, include both completed and cancelled.

* Fixed a bug where small images wouldn't have the pagination area extend to the bottom on manga reader

* Added a new method for hashing during prod builds and ensure we always use aot

* Fixed a bug where the save button wouldn't enable when color change occured.

* Cleaned up some issues in one of contributor's PR.
This commit is contained in:
Joseph Milazzo 2022-09-16 08:06:33 -05:00 committed by GitHub
parent 52c10510b2
commit 9cf4cf742b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 408 additions and 221 deletions

View File

@ -10,8 +10,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.1" /> <PackageReference Include="BenchmarkDotNet" Version="0.13.2" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.1" /> <PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.2" />
<PackageReference Include="NSubstitute" Version="4.4.0" /> <PackageReference Include="NSubstitute" Version="4.4.0" />
</ItemGroup> </ItemGroup>

View File

@ -7,10 +7,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.1" />
<PackageReference Include="NSubstitute" Version="4.4.0" /> <PackageReference Include="NSubstitute" Version="4.4.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="17.0.24" /> <PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="17.2.1" />
<PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -138,6 +138,9 @@ public class ParserTests
[InlineData("40", 40)] [InlineData("40", 40)]
[InlineData("40a-040b", 0)] [InlineData("40a-040b", 0)]
[InlineData("40.1_a", 0)] [InlineData("40.1_a", 0)]
[InlineData("3.5", 3.5)]
[InlineData("3.5-4.0", 3.5)]
[InlineData("asdfasdf", 0.0)]
public void MinimumNumberFromRangeTest(string input, float expected) public void MinimumNumberFromRangeTest(string input, float expected)
{ {
Assert.Equal(expected, MinNumberFromRange(input)); Assert.Equal(expected, MinNumberFromRange(input));
@ -151,6 +154,9 @@ public class ParserTests
[InlineData("40", 40)] [InlineData("40", 40)]
[InlineData("40a-040b", 0)] [InlineData("40a-040b", 0)]
[InlineData("40.1_a", 0)] [InlineData("40.1_a", 0)]
[InlineData("3.5", 3.5)]
[InlineData("3.5-4.0", 4.0)]
[InlineData("asdfasdf", 0.0)]
public void MaximumNumberFromRangeTest(string input, float expected) public void MaximumNumberFromRangeTest(string input, float expected)
{ {
Assert.Equal(expected, MaxNumberFromRange(input)); Assert.Equal(expected, MaxNumberFromRange(input));

View File

@ -39,7 +39,7 @@ public class ArchiveServiceTests
{ {
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives");
var file = Path.Join(testDirectory, archivePath); var file = Path.Join(testDirectory, archivePath);
using ZipArchive archive = ZipFile.OpenRead(file); using var archive = ZipFile.OpenRead(file);
Assert.Equal(expected, _archiveService.ArchiveNeedsFlattening(archive)); Assert.Equal(expected, _archiveService.ArchiveNeedsFlattening(archive));
} }
@ -279,15 +279,16 @@ public class ArchiveServiceTests
var comicInfo = _archiveService.GetComicInfo(archive); var comicInfo = _archiveService.GetComicInfo(archive);
Assert.NotNull(comicInfo); Assert.NotNull(comicInfo);
Assert.Equal(comicInfo.Publisher, "Yen Press"); Assert.Equal("Yen Press", comicInfo.Publisher);
Assert.Equal(comicInfo.Genre, "Manga, Movies & TV"); Assert.Equal("Manga, Movies & TV", comicInfo.Genre);
Assert.Equal(comicInfo.Summary, "By all counts, Ryouta Sakamoto is a loser when he's not holed up in his room, bombing things into oblivion in his favorite online action RPG. But his very own uneventful life is blown to pieces when he's abducted and taken to an uninhabited island, where he soon learns the hard way that he's being pitted against others just like him in a explosives-riddled death match! How could this be happening? Who's putting them up to this? And why!? The name, not to mention the objective, of this very real survival game is eerily familiar to Ryouta, who has mastered its virtual counterpart-BTOOOM! Can Ryouta still come out on top when he's playing for his life!?"); Assert.Equal("By all counts, Ryouta Sakamoto is a loser when he's not holed up in his room, bombing things into oblivion in his favorite online action RPG. But his very own uneventful life is blown to pieces when he's abducted and taken to an uninhabited island, where he soon learns the hard way that he's being pitted against others just like him in a explosives-riddled death match! How could this be happening? Who's putting them up to this? And why!? The name, not to mention the objective, of this very real survival game is eerily familiar to Ryouta, who has mastered its virtual counterpart-BTOOOM! Can Ryouta still come out on top when he's playing for his life!?",
Assert.Equal(comicInfo.PageCount, 194); comicInfo.Summary);
Assert.Equal(comicInfo.LanguageISO, "en"); Assert.Equal(194, comicInfo.PageCount);
Assert.Equal(comicInfo.Notes, "Scraped metadata from Comixology [CMXDB450184]"); Assert.Equal("en", comicInfo.LanguageISO);
Assert.Equal(comicInfo.Series, "BTOOOM!"); Assert.Equal("Scraped metadata from Comixology [CMXDB450184]", comicInfo.Notes);
Assert.Equal(comicInfo.Title, "v01"); Assert.Equal("BTOOOM!", comicInfo.Series);
Assert.Equal(comicInfo.Web, "https://www.comixology.com/BTOOOM/digital-comic/450184"); Assert.Equal("v01", comicInfo.Title);
Assert.Equal("https://www.comixology.com/BTOOOM/digital-comic/450184", comicInfo.Web);
} }
#endregion #endregion

View File

@ -52,46 +52,46 @@
<PackageReference Include="ExCSS" Version="4.1.0" /> <PackageReference Include="ExCSS" Version="4.1.0" />
<PackageReference Include="Flurl" Version="3.0.6" /> <PackageReference Include="Flurl" Version="3.0.6" />
<PackageReference Include="Flurl.Http" Version="3.2.4" /> <PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hangfire" Version="1.7.30" /> <PackageReference Include="Hangfire" Version="1.7.31" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.30" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.7.31" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" /> <PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" /> <PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.3.2" /> <PackageReference Include="Hangfire.Storage.SQLite" Version="0.3.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.43" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.46" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" /> <PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.9" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.0" /> <PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.1" />
<PackageReference Include="NetVips" Version="2.2.0" /> <PackageReference Include="NetVips" Version="2.2.0" />
<PackageReference Include="NetVips.Native" Version="8.13.0" /> <PackageReference Include="NetVips.Native" Version="8.13.1" />
<PackageReference Include="NReco.Logging.File" Version="1.1.5" /> <PackageReference Include="NReco.Logging.File" Version="1.1.5" />
<PackageReference Include="Serilog" Version="2.11.0" /> <PackageReference Include="Serilog" Version="2.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="6.0.1" /> <PackageReference Include="Serilog.AspNetCore" Version="6.0.1" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="5.0.1" /> <PackageReference Include="Serilog.Extensions.Hosting" Version="5.0.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="3.4.0" />
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" /> <PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" /> <PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.32.2" /> <PackageReference Include="SharpCompress" Version="0.32.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" /> <PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.43.0.51858"> <PackageReference Include="SonarAnalyzer.CSharp" Version="8.44.0.52574">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
<PackageReference Include="System.Drawing.Common" Version="6.0.0" /> <PackageReference Include="System.Drawing.Common" Version="6.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.22.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.23.1" />
<PackageReference Include="System.IO.Abstractions" Version="17.0.24" /> <PackageReference Include="System.IO.Abstractions" Version="17.2.1" />
<PackageReference Include="VersOne.Epub" Version="3.1.2" /> <PackageReference Include="VersOne.Epub" Version="3.2.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -61,13 +61,10 @@ public class BookController : BaseApiController
break; break;
} }
case MangaFormat.Image: case MangaFormat.Image:
break;
case MangaFormat.Archive: case MangaFormat.Archive:
break;
case MangaFormat.Unknown: case MangaFormat.Unknown:
break;
default: default:
throw new ArgumentOutOfRangeException(); break;
} }
return Ok(new BookInfoDto() return Ok(new BookInfoDto()

View File

@ -17,6 +17,7 @@ using Hangfire;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
namespace API.Controllers; namespace API.Controllers;
@ -657,6 +658,7 @@ public class ReaderController : BaseApiController
/// <summary> /// <summary>
/// Bookmarks a page against a Chapter /// Bookmarks a page against a Chapter
/// </summary> /// </summary>
/// <remarks>This has a side effect of caching the chapter files to disk</remarks>
/// <param name="bookmarkDto"></param> /// <param name="bookmarkDto"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost("bookmark")] [HttpPost("bookmark")]
@ -669,18 +671,16 @@ public class ReaderController : BaseApiController
if (!await _accountService.HasBookmarkPermission(user)) if (!await _accountService.HasBookmarkPermission(user))
return BadRequest("You do not have permission to bookmark"); return BadRequest("You do not have permission to bookmark");
bookmarkDto.Page = await _readerService.CapPageToChapter(bookmarkDto.ChapterId, bookmarkDto.Page);
var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId); var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId);
if (chapter == null) return BadRequest("Could not find cached image. Reload and try again."); if (chapter == null) return BadRequest("Could not find cached image. Reload and try again.");
bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
var path = _cacheService.GetCachedPagePath(chapter, bookmarkDto.Page); var path = _cacheService.GetCachedPagePath(chapter, bookmarkDto.Page);
if (await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) return BadRequest("Could not save bookmark");
{
BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId));
return Ok();
}
return BadRequest("Could not save bookmark"); BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId));
return Ok();
} }
/// <summary> /// <summary>
@ -693,18 +693,15 @@ public class ReaderController : BaseApiController
{ {
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
if (user == null) return new UnauthorizedResult(); if (user == null) return new UnauthorizedResult();
if (user.Bookmarks == null) return Ok(); if (user.Bookmarks.IsNullOrEmpty()) return Ok();
if (!await _accountService.HasBookmarkPermission(user)) if (!await _accountService.HasBookmarkPermission(user))
return BadRequest("You do not have permission to unbookmark"); return BadRequest("You do not have permission to unbookmark");
if (await _bookmarkService.RemoveBookmarkPage(user, bookmarkDto)) if (!await _bookmarkService.RemoveBookmarkPage(user, bookmarkDto))
{ return BadRequest("Could not remove bookmark");
BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId));
return Ok(); return Ok();
}
return BadRequest("Could not remove bookmark");
} }
/// <summary> /// <summary>

View File

@ -46,6 +46,7 @@ public class ReadingListController : BaseApiController
/// Returns reading lists (paginated) for a given user. /// Returns reading lists (paginated) for a given user.
/// </summary> /// </summary>
/// <param name="includePromoted">Defaults to true</param> /// <param name="includePromoted">Defaults to true</param>
/// <param name="userParams">Pagination parameters</param>
/// <returns></returns> /// <returns></returns>
[HttpPost("lists")] [HttpPost("lists")]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForUser([FromQuery] UserParams userParams, [FromQuery] bool includePromoted = true) public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForUser([FromQuery] UserParams userParams, [FromQuery] bool includePromoted = true)

View File

@ -77,6 +77,19 @@ public class ServerController : BaseApiController
return Ok(); return Ok();
} }
/// <summary>
/// Performs an ad-hoc cleanup of Want To Read, by removing want to read series for users, where the series are fully read and in Completed publication status.
/// </summary>
/// <returns></returns>
[HttpPost("cleanup-want-to-read")]
public ActionResult CleanupWantToRead()
{
_logger.LogInformation("{UserName} is clearing running want to read cleanup from admin dashboard", User.GetUsername());
RecurringJob.TriggerJob(API.Services.TaskScheduler.RemoveFromWantToReadTaskId);
return Ok();
}
/// <summary> /// <summary>
/// Performs an ad-hoc backup of the Database /// Performs an ad-hoc backup of the Database
/// </summary> /// </summary>
@ -85,7 +98,7 @@ public class ServerController : BaseApiController
public ActionResult BackupDatabase() public ActionResult BackupDatabase()
{ {
_logger.LogInformation("{UserName} is backing up database of server from admin dashboard", User.GetUsername()); _logger.LogInformation("{UserName} is backing up database of server from admin dashboard", User.GetUsername());
RecurringJob.Trigger("backup"); RecurringJob.TriggerJob(API.Services.TaskScheduler.BackupTaskId);
return Ok(); return Ok();
} }

View File

@ -99,4 +99,8 @@ public class FilterDto
/// An optional name string to filter by. Empty string will ignore. /// An optional name string to filter by. Empty string will ignore.
/// </summary> /// </summary>
public string SeriesNameQuery { get; init; } = string.Empty; public string SeriesNameQuery { get; init; } = string.Empty;
/// <summary>
/// An optional release year to filter by. Null will ignore. You can pass 0 for an individual field to ignore it.
/// </summary>
public Range<int>? ReleaseYearRange { get; init; } = null;
} }

View File

@ -0,0 +1,14 @@
namespace API.DTOs.Filtering;
/// <summary>
/// Represents a range between two int/float/double
/// </summary>
public class Range<T>
{
public T Min { get; set; }
public T Max { get; set; }
public override string ToString()
{
return $"{Min}-{Max}";
}
}

View File

@ -21,5 +21,9 @@ public enum SortField
/// <summary> /// <summary>
/// Time it takes to read. Uses Average. /// Time it takes to read. Uses Average.
/// </summary> /// </summary>
TimeToRead = 5 TimeToRead = 5,
/// <summary>
/// Release Year of the Series
/// </summary>
ReleaseYear = 6
} }

View File

@ -63,7 +63,7 @@ public class ChapterRepository : IChapterRepository
.Join(_context.Volume, c => c.VolumeId, v => v.Id, (chapter, volume) => new .Join(_context.Volume, c => c.VolumeId, v => v.Id, (chapter, volume) => new
{ {
ChapterNumber = chapter.Range, ChapterNumber = chapter.Range,
VolumeNumber = volume.Number, VolumeNumber = volume.Name,
VolumeId = volume.Id, VolumeId = volume.Id,
chapter.IsSpecial, chapter.IsSpecial,
chapter.TitleName, chapter.TitleName,

View File

@ -605,7 +605,7 @@ public class SeriesRepository : ISeriesRepository
private IList<MangaFormat> ExtractFilters(int libraryId, int userId, FilterDto filter, ref List<int> userLibraries, private IList<MangaFormat> ExtractFilters(int libraryId, int userId, FilterDto filter, ref List<int> userLibraries,
out List<int> allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter, out List<int> allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter,
out bool hasRatingFilter, out bool hasProgressFilter, out IList<int> seriesIds, out bool hasAgeRating, out bool hasTagsFilter, out bool hasRatingFilter, out bool hasProgressFilter, out IList<int> seriesIds, out bool hasAgeRating, out bool hasTagsFilter,
out bool hasLanguageFilter, out bool hasPublicationFilter, out bool hasSeriesNameFilter) out bool hasLanguageFilter, out bool hasPublicationFilter, out bool hasSeriesNameFilter, out bool hasReleaseYearMinFilter, out bool hasReleaseYearMaxFilter)
{ {
var formats = filter.GetSqlFilter(); var formats = filter.GetSqlFilter();
@ -640,6 +640,9 @@ public class SeriesRepository : ISeriesRepository
hasLanguageFilter = filter.Languages.Count > 0; hasLanguageFilter = filter.Languages.Count > 0;
hasPublicationFilter = filter.PublicationStatus.Count > 0; hasPublicationFilter = filter.PublicationStatus.Count > 0;
hasReleaseYearMinFilter = filter.ReleaseYearRange != null && filter.ReleaseYearRange.Min != 0;
hasReleaseYearMaxFilter = filter.ReleaseYearRange != null && filter.ReleaseYearRange.Max != 0;
bool ProgressComparison(int pagesRead, int totalPages) bool ProgressComparison(int pagesRead, int totalPages)
{ {
@ -731,7 +734,8 @@ public class SeriesRepository : ISeriesRepository
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries, var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter, out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter, out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter,
out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter, out var hasSeriesNameFilter); out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter,
out var hasPublicationFilter, out var hasSeriesNameFilter, out var hasReleaseYearMinFilter, out var hasReleaseYearMaxFilter);
var query = _context.Series var query = _context.Series
.Where(s => userLibraries.Contains(s.LibraryId) .Where(s => userLibraries.Contains(s.LibraryId)
@ -745,6 +749,8 @@ public class SeriesRepository : ISeriesRepository
&& (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating)) && (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating))
&& (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id))) && (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id)))
&& (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language)) && (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language))
&& (!hasReleaseYearMinFilter || s.Metadata.ReleaseYear >= filter.ReleaseYearRange.Min)
&& (!hasReleaseYearMaxFilter || s.Metadata.ReleaseYear <= filter.ReleaseYearRange.Max)
&& (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus))) && (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus)))
.Where(s => !hasSeriesNameFilter || .Where(s => !hasSeriesNameFilter ||
EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%") EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%")
@ -768,6 +774,7 @@ public class SeriesRepository : ISeriesRepository
SortField.LastModifiedDate => query.OrderBy(s => s.LastModified), SortField.LastModifiedDate => query.OrderBy(s => s.LastModified),
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded), SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead), SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
SortField.ReleaseYear => query.OrderBy(s => s.Metadata.ReleaseYear),
_ => query _ => query
}; };
} }
@ -780,6 +787,7 @@ public class SeriesRepository : ISeriesRepository
SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified), SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified),
SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded), SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead), SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead),
SortField.ReleaseYear => query.OrderByDescending(s => s.Metadata.ReleaseYear),
_ => query _ => query
}; };
} }
@ -793,7 +801,8 @@ public class SeriesRepository : ISeriesRepository
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries, var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter, out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter, out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter,
out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter, out var hasSeriesNameFilter); out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter,
out var hasPublicationFilter, out var hasSeriesNameFilter, out var hasReleaseYearMinFilter, out var hasReleaseYearMaxFilter);
var query = sQuery var query = sQuery
.Where(s => userLibraries.Contains(s.LibraryId) .Where(s => userLibraries.Contains(s.LibraryId)
@ -807,6 +816,8 @@ public class SeriesRepository : ISeriesRepository
&& (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating)) && (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating))
&& (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id))) && (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id)))
&& (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language)) && (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language))
&& (!hasReleaseYearMinFilter || s.Metadata.ReleaseYear >= filter.ReleaseYearRange.Min)
&& (!hasReleaseYearMaxFilter || s.Metadata.ReleaseYear <= filter.ReleaseYearRange.Max)
&& (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus))) && (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus)))
.Where(s => !hasSeriesNameFilter || .Where(s => !hasSeriesNameFilter ||
EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%") EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%")
@ -1069,14 +1080,6 @@ public class SeriesRepository : ISeriesRepository
.ToListAsync(); .ToListAsync();
} }
private IQueryable<int> GetLibraryIdsForUser(int userId)
{
return _context.AppUser
.Where(u => u.Id == userId)
.AsSplitQuery()
.SelectMany(l => l.Libraries.Select(lib => lib.Id));
}
public async Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams) public async Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams)
{ {
var libraryIds = GetLibraryIdsForUser(userId, libraryId); var libraryIds = GetLibraryIdsForUser(userId, libraryId);
@ -1219,6 +1222,7 @@ public class SeriesRepository : ISeriesRepository
/// <param name="seriesName"></param> /// <param name="seriesName"></param>
/// <param name="localizedName"></param> /// <param name="localizedName"></param>
/// <param name="libraryId"></param> /// <param name="libraryId"></param>
/// <param name="format"></param>
/// <param name="withFullIncludes">Defaults to true. This will query against all foreign keys (deep). If false, just the series will come back</param> /// <param name="withFullIncludes">Defaults to true. This will query against all foreign keys (deep). If false, just the series will come back</param>
/// <returns></returns> /// <returns></returns>
public Task<Series> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true) public Task<Series> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true)
@ -1375,11 +1379,20 @@ public class SeriesRepository : ISeriesRepository
/// <param name="userId"></param> /// <param name="userId"></param>
/// <param name="libraryId">0 for no library filter</param> /// <param name="libraryId">0 for no library filter</param>
/// <returns></returns> /// <returns></returns>
private IQueryable<int> GetLibraryIdsForUser(int userId, int libraryId) private IQueryable<int> GetLibraryIdsForUser(int userId, int libraryId = 0)
{ {
return _context.AppUser var query = _context.AppUser
.Where(u => u.Id == userId) .AsSplitQuery()
.SelectMany(l => l.Libraries.Where(l => l.Id == libraryId || libraryId == 0).Select(lib => lib.Id)); .AsNoTracking()
.Where(u => u.Id == userId);
if (libraryId == 0)
{
return query.SelectMany(l => l.Libraries.Select(lib => lib.Id));
}
return query.SelectMany(l =>
l.Libraries.Where(lib => lib.Id == libraryId).Select(lib => lib.Id));
} }
public async Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId) public async Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId)

View File

@ -35,6 +35,7 @@ public interface IUserRepository
void Update(AppUser user); void Update(AppUser user);
void Update(AppUserPreferences preferences); void Update(AppUserPreferences preferences);
void Update(AppUserBookmark bookmark); void Update(AppUserBookmark bookmark);
void Add(AppUserBookmark bookmark);
public void Delete(AppUser user); public void Delete(AppUser user);
void Delete(AppUserBookmark bookmark); void Delete(AppUserBookmark bookmark);
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(); Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync();
@ -90,6 +91,11 @@ public class UserRepository : IUserRepository
_context.Entry(bookmark).State = EntityState.Modified; _context.Entry(bookmark).State = EntityState.Modified;
} }
public void Add(AppUserBookmark bookmark)
{
_context.AppUserBookmark.Add(bookmark);
}
public void Delete(AppUser user) public void Delete(AppUser user)
{ {
_context.AppUser.Remove(user); _context.AppUser.Remove(user);
@ -229,7 +235,8 @@ public class UserRepository : IUserRepository
public async Task<IEnumerable<AppUser>> GetAllUsers() public async Task<IEnumerable<AppUser>> GetAllUsers()
{ {
return await _context.AppUser.ToListAsync(); return await _context.AppUser
.ToListAsync();
} }
public async Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId) public async Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId)

View File

@ -82,11 +82,11 @@ namespace API.Helpers.Filters;
// } // }
[AttributeUsage(AttributeTargets.Method)] [AttributeUsage(AttributeTargets.Method)]
public class ETagFilter : Attribute, IActionFilter public class ETagFilterAttribute : Attribute, IActionFilter
{ {
private readonly int[] _statusCodes; private readonly int[] _statusCodes;
public ETagFilter(params int[] statusCodes) public ETagFilterAttribute(params int[] statusCodes)
{ {
_statusCodes = statusCodes; _statusCodes = statusCodes;
if (statusCodes.Length == 0) _statusCodes = new[] { 200 }; if (statusCodes.Length == 0) _statusCodes = new[] { 200 };
@ -94,6 +94,7 @@ public class ETagFilter : Attribute, IActionFilter
public void OnActionExecuting(ActionExecutingContext context) public void OnActionExecuting(ActionExecutingContext context)
{ {
/* Nothing needs to be done here */
} }
public void OnActionExecuted(ActionExecutedContext context) public void OnActionExecuted(ActionExecutedContext context)
@ -101,16 +102,13 @@ public class ETagFilter : Attribute, IActionFilter
if (context.HttpContext.Request.Method != "GET" || context.HttpContext.Request.Method != "HEAD") return; if (context.HttpContext.Request.Method != "GET" || context.HttpContext.Request.Method != "HEAD") return;
if (!_statusCodes.Contains(context.HttpContext.Response.StatusCode)) return; if (!_statusCodes.Contains(context.HttpContext.Response.StatusCode)) return;
var etag = string.Empty;; var etag = string.Empty;
//I just serialize the result to JSON, could do something less costly //I just serialize the result to JSON, could do something less costly
if (context.Result is PhysicalFileResult) if (context.Result is PhysicalFileResult fileResult)
{ {
// Do a cheap LastWriteTime etag gen // Do a cheap LastWriteTime etag gen
if (context.Result is PhysicalFileResult fileResult) etag = ETagGenerator.GenerateEtagFromFilename(fileResult.FileName);
{ context.HttpContext.Response.Headers.LastModified = File.GetLastWriteTimeUtc(fileResult.FileName).ToLongDateString();
etag = ETagGenerator.GenerateEtagFromFilename(fileResult.FileName);
context.HttpContext.Response.Headers.LastModified = File.GetLastWriteTimeUtc(fileResult.FileName).ToLongDateString();
}
} }
if (string.IsNullOrEmpty(etag)) if (string.IsNullOrEmpty(etag))

View File

@ -64,7 +64,7 @@ public static class LogLevelOptions
AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Debug; AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Debug;
break; break;
case "Information": case "Information":
LogLevelSwitch.MinimumLevel = LogEventLevel.Information; LogLevelSwitch.MinimumLevel = LogEventLevel.Error;
MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error; MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error; AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
break; break;
@ -79,7 +79,7 @@ public static class LogLevelOptions
AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error; AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
break; break;
case "Critical": case "Critical":
LogLevelSwitch.MinimumLevel = LogEventLevel.Error; LogLevelSwitch.MinimumLevel = LogEventLevel.Fatal;
MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error; MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error; AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error;
break; break;

View File

@ -19,7 +19,7 @@ public interface IAccountService
Task<IEnumerable<ApiException>> ValidateUsername(string username); Task<IEnumerable<ApiException>> ValidateUsername(string username);
Task<IEnumerable<ApiException>> ValidateEmail(string email); Task<IEnumerable<ApiException>> ValidateEmail(string email);
Task<bool> HasBookmarkPermission(AppUser user); Task<bool> HasBookmarkPermission(AppUser user);
Task<bool> HasDownloadPermission(AppUser appuser); Task<bool> HasDownloadPermission(AppUser user);
} }
public class AccountService : IAccountService public class AccountService : IAccountService

View File

@ -44,7 +44,7 @@ public class ArchiveService : IArchiveService
private readonly ILogger<ArchiveService> _logger; private readonly ILogger<ArchiveService> _logger;
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService; private readonly IImageService _imageService;
private const string ComicInfoFilename = "comicinfo"; private const string ComicInfoFilename = "ComicInfo.xml";
public ArchiveService(ILogger<ArchiveService> logger, IDirectoryService directoryService, IImageService imageService) public ArchiveService(ILogger<ArchiveService> logger, IDirectoryService directoryService, IImageService imageService)
{ {
@ -332,9 +332,8 @@ public class ArchiveService : IArchiveService
{ {
var filenameWithoutExtension = Path.GetFileNameWithoutExtension(name).ToLower(); var filenameWithoutExtension = Path.GetFileNameWithoutExtension(name).ToLower();
return !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(fullName) return !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(fullName)
&& filenameWithoutExtension.Equals(ComicInfoFilename, StringComparison.InvariantCultureIgnoreCase) && fullName.Equals(ComicInfoFilename)
&& !filenameWithoutExtension.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith) && !filenameWithoutExtension.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith);
&& Tasks.Scanner.Parser.Parser.IsXml(name);
} }
/// <summary> /// <summary>

View File

@ -561,8 +561,6 @@ public class BookService : IBookService
var seriesIndex = string.Empty; var seriesIndex = string.Empty;
var series = string.Empty; var series = string.Empty;
var specialName = string.Empty; var specialName = string.Empty;
var groupPosition = string.Empty;
var titleSort = string.Empty;
foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems) foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems)
@ -578,7 +576,6 @@ public class BookService : IBookService
break; break;
case "calibre:title_sort": case "calibre:title_sort":
specialName = metadataItem.Content; specialName = metadataItem.Content;
titleSort = metadataItem.Content;
break; break;
} }
@ -592,7 +589,7 @@ public class BookService : IBookService
series = metadataItem.Content; series = metadataItem.Content;
break; break;
case "collection-type": case "collection-type":
groupPosition = metadataItem.Content; // These look to be genres from https://manual.calibre-ebook.com/sub_groups.html
break; break;
} }
} }
@ -965,7 +962,7 @@ public class BookService : IBookService
} }
catch (Exception) catch (Exception)
{ {
/* Swallow exception. Some css doesn't have style rules ending in ; */ /* Swallow exception. Some css don't have style rules ending in ; */
} }
body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1"); body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1");

View File

@ -79,15 +79,14 @@ public class BookmarkService : IBookmarkService
/// <returns>If the save to DB and copy was successful</returns> /// <returns>If the save to DB and copy was successful</returns>
public async Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark) public async Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark)
{ {
if (userWithBookmarks == null || userWithBookmarks.Bookmarks == null) return false;
try try
{ {
var userBookmark = var userBookmark = userWithBookmarks.Bookmarks.SingleOrDefault(b => b.Page == bookmarkDto.Page && b.ChapterId == bookmarkDto.ChapterId);
await _unitOfWork.UserRepository.GetBookmarkForPage(bookmarkDto.Page, bookmarkDto.ChapterId, userWithBookmarks.Id);
if (userBookmark != null) if (userBookmark != null)
{ {
_logger.LogError("Bookmark already exists for Series {SeriesId}, Volume {VolumeId}, Chapter {ChapterId}, Page {PageNum}", bookmarkDto.SeriesId, bookmarkDto.VolumeId, bookmarkDto.ChapterId, bookmarkDto.Page); _logger.LogError("Bookmark already exists for Series {SeriesId}, Volume {VolumeId}, Chapter {ChapterId}, Page {PageNum}", bookmarkDto.SeriesId, bookmarkDto.VolumeId, bookmarkDto.ChapterId, bookmarkDto.Page);
return false; return true;
} }
var fileInfo = _directoryService.FileSystem.FileInfo.FromFileName(imageToBookmark); var fileInfo = _directoryService.FileSystem.FileInfo.FromFileName(imageToBookmark);
@ -101,14 +100,13 @@ public class BookmarkService : IBookmarkService
VolumeId = bookmarkDto.VolumeId, VolumeId = bookmarkDto.VolumeId,
SeriesId = bookmarkDto.SeriesId, SeriesId = bookmarkDto.SeriesId,
ChapterId = bookmarkDto.ChapterId, ChapterId = bookmarkDto.ChapterId,
FileName = Path.Join(targetFolderStem, fileInfo.Name) FileName = Path.Join(targetFolderStem, fileInfo.Name),
AppUserId = userWithBookmarks.Id
}; };
_directoryService.CopyFileToDirectory(imageToBookmark, targetFilepath); _directoryService.CopyFileToDirectory(imageToBookmark, targetFilepath);
userWithBookmarks.Bookmarks ??= new List<AppUserBookmark>();
userWithBookmarks.Bookmarks.Add(bookmark);
_unitOfWork.UserRepository.Update(userWithBookmarks); _unitOfWork.UserRepository.Add(bookmark);
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
if (settings.ConvertBookmarkToWebP) if (settings.ConvertBookmarkToWebP)
@ -136,15 +134,12 @@ public class BookmarkService : IBookmarkService
public async Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto) public async Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto)
{ {
if (userWithBookmarks.Bookmarks == null) return true; if (userWithBookmarks.Bookmarks == null) return true;
var bookmarkToDelete = userWithBookmarks.Bookmarks.SingleOrDefault(x =>
x.ChapterId == bookmarkDto.ChapterId && x.Page == bookmarkDto.Page);
try try
{ {
var bookmarkToDelete = userWithBookmarks.Bookmarks.SingleOrDefault(x =>
x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == userWithBookmarks.Id && x.Page == bookmarkDto.Page &&
x.SeriesId == bookmarkDto.SeriesId);
if (bookmarkToDelete != null) if (bookmarkToDelete != null)
{ {
await DeleteBookmarkFiles(new[] {bookmarkToDelete});
_unitOfWork.UserRepository.Delete(bookmarkToDelete); _unitOfWork.UserRepository.Delete(bookmarkToDelete);
} }
@ -152,10 +147,10 @@ public class BookmarkService : IBookmarkService
} }
catch (Exception) catch (Exception)
{ {
await _unitOfWork.RollbackAsync();
return false; return false;
} }
await DeleteBookmarkFiles(new[] {bookmarkToDelete});
return true; return true;
} }

View File

@ -368,15 +368,22 @@ public class DirectoryService : IDirectoryService
{ {
var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath); var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath);
if (!di.Exists) return; if (!di.Exists) return;
try
{
foreach (var file in di.EnumerateFiles())
{
file.Delete();
}
foreach (var dir in di.EnumerateDirectories())
{
dir.Delete(true);
}
}
catch (UnauthorizedAccessException ex)
{
_logger.LogError(ex, "[ClearDirectory] Could not delete {DirectoryPath} due to permission issue", directoryPath);
}
foreach (var file in di.EnumerateFiles())
{
file.Delete();
}
foreach (var dir in di.EnumerateDirectories())
{
dir.Delete(true);
}
} }
/// <summary> /// <summary>

View File

@ -128,7 +128,7 @@ public class ImageService : IImageService
return true; return true;
} }
catch (Exception ex) catch (Exception)
{ {
/* Swallow Exception */ /* Swallow Exception */
} }

View File

@ -25,6 +25,7 @@ public interface IReaderService
Task MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable<Chapter> chapters); Task MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable<Chapter> chapters);
Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId); Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId);
Task<int> CapPageToChapter(int chapterId, int page); Task<int> CapPageToChapter(int chapterId, int page);
int CapPageToChapter(Chapter chapter, int page);
Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
Task<ChapterDto> GetContinuePoint(int seriesId, int userId); Task<ChapterDto> GetContinuePoint(int seriesId, int userId);
@ -273,6 +274,21 @@ public class ReaderService : IReaderService
return page; return page;
} }
public int CapPageToChapter(Chapter chapter, int page)
{
if (page > chapter.Pages)
{
page = chapter.Pages;
}
if (page < 0)
{
page = 0;
}
return page;
}
/// <summary> /// <summary>
/// Tries to find the next logical Chapter /// Tries to find the next logical Chapter
/// </summary> /// </summary>

View File

@ -1,14 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Entities.Enums; using API.Entities.Enums;
using API.Helpers.Converters; using API.Helpers.Converters;
using API.Services.Tasks; using API.Services.Tasks;
using API.Services.Tasks.Metadata; using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner;
using Hangfire; using Hangfire;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -49,9 +48,13 @@ public class TaskScheduler : ITaskScheduler
public static BackgroundJobServer Client => new BackgroundJobServer(); public static BackgroundJobServer Client => new BackgroundJobServer();
public const string ScanQueue = "scan"; public const string ScanQueue = "scan";
public const string DefaultQueue = "default"; public const string DefaultQueue = "default";
public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read";
public const string CleanupDbTaskId = "cleanup-db";
public const string CleanupTaskId = "cleanup";
public const string BackupTaskId = "backup";
public const string ScanLibrariesTaskId = "scan-libraries";
public static readonly IList<string> ScanTasks = new List<string>() private static readonly ImmutableArray<string> ScanTasks = ImmutableArray.Create("ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries");
{"ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"};
private static readonly Random Rnd = new Random(); private static readonly Random Rnd = new Random();
@ -83,27 +86,28 @@ public class TaskScheduler : ITaskScheduler
{ {
var scanLibrarySetting = setting; var scanLibrarySetting = setting;
_logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting); _logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting);
RecurringJob.AddOrUpdate("scan-libraries", () => _scannerService.ScanLibraries(), RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => _scannerService.ScanLibraries(),
() => CronConverter.ConvertToCronNotation(scanLibrarySetting), TimeZoneInfo.Local); () => CronConverter.ConvertToCronNotation(scanLibrarySetting), TimeZoneInfo.Local);
} }
else else
{ {
RecurringJob.AddOrUpdate("scan-libraries", () => ScanLibraries(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(), Cron.Daily, TimeZoneInfo.Local);
} }
setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Value; setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Value;
if (setting != null) if (setting != null)
{ {
_logger.LogDebug("Scheduling Backup Task for {Setting}", setting); _logger.LogDebug("Scheduling Backup Task for {Setting}", setting);
RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), TimeZoneInfo.Local); RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), TimeZoneInfo.Local);
} }
else else
{ {
RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), Cron.Weekly, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), Cron.Weekly, TimeZoneInfo.Local);
} }
RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local);
RecurringJob.AddOrUpdate("cleanup-db", () => _cleanupService.CleanupDbEntries(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(CleanupDbTaskId, () => _cleanupService.CleanupDbEntries(), Cron.Daily, TimeZoneInfo.Local);
RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, TimeZoneInfo.Local);
} }
#region StatsTasks #region StatsTasks
@ -154,7 +158,6 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _themeService.Scan()); BackgroundJob.Enqueue(() => _themeService.Scan());
} }
#endregion #endregion
#region UpdateTasks #region UpdateTasks

View File

@ -1,9 +1,14 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Data.Repositories;
using API.DTOs.Filtering;
using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Helpers;
using API.SignalR; using API.SignalR;
using Hangfire; using Hangfire;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
@ -21,6 +26,11 @@ public interface ICleanupService
Task DeleteTagCoverImages(); Task DeleteTagCoverImages();
Task CleanupBackups(); Task CleanupBackups();
void CleanupTemp(); void CleanupTemp();
/// <summary>
/// Responsible to remove Series from Want To Read when user's have fully read the series and the series has Publication Status of Completed or Cancelled.
/// </summary>
/// <returns></returns>
Task CleanupWantToRead();
} }
/// <summary> /// <summary>
/// Cleans up after operations on reoccurring basis /// Cleans up after operations on reoccurring basis
@ -195,4 +205,43 @@ public class CleanupService : ICleanupService
_logger.LogInformation("Temp directory purged"); _logger.LogInformation("Temp directory purged");
} }
public async Task CleanupWantToRead()
{
_logger.LogInformation("Performing cleanup of Series that are Completed and have been fully read that are in Want To Read list");
var libraryIds = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Select(l => l.Id).ToList();
var filter = new FilterDto()
{
PublicationStatus = new List<PublicationStatus>()
{
PublicationStatus.Completed,
PublicationStatus.Cancelled
},
Libraries = libraryIds,
ReadStatus = new ReadStatus()
{
Read = true,
InProgress = false,
NotRead = false
}
};
foreach (var user in await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.WantToRead))
{
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(0, user.Id, new UserParams(), filter);
var seriesIds = series.Select(s => s.Id).ToList();
if (seriesIds.Count == 0) continue;
user.WantToRead ??= new List<Series>();
user.WantToRead = user.WantToRead.Where(s => !seriesIds.Contains(s.Id)).ToList();
_unitOfWork.UserRepository.Update(user);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
}
_logger.LogInformation("Performing cleanup of Series that are Completed and have been fully read that are in Want To Read list, completed");
}
} }

View File

@ -215,6 +215,10 @@ public class ParseScannedFiles
/// <param name="libraryType"></param> /// <param name="libraryType"></param>
/// <param name="folders"></param> /// <param name="folders"></param>
/// <param name="libraryName"></param> /// <param name="libraryName"></param>
/// <param name="isLibraryScan">If true, does a directory scan first (resulting in folders being tackled in parallel), else does an immediate scan files</param>
/// <param name="seriesPaths">A map of Series names -> existing folder paths to handle skipping folders</param>
/// <param name="processSeriesInfos">Action which returns if the folder was skipped and the infos from said folder</param>
/// <param name="forceCheck">Defaults to false</param>
/// <returns></returns> /// <returns></returns>
public async Task ScanLibrariesForSeries(LibraryType libraryType, public async Task ScanLibrariesForSeries(LibraryType libraryType,
IEnumerable<string> folders, string libraryName, bool isLibraryScan, IEnumerable<string> folders, string libraryName, bool isLibraryScan,

View File

@ -1029,7 +1029,7 @@ public static class Parser
{ {
try try
{ {
if (!Regex.IsMatch(range, @"^[\d-.]+$")) if (!Regex.IsMatch(range, @"^[\d\-.]+$"))
{ {
return (float) 0.0; return (float) 0.0;
} }
@ -1047,7 +1047,7 @@ public static class Parser
{ {
try try
{ {
if (!Regex.IsMatch(range, @"^[\d-.]+$")) if (!Regex.IsMatch(range, @"^[\d\-.]+$"))
{ {
return (float) 0.0; return (float) 0.0;
} }

View File

@ -210,13 +210,13 @@ public class ProcessSeries : IProcessSeries
if (!library.Folders.Select(f => f.Path).Contains(seriesDirs.Keys.First())) if (!library.Folders.Select(f => f.Path).Contains(seriesDirs.Keys.First()))
{ {
series.FolderPath = Parser.Parser.NormalizePath(seriesDirs.Keys.First()); series.FolderPath = Parser.Parser.NormalizePath(seriesDirs.Keys.First());
_logger.LogDebug("Updating {Series} FolderPath to {FolderPath}", series.Name, series.FolderPath);
} }
} }
} }
public void EnqueuePostSeriesProcessTasks(int libraryId, int seriesId, bool forceUpdate = false) public void EnqueuePostSeriesProcessTasks(int libraryId, int seriesId, bool forceUpdate = false)
{ {
//BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(libraryId, seriesId, forceUpdate));
BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(libraryId, seriesId, forceUpdate)); BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(libraryId, seriesId, forceUpdate));
} }

View File

@ -25,6 +25,7 @@ public interface IScannerService
/// cover images if forceUpdate is true. /// cover images if forceUpdate is true.
/// </summary> /// </summary>
/// <param name="libraryId">Library to scan against</param> /// <param name="libraryId">Library to scan against</param>
/// <param name="forceUpdate">Don't perform optimization checks, defaults to false</param>
[Queue(TaskScheduler.ScanQueue)] [Queue(TaskScheduler.ScanQueue)]
[DisableConcurrentExecution(60 * 60 * 60)] [DisableConcurrentExecution(60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
@ -396,6 +397,7 @@ public class ScannerService : IScannerService
/// ie) all entities will be rechecked for new cover images and comicInfo.xml changes /// ie) all entities will be rechecked for new cover images and comicInfo.xml changes
/// </summary> /// </summary>
/// <param name="libraryId"></param> /// <param name="libraryId"></param>
/// <param name="forceUpdate">Defaults to false</param>
[Queue(TaskScheduler.ScanQueue)] [Queue(TaskScheduler.ScanQueue)]
[DisableConcurrentExecution(60 * 60 * 60)] [DisableConcurrentExecution(60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
@ -484,7 +486,7 @@ public class ScannerService : IScannerService
{ {
_logger.LogInformation( _logger.LogInformation(
"[ScannerService] Finished library scan of {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}. There were no changes", "[ScannerService] Finished library scan of {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}. There were no changes",
totalFiles, seenSeries.Count, sw.ElapsedMilliseconds, library.Name); seenSeries.Count, sw.ElapsedMilliseconds, library.Name);
} }
else else
{ {

View File

@ -14,7 +14,7 @@
<PackageReference Include="Flurl.Http" Version="3.2.4" /> <PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.43.0.51858"> <PackageReference Include="SonarAnalyzer.CSharp" Version="8.44.0.52574">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

View File

@ -5,7 +5,7 @@
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"prod": "ng build --configuration production", "prod": "ng build --configuration production --aot --output-hashing=all",
"explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json", "explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",

View File

@ -6,6 +6,11 @@ export interface FilterItem<T> {
selected: boolean; selected: boolean;
} }
export interface Range<T> {
min: T;
max: T;
}
export interface SeriesFilter { export interface SeriesFilter {
formats: Array<MangaFormat>; formats: Array<MangaFormat>;
libraries: Array<number>, libraries: Array<number>,
@ -30,6 +35,7 @@ export interface SeriesFilter {
languages: Array<string>; languages: Array<string>;
publicationStatus: Array<number>; publicationStatus: Array<number>;
seriesNameQuery: string; seriesNameQuery: string;
releaseYearRange: Range<number> | null;
} }
export interface SortOptions { export interface SortOptions {
@ -42,7 +48,8 @@ export enum SortField {
Created = 2, Created = 2,
LastModified = 3, LastModified = 3,
LastChapterAdded = 4, LastChapterAdded = 4,
TimeToRead = 5 TimeToRead = 5,
ReleaseYear = 6,
} }
export interface ReadStatus { export interface ReadStatus {

View File

@ -26,6 +26,10 @@ export class ServerService {
return this.httpClient.post(this.baseUrl + 'server/clear-cache', {}); return this.httpClient.post(this.baseUrl + 'server/clear-cache', {});
} }
cleanupWantToRead() {
return this.httpClient.post(this.baseUrl + 'server/cleanup-want-to-read', {});
}
backupDatabase() { backupDatabase() {
return this.httpClient.post(this.baseUrl + 'server/backup-db', {}); return this.httpClient.post(this.baseUrl + 'server/backup-db', {});
} }
@ -42,7 +46,7 @@ export class ServerService {
return this.httpClient.get<boolean>(this.baseUrl + 'server/accessible'); return this.httpClient.get<boolean>(this.baseUrl + 'server/accessible');
} }
getReoccuringJobs() { getRecurringJobs() {
return this.httpClient.get<Job[]>(this.baseUrl + 'server/jobs'); return this.httpClient.get<Job[]>(this.baseUrl + 'server/jobs');
} }

View File

@ -1,6 +1,6 @@
<div class="container-fluid"> <div class="container-fluid">
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined"> <form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<h4>Reoccuring Tasks</h4> <h4>Recurring Tasks</h4>
<div class="mb-3"> <div class="mb-3">
<label for="settings-tasks-scan" class="form-label">Library Scan</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i> <label for="settings-tasks-scan" class="form-label">Library Scan</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>
<ng-template #taskScanTooltip>How often Kavita will scan and refresh metadata around manga files.</ng-template> <ng-template #taskScanTooltip>How often Kavita will scan and refresh metadata around manga files.</ng-template>
@ -43,7 +43,7 @@
</tbody> </tbody>
</table> </table>
<h4>Reoccuring Tasks</h4> <h4>Recurring Tasks</h4>
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
@ -53,7 +53,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let task of reoccuringTasks$ | async; index as i"> <tr *ngFor="let task of recurringTasks$ | async; index as i">
<td> <td>
{{task.title | titlecase}} {{task.title | titlecase}}
</td> </td>

View File

@ -1,10 +1,9 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms'; import { FormGroup, FormControl, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { SettingsService } from '../settings.service'; import { SettingsService } from '../settings.service';
import { ServerSettings } from '../_models/server-settings'; import { ServerSettings } from '../_models/server-settings';
import { catchError, finalize, shareReplay, take, takeWhile } from 'rxjs/operators'; import { shareReplay, take } from 'rxjs/operators';
import { defer, forkJoin, Observable, of } from 'rxjs'; import { defer, forkJoin, Observable, of } from 'rxjs';
import { ServerService } from 'src/app/_services/server.service'; import { ServerService } from 'src/app/_services/server.service';
import { Job } from 'src/app/_models/job/job'; import { Job } from 'src/app/_models/job/job';
@ -32,7 +31,7 @@ export class ManageTasksSettingsComponent implements OnInit {
taskFrequencies: Array<string> = []; taskFrequencies: Array<string> = [];
logLevels: Array<string> = []; logLevels: Array<string> = [];
reoccuringTasks$: Observable<Array<Job>> = of([]); recurringTasks$: Observable<Array<Job>> = of([]);
adhocTasks: Array<AdhocTask> = [ adhocTasks: Array<AdhocTask> = [
{ {
name: 'Convert Bookmarks to WebP', name: 'Convert Bookmarks to WebP',
@ -46,6 +45,12 @@ export class ManageTasksSettingsComponent implements OnInit {
api: this.serverService.clearCache(), api: this.serverService.clearCache(),
successMessage: 'Cache has been cleared' successMessage: 'Cache has been cleared'
}, },
{
name: 'Clean up Want to Read',
description: 'Removes any series that users have fully read that are within want to read and have a publication status of Completed. Runs every 24 hours.',
api: this.serverService.cleanupWantToRead(),
successMessage: 'Want to Read has been cleaned up'
},
{ {
name: 'Backup Database', name: 'Backup Database',
description: 'Takes a backup of the database, bookmarks, themes, manually uploaded covers, and config files', description: 'Takes a backup of the database, bookmarks, themes, manually uploaded covers, and config files',
@ -93,7 +98,7 @@ export class ManageTasksSettingsComponent implements OnInit {
this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required])); this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required]));
}); });
this.reoccuringTasks$ = this.serverService.getReoccuringJobs().pipe(shareReplay()); this.recurringTasks$ = this.serverService.getRecurringJobs().pipe(shareReplay());
} }
resetForm() { resetForm() {
@ -110,7 +115,7 @@ export class ManageTasksSettingsComponent implements OnInit {
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => { this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => {
this.serverSettings = settings; this.serverSettings = settings;
this.resetForm(); this.resetForm();
this.reoccuringTasks$ = this.serverService.getReoccuringJobs().pipe(shareReplay()); this.recurringTasks$ = this.serverService.getRecurringJobs().pipe(shareReplay());
this.toastr.success('Server settings updated'); this.toastr.success('Server settings updated');
}, (err: any) => { }, (err: any) => {
console.error('error: ', err); console.error('error: ', err);

View File

@ -94,6 +94,7 @@ export class EditCollectionTagsComponent implements OnInit {
this.pagination = series.pagination; this.pagination = series.pagination;
this.series = series.result; this.series = series.result;
this.imageUrls.push(...this.series.map(s => this.imageService.getSeriesCoverImage(s.id)));
this.selections = new SelectionModel<Series>(true, this.series); this.selections = new SelectionModel<Series>(true, this.series);
this.isLoading = false; this.isLoading = false;

View File

@ -48,6 +48,16 @@
</form> </form>
<div class="row g-0 chooser" style="padding-top: 10px"> <div class="row g-0 chooser" style="padding-top: 10px">
<div class="image-card col-auto"
*ngIf="showReset" tabindex="0" aria-label="Reset cover image" (click)="reset()"
[ngClass]="{'selected': !showApplyButton && selectedIndex === -1}">
<app-image class="card-img-top" title="Reset Cover Image" height="230px" width="158px" [imageUrl]="imageService.resetCoverImage"></app-image>
<ng-container *ngIf="showApplyButton">
<br>
<button style="width: 100%;" class="btn btn-secondary" aria-label="Reset to generated image" (click)="resetImage()">Reset</button>
</ng-container>
</div>
<div class="image-card col-auto" <div class="image-card col-auto"
*ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)" *ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)"
[ngClass]="{'selected': !showApplyButton && selectedIndex === idx}"> [ngClass]="{'selected': !showApplyButton && selectedIndex === idx}">
@ -60,16 +70,6 @@
</button> </button>
</ng-container> </ng-container>
</div> </div>
<div class="image-card col-auto"
*ngIf="showReset" tabindex="0" aria-label="Reset cover image" (click)="reset()"
[ngClass]="{'selected': !showApplyButton && selectedIndex === -1}">
<app-image class="card-img-top" title="Reset Cover Image" height="230px" width="158px" [imageUrl]="imageService.resetCoverImage"></app-image>
<ng-container *ngIf="showApplyButton">
<br>
<button style="width: 100%;" class="btn btn-secondary" aria-label="Reset to generated image" (click)="resetImage()">Reset</button>
</ng-container>
</div>
</div> </div>
</div> </div>

View File

@ -157,7 +157,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
@Inject(DOCUMENT) private document: Document, private scrollService: ScrollService, @Inject(DOCUMENT) private document: Document, private scrollService: ScrollService,
private readonly cdRef: ChangeDetectorRef) { private readonly cdRef: ChangeDetectorRef) {
// This will always exist at this point in time since this is used within manga reader // This will always exist at this point in time since this is used within manga reader
const reader = document.querySelector('.reader'); const reader = document.querySelector('.reading-area');
if (reader !== null) { if (reader !== null) {
this.readerElemRef = new ElementRef(reader as HTMLDivElement); this.readerElemRef = new ElementRef(reader as HTMLDivElement);
} }
@ -182,7 +182,9 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
* gets promoted to fullscreen. * gets promoted to fullscreen.
*/ */
initScrollHandler() { initScrollHandler() {
console.log('Setting up Scroll handler on ', this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body);
fromEvent(this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body, 'scroll') fromEvent(this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body, 'scroll')
//fromEvent(this.document.body, 'scroll')
.pipe(debounceTime(20), takeUntil(this.onDestroy)) .pipe(debounceTime(20), takeUntil(this.onDestroy))
.subscribe((event) => this.handleScrollEvent(event)); .subscribe((event) => this.handleScrollEvent(event));
} }
@ -263,6 +265,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
*/ */
handleScrollEvent(event?: any) { handleScrollEvent(event?: any) {
const verticalOffset = this.getVerticalOffset(); const verticalOffset = this.getVerticalOffset();
console.log('offset: ', verticalOffset);
if (verticalOffset > this.prevScrollPosition) { if (verticalOffset > this.prevScrollPosition) {
this.scrollingDirection = PAGING_DIRECTION.FORWARD; this.scrollingDirection = PAGING_DIRECTION.FORWARD;

View File

@ -14,11 +14,11 @@
</div> </div>
<div style="margin-left: auto; padding-right: 3%;"> <div style="margin-left: auto; padding-right: 3%;">
<button class="btn btn-icon btn-small" title="Shortcuts" (click)="openShortcutModal()"> <button class="btn btn-icon btn-sm" title="Shortcuts" (click)="openShortcutModal()">
<i class="fa-regular fa-rectangle-list" aria-hidden="true"></i> <i class="fa-regular fa-rectangle-list" aria-hidden="true"></i>
<span class="visually-hidden">Keyboard Shortcuts Modal</span> <span class="visually-hidden">Keyboard Shortcuts Modal</span>
</button> </button>
<button *ngIf="!bookmarkMode && hasBookmarkRights" class="btn btn-icon btn-small" role="checkbox" [attr.aria-checked]="CurrentPageBookmarked" <button *ngIf="!bookmarkMode && hasBookmarkRights" class="btn btn-icon btn-sm" role="checkbox" [attr.aria-checked]="CurrentPageBookmarked"
title="{{CurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}" (click)="bookmarkPage()"> title="{{CurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}" (click)="bookmarkPage()">
<i class="{{CurrentPageBookmarked ? 'fa' : 'far'}} fa-bookmark" aria-hidden="true"></i> <i class="{{CurrentPageBookmarked ? 'fa' : 'far'}} fa-bookmark" aria-hidden="true"></i>
<span class="visually-hidden">{{CurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}</span> <span class="visually-hidden">{{CurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}</span>
@ -32,7 +32,7 @@
</div> </div>
</ng-container> </ng-container>
<div (click)="toggleMenu()" class="reading-area" <div class="reading-area"
[ngStyle]="{'background-color': backgroundColor, 'height': readerMode === ReaderMode.Webtoon ? 'inherit' : 'calc(var(--vh)*100)'}" #readingArea> [ngStyle]="{'background-color': backgroundColor, 'height': readerMode === ReaderMode.Webtoon ? 'inherit' : 'calc(var(--vh)*100)'}" #readingArea>
<ng-container *ngIf="readerMode !== ReaderMode.Webtoon; else webtoon"> <ng-container *ngIf="readerMode !== ReaderMode.Webtoon; else webtoon">
<div class="image-container" [ngClass]="{'d-none': !renderWithCanvas }" [style.filter]="'brightness(' + generalSettingsForm.get('darkness')?.value + '%)'"> <div class="image-container" [ngClass]="{'d-none': !renderWithCanvas }" [style.filter]="'brightness(' + generalSettingsForm.get('darkness')?.value + '%)'">
@ -65,7 +65,7 @@
'fit-to-width-double-offset' : FittingOption === FITTING_OPTION.WIDTH && ShouldRenderDoublePage, 'fit-to-width-double-offset' : FittingOption === FITTING_OPTION.WIDTH && ShouldRenderDoublePage,
'fit-to-height-double-offset': FittingOption === FITTING_OPTION.HEIGHT && ShouldRenderDoublePage, 'fit-to-height-double-offset': FittingOption === FITTING_OPTION.HEIGHT && ShouldRenderDoublePage,
'original-double-offset' : FittingOption === FITTING_OPTION.ORIGINAL && ShouldRenderDoublePage}" 'original-double-offset' : FittingOption === FITTING_OPTION.ORIGINAL && ShouldRenderDoublePage}"
[style.filter]="'brightness(' + generalSettingsForm.get('darkness')?.value + '%)' | safeStyle"> [style.filter]="'brightness(' + generalSettingsForm.get('darkness')?.value + '%)' | safeStyle" (dblclick)="bookmarkPage($event)">
<img #image [src]="canvasImage.src" id="image-1" <img #image [src]="canvasImage.src" id="image-1"
class="{{getFittingOptionClass()}} {{readerMode === ReaderMode.LeftRight || readerMode === ReaderMode.UpDown ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}"> class="{{getFittingOptionClass()}} {{readerMode === ReaderMode.LeftRight || readerMode === ReaderMode.UpDown ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}">
@ -98,8 +98,8 @@
<div class="mb-3" *ngIf="pageOptions != undefined && pageOptions.ceil != undefined"> <div class="mb-3" *ngIf="pageOptions != undefined && pageOptions.ceil != undefined">
<span class="visually-hidden" id="slider-info"></span> <span class="visually-hidden" id="slider-info"></span>
<div class="row g-0"> <div class="row g-0">
<button class="btn btn-small btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter();resetMenuCloseTimer();" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button> <button class="btn btn-sm btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter();resetMenuCloseTimer();" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
<button class="btn btn-small btn-icon col-1" [disabled]="prevPageDisabled || pageNum === 0" (click)="goToPage(0);resetMenuCloseTimer();" title="First Page"><i class="fa fa-step-backward" aria-hidden="true"></i></button> <button class="btn btn-sm btn-icon col-1" [disabled]="prevPageDisabled || pageNum === 0" (click)="goToPage(0);resetMenuCloseTimer();" title="First Page"><i class="fa fa-step-backward" aria-hidden="true"></i></button>
<div class="col custom-slider" *ngIf="pageOptions.ceil > 0; else noSlider"> <div class="col custom-slider" *ngIf="pageOptions.ceil > 0; else noSlider">
<ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" [manualRefresh]="refreshSlider" (userChangeEnd)="sliderPageUpdate($event);startMenuCloseTimer()" (userChange)="sliderDragUpdate($event)" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider> <ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" [manualRefresh]="refreshSlider" (userChangeEnd)="sliderPageUpdate($event);startMenuCloseTimer()" (userChange)="sliderDragUpdate($event)" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
</div> </div>
@ -108,8 +108,8 @@
<ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" (userChangeEnd)="startMenuCloseTimer()" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider> <ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" (userChangeEnd)="startMenuCloseTimer()" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
</div> </div>
</ng-template> </ng-template>
<button class="btn btn-small btn-icon col-2" [disabled]="nextPageDisabled || pageNum >= maxPages - 1" (click)="goToPage(this.maxPages);resetMenuCloseTimer();" title="Last Page"><i class="fa fa-step-forward" aria-hidden="true"></i></button> <button class="btn btn-sm btn-icon col-2" [disabled]="nextPageDisabled || pageNum >= maxPages - 1" (click)="goToPage(this.maxPages);resetMenuCloseTimer();" title="Last Page"><i class="fa fa-step-forward" aria-hidden="true"></i></button>
<button class="btn btn-small btn-icon col-1" [disabled]="nextChapterDisabled" (click)="loadNextChapter();resetMenuCloseTimer();" title="Next Chapter/Volume"><i class="fa fa-fast-forward" aria-hidden="true"></i></button> <button class="btn btn-sm btn-icon col-1" [disabled]="nextChapterDisabled" (click)="loadNextChapter();resetMenuCloseTimer();" title="Next Chapter/Volume"><i class="fa fa-fast-forward" aria-hidden="true"></i></button>
</div> </div>
</div> </div>
<div class="row pt-4 ms-2 me-2"> <div class="row pt-4 ms-2 me-2">
@ -217,10 +217,9 @@
</div> </div>
<div class="row mb-2"> <div class="row mb-2">
<div class="col-md-6 col-sm-12"> <div class="col-md-6 col-sm-12">
<label for="darkness" class="form-label range-label">Brightness</label> <label for="darkness" class="form-label range-label">Brightness</label><span class="ms-1 range-text">{{generalSettingsForm.get('darkness')?.value + '%'}}</span>
<input type="range" class="form-range" id="darkness" <input type="range" class="form-range" id="darkness"
min="10" max="100" step="1" formControlName="darkness"> min="10" max="100" step="1" formControlName="darkness">
<span class="range-text">{{generalSettingsForm.get('darkness')?.value + '%'}}</span>
</div> </div>
</div> </div>
</form> </form>

View File

@ -13,7 +13,6 @@ import { PageSplitOption } from '../_models/preferences/page-split-option';
import { BehaviorSubject, forkJoin, fromEvent, ReplaySubject, Subject } from 'rxjs'; import { BehaviorSubject, forkJoin, fromEvent, ReplaySubject, Subject } from 'rxjs';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { Breakpoint, KEY_CODES, UtilityService } from '../shared/_services/utility.service'; import { Breakpoint, KEY_CODES, UtilityService } from '../shared/_services/utility.service';
import { CircularArray } from '../shared/data-structures/circular-array';
import { MemberService } from '../_services/member.service'; import { MemberService } from '../_services/member.service';
import { Stack } from '../shared/data-structures/stack'; import { Stack } from '../shared/data-structures/stack';
import { ChangeContext, LabelType, Options } from '@angular-slider/ngx-slider'; import { ChangeContext, LabelType, Options } from '@angular-slider/ngx-slider';
@ -27,7 +26,7 @@ import { ShortcutsModalComponent } from '../reader-shared/_modals/shortcuts-moda
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { LayoutMode } from './_models/layout-mode'; import { LayoutMode } from './_models/layout-mode';
const PREFETCH_PAGES = 8; const PREFETCH_PAGES = 10;
const CHAPTER_ID_NOT_FETCHED = -2; const CHAPTER_ID_NOT_FETCHED = -2;
const CHAPTER_ID_DOESNT_EXIST = -1; const CHAPTER_ID_DOESNT_EXIST = -1;
@ -162,10 +161,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
renderWithCanvas: boolean = false; renderWithCanvas: boolean = false;
/** /**
* A circular array of size PREFETCH_PAGES + 2. Maintains prefetched Images around the current page to load from to avoid loading animation. * A circular array of size PREFETCH_PAGES. Maintains prefetched Images around the current page to load from to avoid loading animation.
* @see CircularArray * @see CircularArray
*/ */
cachedImages!: CircularArray<HTMLImageElement>; cachedImages!: Array<HTMLImageElement>;
/** /**
* A stack of the chapter ids we come across during continuous reading mode. When we traverse a boundary, we use this to avoid extra API calls. * A stack of the chapter ids we come across during continuous reading mode. When we traverse a boundary, we use this to avoid extra API calls.
* @see Stack * @see Stack
@ -289,6 +288,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
*/ */
rightPaginationOffset = 0; rightPaginationOffset = 0;
bookmarkPageHandler = this.bookmarkPage.bind(this);
getPageUrl = (pageNum: number) => { getPageUrl = (pageNum: number) => {
if (this.bookmarkMode) return this.readerService.getBookmarkPageUrl(this.seriesId, this.user.apiKey, pageNum); if (this.bookmarkMode) return this.readerService.getBookmarkPageUrl(this.seriesId, this.user.apiKey, pageNum);
return this.readerService.getPageUrl(this.chapterId, pageNum); return this.readerService.getPageUrl(this.chapterId, pageNum);
@ -328,7 +329,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const result = !( const result = !(
this.isCoverImage() this.isCoverImage()
|| this.isCoverImage(this.pageNum - 1) || this.isCoverImage(this.pageNum - 1) // This is because we use prev page and hence the cover will re-show
|| this.isWideImage(this.canvasImage) || this.isWideImage(this.canvasImage)
|| this.isWideImage(this.canvasImageNext) || this.isWideImage(this.canvasImageNext)
); );
@ -357,7 +358,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (this.isWideImage() || this.FittingOption === FITTING_OPTION.WIDTH) { if (this.isWideImage() || this.FittingOption === FITTING_OPTION.WIDTH) {
return this.WindowHeight; return this.WindowHeight;
} }
return this.image?.nativeElement.height + 'px'; return Math.max(this.readingArea?.nativeElement?.clientHeight, this.image?.nativeElement.height) + 'px';
} }
get RightPaginationOffset() { get RightPaginationOffset() {
@ -511,7 +512,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}); });
this.generalSettingsForm.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((changes: SimpleChanges) => { this.generalSettingsForm.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((changes: SimpleChanges) => {
this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value; // TODO: Do I need cd check here? this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value;
const needsSplitting = this.isWideImage(); const needsSplitting = this.isWideImage();
// If we need to split on a menu change, then we need to re-render. // If we need to split on a menu change, then we need to re-render.
if (needsSplitting) { if (needsSplitting) {
@ -542,6 +543,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
fromEvent(this.readingArea.nativeElement, 'click').pipe(debounceTime(200)).subscribe((event: MouseEvent | any) => {
if (event.detail > 1) return;
this.toggleMenu();
});
if (this.canvas) { if (this.canvas) {
this.ctx = this.canvas.nativeElement.getContext('2d', { alpha: false }); this.ctx = this.canvas.nativeElement.getContext('2d', { alpha: false });
this.canvasImage.onload = () => this.renderPage(); this.canvasImage.onload = () => this.renderPage();
@ -678,12 +684,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.inSetup = false; this.inSetup = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
const images = []; this.cachedImages = [];
for (let i = 0; i < PREFETCH_PAGES + 2; i++) { for (let i = 0; i < PREFETCH_PAGES; i++) {
images.push(new Image()); this.cachedImages.push(new Image())
} }
this.cachedImages = new CircularArray<HTMLImageElement>(images, 0);
this.goToPageEvent = new BehaviorSubject<number>(this.pageNum); this.goToPageEvent = new BehaviorSubject<number>(this.pageNum);
this.render(); this.render();
@ -751,14 +756,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
}); });
this.cachedImages = [];
const images = []; for (let i = 0; i < PREFETCH_PAGES; i++) {
for (let i = 0; i < PREFETCH_PAGES + 2; i++) { this.cachedImages.push(new Image());
images.push(new Image());
} }
this.cachedImages = new CircularArray<HTMLImageElement>(images, 0);
this.render(); this.render();
}, () => { }, () => {
@ -1071,7 +1073,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
*/ */
setCanvasImage() { setCanvasImage() {
if (this.layoutMode === LayoutMode.Single) { if (this.layoutMode === LayoutMode.Single) {
const img = this.cachedImages.arr.find(img => this.readerService.imageUrlToPageNum(img.src) === this.pageNum); const img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === this.pageNum);
if (img) { if (img) {
this.canvasImage = img; // If we tried to use this for double, then the loadPage might not render correctly when switching layout mode this.canvasImage = img; // If we tried to use this for double, then the loadPage might not render correctly when switching layout mode
} else { } else {
@ -1294,26 +1296,21 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
/** /**
* Maintains a circular array of images (that are requested from backend) around the user's current page. This allows for quick loading (seemless to user) * Maintains an array of images (that are requested from backend) around the user's current page. This allows for quick loading (seemless to user)
* and also maintains page info (wide image, etc) due to onload event. * and also maintains page info (wide image, etc) due to onload event.
*/ */
prefetch() { prefetch() {
let index = 1; for(let i = 1; i <= PREFETCH_PAGES - 3; i++) {
const numOffset = this.pageNum + i;
if (numOffset > this.maxPages - 1) continue;
this.cachedImages.applyFor((item, _) => { const index = numOffset % this.cachedImages.length;
const offsetIndex = this.pageNum + index; if (this.readerService.imageUrlToPageNum(this.cachedImages[index].src) !== numOffset) {
const urlPageNum = this.readerService.imageUrlToPageNum(item.src); this.cachedImages[index].src = this.getPageUrl(numOffset);
}
}
if (urlPageNum === offsetIndex || urlPageNum === this.pageNum) { //console.log(this.pageNum, ' Prefetched pages: ', this.cachedImages.map(img => this.readerService.imageUrlToPageNum(img.src)));
index += 1;
return;
}
if (offsetIndex < this.maxPages - 1) {
item.src = this.getPageUrl(offsetIndex);
index += 1;
}
}, this.cachedImages.size() - 3);
} }
@ -1490,7 +1487,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
// We must set this here because loadPage from render doesn't call if we aren't page splitting // We must set this here because loadPage from render doesn't call if we aren't page splitting
if (this.readerMode !== ReaderMode.Webtoon) { if (this.readerMode !== ReaderMode.Webtoon) {
this.canvasImage = this.cachedImages.current(); this.canvasImage = this.cachedImages[this.pageNum & this.cachedImages.length];
this.isLoading = true; this.isLoading = true;
} }
@ -1524,7 +1521,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
/** /**
* Bookmarks the current page for the chapter * Bookmarks the current page for the chapter
*/ */
bookmarkPage() { bookmarkPage(event: MouseEvent | undefined = undefined) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
const pageNum = this.pageNum; const pageNum = this.pageNum;
const isDouble = this.layoutMode === LayoutMode.Double || this.layoutMode === LayoutMode.DoubleReversed; const isDouble = this.layoutMode === LayoutMode.Double || this.layoutMode === LayoutMode.DoubleReversed;
@ -1533,40 +1534,42 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (isDouble) apis.push(this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1)); if (isDouble) apis.push(this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1));
forkJoin(apis).pipe(take(1)).subscribe(() => { forkJoin(apis).pipe(take(1)).subscribe(() => {
delete this.bookmarks[pageNum]; delete this.bookmarks[pageNum];
if (isDouble) delete this.bookmarks[pageNum + 1];
}); });
} else { } else {
let apis = [this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)]; let apis = [this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)];
if (isDouble) apis.push(this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1)); if (isDouble) apis.push(this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1));
forkJoin(apis).pipe(take(1)).subscribe(() => { forkJoin(apis).pipe(take(1)).subscribe(() => {
this.bookmarks[pageNum] = 1; this.bookmarks[pageNum] = 1;
if (isDouble) this.bookmarks[pageNum + 1] = 1;
}); });
} }
// Show an effect on the image to show that it was bookmarked // Show an effect on the image to show that it was bookmarked
this.showBookmarkEffectEvent.next(pageNum); this.showBookmarkEffectEvent.next(pageNum);
if (this.readerMode != ReaderMode.Webtoon) { if (this.readerMode === ReaderMode.Webtoon) return;
let elements:Array<Element | ElementRef> = []; let elements:Array<Element | ElementRef> = [];
if (this.renderWithCanvas && this.canvas) { if (this.renderWithCanvas && this.canvas) {
elements.push(this.canvas?.nativeElement); elements.push(this.canvas?.nativeElement);
} else { } else {
const image1 = this.document.querySelector('#image-1'); const image1 = this.document.querySelector('#image-1');
if (image1 != null) elements.push(image1); if (image1 != null) elements.push(image1);
if (this.layoutMode === LayoutMode.Double) { if (this.layoutMode !== LayoutMode.Single) {
const image2 = this.document.querySelector('#image-2'); const image2 = this.document.querySelector('#image-2');
if (image2 != null) elements.push(image2); if (image2 != null) elements.push(image2);
}
}
if (elements.length > 0) {
elements.forEach(elem => this.renderer.addClass(elem, 'bookmark-effect'));
setTimeout(() => {
elements.forEach(elem => this.renderer.removeClass(elem, 'bookmark-effect'));
}, 1000);
} }
} }
if (elements.length > 0) {
elements.forEach(elem => this.renderer.addClass(elem, 'bookmark-effect'));
setTimeout(() => {
elements.forEach(elem => this.renderer.removeClass(elem, 'bookmark-effect'));
}, 1000);
}
} }
/** /**

View File

@ -14,6 +14,7 @@ export class FilterSettings {
languageDisabled = false; languageDisabled = false;
publicationStatusDisabled = false; publicationStatusDisabled = false;
searchNameDisabled = false; searchNameDisabled = false;
releaseYearDisabled = false;
presets: SeriesFilter | undefined; presets: SeriesFilter | undefined;
/** /**
* Should the filter section be open by default * Should the filter section be open by default

View File

@ -325,6 +325,21 @@
</div> </div>
</form> </form>
</div> </div>
<div class="col-md-2 me-3">
<form [formGroup]="releaseYearRange" class="d-flex justify-content-between">
<div class="mb-3">
<label for="release-year-min" class="form-label">Release Year</label>
<input type="text" id="release-year-min" formControlName="min" class="form-control" style="width: 62px" placeholder="Min">
</div>
<div style="margin-top: 37px !important">
<i class="fa-solid fa-minus" aria-hidden="true"></i>
</div>
<div class="mb-3" style="margin-top: 0.5rem">
<label for="release-year-max" class="form-label"><span class="visually-hidden">Max</span></label>
<input type="text" id="release-year-max" formControlName="max" class="form-control" style="width: 62px" placeholder="Max">
</div>
</form>
</div>
<div class="col-md-2 me-3"> <div class="col-md-2 me-3">
<form [formGroup]="sortGroup"> <form [formGroup]="sortGroup">
<div class="mb-3"> <div class="mb-3">
@ -341,11 +356,11 @@
<option [value]="SortField.LastModified">Last Modified</option> <option [value]="SortField.LastModified">Last Modified</option>
<option [value]="SortField.LastChapterAdded">Item Added</option> <option [value]="SortField.LastChapterAdded">Item Added</option>
<option [value]="SortField.TimeToRead">Time to Read</option> <option [value]="SortField.TimeToRead">Time to Read</option>
<option [value]="SortField.ReleaseYear">Release Year</option>
</select> </select>
</div> </div>
</form> </form>
</div> </div>
<div class="col-md-2 me-3"></div>
<div class="col-md-2 me-3 mt-4"> <div class="col-md-2 me-3 mt-4">
<button class="btn btn-secondary col-12" (click)="clear()">Clear</button> <button class="btn btn-secondary col-12" (click)="clear()">Clear</button>
</div> </div>

View File

@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms'; import { FormControl, FormGroup, Validators } from '@angular/forms';
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap'; import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
import { distinctUntilChanged, forkJoin, map, Observable, of, ReplaySubject, Subject, takeUntil } from 'rxjs'; import { distinctUntilChanged, forkJoin, map, Observable, of, ReplaySubject, Subject, takeUntil } from 'rxjs';
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service'; import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
@ -69,6 +69,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
readProgressGroup!: FormGroup; readProgressGroup!: FormGroup;
sortGroup!: FormGroup; sortGroup!: FormGroup;
seriesNameGroup!: FormGroup; seriesNameGroup!: FormGroup;
releaseYearRange!: FormGroup;
isAscendingSort: boolean = true; isAscendingSort: boolean = true;
updateApplied: number = 0; updateApplied: number = 0;
@ -120,6 +121,11 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
seriesNameQuery: new FormControl({value: this.filter.seriesNameQuery || '', disabled: this.filterSettings.searchNameDisabled}, []) seriesNameQuery: new FormControl({value: this.filter.seriesNameQuery || '', disabled: this.filterSettings.searchNameDisabled}, [])
}); });
this.releaseYearRange = new FormGroup({
min: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999)]),
max: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999)])
});
this.readProgressGroup.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(changes => { this.readProgressGroup.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(changes => {
this.filter.readStatus.read = this.readProgressGroup.get('read')?.value; this.filter.readStatus.read = this.readProgressGroup.get('read')?.value;
this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value; this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value;
@ -163,6 +169,15 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
this.releaseYearRange.valueChanges.pipe(
distinctUntilChanged(),
takeUntil(this.onDestroy)
)
.subscribe(changes => {
this.filter.releaseYearRange = {min: this.releaseYearRange.get('min')?.value, max: this.releaseYearRange.get('max')?.value};
this.cdRef.markForCheck();
});
this.loadFromPresetsAndSetup(); this.loadFromPresetsAndSetup();
} }

View File

@ -336,6 +336,7 @@ export class FilterUtilitiesService {
languages: [], languages: [],
publicationStatus: [], publicationStatus: [],
seriesNameQuery: '', seriesNameQuery: '',
releaseYearRange: null
}; };
return data; return data;

View File

@ -58,7 +58,7 @@ import { IconAndTitleComponent } from './icon-and-title/icon-and-title.component
PersonBadgeComponent, // Used Series Detail PersonBadgeComponent, // Used Series Detail
BadgeExpanderComponent, // Used Series Detail/Metadata BadgeExpanderComponent, // Used Series Detail/Metadata
IconAndTitleComponent // Used in Series Detail/Metadata IconAndTitleComponent, // Used in Series Detail/Metadata
], ],
}) })

View File

@ -126,7 +126,7 @@
<label for="settings-backgroundcolor-option" class="form-label">Background Color</label> <label for="settings-backgroundcolor-option" class="form-label">Background Color</label>
<input [value]="user.preferences.backgroundColor" <input [value]="user.preferences.backgroundColor"
class="form-control" class="form-control"
(colorPickerChange)="settingsForm.markAsTouched()" (colorPickerChange)="handleBackgroundColorChange()"
[style.background]="user.preferences.backgroundColor" [style.background]="user.preferences.backgroundColor"
[cpAlphaChannel]="'disabled'" [cpAlphaChannel]="'disabled'"
[(colorPicker)]="user.preferences.backgroundColor"/> [(colorPicker)]="user.preferences.backgroundColor"/>

View File

@ -252,4 +252,10 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
transformKeyToOpdsUrl(key: string) { transformKeyToOpdsUrl(key: string) {
return `${location.origin}/api/opds/${key}`; return `${location.origin}/api/opds/${key}`;
} }
handleBackgroundColorChange() {
this.settingsForm.markAsDirty();
this.settingsForm.markAsTouched();
this.cdRef.markForCheck();
}
} }

View File

@ -204,7 +204,7 @@
--manga-reader-overlay-filter: blur(10px); --manga-reader-overlay-filter: blur(10px);
--manga-reader-overlay-bg-color: rgba(0,0,0,0.5); --manga-reader-overlay-bg-color: rgba(0,0,0,0.5);
--manga-reader-overlay-text-color: white; --manga-reader-overlay-text-color: white;
--manga-reader-bg-color: black; --manga-reader-bg-color: black; // TODO: Remove this
--manga-reader-next-highlight-bg-color: rgba(65, 225, 100, 0.5); --manga-reader-next-highlight-bg-color: rgba(65, 225, 100, 0.5);
--manga-reader-prev-highlight-bg-color: rgba(65, 105, 225, 0.5); --manga-reader-prev-highlight-bg-color: rgba(65, 105, 225, 0.5);