Shakeout Fixes (#422)

# Fixed
- Fixed: Clean the pdf extension from Series title for PDF types
- Fixed: Fixed a bug where a forced metadata refresh wouldn't trigger a volume to force a refresh of cover image
- Fixed: Fixed an issue where Removing series no longer on disk would not use the Series Format and thus after deleting files, they would not be removed.
- Fixed: Fixed an issue with reading a single image file, where the cache code would not properly move the file
- Fixed: For Specials, Get Next/Prev Chapter should use the filename instead of arbitrary Number (which is always 0). Use the same sorting logic when requesting volumes on series detail, so sorting can happen in the backend.

# Added
- Added: (Accessibility) Nearly every page has had a title set for it 

===============================================================================

* Clean the pdf extension from ParseSeries

* Fixed a bug where forced metadata refresh wouldn't trigger the volume to update it's image.

* Added titles to most pages to help distinguish back/forward history.

Fixed a bug in the scanner which didn't account for Format when calculating if we need to remove a series not on disk.

* For Specials, Get Next/Prev Chapter should use the filename instead of arbitrary Number (which is always 0). Use the same sorting logic when requesting volumes on series detail, so sorting can happen in the backend.

* Fixed unit tests
This commit is contained in:
Joseph Milazzo 2021-07-23 18:02:14 -05:00 committed by GitHub
parent 29edadb506
commit ebd4ec25bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 143 additions and 97 deletions

View File

@ -11,4 +11,4 @@ namespace API.Tests.Parser
Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename)); Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename));
} }
} }
} }

View File

@ -52,7 +52,7 @@ namespace API.Tests.Services
IUnitOfWork unitOfWork = new UnitOfWork(_context, Substitute.For<IMapper>(), null); IUnitOfWork unitOfWork = new UnitOfWork(_context, Substitute.For<IMapper>(), null);
IMetadataService metadataService = Substitute.For<MetadataService>(unitOfWork, _metadataLogger, _archiveService, _bookService, _directoryService, _imageService); IMetadataService metadataService = Substitute.For<MetadataService>(unitOfWork, _metadataLogger, _archiveService, _bookService, _imageService);
_scannerService = new ScannerService(unitOfWork, _logger, _archiveService, metadataService, _bookService); _scannerService = new ScannerService(unitOfWork, _logger, _archiveService, metadataService, _bookService);
} }

View File

@ -21,6 +21,7 @@ namespace API.Controllers
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer();
public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork) public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork)
{ {
@ -295,8 +296,8 @@ namespace API.Controllers
var currentChapter = await _unitOfWork.VolumeRepository.GetChapterAsync(currentChapterId); var currentChapter = await _unitOfWork.VolumeRepository.GetChapterAsync(currentChapterId);
if (currentVolume.Number == 0) if (currentVolume.Number == 0)
{ {
// Handle specials // Handle specials by sorting on their Filename aka Range
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer), currentChapter.Number); var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, _naturalSortComparer), currentChapter.Number);
if (chapterId > 0) return Ok(chapterId); if (chapterId > 0) return Ok(chapterId);
} }
@ -362,7 +363,7 @@ namespace API.Controllers
if (currentVolume.Number == 0) if (currentVolume.Number == 0)
{ {
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).Reverse(), currentChapter.Number); var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, _naturalSortComparer).Reverse(), currentChapter.Number);
if (chapterId > 0) return Ok(chapterId); if (chapterId > 0) return Ok(chapterId);
} }

View File

@ -1,6 +1,8 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Comparators;
using API.DTOs; using API.DTOs;
using API.Entities; using API.Entities;
using API.Extensions; using API.Extensions;
@ -16,7 +18,7 @@ namespace API.Data
{ {
private readonly DataContext _context; private readonly DataContext _context;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly NaturalSortComparer _naturalSortComparer = new ();
public SeriesRepository(DataContext context, IMapper mapper) public SeriesRepository(DataContext context, IMapper mapper)
{ {
_context = context; _context = context;
@ -37,7 +39,7 @@ namespace API.Data
{ {
return await _context.SaveChangesAsync() > 0; return await _context.SaveChangesAsync() > 0;
} }
public bool SaveAll() public bool SaveAll()
{ {
return _context.SaveChanges() > 0; return _context.SaveChanges() > 0;
@ -60,12 +62,12 @@ namespace API.Data
.Where(s => libraries.Contains(s.LibraryId) && s.Name == name) .Where(s => libraries.Contains(s.LibraryId) && s.Name == name)
.CountAsync() > 1; .CountAsync() > 1;
} }
public Series GetSeriesByName(string name) public Series GetSeriesByName(string name)
{ {
return _context.Series.SingleOrDefault(x => x.Name == name); return _context.Series.SingleOrDefault(x => x.Name == name);
} }
public async Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId) public async Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId)
{ {
return await _context.Series return await _context.Series
@ -73,7 +75,7 @@ namespace API.Data
.OrderBy(s => s.SortName) .OrderBy(s => s.SortName)
.ToListAsync(); .ToListAsync();
} }
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams) public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams)
{ {
var query = _context.Series var query = _context.Series
@ -84,12 +86,12 @@ namespace API.Data
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize); return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
} }
public async Task<IEnumerable<SearchResultDto>> SearchSeries(int[] libraryIds, string searchQuery) public async Task<IEnumerable<SearchResultDto>> SearchSeries(int[] libraryIds, string searchQuery)
{ {
return await _context.Series return await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId)) .Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%") .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")
|| EF.Functions.Like(s.OriginalName, $"%{searchQuery}%") || EF.Functions.Like(s.OriginalName, $"%{searchQuery}%")
|| EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%")) || EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%"))
.Include(s => s.Library) .Include(s => s.Library)
@ -108,12 +110,23 @@ namespace API.Data
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider) .ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
.AsNoTracking() .AsNoTracking()
.ToListAsync(); .ToListAsync();
await AddVolumeModifiers(userId, volumes); await AddVolumeModifiers(userId, volumes);
SortSpecialChapters(volumes);
return volumes; return volumes;
} }
private void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
{
foreach (var v in volumes.Where(vdto => vdto.Number == 0))
{
v.Chapters = v.Chapters.OrderBy(x => x.Range, _naturalSortComparer).ToList();
}
}
public async Task<IEnumerable<Volume>> GetVolumes(int seriesId) public async Task<IEnumerable<Volume>> GetVolumes(int seriesId)
{ {
@ -133,7 +146,7 @@ namespace API.Data
var seriesList = new List<SeriesDto>() {series}; var seriesList = new List<SeriesDto>() {series};
await AddSeriesModifiers(userId, seriesList); await AddSeriesModifiers(userId, seriesList);
return seriesList[0]; return seriesList[0];
} }
@ -152,7 +165,7 @@ namespace API.Data
.AsNoTracking() .AsNoTracking()
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider) .ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
.SingleAsync(); .SingleAsync();
} }
public async Task<VolumeDto> GetVolumeDtoAsync(int volumeId, int userId) public async Task<VolumeDto> GetVolumeDtoAsync(int volumeId, int userId)
@ -163,7 +176,7 @@ namespace API.Data
.ThenInclude(c => c.Files) .ThenInclude(c => c.Files)
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider) .ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
.SingleAsync(vol => vol.Id == volumeId); .SingleAsync(vol => vol.Id == volumeId);
var volumeList = new List<VolumeDto>() {volume}; var volumeList = new List<VolumeDto>() {volume};
await AddVolumeModifiers(userId, volumeList); await AddVolumeModifiers(userId, volumeList);
@ -186,7 +199,7 @@ namespace API.Data
{ {
var series = await _context.Series.Where(s => s.Id == seriesId).SingleOrDefaultAsync(); var series = await _context.Series.Where(s => s.Id == seriesId).SingleOrDefaultAsync();
_context.Series.Remove(series); _context.Series.Remove(series);
return await _context.SaveChangesAsync() > 0; return await _context.SaveChangesAsync() > 0;
} }
@ -212,7 +225,7 @@ namespace API.Data
.Include(s => s.Volumes) .Include(s => s.Volumes)
.ThenInclude(v => v.Chapters) .ThenInclude(v => v.Chapters)
.ToListAsync(); .ToListAsync();
IList<int> chapterIds = new List<int>(); IList<int> chapterIds = new List<int>();
foreach (var s in series) foreach (var s in series)
{ {
@ -266,7 +279,7 @@ namespace API.Data
.SingleOrDefaultAsync(); .SingleOrDefaultAsync();
} }
private async Task AddVolumeModifiers(int userId, List<VolumeDto> volumes) private async Task AddVolumeModifiers(int userId, IReadOnlyCollection<VolumeDto> volumes)
{ {
var userProgress = await _context.AppUserProgresses var userProgress = await _context.AppUserProgresses
.Where(p => p.AppUserId == userId && volumes.Select(s => s.Id).Contains(p.VolumeId)) .Where(p => p.AppUserId == userId && volumes.Select(s => s.Id).Contains(p.VolumeId))
@ -279,7 +292,7 @@ namespace API.Data
{ {
c.PagesRead = userProgress.Where(p => p.ChapterId == c.Id).Sum(p => p.PagesRead); c.PagesRead = userProgress.Where(p => p.ChapterId == c.Id).Sum(p => p.PagesRead);
} }
v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead); v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead);
} }
} }
@ -311,7 +324,7 @@ namespace API.Data
return await PagedList<SeriesDto>.CreateAsync(allQuery, userParams.PageNumber, userParams.PageSize); return await PagedList<SeriesDto>.CreateAsync(allQuery, userParams.PageNumber, userParams.PageSize);
} }
var query = _context.Series var query = _context.Series
.Where(s => s.LibraryId == libraryId) .Where(s => s.LibraryId == libraryId)
.AsNoTracking() .AsNoTracking()
@ -356,7 +369,7 @@ namespace API.Data
{ {
series = series.Where(s => s.AppUserId == userId series = series.Where(s => s.AppUserId == userId
&& s.PagesRead > 0 && s.PagesRead > 0
&& s.PagesRead < s.Series.Pages && s.PagesRead < s.Series.Pages
&& s.Series.LibraryId == libraryId); && s.Series.LibraryId == libraryId);
} }
var retSeries = await series var retSeries = await series
@ -365,7 +378,7 @@ namespace API.Data
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking() .AsNoTracking()
.ToListAsync(); .ToListAsync();
return retSeries.DistinctBy(s => s.Name).Take(limit); return retSeries.DistinctBy(s => s.Name).Take(limit);
} }
@ -386,7 +399,7 @@ namespace API.Data
.AsNoTracking() .AsNoTracking()
.ToListAsync(); .ToListAsync();
} }
return metadataDto; return metadataDto;
} }
@ -398,7 +411,7 @@ namespace API.Data
.AsNoTracking() .AsNoTracking()
.Select(library => library.Id) .Select(library => library.Id)
.ToList(); .ToList();
var query = _context.CollectionTag var query = _context.CollectionTag
.Where(s => s.Id == collectionId) .Where(s => s.Id == collectionId)
.Include(c => c.SeriesMetadatas) .Include(c => c.SeriesMetadatas)
@ -423,4 +436,4 @@ namespace API.Data
.ToListAsync(); .ToListAsync();
} }
} }
} }

View File

@ -1,8 +1,8 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using API.Entities; using API.Entities;
using API.Entities.Enums;
using API.Parser; using API.Parser;
using API.Services;
using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner;
namespace API.Extensions namespace API.Extensions

View File

@ -498,6 +498,12 @@ namespace API.Parser
ret.Series = CleanTitle(fileName); ret.Series = CleanTitle(fileName);
} }
// Pdfs may have .pdf in the series name, remove that
if (IsPdf(fileName) && ret.Series.ToLower().EndsWith(".pdf"))
{
ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length);
}
return ret.Series == string.Empty ? null : ret; return ret.Series == string.Empty ? null : ret;
} }

View File

@ -60,8 +60,15 @@ namespace API.Services
if (files.Count > 0 && files[0].Format == MangaFormat.Image) if (files.Count > 0 && files[0].Format == MangaFormat.Image)
{ {
DirectoryService.ExistOrCreate(extractPath); DirectoryService.ExistOrCreate(extractPath);
var pattern = (files.Count == 1) ? (@"\" + Path.GetExtension(files[0].FilePath)) : Parser.Parser.ImageFileExtensions; if (files.Count == 1)
_directoryService.CopyDirectoryToDirectory(Path.GetDirectoryName(files[0].FilePath), extractPath, pattern); {
_directoryService.CopyFileToDirectory(files[0].FilePath, extractPath);
}
else
{
_directoryService.CopyDirectoryToDirectory(Path.GetDirectoryName(files[0].FilePath), extractPath, Parser.Parser.ImageFileExtensions);
}
extractDi.Flatten(); extractDi.Flatten();
return chapter; return chapter;
} }

View File

@ -20,19 +20,17 @@ namespace API.Services
private readonly ILogger<MetadataService> _logger; private readonly ILogger<MetadataService> _logger;
private readonly IArchiveService _archiveService; private readonly IArchiveService _archiveService;
private readonly IBookService _bookService; private readonly IBookService _bookService;
private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService; private readonly IImageService _imageService;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
public static readonly int ThumbnailWidth = 320; // 153w x 230h public static readonly int ThumbnailWidth = 320; // 153w x 230h
public MetadataService(IUnitOfWork unitOfWork, ILogger<MetadataService> logger, public MetadataService(IUnitOfWork unitOfWork, ILogger<MetadataService> logger,
IArchiveService archiveService, IBookService bookService, IDirectoryService directoryService, IImageService imageService) IArchiveService archiveService, IBookService bookService, IImageService imageService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_logger = logger; _logger = logger;
_archiveService = archiveService; _archiveService = archiveService;
_bookService = bookService; _bookService = bookService;
_directoryService = directoryService;
_imageService = imageService; _imageService = imageService;
} }
@ -71,25 +69,24 @@ namespace API.Services
public void UpdateMetadata(Volume volume, bool forceUpdate) public void UpdateMetadata(Volume volume, bool forceUpdate)
{ {
if (volume != null && ShouldFindCoverImage(volume.CoverImage, forceUpdate)) if (volume == null || !ShouldFindCoverImage(volume.CoverImage, forceUpdate)) return;
{
volume.Chapters ??= new List<Chapter>();
var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).FirstOrDefault();
// Skip calculating Cover Image (I/O) if the chapter already has it set volume.Chapters ??= new List<Chapter>();
if (firstChapter == null || ShouldFindCoverImage(firstChapter.CoverImage)) var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).FirstOrDefault();
// Skip calculating Cover Image (I/O) if the chapter already has it set
if (firstChapter == null || ShouldFindCoverImage(firstChapter.CoverImage, forceUpdate))
{
var firstFile = firstChapter?.Files.OrderBy(x => x.Chapter).FirstOrDefault();
if (firstFile != null && !new FileInfo(firstFile.FilePath).IsLastWriteLessThan(firstFile.LastModified))
{ {
var firstFile = firstChapter?.Files.OrderBy(x => x.Chapter).FirstOrDefault(); volume.CoverImage = GetCoverImage(firstFile);
if (firstFile != null && !new FileInfo(firstFile.FilePath).IsLastWriteLessThan(firstFile.LastModified))
{
volume.CoverImage = GetCoverImage(firstFile);
}
}
else
{
volume.CoverImage = firstChapter.CoverImage;
} }
} }
else
{
volume.CoverImage = firstChapter.CoverImage;
}
} }
public void UpdateMetadata(Series series, bool forceUpdate) public void UpdateMetadata(Series series, bool forceUpdate)

View File

@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Interfaces.Services; using API.Interfaces.Services;
using API.Parser; using API.Parser;
@ -38,6 +39,20 @@ namespace API.Services.Tasks.Scanner
_scannedSeries = new ConcurrentDictionary<ParsedSeries, List<ParserInfo>>(); _scannedSeries = new ConcurrentDictionary<ParsedSeries, List<ParserInfo>>();
} }
public static IList<ParserInfo> GetInfosByName(Dictionary<ParsedSeries, List<ParserInfo>> parsedSeries, Series series)
{
var existingKey = parsedSeries.Keys.FirstOrDefault(ps =>
ps.Format == series.Format && ps.NormalizedName == Parser.Parser.Normalize(series.OriginalName));
existingKey ??= new ParsedSeries()
{
Format = series.Format,
Name = series.OriginalName,
NormalizedName = Parser.Parser.Normalize(series.OriginalName)
};
return parsedSeries[existingKey];
}
/// <summary> /// <summary>
/// Processes files found during a library scan. /// Processes files found during a library scan.
/// Populates a collection of <see cref="ParserInfo"/> for DB updates later. /// Populates a collection of <see cref="ParserInfo"/> for DB updates later.
@ -144,7 +159,7 @@ namespace API.Services.Tasks.Scanner
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
totalFiles = 0; totalFiles = 0;
var searchPattern = GetLibrarySearchPattern(libraryType); var searchPattern = GetLibrarySearchPattern();
foreach (var folderPath in folders) foreach (var folderPath in folders)
{ {
try try
@ -174,12 +189,7 @@ namespace API.Services.Tasks.Scanner
return SeriesWithInfos(); return SeriesWithInfos();
} }
/// <summary> private static string GetLibrarySearchPattern()
/// Given the Library Type, returns the regex pattern that restricts which files types will be found during a file scan.
/// </summary>
/// <param name="libraryType"></param>
/// <returns></returns>
private static string GetLibrarySearchPattern(LibraryType libraryType)
{ {
return Parser.Parser.SupportedExtensions; return Parser.Parser.SupportedExtensions;
} }

View File

@ -1,5 +1,4 @@
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
@ -27,7 +26,7 @@ namespace API.Services.Tasks
private readonly IArchiveService _archiveService; private readonly IArchiveService _archiveService;
private readonly IMetadataService _metadataService; private readonly IMetadataService _metadataService;
private readonly IBookService _bookService; private readonly IBookService _bookService;
private readonly NaturalSortComparer _naturalSort; private readonly NaturalSortComparer _naturalSort = new ();
public ScannerService(IUnitOfWork unitOfWork, ILogger<ScannerService> logger, IArchiveService archiveService, public ScannerService(IUnitOfWork unitOfWork, ILogger<ScannerService> logger, IArchiveService archiveService,
IMetadataService metadataService, IBookService bookService) IMetadataService metadataService, IBookService bookService)
@ -37,7 +36,6 @@ namespace API.Services.Tasks
_archiveService = archiveService; _archiveService = archiveService;
_metadataService = metadataService; _metadataService = metadataService;
_bookService = bookService; _bookService = bookService;
_naturalSort = new NaturalSortComparer();
} }
[DisableConcurrentExecution(timeoutInSeconds: 360)] [DisableConcurrentExecution(timeoutInSeconds: 360)]
@ -200,10 +198,16 @@ namespace API.Services.Tasks
_logger.LogInformation("Removed {RemoveMissingSeries} series that are no longer on disk:", removeCount); _logger.LogInformation("Removed {RemoveMissingSeries} series that are no longer on disk:", removeCount);
foreach (var s in missingSeries) foreach (var s in missingSeries)
{ {
_logger.LogDebug("Removed {SeriesName}", s.Name); _logger.LogDebug("Removed {SeriesName} ({Format})", s.Name, s.Format);
} }
} }
if (library.Series.Count == 0)
{
_logger.LogDebug("Removed all Series, returning without checking reset of files scanned");
return;
}
// Add new series that have parsedInfos // Add new series that have parsedInfos
foreach (var (key, infos) in parsedSeries) foreach (var (key, infos) in parsedSeries)
@ -218,11 +222,11 @@ namespace API.Services.Tasks
} }
catch (Exception e) catch (Exception e)
{ {
_logger.LogCritical(e, "There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it", key); _logger.LogCritical(e, "There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it", key.NormalizedName);
var duplicateSeries = library.Series.Where(s => s.NormalizedName == key.NormalizedName || Parser.Parser.Normalize(s.OriginalName) == key.NormalizedName).ToList(); var duplicateSeries = library.Series.Where(s => s.NormalizedName == key.NormalizedName || Parser.Parser.Normalize(s.OriginalName) == key.NormalizedName).ToList();
foreach (var series in duplicateSeries) foreach (var series in duplicateSeries)
{ {
_logger.LogCritical("{Key} maps with {Series}", key, series.OriginalName); _logger.LogCritical("{Key} maps with {Series}", key.Name, series.OriginalName);
} }
continue; continue;
@ -247,7 +251,7 @@ namespace API.Services.Tasks
try try
{ {
_logger.LogInformation("Processing series {SeriesName}", series.OriginalName); _logger.LogInformation("Processing series {SeriesName}", series.OriginalName);
UpdateVolumes(series, GetInfosByName(parsedSeries, series).ToArray()); UpdateVolumes(series, ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray());
series.Pages = series.Volumes.Sum(v => v.Pages); series.Pages = series.Volumes.Sum(v => v.Pages);
} }
catch (Exception ex) catch (Exception ex)
@ -257,25 +261,15 @@ namespace API.Services.Tasks
}); });
} }
private static IList<ParserInfo> GetInfosByName(Dictionary<ParsedSeries, List<ParserInfo>> parsedSeries, Series series)
{
// TODO: Move this into a common place
var existingKey = parsedSeries.Keys.FirstOrDefault(ps =>
ps.Format == series.Format && ps.NormalizedName == Parser.Parser.Normalize(series.OriginalName));
existingKey ??= new ParsedSeries()
{
Format = series.Format,
Name = series.OriginalName,
NormalizedName = Parser.Parser.Normalize(series.OriginalName)
};
return parsedSeries[existingKey];
}
public IEnumerable<Series> FindSeriesNotOnDisk(ICollection<Series> existingSeries, Dictionary<ParsedSeries, List<ParserInfo>> parsedSeries) public IEnumerable<Series> FindSeriesNotOnDisk(ICollection<Series> existingSeries, Dictionary<ParsedSeries, List<ParserInfo>> parsedSeries)
{ {
var foundSeries = parsedSeries.Select(s => s.Key.Name).ToList(); // It is safe to check only first since Parser ensures that a Series only has one type
return existingSeries.Where(es => !es.NameInList(foundSeries)); var format = MangaFormat.Unknown;
var firstPs = parsedSeries.Keys.DistinctBy(ps => ps.Format).FirstOrDefault();
if (firstPs != null) format = firstPs.Format;
var foundSeries = parsedSeries.Select(s => s.Key.Name).ToList();
return existingSeries.Where(es => !es.NameInList(foundSeries) || es.Format != format);
} }
/// <summary> /// <summary>
@ -293,7 +287,7 @@ namespace API.Services.Tasks
existingSeries = existingSeries.Where( existingSeries = existingSeries.Where(
s => !missingList.Exists( s => !missingList.Exists(
m => m.NormalizedName.Equals(s.NormalizedName))).ToList(); m => m.NormalizedName.Equals(s.NormalizedName) && m.Format == s.Format)).ToList();
removeCount = existingCount - existingSeries.Count; removeCount = existingCount - existingSeries.Count;

View File

@ -18,4 +18,4 @@ namespace Kavita.Common
} }
} }
} }

View File

@ -21,7 +21,7 @@ export class AuthGuard implements CanActivate {
} }
this.toastr.error('You are not authorized to view this page.'); this.toastr.error('You are not authorized to view this page.');
localStorage.setItem(this.urlKey, window.location.pathname); localStorage.setItem(this.urlKey, window.location.pathname);
this.router.navigateByUrl('/home'); this.router.navigateByUrl('/libraries');
return false; return false;
}) })
); );

View File

@ -3,6 +3,7 @@ import { ActivatedRoute } from '@angular/router';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { ServerService } from 'src/app/_services/server.service'; import { ServerService } from 'src/app/_services/server.service';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { Title } from '@angular/platform-browser';
@ -22,7 +23,7 @@ export class DashboardComponent implements OnInit {
counter = this.tabs.length + 1; counter = this.tabs.length + 1;
active = this.tabs[0]; active = this.tabs[0];
constructor(public route: ActivatedRoute, private serverService: ServerService, private toastr: ToastrService) { constructor(public route: ActivatedRoute, private serverService: ServerService, private toastr: ToastrService, private titleService: Title) {
this.route.fragment.subscribe(frag => { this.route.fragment.subscribe(frag => {
const tab = this.tabs.filter(item => item.fragment === frag); const tab = this.tabs.filter(item => item.fragment === frag);
if (tab.length > 0) { if (tab.length > 0) {
@ -34,7 +35,9 @@ export class DashboardComponent implements OnInit {
} }
ngOnInit() {} ngOnInit() {
this.titleService.setTitle('Kavita - Admin Dashboard');
}
restartServer() { restartServer() {
this.serverService.restart().subscribe(() => { this.serverService.restart().subscribe(() => {

View File

@ -1,4 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
@ -28,7 +29,9 @@ export class AllCollectionsComponent implements OnInit {
seriesPagination!: Pagination; seriesPagination!: Pagination;
collectionTagActions: ActionItem<CollectionTag>[] = []; collectionTagActions: ActionItem<CollectionTag>[] = [];
constructor(private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService, private modalService: NgbModal) { constructor(private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute,
private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService,
private modalService: NgbModal, private titleService: Title) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.router.routeReuseStrategy.shouldReuseRoute = () => false;
const routeId = this.route.snapshot.paramMap.get('id'); const routeId = this.route.snapshot.paramMap.get('id');
@ -43,6 +46,7 @@ export class AllCollectionsComponent implements OnInit {
return; return;
} }
this.collectionTagName = tags.filter(item => item.id === this.collectionTagId)[0].title; this.collectionTagName = tags.filter(item => item.id === this.collectionTagId)[0].title;
this.titleService.setTitle('Kavita - ' + this.collectionTagName + ' Collection');
}); });
} }
} }

View File

@ -4,6 +4,7 @@ import { Router } from '@angular/router';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { MemberService } from '../_services/member.service'; import { MemberService } from '../_services/member.service';
import { AccountService } from '../_services/account.service'; import { AccountService } from '../_services/account.service';
import { Title } from '@angular/platform-browser';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
@ -19,7 +20,7 @@ export class HomeComponent implements OnInit {
password: new FormControl('', [Validators.required]) password: new FormControl('', [Validators.required])
}); });
constructor(public accountService: AccountService, private memberService: MemberService, private router: Router) { constructor(public accountService: AccountService, private memberService: MemberService, private router: Router, private titleService: Title) {
} }
ngOnInit(): void { ngOnInit(): void {
@ -31,6 +32,7 @@ export class HomeComponent implements OnInit {
return; return;
} }
this.titleService.setTitle('Kavita');
this.accountService.currentUser$.pipe(take(1)).subscribe(user => { this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) { if (user) {
this.router.navigateByUrl('/library'); this.router.navigateByUrl('/library');

View File

@ -28,7 +28,7 @@ export class LibraryDetailComponent implements OnInit {
private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService, private actionService: ActionService) { private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService, private actionService: ActionService) {
const routeId = this.route.snapshot.paramMap.get('id'); const routeId = this.route.snapshot.paramMap.get('id');
if (routeId === null) { if (routeId === null) {
this.router.navigateByUrl('/home'); this.router.navigateByUrl('/libraries');
return; return;
} }
this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.router.routeReuseStrategy.shouldReuseRoute = () => false;

View File

@ -1,4 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
@ -34,9 +35,13 @@ export class LibraryComponent implements OnInit {
seriesTrackBy = (index: number, item: any) => `${item.name}_${item.pagesRead}`; seriesTrackBy = (index: number, item: any) => `${item.name}_${item.pagesRead}`;
constructor(public accountService: AccountService, private libraryService: LibraryService, private seriesService: SeriesService, private actionFactoryService: ActionFactoryService, private collectionService: CollectionTagService, private router: Router, private modalService: NgbModal) { } constructor(public accountService: AccountService, private libraryService: LibraryService,
private seriesService: SeriesService, private actionFactoryService: ActionFactoryService,
private collectionService: CollectionTagService, private router: Router,
private modalService: NgbModal, private titleService: Title) { }
ngOnInit(): void { ngOnInit(): void {
this.titleService.setTitle('Kavita - Dashboard');
this.isLoading = true; this.isLoading = true;
this.accountService.currentUser$.pipe(take(1)).subscribe(user => { this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
this.user = user; this.user = user;

View File

@ -245,7 +245,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const chapterId = this.route.snapshot.paramMap.get('chapterId'); const chapterId = this.route.snapshot.paramMap.get('chapterId');
if (libraryId === null || seriesId === null || chapterId === null) { if (libraryId === null || seriesId === null || chapterId === null) {
this.router.navigateByUrl('/home'); this.router.navigateByUrl('/libraries');
return; return;
} }

View File

@ -61,7 +61,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
logout() { logout() {
this.accountService.logout(); this.accountService.logout();
this.navService.hideNavBar(); this.navService.hideNavBar();
this.router.navigateByUrl('/home'); this.router.navigateByUrl('/login');
} }
moveFocus() { moveFocus() {

View File

@ -1,14 +1,13 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { forkJoin } from 'rxjs';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config'; import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
import { ConfirmService } from '../shared/confirm.service'; import { ConfirmService } from '../shared/confirm.service';
import { CardDetailsModalComponent } from '../shared/_modals/card-details-modal/card-details-modal.component'; import { CardDetailsModalComponent } from '../shared/_modals/card-details-modal/card-details-modal.component';
import { DownloadService } from '../shared/_services/download.service'; import { DownloadService } from '../shared/_services/download.service';
import { NaturalSortService } from '../shared/_services/natural-sort.service';
import { UtilityService } from '../shared/_services/utility.service'; import { UtilityService } from '../shared/_services/utility.service';
import { EditSeriesModalComponent } from '../_modals/edit-series-modal/edit-series-modal.component'; import { EditSeriesModalComponent } from '../_modals/edit-series-modal/edit-series-modal.component';
import { ReviewSeriesModalComponent } from '../_modals/review-series-modal/review-series-modal.component'; import { ReviewSeriesModalComponent } from '../_modals/review-series-modal/review-series-modal.component';
@ -81,7 +80,7 @@ export class SeriesDetailComponent implements OnInit {
public utilityService: UtilityService, private toastr: ToastrService, public utilityService: UtilityService, private toastr: ToastrService,
private accountService: AccountService, public imageService: ImageService, private accountService: AccountService, public imageService: ImageService,
private actionFactoryService: ActionFactoryService, private libraryService: LibraryService, private actionFactoryService: ActionFactoryService, private libraryService: LibraryService,
private confirmService: ConfirmService, private naturalSort: NaturalSortService, private confirmService: ConfirmService, private titleService: Title,
private downloadService: DownloadService, private actionService: ActionService) { private downloadService: DownloadService, private actionService: ActionService) {
ratingConfig.max = 5; ratingConfig.max = 5;
this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.router.routeReuseStrategy.shouldReuseRoute = () => false;
@ -97,7 +96,7 @@ export class SeriesDetailComponent implements OnInit {
const routeId = this.route.snapshot.paramMap.get('seriesId'); const routeId = this.route.snapshot.paramMap.get('seriesId');
const libraryId = this.route.snapshot.paramMap.get('libraryId'); const libraryId = this.route.snapshot.paramMap.get('libraryId');
if (routeId === null || libraryId == null) { if (routeId === null || libraryId == null) {
this.router.navigateByUrl('/home'); this.router.navigateByUrl('/libraries');
return; return;
} }
@ -227,6 +226,8 @@ export class SeriesDetailComponent implements OnInit {
this.seriesService.getSeries(seriesId).subscribe(series => { this.seriesService.getSeries(seriesId).subscribe(series => {
this.series = series; this.series = series;
this.createHTML(); this.createHTML();
this.titleService.setTitle('Kavita - ' + this.series.name + ' Details');
this.seriesService.getVolumes(this.series.id).subscribe(volumes => { this.seriesService.getVolumes(this.series.id).subscribe(volumes => {
@ -240,7 +241,6 @@ export class SeriesDetailComponent implements OnInit {
this.specials = vol0.map(v => v.chapters || []) this.specials = vol0.map(v => v.chapters || [])
.flat() .flat()
.filter(c => c.isSpecial || isNaN(parseInt(c.range, 10))) .filter(c => c.isSpecial || isNaN(parseInt(c.range, 10)))
.sort((a, b) => this.naturalSort.compare(a.range, b.range, true))
.map(c => { .map(c => {
c.title = this.utilityService.cleanSpecialTitle(c.title); c.title = this.utilityService.cleanSpecialTitle(c.title);
c.range = this.utilityService.cleanSpecialTitle(c.range); c.range = this.utilityService.cleanSpecialTitle(c.range);
@ -255,6 +255,8 @@ export class SeriesDetailComponent implements OnInit {
this.isLoading = false; this.isLoading = false;
}); });
}, err => {
this.router.navigateByUrl('/libraries');
}); });
} }

View File

@ -5,7 +5,6 @@ import { MangaFile } from 'src/app/_models/manga-file';
import { MangaFormat } from 'src/app/_models/manga-format'; import { MangaFormat } from 'src/app/_models/manga-format';
import { Volume } from 'src/app/_models/volume'; import { Volume } from 'src/app/_models/volume';
import { ImageService } from 'src/app/_services/image.service'; import { ImageService } from 'src/app/_services/image.service';
import { NaturalSortService } from '../../_services/natural-sort.service';
import { UtilityService } from '../../_services/utility.service'; import { UtilityService } from '../../_services/utility.service';
@ -27,7 +26,7 @@ export class CardDetailsModalComponent implements OnInit {
formatKeys = Object.keys(MangaFormat); formatKeys = Object.keys(MangaFormat);
constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService, constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService,
public imageService: ImageService, public naturalSort: NaturalSortService) { } public imageService: ImageService) { }
ngOnInit(): void { ngOnInit(): void {
this.isChapter = this.isObjectChapter(this.data); this.isChapter = this.isObjectChapter(this.data);

View File

@ -2,6 +2,7 @@ import { Injectable, OnDestroy } from '@angular/core';
/** /**
* Soley repsonsible for performing a "natural" sort. This is the UI counterpart to the BE NaturalSortComparer. * Soley repsonsible for performing a "natural" sort. This is the UI counterpart to the BE NaturalSortComparer.
* Note: This does not work the same. Better to have the Backend perform the sort before sending to UI.
*/ */
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',

View File

@ -8,6 +8,7 @@ import { AccountService } from '../_services/account.service';
import { Options } from '@angular-slider/ngx-slider'; import { Options } from '@angular-slider/ngx-slider';
import { BookService } from '../book-reader/book.service'; import { BookService } from '../book-reader/book.service';
import { NavService } from '../_services/nav.service'; import { NavService } from '../_services/nav.service';
import { Title } from '@angular/platform-browser';
@Component({ @Component({
selector: 'app-user-preferences', selector: 'app-user-preferences',
@ -47,11 +48,12 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
}; };
fontFamilies: Array<string> = []; fontFamilies: Array<string> = [];
constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService, private navService: NavService) { constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService, private navService: NavService, private titleService: Title) {
this.fontFamilies = this.bookService.getFontFamilies(); this.fontFamilies = this.bookService.getFontFamilies();
} }
ngOnInit(): void { ngOnInit(): void {
this.titleService.setTitle('Kavita - User Preferences');
this.accountService.currentUser$.pipe(take(1)).subscribe((user: User) => { this.accountService.currentUser$.pipe(take(1)).subscribe((user: User) => {
if (user) { if (user) {
this.user = user; this.user = user;