Bugs, Enhancements, and Performance (#580)

* Added parser case for "The Duke of Death and His Black Maid - Ch. 177 - The Ball (3).cbz"

* Removed a file that is created and modified every test run.

* Fixed a bad parser case for "Batman Beyond 02 (of 6) (1999)" which was consuming too many characters

* Removed a lot of "Volume" parsing for Comics that don't make sense. This is prep work for the upcoming Comic Rework release.

* Reworked a lot of parsing cases for comics based on naming conventions observed from releases found online.

* Added a way for external scripts to use a user api key to authenticate

* Fixed an issue if the manga only had one page, the bottom menu would be missing page and chapter controls.

* Fixed a bug where on small phones, nav bar could overflow due to scroll to top

* Tweaked a lot of regex for manga parsing to handle some cases where poorly named files, like "Vol. 03 Ch. 21" would end up parsing as Series "Vol. 03".

* Even more handling of parser cases. Manga parser should be as it was but more robust to handle bad naming.

* Fixed: Don't force metadata refresh on Scan Series, only on refresh metadata

* Implemented the ability to automatically refresh after a series scan based on when server finishes. Remove a duplicate API call from series detail.

* Removed another API call for series metadata that isn't needed.

* Refactored Message creation to a factory, hardcoded strings are centralized, and RefreshSeriesMetadata sends an event and is refactored to be async.

* Fixed a bug when really poorly named files are within a folder that contains the series name, fallback couldn't occur due to it being taken as root folder. Now we detect said condition and will go one level higher, resulting in potentially more I/O, but the series will not be deleted.

* Added the Read in Incognito context item for Chapter cards

* Skip an additional check for series summary for series that aren't EPUB or Archive formats.

* Fixed an issue where cover image generation could occur due to a bad check on LastWriteTime on the underlying file.

* Added some extra comic parser tests

* Added a ScanLibrary event (not hooked up in UI)

* Performance improvement on metadata service. Now when we scan for cover image changes, we emit when a change occurs and only then do we update parent entities (array copy).

* Removed an hr from series detail and ensure we update the cover image for series when scan series finishes.

* Updated the infinite scroller to use a Flags pattern for the debug mode. Updated a few logical conditions for mobile.

* Removed the concurrency check on row progress as if too many calls hit the DB, it will throw, but it doesn't matter.

Fixed a bad logic code which could cause scrolling after hitting the bottom of the chapter.

* Ensure prefetching uses totalPages + 1 since we pass in totalPages as - 1 from manga reader

* Fixed issue where last page of webtoon wouldn't be prefetched due to a < instead of <= on prefetching code

* Implemented ability to send images from archives to the UI without incurring any extra memory pressure.

* Dropdown menus now have a darker background

* Webtoon reader now works on mobile.

* Fixed how keyboard presses for up/down/left/right work with MANGA_UD reading mode. See issue #579

* Fixed cont reader for webtoons on mobile

* Fixed a small issue where top spacer would too quickly switch to prev chapter

* Updated user preferences to use same slider style. Removed some css that is not used.

* Added comic parser case for "Saga 001 (2012) (Digital) (Empire-Zone)"

* Added accessibility toggle to reading list order and aligned sliders to all use the same style.

* Removed a todo for checking on new image serving code. It works great.

* Fixed a missing await

* Auth guard will now check if an existing toast is present giving same message before poping the toast.

* Fixed alignment on phones for reading lists

* Moved sorters so they aren't resused between multiple threads. Slightly higher memory footprint.

* Fixed a broken unit test

* Code smells

* More unit test fixing
This commit is contained in:
Joseph Milazzo 2021-09-15 11:06:29 -07:00 committed by GitHub
parent b62d581491
commit cf4fd2cb9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 685 additions and 336 deletions

View File

@ -30,4 +30,8 @@
<Folder Include="Services\Test Data\ScannerService\Manga" />
</ItemGroup>
<ItemGroup>
<None Remove="Extensions\Test Data\modified on run.txt" />
</ItemGroup>
</Project>

View File

@ -1,3 +0,0 @@
This file should be modified by the unit test08/20/2021 10:26:03
08/20/2021 10:26:29
08/22/2021 12:39:58

View File

@ -23,27 +23,34 @@ namespace API.Tests.Parser
[InlineData("Amazing Man Comics chapter 25", "Amazing Man Comics")]
[InlineData("Amazing Man Comics issue #25", "Amazing Man Comics")]
[InlineData("Teen Titans v1 038 (1972) (c2c).cbr", "Teen Titans")]
[InlineData("Batman Beyond 02 (of 6) (1999)", "Batman Beyond")]
[InlineData("Batman Beyond - Return of the Joker (2001)", "Batman Beyond - Return of the Joker")]
[InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "Invincible")]
[InlineData("Batman Wayne Family Adventures - Ep. 001 - Moving In", "Batman Wayne Family Adventures")]
[InlineData("Saga 001 (2012) (Digital) (Empire-Zone).cbr", "Saga")]
public void ParseComicSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseComicSeries(filename));
}
[Theory]
[InlineData("01 Spider-Man & Wolverine 01.cbr", "1")]
[InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "4")]
[InlineData("01 Spider-Man & Wolverine 01.cbr", "0")]
[InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "0")]
[InlineData("The First Asterix Frieze (WebP by Doc MaKS)", "0")]
[InlineData("Batman & Catwoman - Trail of the Gun 01", "1")]
[InlineData("Batman & Catwoman - Trail of the Gun 01", "0")]
[InlineData("Batman & Daredevil - King of New York", "0")]
[InlineData("Batman & Grendel (1996) 01 - Devil's Bones", "1")]
[InlineData("Batman & Grendel (1996) 01 - Devil's Bones", "0")]
[InlineData("Batman & Robin the Teen Wonder #0", "0")]
[InlineData("Batman & Wildcat (1 of 3)", "0")]
[InlineData("Batman And Superman World's Finest #01", "1")]
[InlineData("Babe 01", "1")]
[InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", "1")]
[InlineData("Batman And Superman World's Finest #01", "0")]
[InlineData("Babe 01", "0")]
[InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", "0")]
[InlineData("Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")]
[InlineData("Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)", "2")]
[InlineData("Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)", "0")]
[InlineData("Superman v1 024 (09-10 1943)", "1")]
[InlineData("Amazing Man Comics chapter 25", "0")]
[InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "0")]
[InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", "0")]
public void ParseComicVolumeTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseComicVolume(filename));
@ -66,6 +73,9 @@ namespace API.Tests.Parser
[InlineData("Superman v1 024 (09-10 1943)", "24")]
[InlineData("Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr", "70.5")]
[InlineData("Amazing Man Comics chapter 25", "25")]
[InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "33.5")]
[InlineData("Batman Wayne Family Adventures - Ep. 014 - Moving In", "14")]
[InlineData("Saga 001 (2012) (Digital) (Empire-Zone)", "1")]
public void ParseComicChapterTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseComicChapter(filename));

View File

@ -159,7 +159,13 @@ namespace API.Tests.Parser
[InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "Hentai Ouji to Warawanai Neko.")]
[InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz", "The 100 Girlfriends Who Really, Really, Really, Really, Really Love You")]
[InlineData("Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 1-10", "Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo")]
[InlineData("The Duke of Death and His Black Maid - Ch. 177 - The Ball (3).cbz", "The Duke of Death and His Black Maid")]
[InlineData("A Compendium of Ghosts - 031 - The Third Story_ Part 12 (Digital) (Cobalt001)", "A Compendium of Ghosts")]
[InlineData("The Duke of Death and His Black Maid - Vol. 04 Ch. 054.5 - V4 Omake", "The Duke of Death and His Black Maid")]
[InlineData("Vol. 04 Ch. 054.5", "")]
[InlineData("Great_Teacher_Onizuka_v16[TheSpectrum]", "Great Teacher Onizuka")]
[InlineData("[Renzokusei]_Kimi_wa_Midara_na_Boku_no_Joou_Ch5_Final_Chapter", "Kimi wa Midara na Boku no Joou")]
[InlineData("Battle Royale, v01 (2000) [TokyoPop] [Manga-Sketchbook]", "Battle Royale")]
public void ParseSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename));
@ -412,6 +418,22 @@ namespace API.Tests.Parser
FullFilePath = filepath, IsSpecial = false
});
filepath = @"E:\Manga\Kono Subarashii Sekai ni Bakuen wo!\Vol. 00 Ch. 000.cbz";
expected.Add(filepath, new ParserInfo
{
Series = "Kono Subarashii Sekai ni Bakuen wo!", Volumes = "0", Edition = "",
Chapters = "0", Filename = "Vol. 00 Ch. 000.cbz", Format = MangaFormat.Archive,
FullFilePath = filepath, IsSpecial = false
});
filepath = @"E:\Manga\Toukyou Akazukin\Vol. 01 Ch. 001.cbz";
expected.Add(filepath, new ParserInfo
{
Series = "Toukyou Akazukin", Volumes = "1", Edition = "",
Chapters = "1", Filename = "Vol. 01 Ch. 001.cbz", Format = MangaFormat.Archive,
FullFilePath = filepath, IsSpecial = false
});
// If an image is cover exclusively, ignore it
filepath = @"E:\Manga\Seraph of the End\cover.png";
expected.Add(filepath, null);

View File

@ -89,6 +89,15 @@ namespace API.Tests.Services
}
[Theory]
[InlineData(new string[] {"C:/Manga/"}, new string[] {"C:/Manga/Love Hina/Vol. 01.cbz"}, "C:/Manga/Love Hina")]
public void FindHighestDirectoriesFromFilesTest(string[] rootDirectories, string[] folders, string expectedDirectory)
{
var actual = DirectoryService.FindHighestDirectoriesFromFiles(rootDirectories, folders);
var expected = new Dictionary<string, string> {{expectedDirectory, ""}};
Assert.Equal(expected, actual);
}
[Theory]
[InlineData("C:/Manga/", "C:/Manga/Love Hina/Specials/Omake/", "Omake,Specials,Love Hina")]
[InlineData("C:/Manga/", "C:/Manga/Love Hina/Specials/Omake", "Omake,Specials,Love Hina")]
@ -102,6 +111,7 @@ namespace API.Tests.Services
[InlineData(@"C:/", @"C://Btooom!/Vol.1 Chapter 2/1.cbz", "Vol.1 Chapter 2,Btooom!")]
[InlineData(@"C:\\", @"C://Btooom!/Vol.1 Chapter 2/1.cbz", "Vol.1 Chapter 2,Btooom!")]
[InlineData(@"C://mount/gdrive/Library/Test Library/Comics", @"C://mount/gdrive/Library/Test Library/Comics/Dragon Age/Test", "Test,Dragon Age")]
[InlineData(@"M:\", @"M:\Toukyou Akazukin\Vol. 01 Ch. 005.cbz", @"Toukyou Akazukin")]
public void GetFoldersTillRoot_Test(string rootPath, string fullpath, string expectedArray)
{
var expected = expectedArray.Split(",");

View File

@ -4,6 +4,8 @@ using API.Entities;
using API.Interfaces;
using API.Interfaces.Services;
using API.Services;
using API.SignalR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
@ -19,10 +21,11 @@ namespace API.Tests.Services
private readonly IBookService _bookService = Substitute.For<IBookService>();
private readonly IArchiveService _archiveService = Substitute.For<IArchiveService>();
private readonly ILogger<MetadataService> _logger = Substitute.For<ILogger<MetadataService>>();
private readonly IHubContext<MessageHub> _messageHub = Substitute.For<IHubContext<MessageHub>>();
public MetadataServiceTests()
{
_metadataService = new MetadataService(_unitOfWork, _logger, _archiveService, _bookService, _imageService);
_metadataService = new MetadataService(_unitOfWork, _logger, _archiveService, _bookService, _imageService, _messageHub);
}
[Fact]
@ -108,5 +111,16 @@ namespace API.Tests.Services
LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime
}, false, false));
}
[Fact]
public void ShouldUpdateCoverImage_OnSecondRun_HasCoverImage_NoForceUpdate_NoLock()
{
Assert.False(MetadataService.ShouldUpdateCoverImage(new byte[] {1}, new MangaFile()
{
FilePath = Path.Join(_testDirectory, "file in folder.zip"),
LastModified = DateTime.Now
}, false, false));
}
}
}

View File

@ -14,8 +14,10 @@ using API.Parser;
using API.Services;
using API.Services.Tasks;
using API.Services.Tasks.Scanner;
using API.SignalR;
using API.Tests.Helpers;
using AutoMapper;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@ -34,6 +36,7 @@ namespace API.Tests.Services
private readonly IImageService _imageService = Substitute.For<IImageService>();
private readonly ILogger<MetadataService> _metadataLogger = Substitute.For<ILogger<MetadataService>>();
private readonly ICacheService _cacheService = Substitute.For<ICacheService>();
private readonly IHubContext<MessageHub> _messageHub = Substitute.For<IHubContext<MessageHub>>();
private readonly DbConnection _connection;
private readonly DataContext _context;
@ -52,8 +55,8 @@ namespace API.Tests.Services
IUnitOfWork unitOfWork = new UnitOfWork(_context, Substitute.For<IMapper>(), null);
IMetadataService metadataService = Substitute.For<MetadataService>(unitOfWork, _metadataLogger, _archiveService, _bookService, _imageService);
_scannerService = new ScannerService(unitOfWork, _logger, _archiveService, metadataService, _bookService, _cacheService);
IMetadataService metadataService = Substitute.For<MetadataService>(unitOfWork, _metadataLogger, _archiveService, _bookService, _imageService, _messageHub);
_scannerService = new ScannerService(unitOfWork, _logger, _archiveService, metadataService, _bookService, _cacheService, _messageHub);
}
private async Task<bool> SeedDb()
@ -111,6 +114,7 @@ namespace API.Tests.Services
Assert.Empty(_scannerService.FindSeriesNotOnDisk(existingSeries, infos));
}
// TODO: Figure out how to do this with ParseScannedFiles
// [Theory]
// [InlineData(new [] {"Darker than Black"}, "Darker than Black", "Darker than Black")]

View File

@ -23,7 +23,7 @@ namespace API.Comparators
{
if (x == y) return 0;
// BUG: Operations that change non-concurrent collections must have exclusive access. A concurrent update was performed on this collection and corrupted its state. The collection's state is no longer correct.
// Should be fixed: Operations that change non-concurrent collections must have exclusive access. A concurrent update was performed on this collection and corrupted its state. The collection's state is no longer correct.
if (!_table.TryGetValue(x ?? Empty, out var x1))
{
x1 = Regex.Split(x ?? Empty, "([0-9]+)");
@ -33,6 +33,7 @@ namespace API.Comparators
if (!_table.TryGetValue(y ?? Empty, out var y1))
{
y1 = Regex.Split(y ?? Empty, "([0-9]+)");
// Should be fixed: EXCEPTION: An item with the same key has already been added. Key: M:\Girls of the Wild's\Girls of the Wild's - Ep. 083 (Season 1) [LINE Webtoon].cbz
_table.Add(y ?? Empty, y1);
}

View File

@ -0,0 +1,45 @@
using System.Threading.Tasks;
using API.DTOs;
using API.Interfaces;
using API.Interfaces.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers
{
public class PluginController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITokenService _tokenService;
private readonly ILogger<PluginController> _logger;
public PluginController(IUnitOfWork unitOfWork, ITokenService tokenService, ILogger<PluginController> logger)
{
_unitOfWork = unitOfWork;
_tokenService = tokenService;
_logger = logger;
}
/// <summary>
/// Authenticate with the Server given an apiKey. This will log you in by returning the user object and the JWT token.
/// </summary>
/// <param name="apiKey"></param>
/// <param name="pluginName">Name of the Plugin</param>
/// <returns></returns>
[HttpPost("authenticate")]
public async Task<ActionResult<UserDto>> Authenticate(string apiKey, string pluginName)
{
// NOTE: In order to log information about plugins, we need some Plugin Description information for each request
// Should log into access table so we can tell the user
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
_logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName, user.UserName, userId);
return new UserDto
{
Username = user.UserName,
Token = await _tokenService.CreateToken(user),
ApiKey = user.ApiKey,
};
}
}
}

View File

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Reader;
@ -21,17 +20,15 @@ namespace API.Controllers
/// </summary>
public class ReaderController : BaseApiController
{
private readonly IDirectoryService _directoryService;
private readonly ICacheService _cacheService;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReaderController> _logger;
private readonly IReaderService _readerService;
/// <inheritdoc />
public ReaderController(IDirectoryService directoryService, ICacheService cacheService,
public ReaderController(ICacheService cacheService,
IUnitOfWork unitOfWork, ILogger<ReaderController> logger, IReaderService readerService)
{
_directoryService = directoryService;
_cacheService = cacheService;
_unitOfWork = unitOfWork;
_logger = logger;
@ -55,14 +52,9 @@ namespace API.Controllers
{
var (path, _) = await _cacheService.GetCachedPagePath(chapter, page);
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}");
var content = await _directoryService.ReadFileAsync(path);
var format = Path.GetExtension(path).Replace(".", "");
// Calculates SHA1 Hash for byte[]
Response.AddCacheHeader(content);
return File(content, "image/" + format);
return PhysicalFile(path, "image/" + format);
}
catch (Exception)
{

View File

@ -190,7 +190,7 @@ namespace API.Controllers
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id);
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true);
return Ok();
}

View File

@ -18,7 +18,6 @@ namespace API.Data.Repositories
{
private readonly DataContext _context;
private readonly IMapper _mapper;
private readonly NaturalSortComparer _naturalSortComparer = new ();
public SeriesRepository(DataContext context, IMapper mapper)
{
_context = context;
@ -118,11 +117,12 @@ namespace API.Data.Repositories
return volumes;
}
private void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
private static void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
{
var sorter = new NaturalSortComparer();
foreach (var v in volumes.Where(vDto => vDto.Number == 0))
{
v.Chapters = v.Chapters.OrderBy(x => x.Range, _naturalSortComparer).ToList();
v.Chapters = v.Chapters.OrderBy(x => x.Range, sorter).ToList();
}
}

View File

@ -9,7 +9,7 @@ namespace API.Entities
/// Represents the progress a single user has on a given Chapter.
/// </summary>
//[Index(nameof(SeriesId), nameof(VolumeId), nameof(ChapterId), nameof(AppUserId), IsUnique = true)]
public class AppUserProgress : IEntityDate, IHasConcurrencyToken
public class AppUserProgress : IEntityDate
{
/// <summary>
/// Id of Entity
@ -55,16 +55,5 @@ namespace API.Entities
/// Last date this was updated
/// </summary>
public DateTime LastModified { get; set; }
/// <inheritdoc />
[ConcurrencyCheck]
public uint RowVersion { get; private set; }
/// <inheritdoc />
public void OnSavingChanges()
{
RowVersion++;
}
}
}

View File

@ -30,9 +30,13 @@ namespace API.Entities
public int ChapterId { get; set; }
// Methods
/// <summary>
/// If the File on disk's last modified time is after what is stored in MangaFile
/// </summary>
/// <returns></returns>
public bool HasFileBeenModified()
{
return !File.GetLastWriteTime(FilePath).Equals(LastModified);
return File.GetLastWriteTime(FilePath) > LastModified;
}
}
}

View File

@ -14,7 +14,7 @@ namespace API.Interfaces
void CleanupChapters(int[] chapterIds);
void RefreshMetadata(int libraryId, bool forceUpdate = true);
void CleanupTemp();
void RefreshSeriesMetadata(int libraryId, int seriesId);
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false);
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
void CancelStatsTasks();
void RunStatCollection();

View File

@ -1,4 +1,5 @@
using API.Entities;
using System.Threading.Tasks;
using API.Entities;
namespace API.Interfaces.Services
{
@ -11,14 +12,14 @@ namespace API.Interfaces.Services
/// <param name="forceUpdate"></param>
void RefreshMetadata(int libraryId, bool forceUpdate = false);
public void UpdateMetadata(Chapter chapter, bool forceUpdate);
public void UpdateMetadata(Volume volume, bool forceUpdate);
public void UpdateMetadata(Series series, bool forceUpdate);
public bool UpdateMetadata(Chapter chapter, bool forceUpdate);
public bool UpdateMetadata(Volume volume, bool forceUpdate);
public bool UpdateMetadata(Series series, bool forceUpdate);
/// <summary>
/// Performs a forced refresh of metatdata just for a series and it's nested entities
/// </summary>
/// <param name="libraryId"></param>
/// <param name="seriesId"></param>
void RefreshMetadataForSeries(int libraryId, int seriesId);
Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false);
}
}
}

View File

@ -12,8 +12,8 @@ namespace API.Interfaces.Services
/// </summary>
/// <param name="libraryId">Library to scan against</param>
/// <param name="forceUpdate">Force overwriting for cover images</param>
void ScanLibrary(int libraryId, bool forceUpdate);
void ScanLibraries();
Task ScanLibrary(int libraryId, bool forceUpdate);
Task ScanLibraries();
Task ScanSeries(int libraryId, int seriesId, bool forceUpdate, CancellationToken token);
}
}

View File

@ -1,6 +1,7 @@

using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
@ -17,7 +18,6 @@ namespace API.Interfaces.Services
private readonly ILogger<ReaderService> _logger;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer();
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger)
{
@ -44,7 +44,8 @@ namespace API.Interfaces.Services
if (userProgress == null)
{
// Create a user object
var userWithProgress = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Progress);
var userWithProgress =
await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Progress);
userWithProgress.Progresses ??= new List<AppUserProgress>();
userWithProgress.Progresses.Add(new AppUserProgress
{
@ -74,7 +75,6 @@ namespace API.Interfaces.Services
}
catch (Exception exception)
{
// When opening a fresh chapter, this seems to fail (sometimes)
_logger.LogError(exception, "Could not save progress");
await _unitOfWork.RollbackAsync();
}
@ -118,7 +118,7 @@ namespace API.Interfaces.Services
if (currentVolume.Number == 0)
{
// Handle specials by sorting on their Filename aka Range
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, _naturalSortComparer), currentChapter.Number);
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, new NaturalSortComparer()), currentChapter.Number);
if (chapterId > 0) return chapterId;
}
@ -169,7 +169,7 @@ namespace API.Interfaces.Services
if (currentVolume.Number == 0)
{
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, _naturalSortComparer).Reverse(), currentChapter.Number);
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, new NaturalSortComparer()).Reverse(), currentChapter.Number);
if (chapterId > 0) return chapterId;
}

View File

@ -102,11 +102,17 @@ namespace API.Parser
@"^(?<Series>.*)( |_)Vol\.?(\d+|tbd)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Mad Chimera World - Volume 005 - Chapter 026.cbz (couldn't figure out how to get Volume negative lookaround working on below regex),
// The Duke of Death and His Black Maid - Vol. 04 Ch. 054.5 - V4 Omake
new Regex(
@"(?<Series>.+?)(\s|_|-)+(?:Vol(ume|\.)?(\s|_|-)+\d+)(\s|_|-)+(?:(Ch|Chapter|Ch)\.?)(\s|_|-)+(?<Chapter>\d+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip, VanDread-v01-c01.zip
new Regex(
@"(?<Series>.*)(\b|_)v(?<Volume>\d+-?\d*)(\s|_|-)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
@"(?<Series>.*)(\b|_)v(?<Volume>\d+-?\d*)(\s|_|-)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA], Black Bullet - v4 c17 [batoto]
new Regex(
@"(?<Series>.*)( - )(?:v|vo|c)\d",
@ -117,11 +123,6 @@ namespace API.Parser
@"(?<Series>.*)(?:, Chapter )(?<Chapter>\d+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Mad Chimera World - Volume 005 - Chapter 026.cbz (couldn't figure out how to get Volume negative lookaround working on below regex)
new Regex(
@"(?<Series>.*)(\s|_|-)(?:Volume(\s|_|-)+\d+)(\s|_|-)+(?:Chapter)(\s|_|-)(?<Chapter>\d+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz
new Regex(
@"(?<Series>.*)(\s|_|-)(?!Vol)(\s|_|-)(?:Chapter)(\s|_|-)(?<Chapter>\d+)",
@ -149,7 +150,7 @@ namespace API.Parser
RegexTimeout),
// Momo The Blood Taker - Chapter 027 Violent Emotion.cbz, Grand Blue Dreaming - SP02 Extra (2019) (Digital) (danke-Empire).cbz
new Regex(
@"(?<Series>.*)(\b|_|-|\s)(?:(chapter(\b|_|-|\s))|sp)\d",
@"^(?<Series>(?!Vol).+?)(?:(ch(apter|\.)(\b|_|-|\s))|sp)\d",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Historys Strongest Disciple Kenichi_v11_c90-98.zip, Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)
@ -294,9 +295,14 @@ namespace API.Parser
@"^(?<Series>.*)(?: |_)i(ssue) #\d+",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Batman Wayne Family Adventures - Ep. 001 - Moving In
new Regex(
@"^(?<Series>.+?)(\s|_|-)?(?:Ep\.?)(\s|_|-)+\d+",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)
new Regex(
@"^(?<Series>.*)(?: \d+)",
@"^(?<Series>.+?)(?: \d+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Batman & Robin the Teen Wonder #0
@ -323,41 +329,44 @@ namespace API.Parser
private static readonly Regex[] ComicVolumeRegex = new[]
{
// 04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)
new Regex(
@"^(?<Volume>\d+) (- |_)?(?<Series>.*(\d{4})?)( |_)(\(|\d+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// 01 Spider-Man & Wolverine 01.cbr
new Regex(
@"^(?<Volume>\d+) (?:- )?(?<Series>.*) (\d+)?",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Batman & Wildcat (1 of 3)
new Regex(
@"(?<Series>.*(\d{4})?)( |_)(?:\((?<Chapter>\d+) of \d+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// // 04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)
// new Regex(
// @"^(?<Volume>\d+) (- |_)?(?<Series>.*(\d{4})?)( |_)(\(|\d+)",
// RegexOptions.IgnoreCase | RegexOptions.Compiled,
// RegexTimeout),
// // 01 Spider-Man & Wolverine 01.cbr
// new Regex(
// @"^(?<Volume>\d+) (?:- )?(?<Series>.*) (\d+)?",
// RegexOptions.IgnoreCase | RegexOptions.Compiled,
// RegexTimeout),
// // Batman & Wildcat (1 of 3)
// new Regex(
// @"(?<Series>.*(\d{4})?)( |_)(?:\((?<Chapter>\d+) of \d+)",
// RegexOptions.IgnoreCase | RegexOptions.Compiled,
// RegexTimeout),
// Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)
new Regex(
@"^(?<Series>.*)(?: |_)v(?<Volume>\d+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)
new Regex(
@"^(?<Series>.*)(?<!c(hapter)|i(ssue))(?<!of)(?: |_)(?<!of )(?<Volume>\d+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// BUG: Negative lookbehind has to be fixed width
// NOTE: The case this is built for does not make much sense.
// new Regex(
// @"^(?<Series>.+?)(?<!c(hapter)|i(ssue))(?<!of)(?: |_)(?<!of )(?<Volume>\d+)",
// RegexOptions.IgnoreCase | RegexOptions.Compiled,
// RegexTimeout),
// Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)
new Regex(
@"^(?<Series>.*)(?<!c(hapter)|i(ssue))(?<!of)(?: (?<Volume>\d+))",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Batman & Robin the Teen Wonder #0
new Regex(
@"^(?<Series>.*)(?: |_)#(?<Volume>\d+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// new Regex(
// @"^(?<Series>.+?)(?<!c(hapter)|i(ssue))(?<!of)(?: (?<Volume>\d+))",
// RegexOptions.IgnoreCase | RegexOptions.Compiled,
// RegexTimeout),
// // Batman & Robin the Teen Wonder #0
// new Regex(
// @"^(?<Series>.*)(?: |_)#(?<Volume>\d+)",
// RegexOptions.IgnoreCase | RegexOptions.Compiled,
// RegexTimeout),
};
private static readonly Regex[] ComicChapterRegex = new[]
@ -387,6 +396,11 @@ namespace API.Parser
@"^(?<Series>.*)(?: |_)(c? ?)(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Saga 001 (2012) (Digital) (Empire-Zone)
new Regex(
@"(?<Series>.+?)(?: |_)(c? ?)(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)\s\(\d{4}",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Amazing Man Comics chapter 25
new Regex(
@"^(?!Vol)(?<Series>.*)( |_)c(hapter)( |_)(?<Chapter>\d*)",
@ -930,6 +944,9 @@ namespace API.Parser
/// <summary>
/// Translates _ -> spaces, trims front and back of string, removes release groups
/// <example>
/// Hippos_the_Great [Digital], -> Hippos the Great
/// </example>
/// </summary>
/// <param name="title"></param>
/// <returns></returns>
@ -942,7 +959,7 @@ namespace API.Parser
title = RemoveSpecialTags(title);
title = title.Replace("_", " ").Trim();
if (title.EndsWith("-"))
if (title.EndsWith("-") || title.EndsWith(","))
{
title = title.Substring(0, title.Length - 1);
}

View File

@ -305,6 +305,44 @@ namespace API.Services
}
/// <summary>
/// Finds the highest directories from a set of MangaFiles
/// </summary>
/// <param name="libraryFolders">List of top level folders which files belong to</param>
/// <param name="filePaths">List of file paths that belong to libraryFolders</param>
/// <returns></returns>
public static Dictionary<string, string> FindHighestDirectoriesFromFiles(IEnumerable<string> libraryFolders, IList<string> filePaths)
{
var stopLookingForDirectories = false;
var dirs = new Dictionary<string, string>();
foreach (var folder in libraryFolders)
{
if (stopLookingForDirectories) break;
foreach (var file in filePaths)
{
if (!file.Contains(folder)) continue;
var parts = GetFoldersTillRoot(folder, file).ToList();
if (parts.Count == 0)
{
// Break from all loops, we done, just scan folder.Path (library root)
dirs.Add(folder, string.Empty);
stopLookingForDirectories = true;
break;
}
var fullPath = Path.Join(folder, parts.Last());
if (!dirs.ContainsKey(fullPath))
{
dirs.Add(fullPath, string.Empty);
}
}
}
return dirs;
}
/// <summary>
/// Recursively scans files and applies an action on them. This uses as many cores the underlying PC has to speed
/// up processing.

View File

@ -14,13 +14,11 @@ namespace API.Services
{
private readonly ILogger<ImageService> _logger;
private readonly IDirectoryService _directoryService;
private readonly NaturalSortComparer _naturalSortComparer;
public ImageService(ILogger<ImageService> logger, IDirectoryService directoryService)
{
_logger = logger;
_directoryService = directoryService;
_naturalSortComparer = new NaturalSortComparer();
}
/// <summary>
@ -38,7 +36,7 @@ namespace API.Services
}
var firstImage = _directoryService.GetFilesWithExtension(directory, Parser.Parser.ImageFileExtensions)
.OrderBy(f => f, _naturalSortComparer).FirstOrDefault();
.OrderBy(f => f, new NaturalSortComparer()).FirstOrDefault();
return firstImage;
}

View File

@ -9,6 +9,8 @@ using API.Entities.Enums;
using API.Extensions;
using API.Interfaces;
using API.Interfaces.Services;
using API.SignalR;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace API.Services
@ -20,6 +22,7 @@ namespace API.Services
private readonly IArchiveService _archiveService;
private readonly IBookService _bookService;
private readonly IImageService _imageService;
private readonly IHubContext<MessageHub> _messageHub;
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
/// <summary>
/// Width of the Thumbnail generation
@ -27,13 +30,14 @@ namespace API.Services
public static readonly int ThumbnailWidth = 320; // 153w x 230h
public MetadataService(IUnitOfWork unitOfWork, ILogger<MetadataService> logger,
IArchiveService archiveService, IBookService bookService, IImageService imageService)
IArchiveService archiveService, IBookService bookService, IImageService imageService, IHubContext<MessageHub> messageHub)
{
_unitOfWork = unitOfWork;
_logger = logger;
_archiveService = archiveService;
_bookService = bookService;
_imageService = imageService;
_messageHub = messageHub;
}
/// <summary>
@ -81,14 +85,17 @@ namespace API.Services
/// </summary>
/// <param name="chapter"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
public void UpdateMetadata(Chapter chapter, bool forceUpdate)
public bool UpdateMetadata(Chapter chapter, bool forceUpdate)
{
var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault();
if (ShouldUpdateCoverImage(chapter.CoverImage, firstFile, forceUpdate, chapter.CoverImageLocked))
{
chapter.CoverImage = GetCoverImage(firstFile);
return true;
}
return false;
}
/// <summary>
@ -96,17 +103,18 @@ namespace API.Services
/// </summary>
/// <param name="volume"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
public void UpdateMetadata(Volume volume, bool forceUpdate)
public bool UpdateMetadata(Volume volume, bool forceUpdate)
{
// We need to check if Volume coverImage matches first chapters if forceUpdate is false
if (volume == null || !ShouldUpdateCoverImage(volume.CoverImage, null, forceUpdate
, false)) return;
, false)) return false;
volume.Chapters ??= new List<Chapter>();
var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).FirstOrDefault();
if (firstChapter == null) return;
if (firstChapter == null) return false;
volume.CoverImage = firstChapter.CoverImage;
return true;
}
/// <summary>
@ -114,9 +122,10 @@ namespace API.Services
/// </summary>
/// <param name="series"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
public void UpdateMetadata(Series series, bool forceUpdate)
public bool UpdateMetadata(Series series, bool forceUpdate)
{
if (series == null) return;
var madeUpdate = false;
if (series == null) return false;
if (ShouldUpdateCoverImage(series.CoverImage, null, forceUpdate, series.CoverImageLocked))
{
series.Volumes ??= new List<Volume>();
@ -129,39 +138,46 @@ namespace API.Services
{
coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting)
.FirstOrDefault(c => !c.IsSpecial)?.CoverImage;
madeUpdate = true;
}
if (!HasCoverImage(coverImage))
{
coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting)
.FirstOrDefault()?.CoverImage;
madeUpdate = true;
}
}
series.CoverImage = firstCover?.CoverImage ?? coverImage;
}
UpdateSeriesSummary(series, forceUpdate);
return UpdateSeriesSummary(series, forceUpdate) || madeUpdate ;
}
private void UpdateSeriesSummary(Series series, bool forceUpdate)
private bool UpdateSeriesSummary(Series series, bool forceUpdate)
{
if (!string.IsNullOrEmpty(series.Summary) && !forceUpdate) return;
if (!string.IsNullOrEmpty(series.Summary) && !forceUpdate) return false;
var isBook = series.Library.Type == LibraryType.Book;
var firstVolume = series.Volumes.FirstWithChapters(isBook);
var firstChapter = firstVolume?.Chapters.GetFirstChapterWithFiles();
var firstFile = firstChapter?.Files.FirstOrDefault();
if (firstFile == null || (!forceUpdate && !firstFile.HasFileBeenModified())) return;
if (Parser.Parser.IsPdf(firstFile.FilePath)) return;
if (firstFile == null || (!forceUpdate && !firstFile.HasFileBeenModified())) return false;
if (Parser.Parser.IsPdf(firstFile.FilePath)) return false;
var summary = Parser.Parser.IsEpub(firstFile.FilePath) ? _bookService.GetSummaryInfo(firstFile.FilePath) : _archiveService.GetSummaryInfo(firstFile.FilePath);
if (string.IsNullOrEmpty(series.Summary))
if (series.Format is MangaFormat.Archive or MangaFormat.Epub)
{
series.Summary = summary;
var summary = Parser.Parser.IsEpub(firstFile.FilePath) ? _bookService.GetSummaryInfo(firstFile.FilePath) : _archiveService.GetSummaryInfo(firstFile.FilePath);
if (!string.IsNullOrEmpty(series.Summary))
{
series.Summary = summary;
firstFile.LastModified = DateTime.Now;
return true;
}
}
firstFile.LastModified = DateTime.Now;
firstFile.LastModified = DateTime.Now; // NOTE: Should I put this here as well since it might not have actually been parsed?
return false;
}
@ -180,17 +196,19 @@ namespace API.Services
_logger.LogInformation("Beginning metadata refresh of {LibraryName}", library.Name);
foreach (var series in library.Series)
{
var volumeUpdated = false;
foreach (var volume in series.Volumes)
{
var chapterUpdated = false;
foreach (var chapter in volume.Chapters)
{
UpdateMetadata(chapter, forceUpdate);
chapterUpdated = UpdateMetadata(chapter, forceUpdate);
}
UpdateMetadata(volume, forceUpdate);
volumeUpdated = UpdateMetadata(volume, chapterUpdated || forceUpdate);
}
UpdateMetadata(series, forceUpdate);
UpdateMetadata(series, volumeUpdated || forceUpdate);
_unitOfWork.SeriesRepository.Update(series);
}
@ -207,7 +225,7 @@ namespace API.Services
/// </summary>
/// <param name="libraryId"></param>
/// <param name="seriesId"></param>
public void RefreshMetadataForSeries(int libraryId, int seriesId)
public async Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false)
{
var sw = Stopwatch.StartNew();
var library = Task.Run(() => _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId)).GetAwaiter().GetResult();
@ -219,23 +237,26 @@ namespace API.Services
return;
}
_logger.LogInformation("Beginning metadata refresh of {SeriesName}", series.Name);
var volumeUpdated = false;
foreach (var volume in series.Volumes)
{
var chapterUpdated = false;
foreach (var chapter in volume.Chapters)
{
UpdateMetadata(chapter, true);
chapterUpdated = UpdateMetadata(chapter, forceUpdate);
}
UpdateMetadata(volume, true);
volumeUpdated = UpdateMetadata(volume, chapterUpdated || forceUpdate);
}
UpdateMetadata(series, true);
UpdateMetadata(series, volumeUpdated || forceUpdate);
_unitOfWork.SeriesRepository.Update(series);
if (_unitOfWork.HasChanges() && Task.Run(() => _unitOfWork.CommitAsync()).Result)
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
{
_logger.LogInformation("Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds);
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.RefreshMetadataEvent(libraryId, seriesId));
}
}
}

View File

@ -141,10 +141,10 @@ namespace API.Services
BackgroundJob.Enqueue(() => DirectoryService.ClearDirectory(tempDirectory));
}
public void RefreshSeriesMetadata(int libraryId, int seriesId)
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false)
{
_logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId);
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId));
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate));
}
public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)

View File

@ -14,7 +14,9 @@ using API.Interfaces;
using API.Interfaces.Services;
using API.Parser;
using API.Services.Tasks.Scanner;
using API.SignalR;
using Hangfire;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace API.Services.Tasks
@ -27,10 +29,11 @@ namespace API.Services.Tasks
private readonly IMetadataService _metadataService;
private readonly IBookService _bookService;
private readonly ICacheService _cacheService;
private readonly IHubContext<MessageHub> _messageHub;
private readonly NaturalSortComparer _naturalSort = new ();
public ScannerService(IUnitOfWork unitOfWork, ILogger<ScannerService> logger, IArchiveService archiveService,
IMetadataService metadataService, IBookService bookService, ICacheService cacheService)
IMetadataService metadataService, IBookService bookService, ICacheService cacheService, IHubContext<MessageHub> messageHub)
{
_unitOfWork = unitOfWork;
_logger = logger;
@ -38,6 +41,7 @@ namespace API.Services.Tasks
_metadataService = metadataService;
_bookService = bookService;
_cacheService = cacheService;
_messageHub = messageHub;
}
[DisableConcurrentExecution(timeoutInSeconds: 360)]
@ -47,7 +51,7 @@ namespace API.Services.Tasks
var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
var library = await _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId, seriesId);
var dirs = FindHighestDirectoriesFromFiles(library, files);
var dirs = DirectoryService.FindHighestDirectoriesFromFiles(library.Folders.Select(f => f.Path), files.Select(f => f.FilePath).ToList());
var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new []{ seriesId });
_logger.LogInformation("Beginning file scan on {SeriesName}", series.Name);
@ -63,6 +67,37 @@ namespace API.Services.Tasks
parsedSeries.Remove(key);
}
if (parsedSeries.Count == 0)
{
// We need to do an additional check for an edge case: If the scan ran and the files do not match the existing Series name, then it is very likely,
// the files have crap naming and if we don't correct, the series will get deleted due to the parser not being able to fallback onto folder parsing as the root
// is the series folder.
var existingFolder = dirs.Keys.FirstOrDefault(key => key.Contains(series.OriginalName));
if (dirs.Keys.Count == 1 && !string.IsNullOrEmpty(existingFolder))
{
dirs = new Dictionary<string, string>();
var path = Path.GetPathRoot(existingFolder);
if (!string.IsNullOrEmpty(path))
{
dirs[path] = string.Empty;
}
}
_logger.LogDebug("{SeriesName} has bad naming convention, forcing rescan at a higher directory.", series.OriginalName);
scanner = new ParseScannedFiles(_bookService, _logger);
parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles2, out var scanElapsedTime2);
totalFiles += totalFiles2;
scanElapsedTime += scanElapsedTime2;
// If a root level folder scan occurs, then multiple series gets passed in and thus we get a unique constraint issue
// Hence we clear out anything but what we selected for
firstSeries = library.Series.FirstOrDefault();
keys = parsedSeries.Keys;
foreach (var key in keys.Where(key => !firstSeries.NameInParserInfo(parsedSeries[key].FirstOrDefault()) || firstSeries?.Format != key.Format))
{
parsedSeries.Remove(key);
}
}
var sw = new Stopwatch();
UpdateLibrary(library, parsedSeries);
@ -74,8 +109,10 @@ namespace API.Services.Tasks
totalFiles, parsedSeries.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, series.Name);
CleanupDbEntities();
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId));
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate));
BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
// Tell UI that this series is done
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId), cancellationToken: token);
}
else
{
@ -83,54 +120,18 @@ namespace API.Services.Tasks
"There was a critical error that resulted in a failed scan. Please check logs and rescan");
await _unitOfWork.RollbackAsync();
}
}
/// <summary>
/// Finds the highest directories from a set of MangaFiles
/// </summary>
/// <param name="library"></param>
/// <param name="files"></param>
/// <returns></returns>
private static Dictionary<string, string> FindHighestDirectoriesFromFiles(Library library, IList<MangaFile> files)
{
var stopLookingForDirectories = false;
var dirs = new Dictionary<string, string>();
foreach (var folder in library.Folders)
{
if (stopLookingForDirectories) break;
foreach (var file in files)
{
if (!file.FilePath.Contains(folder.Path)) continue;
var parts = DirectoryService.GetFoldersTillRoot(folder.Path, file.FilePath).ToList();
if (parts.Count == 0)
{
// Break from all loops, we done, just scan folder.Path (library root)
dirs.Add(folder.Path, string.Empty);
stopLookingForDirectories = true;
break;
}
var fullPath = Path.Join(folder.Path, parts.Last());
if (!dirs.ContainsKey(fullPath))
{
dirs.Add(fullPath, string.Empty);
}
}
}
return dirs;
}
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public void ScanLibraries()
public async Task ScanLibraries()
{
var libraries = Task.Run(() => _unitOfWork.LibraryRepository.GetLibrariesAsync()).Result.ToList();
foreach (var lib in libraries)
{
ScanLibrary(lib.Id, false);
await ScanLibrary(lib.Id, false);
}
}
@ -145,7 +146,7 @@ namespace API.Services.Tasks
/// <param name="forceUpdate"></param>
[DisableConcurrentExecution(360)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public void ScanLibrary(int libraryId, bool forceUpdate)
public async Task ScanLibrary(int libraryId, bool forceUpdate)
{
Library library;
try
@ -188,6 +189,7 @@ namespace API.Services.Tasks
CleanupAbandonedChapters();
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate));
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibrary, MessageFactory.ScanLibraryEvent(libraryId, "complete"));
}
/// <summary>

View File

@ -140,11 +140,7 @@ namespace API.Services.Tasks
connections.AddRange(await _tracker.GetConnectionsForUser(admin));
}
await _messageHub.Clients.Users(admins).SendAsync("UpdateAvailable", new SignalRMessage
{
Name = "UpdateAvailable",
Body = update
});
await _messageHub.Clients.Users(admins).SendAsync(SignalREvents.UpdateVersion, MessageFactory.UpdateVersionEvent(update));
}

View File

@ -0,0 +1,56 @@
using System.Threading;
using API.DTOs.Update;
namespace API.SignalR
{
public static class MessageFactory
{
public static SignalRMessage ScanSeriesEvent(int seriesId)
{
return new SignalRMessage()
{
Name = SignalREvents.ScanSeries,
Body = new
{
SeriesId = seriesId
}
};
}
public static SignalRMessage ScanLibraryEvent(int libraryId, string stage)
{
return new SignalRMessage()
{
Name = SignalREvents.ScanLibrary,
Body = new
{
LibraryId = libraryId,
Stage = stage
}
};
}
public static SignalRMessage RefreshMetadataEvent(int libraryId, int seriesId)
{
return new SignalRMessage()
{
Name = SignalREvents.RefreshMetadata,
Body = new
{
SeriesId = seriesId,
LibraryId = libraryId
}
};
}
public static SignalRMessage UpdateVersionEvent(UpdateNotificationDto update)
{
return new SignalRMessage
{
Name = SignalREvents.UpdateVersion,
Body = update
};
}
}
}

View File

@ -0,0 +1,11 @@
namespace API.SignalR
{
public static class SignalREvents
{
public const string UpdateVersion = "UpdateVersion";
public const string ScanSeries = "ScanSeries";
public const string RefreshMetadata = "RefreshMetadata";
public const string ScanLibrary = "ScanLibrary";
}
}

View File

@ -19,7 +19,7 @@ export class AdminGuard implements CanActivate {
if (this.accountService.hasAdminRole(user)) {
return true;
}
this.toastr.error('You are not authorized to view this page.');
return false;
})

View File

@ -19,7 +19,9 @@ export class AuthGuard implements CanActivate {
if (user) {
return true;
}
this.toastr.error('You are not authorized to view this page.');
if (this.toastr.toasts.filter(toast => toast.message === 'Unauthorized' || toast.message === 'You are not authorized to view this page.').length === 0) {
this.toastr.error('You are not authorized to view this page.');
}
localStorage.setItem(this.urlKey, window.location.pathname);
this.router.navigateByUrl('/libraries');
return false;

View File

@ -0,0 +1,4 @@
export interface ScanLibraryEvent {
libraryId: number;
stage: 'complete';
}

View File

@ -0,0 +1,3 @@
export interface ScanSeriesEvent {
seriesId: number;
}

View File

@ -259,6 +259,12 @@ export class ActionFactoryService {
callback: this.dummyCallback,
requiresAdmin: false
},
{
action: Action.IncognitoRead,
title: 'Read in Incognito',
callback: this.dummyCallback,
requiresAdmin: false
},
{
action: Action.AddToReadingList,
title: 'Add to Reading List',

View File

@ -1,13 +1,18 @@
import { Injectable } from '@angular/core';
import { EventEmitter, Injectable } from '@angular/core';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { User } from '@sentry/angular';
import { BehaviorSubject, ReplaySubject } from 'rxjs';
import { environment } from 'src/environments/environment';
import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component';
import { ScanLibraryEvent } from '../_models/events/scan-library-event';
import { ScanSeriesEvent } from '../_models/events/scan-series-event';
export enum EVENTS {
UpdateAvailable = 'UpdateAvailable'
UpdateAvailable = 'UpdateAvailable',
ScanSeries = 'ScanSeries',
ScanLibrary = 'ScanLibrary',
RefreshMetadata = 'RefreshMetadata',
}
export interface Message<T> {
@ -26,6 +31,9 @@ export class MessageHubService {
private messagesSource = new ReplaySubject<Message<any>>(1);
public messages$ = this.messagesSource.asObservable();
public scanSeries: EventEmitter<ScanSeriesEvent> = new EventEmitter<ScanSeriesEvent>();
public scanLibrary: EventEmitter<ScanLibraryEvent> = new EventEmitter<ScanLibraryEvent>();
constructor(private modalService: NgbModal) { }
createHubConnection(user: User) {
@ -44,6 +52,25 @@ export class MessageHubService {
//console.log('[Hub] Body: ', body);
});
this.hubConnection.on(EVENTS.ScanSeries, resp => {
this.messagesSource.next({
event: EVENTS.ScanSeries,
payload: resp.body
});
this.scanSeries.emit(resp.body);
});
this.hubConnection.on(EVENTS.ScanLibrary, resp => {
this.messagesSource.next({
event: EVENTS.ScanLibrary,
payload: resp.body
});
this.scanLibrary.emit(resp.body);
// if ((resp.body as ScanLibraryEvent).stage === 'complete') {
// this.toastr.
// }
});
this.hubConnection.on(EVENTS.UpdateAvailable, resp => {
this.messagesSource.next({
event: EVENTS.UpdateAvailable,

View File

@ -2,21 +2,21 @@ export enum FITTING_OPTION {
HEIGHT = 'full-height',
WIDTH = 'full-width',
ORIGINAL = 'original'
}
}
export enum SPLIT_PAGE_PART {
NO_SPLIT = 'none',
LEFT_PART = 'left',
RIGHT_PART = 'right'
}
}
export enum PAGING_DIRECTION {
FORWARD = 1,
BACKWARDS = -1,
}
}
export enum COLOR_FILTER {
NONE = '',
SEPIA = 'filter-sepia',
DARK = 'filter-dark'
}
}

View File

@ -1,15 +1,15 @@
<div class="fixed-top overlay" *ngIf="debug">
<div class="fixed-top overlay" *ngIf="showDebugBar()">
<strong>Captures Scroll Events:</strong> {{!this.isScrolling && this.allImagesLoaded}}
<strong>Is Scrolling:</strong> {{isScrollingForwards() ? 'Forwards' : 'Backwards'}} {{this.isScrolling}}
<strong>All Images Loaded:</strong> {{this.allImagesLoaded}}
<strong>Prefetched</strong> {{minPageLoaded}}-{{maxPageLoaded}}
<strong>Current Page:</strong>{{pageNum}}
<strong>Width:</strong> {{webtoonImageWidth}}
<strong>Pages:</strong> {{pageNum}} / {{totalPages}}
<strong>At Top:</strong> {{atTop}}
<strong>At Bottom:</strong> {{atBottom}}
<strong>Total Height:</strong> {{getTotalHeight()}}
<strong>Total Scroll:</strong> {{getTotalScroll()}}
<strong>Scroll Top:</strong> {{getScrollTop()}}
</div>
<div *ngIf="atTop" class="spacer top" role="alert" (click)="loadPrevChapter.emit()">
@ -26,7 +26,7 @@
</div>
</div>
<ng-container *ngFor="let item of webtoonImages | async; let index = index;">
<img src="{{item.src}}" style="display: block" class="mx-auto {{pageNum === item.page && debug ? 'active': ''}}" *ngIf="pageNum >= pageNum - bufferPages && pageNum <= pageNum + bufferPages" rel="nofollow" alt="image" (load)="onImageLoad($event)" id="page-{{item.page}}" [attr.page]="item.page" ondragstart="return false;" onselectstart="return false;">
<img src="{{item.src}}" style="display: block" class="mx-auto {{pageNum === item.page && showDebugOutline() ? 'active': ''}}" *ngIf="pageNum >= pageNum - bufferPages && pageNum <= pageNum + bufferPages" rel="nofollow" alt="image" (load)="onImageLoad($event)" id="page-{{item.page}}" [attr.page]="item.page" ondragstart="return false;" onselectstart="return false;">
</ng-container>
<div *ngIf="atBottom" class="spacer bottom" role="alert" (click)="loadPrevChapter.emit()">
<div>

View File

@ -1,7 +1,7 @@
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { BehaviorSubject, fromEvent, ReplaySubject, Subject } from 'rxjs';
import { debounceTime, take, takeUntil } from 'rxjs/operators';
import { debounceTime, takeUntil } from 'rxjs/operators';
import { ReaderService } from '../../_services/reader.service';
import { PAGING_DIRECTION } from '../_models/reader-enums';
import { WebtoonImage } from '../_models/webtoon-image';
@ -11,6 +11,30 @@ import { WebtoonImage } from '../_models/webtoon-image';
*/
const SPACER_SCROLL_INTO_PX = 200;
/**
* Bitwise enums for configuring how much debug information we want
*/
const enum DEBUG_MODES {
/**
* No Debug information
*/
None = 0,
/**
* Turn on debug logging
*/
Logs = 2,
/**
* Turn on the action bar in UI
*/
ActionBar = 4,
/**
* Turn on Page outline
*/
Outline = 8
}
@Component({
selector: 'app-infinite-scroller',
templateUrl: './infinite-scroller.component.html',
@ -91,9 +115,9 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
*/
previousScrollHeightMinusTop: number = 0;
/**
* Debug mode. Will show extra information
* Debug mode. Will show extra information. Use bitwise (|) operators between different modes to enable different output
*/
debug: boolean = false;
debugMode: DEBUG_MODES = DEBUG_MODES.None;
get minPageLoaded() {
return Math.min(...Object.values(this.imagesLoaded));
@ -105,6 +129,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
private readonly onDestroy = new Subject<void>();
constructor(private readerService: ReaderService, private renderer: Renderer2, private toastr: ToastrService) {}
@ -171,39 +196,52 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
}
getTotalHeight() {
let totalHeight = 0;
document.querySelectorAll('img[id^="page-"]').forEach(img => totalHeight += img.getBoundingClientRect().height);
return totalHeight;
}
getTotalScroll() {
return document.documentElement.offsetHeight + document.documentElement.scrollTop;
}
getScrollTop() {
return document.documentElement.scrollTop
}
checkIfShouldTriggerContinuousReader() {
if (this.isScrolling) return;
if (this.scrollingDirection === PAGING_DIRECTION.FORWARD) {
let totalHeight = 0;
document.querySelectorAll('img[id^="page-"]').forEach(img => totalHeight += img.getBoundingClientRect().height);
const totalScroll = document.documentElement.offsetHeight + document.documentElement.scrollTop;
const totalHeight = this.getTotalHeight();
const totalScroll = this.getTotalScroll();
// If we were at top but have started scrolling down past page 0, remove top spacer
if (this.atTop && this.pageNum > 0) {
this.atTop = false;
}
if (totalScroll === totalHeight) {
// debug mode will add an extra pixel from the image border + (this.debug ? 1 : 0)
if (totalScroll === totalHeight && !this.atBottom) {
this.atBottom = true;
this.setPageNum(this.totalPages);
// Scroll user back to original location
this.previousScrollHeightMinusTop = document.documentElement.scrollTop;
setTimeout(() => document.documentElement.scrollTop = this.previousScrollHeightMinusTop + (SPACER_SCROLL_INTO_PX / 2), 10);
requestAnimationFrame(() => document.documentElement.scrollTop = this.previousScrollHeightMinusTop + (SPACER_SCROLL_INTO_PX / 2));
} else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) {
// This if statement will fire once we scroll into the spacer at all
this.loadNextChapter.emit();
}
} else {
if (document.documentElement.scrollTop === 0 && this.pageNum === 0) {
// < 5 because debug mode and FF (mobile) can report non 0, despite being at 0
if (this.getScrollTop() < 5 && this.pageNum === 0 && !this.atTop) {
this.atBottom = false;
if (this.atTop) {
// If already at top, then we moving on
this.loadPrevChapter.emit();
}
this.atTop = true;
// Scroll user back to original location
this.previousScrollHeightMinusTop = document.documentElement.scrollHeight - document.documentElement.scrollTop;
setTimeout(() => document.documentElement.scrollTop = document.documentElement.scrollHeight - this.previousScrollHeightMinusTop - (SPACER_SCROLL_INTO_PX / 2), 10);
requestAnimationFrame(() => window.scrollTo(0, SPACER_SCROLL_INTO_PX));
} else if (this.getScrollTop() < 5 && this.pageNum === 0 && this.atTop) {
// If already at top, then we moving on
this.loadPrevChapter.emit();
}
}
@ -233,7 +271,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
this.imagesLoaded = {};
this.webtoonImages.next([]);
this.atBottom = false;
//this.atTop = document.documentElement.scrollTop === 0 && this.pageNum === 0;
this.checkIfShouldTriggerContinuousReader();
const [startingIndex, endingIndex] = this.calculatePrefetchIndecies();
@ -411,7 +448,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
if (startingIndex === 0 && endingIndex === 0) { return; }
this.debugLog('\t[PREFETCH] prefetching pages: ' + startingIndex + ' to ' + endingIndex);
for(let i = startingIndex; i < endingIndex; i++) {
for(let i = startingIndex; i <= endingIndex; i++) {
this.loadWebtoonImage(i);
}
@ -424,7 +461,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
}
debugLog(message: string, extraData?: any) {
if (!this.debug) { return; }
if (!(this.debugMode & DEBUG_MODES.Logs)) return;
if (extraData !== undefined) {
console.log(message, extraData);
@ -432,4 +469,12 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
console.log(message);
}
}
showDebugBar() {
return this.debugMode & DEBUG_MODES.ActionBar;
}
showDebugOutline() {
return this.debugMode & DEBUG_MODES.Outline;
}
}

View File

@ -30,7 +30,7 @@
<div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading">
<app-infinite-scroller [pageNum]="pageNum" [bufferPages]="5" [goToPage]="goToPageEvent" (pageNumberChange)="handleWebtoonPageChange($event)" [totalPages]="maxPages - 1" [urlProvider]="getPageUrl" (loadNextChapter)="loadNextChapter()" (loadPrevChapter)="loadPrevChapter()"></app-infinite-scroller>
</div>
<ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD"> <!--; else webtoonClickArea; See if people want this mode WEBTOON_WITH_CLICKS-->
<ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD">
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'top'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"></div>
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'left' : 'bottom'}} {{clickOverlayClass('left')}}" (click)="handlePageChange($event, 'left')"></div>
</ng-container>
@ -43,14 +43,19 @@
</div>
<div class="fixed-bottom overlay" *ngIf="menuOpen" [@slideFromBottom]="menuOpen">
<div class="form-group" *ngIf="pageOptions != undefined && pageOptions.ceil != undefined && pageOptions.ceil > 0">
<div class="form-group" *ngIf="pageOptions != undefined && pageOptions.ceil != undefined">
<span class="sr-only" id="slider-info"></span>
<div class="row no-gutters">
<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-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>
<div class="col custom-slider">
<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()" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
</div>
<ng-template #noSlider>
<div class="col custom-slider">
<ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" (userChangeEnd)="startMenuCloseTimer()" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
</div>
</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-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>
</div>
@ -59,7 +64,7 @@
</div>
<div class="row pt-4 ml-2 mr-2">
<div class="col">
<button class="btn btn-icon" (click)="setReadingDirection();resetMenuCloseTimer();" [disabled]="readerMode === READER_MODE.WEBTOON" aria-describedby="reading-direction" title="Reading Direction: {{readingDirection === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}">
<button class="btn btn-icon" (click)="setReadingDirection();resetMenuCloseTimer();" [disabled]="readerMode === READER_MODE.WEBTOON || readerMode === READER_MODE.MANGA_UD" aria-describedby="reading-direction" title="Reading Direction: {{readingDirection === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}">
<i class="fa fa-angle-double-{{readingDirection === ReadingDirection.LeftToRight ? 'right' : 'left'}}" aria-hidden="true"></i>
<span id="reading-direction" class="sr-only">{{readingDirection === ReadingDirection.LeftToRight ? 'Left to Right' : 'Right to Left'}}</span>
</button>

View File

@ -92,20 +92,6 @@ canvas {
}
.center-menu {
position: fixed;
top: 10px;
left: $side-width;
width: $center-width;
border-bottom: $dash-width dashed $secondary-color;
text-align: center;
z-index: 2;
padding-bottom: 10px;
padding-left: 10px;
padding-right: 10px;
}
.right {
position: fixed;
right: 0px;

View File

@ -347,11 +347,26 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
@HostListener('window:keyup', ['$event'])
handleKeyPress(event: KeyboardEvent) {
if (event.key === KEY_CODES.RIGHT_ARROW || event.key === KEY_CODES.DOWN_ARROW) {
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage();
} else if (event.key === KEY_CODES.LEFT_ARROW || event.key === KEY_CODES.UP_ARROW) {
this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage();
} else if (event.key === KEY_CODES.ESC_KEY) {
switch (this.readerMode) {
case READER_MODE.MANGA_LR:
if (event.key === KEY_CODES.RIGHT_ARROW) {
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage();
} else if (event.key === KEY_CODES.LEFT_ARROW) {
this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage();
}
break;
case READER_MODE.MANGA_UD:
case READER_MODE.WEBTOON:
if (event.key === KEY_CODES.DOWN_ARROW) {
this.nextPage()
} else if (event.key === KEY_CODES.UP_ARROW) {
this.prevPage()
}
break;
}
if (event.key === KEY_CODES.ESC_KEY) {
if (this.menuOpen) {
this.toggleMenu();
event.stopPropagation();
@ -427,7 +442,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
});
// ! Should I move the prefetching code if we start in webtoon reader mode?
const images = [];
for (let i = 0; i < PREFETCH_PAGES + 2; i++) {
images.push(new Image());
@ -967,6 +982,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
saveSettings() {
// NOTE: This is not called anywhere
if (this.user === undefined) return;
const data: Preferences = {
@ -1008,6 +1024,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
* Bookmarks the current page for the chapter
*/
bookmarkPage() {
// TODO: Show some sort of UI visual to show that a page was bookmarked
const pageNum = this.pageNum;
if (this.pageBookmarked) {
this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {

View File

@ -2,7 +2,7 @@
<div class="container-fluid">
<a class="sr-only sr-only-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a>
<a class="navbar-brand" routerLink="/library" routerLinkActive="active"><img class="logo" src="../../assets/images/logo.png" alt="kavita icon" aria-hidden="true"/><span class="phone-hidden"> Kavita</span></a>
<ul class="navbar-nav mr-auto">
<ul class="navbar-nav col mr-auto">
<div class="nav-item" *ngIf="(accountService.currentUser$ | async) as user">
<div>

View File

@ -2,16 +2,19 @@
<div class="example-box" *ngFor="let item of items; index as i" cdkDrag [cdkDragData]="item" cdkDragBoundary=".example-list">
<div class="mr-3 align-middle">
<i class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i>
<label for="reorder-{{i}}" class="sr-only">Reorder</label>
<input *ngIf="accessibilityMode" id="reorder-{{i}}" type="number" min="0" [max]="items.length - 1" [value]="i" style="width: 40px" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
</div>
<ng-container style="display: inline-block" [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<div class="align-middle" style="padding-top: 40px">
<label for="reorder-{{i}}" class="sr-only">Reorder</label>
<input *ngIf="accessibilityMode" id="reorder-{{i}}" type="number" min="0" [max]="items.length - 1" [value]="i" style="width: 40px" (focusout)="updateIndex(i, item)" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
</div>
<button class="btn btn-icon pull-right" (click)="removeItem(item, i)">
<i class="fa fa-times" aria-hidden="true"></i>
<span class="sr-only" attr.aria-labelledby="item.id--{{i}}">Remove item</span>
</button>
</button>
</div>
</div>

View File

@ -1,10 +1,8 @@
.example-list {
min-width: 500px;
max-width: 100%;
//border: solid 1px #ccc;
min-height: 60px;
display: block;
//background: white;
border-radius: 4px;
overflow: hidden;
}
@ -12,13 +10,9 @@
.example-box {
padding: 20px 10px;
border-bottom: solid 1px #ccc;
//color: rgba(0, 0, 0, 0.87);
display: flex;
flex-direction: row;
//align-items: center;
//justify-content: space-between;
box-sizing: border-box;
//background: white;
font-size: 14px;
.drag-handle {

View File

@ -1,46 +1,52 @@
<div class="container mt-2" *ngIf="readingList">
<div class="row mb-3">
<div class="col-md-10 col-xs-8 col-sm-6">
<div class="row no-gutters">
<div class="container-sm mt-2" *ngIf="readingList">
<div class="mb-3">
<!-- Title row-->
<div class="row no-gutters">
<h2 style="display: inline-block">
<span *ngIf="actions.length > 0">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="readingList.title"></app-card-actionables>&nbsp;
</span>
{{readingList.title}}&nbsp;<span *ngIf="readingList?.promoted">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>&nbsp;
<span class="badge badge-primary badge-pill" attr.aria-label="{{items.length}} total items">{{items.length}}</span>
</h2>
</div>
<div class="row no-gutters">
<div class="mr-2">
<button class="btn btn-primary" title="Read" (click)="read()">
<span>
<i class="fa fa-book-open" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;Read</span>
</span>
</button>
</div>
<div>
<button class="btn btn-secondary" (click)="removeRead()" [disabled]="readingList?.promoted && !this.isAdmin">
<span>
<i class="fa fa-check"></i>
</span>
<span class="read-btn--text">&nbsp;Remove Read</span>
</button>
</div>
</div>
<div class="row no-gutters mt-2">
<app-read-more [text]="readingList.summary" [maxLength]="250"></app-read-more>
</div>
<h2 style="display: inline-block">
<span *ngIf="actions.length > 0">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="readingList.title"></app-card-actionables>&nbsp;
</span>
{{readingList.title}}&nbsp;<span *ngIf="readingList?.promoted">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>&nbsp;
<span class="badge badge-primary badge-pill" attr.aria-label="{{items.length}} total items">{{items.length}}</span>
</h2>
</div>
<!-- Action row-->
<div class="row no-gutters">
<div class="mr-2">
<button class="btn btn-primary" title="Read" (click)="read()">
<span>
<i class="fa fa-book-open" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;Read</span>
</span>
</button>
</div>
<div>
<button class="btn btn-secondary" (click)="removeRead()" [disabled]="readingList?.promoted && !this.isAdmin">
<span>
<i class="fa fa-check"></i>
</span>
<span class="read-btn--text">&nbsp;Remove Read</span>
</button>
</div>
<div class="ml-2 mt-2" *ngIf="!(readingList?.promoted && !this.isAdmin)">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="accessibilit-mode" [value]="accessibilityMode" (change)="accessibilityMode = !accessibilityMode">
<label class="form-check-label" for="accessibilit-mode">Order Numbers</label>
</div>
</div>
</div>
<!-- Summary row-->
<div class="row no-gutters mt-2">
<app-read-more [text]="readingList.summary" [maxLength]="250"></app-read-more>
</div>
</div>
<div *ngIf="items.length === 0">
No chapters added
</div>
<!-- NOTE: It might be nice to have a switch for the accessibility toggle -->
<app-dragable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" (itemRemove)="itemRemoved($event)">
<app-dragable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" (itemRemove)="itemRemoved($event)" [accessibilityMode]="accessibilityMode">
<ng-template #draggableItem let-item let-position="idx">
<div class="media" style="width: 100%;">
<img width="74px" style="width: 74px;" class="img-top lazyload mr-3" [src]="imageService.placeholderImage" [attr.data-src]="imageService.getChapterCoverImage(item.chapterId)"

View File

@ -0,0 +1,4 @@
.container-sm {
padding-left: 0px;
padding-right: 0px;
}

View File

@ -26,6 +26,7 @@ export class ReadingListDetailComponent implements OnInit {
actions: Array<ActionItem<any>> = [];
isAdmin: boolean = false;
isLoading: boolean = false;
accessibilityMode: boolean = false;
// Downloading
hasDownloadingRole: boolean = false;

View File

@ -95,8 +95,6 @@
</div>
</div>
</div>
<hr>
<div>
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav-tabs nav-pills" [destroyOnHide]="false">

View File

@ -1,9 +1,10 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { finalize, take, takeWhile } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators';
import { CardDetailsModalComponent } from '../cards/_modals/card-details-modal/card-details-modal.component';
import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component';
import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
@ -13,6 +14,7 @@ import { DownloadService } from '../shared/_services/download.service';
import { UtilityService } from '../shared/_services/utility.service';
import { ReviewSeriesModalComponent } from '../_modals/review-series-modal/review-series-modal.component';
import { Chapter } from '../_models/chapter';
import { ScanSeriesEvent } from '../_models/events/scan-series-event';
import { LibraryType } from '../_models/library';
import { MangaFormat } from '../_models/manga-format';
import { Series } from '../_models/series';
@ -23,6 +25,7 @@ import { ActionItem, ActionFactoryService, Action } from '../_services/action-fa
import { ActionService } from '../_services/action.service';
import { ImageService } from '../_services/image.service';
import { LibraryService } from '../_services/library.service';
import { MessageHubService } from '../_services/message-hub.service';
import { ReaderService } from '../_services/reader.service';
import { SeriesService } from '../_services/series.service';
@ -32,7 +35,7 @@ import { SeriesService } from '../_services/series.service';
templateUrl: './series-detail.component.html',
styleUrls: ['./series-detail.component.scss']
})
export class SeriesDetailComponent implements OnInit {
export class SeriesDetailComponent implements OnInit, OnDestroy {
series!: Series;
volumes: Volume[] = [];
@ -76,6 +79,8 @@ export class SeriesDetailComponent implements OnInit {
*/
actionInProgress: boolean = false;
private onDestroy: Subject<void> = new Subject();
get LibraryType(): typeof LibraryType {
return LibraryType;
@ -97,7 +102,7 @@ export class SeriesDetailComponent implements OnInit {
private actionFactoryService: ActionFactoryService, private libraryService: LibraryService,
private confirmService: ConfirmService, private titleService: Title,
private downloadService: DownloadService, private actionService: ActionService,
public imageSerivce: ImageService) {
public imageSerivce: ImageService, private messageHub: MessageHubService) {
ratingConfig.max = 5;
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
@ -116,20 +121,25 @@ export class SeriesDetailComponent implements OnInit {
return;
}
this.messageHub.scanSeries.pipe(takeUntil(this.onDestroy)).subscribe((event: ScanSeriesEvent) => {
if (event.seriesId == this.series.id)
this.loadSeries(seriesId);
this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.series.id));
this.toastr.success('Scan series completed');
});
const seriesId = parseInt(routeId, 10);
this.libraryId = parseInt(libraryId, 10);
this.seriesImage = this.imageService.getSeriesCoverImage(seriesId);
this.loadSeriesMetadata(seriesId);
this.libraryService.getLibraryType(this.libraryId).subscribe(type => {
this.libraryType = type;
this.loadSeries(seriesId);
});
}
loadSeriesMetadata(seriesId: number) {
this.seriesService.getMetadata(seriesId).subscribe(metadata => {
this.seriesMetadata = metadata;
});
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
handleSeriesActionCallback(action: Action, series: Series) {
@ -422,7 +432,6 @@ export class SeriesDetailComponent implements OnInit {
window.scrollTo(0, 0);
if (closeResult.success) {
this.loadSeries(this.series.id);
this.loadSeriesMetadata(this.series.id);
if (closeResult.coverImageUpdate) {
// Random triggers a load change without any problems with API
this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.series.id));

View File

@ -142,20 +142,20 @@
</div>
<div class="form-group">
<label id="font-size">Font Size</label>
<ngx-slider [options]="bookReaderFontSizeOptions" formControlName="bookReaderFontSize" aria-labelledby="font-size"></ngx-slider>
<div class="custom-slider"><ngx-slider [options]="bookReaderFontSizeOptions" formControlName="bookReaderFontSize" aria-labelledby="font-size"></ngx-slider></div>
</div>
<div class="form-group">
<label>Line Height</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookLineHeightOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookLineHeightOptionTooltip>How much spacing between the lines of the book</ng-template>
<span class="sr-only" id="settings-booklineheight-option-help">How much spacing between the lines of the book</span>
<ngx-slider [options]="bookReaderLineSpacingOptions" formControlName="bookReaderLineSpacing" aria-describedby="settings-booklineheight-option-help"></ngx-slider>
<div class="custom-slider"><ngx-slider [options]="bookReaderLineSpacingOptions" formControlName="bookReaderLineSpacing" aria-describedby="settings-booklineheight-option-help"></ngx-slider></div>
</div>
<div class="form-group">
<label>Margin</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookReaderMarginOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookReaderMarginOptionTooltip>How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.</ng-template>
<span class="sr-only" id="settings-bookmargin-option-help">How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.</span>
<ngx-slider [options]="bookReaderMarginOptions" formControlName="bookReaderMargin" aria-describedby="bookmargin"></ngx-slider>
<div class="custom-slider"><ngx-slider [options]="bookReaderMarginOptions" formControlName="bookReaderMargin" aria-describedby="bookmargin"></ngx-slider></div>
</div>
<div class="float-right mb-3">

View File

@ -1,3 +1,55 @@
@import '../../../theme/colors';
.invalid-feedback {
display: inline-block !important;
}
}
// Slider handle override
::ng-deep {
.custom-slider .ngx-slider .ngx-slider-bar {
background: #545a52;
height: 2px;
}
.custom-slider .ngx-slider .ngx-slider-selection {
background: $primary-color;
}
.custom-slider .ngx-slider .ngx-slider-pointer {
width: 8px;
height: 16px;
top: auto; /* to remove the default positioning */
bottom: 0;
background-color: $primary-color;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
.custom-slider .ngx-slider .ngx-slider-pointer:after {
display: none;
}
.custom-slider .ngx-slider .ngx-slider-bubble {
bottom: 14px;
font-weight: bold;
}
.custom-slider .ngx-slider .ngx-slider-limit {
font-weight: bold;
color: white !important;
}
.custom-slider .ngx-slider .ngx-slider-tick {
width: 1px;
height: 10px;
margin-left: 4px;
border-radius: 0;
background: #ffe4d1;
top: -1px;
}
.custom-slider .ngx-slider .ngx-slider-tick.ngx-slider-selected {
background: $primary-color;
}
}

View File

@ -5,6 +5,7 @@ $dark-primary-color: rgba(74, 198, 148, 0.9);
$dark-text-color: #efefef;
$dark-hover-color: #4ac694;
$dark-form-background: rgba(1, 4, 9, 0.5);
$dark-form-background-no-opacity: rgb(1, 4, 9);
$dark-form-placeholder: #efefef;
$dark-link-color: rgb(88, 166, 255);
$dark-icon-color: white;
@ -126,6 +127,7 @@ $dark-item-accent-bg: #292d32;
.dropdown-menu {
background-color: #171719;
.dropdown-item:hover, .dropdown-item:focus {
color: $dark-text-color;
background-color: $dark-hover-color;

View File

@ -51,54 +51,6 @@ body {
cursor: pointer;
}
// Slider handle override
::ng-deep {
.custom-slider .ngx-slider .ngx-slider-bar {
background: #ffe4d1;
height: 2px;
}
.custom-slider .ngx-slider .ngx-slider-selection {
background: orange;
}
.custom-slider .ngx-slider .ngx-slider-pointer {
width: 8px;
height: 16px;
top: auto; /* to remove the default positioning */
bottom: 0;
background-color: #333;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
.custom-slider .ngx-slider .ngx-slider-pointer:after {
display: none;
}
.custom-slider .ngx-slider .ngx-slider-bubble {
bottom: 14px;
}
.custom-slider .ngx-slider .ngx-slider-limit {
font-weight: bold;
color: orange;
}
.custom-slider .ngx-slider .ngx-slider-tick {
width: 1px;
height: 10px;
margin-left: 4px;
border-radius: 0;
background: #ffe4d1;
top: -1px;
}
.custom-slider .ngx-slider .ngx-slider-tick.ngx-slider-selected {
background: orange;
}
}
// Utiliities
@include media-breakpoint-down(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) {

View File

@ -4,6 +4,7 @@
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"removeComments": true /* Do not emit comments to output. */,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitReturns": true,