From fc87dba0a7d494742da53737d652c9a14e0e3d1e Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Thu, 7 Mar 2024 07:13:36 -0700 Subject: [PATCH] Foundational Rework (Round 2) (#2767) --- .../Extensions/ChapterListExtensionsTests.cs | 26 ++++++ .../ParserInfoListExtensionsTests.cs | 29 +++++- API.Tests/Services/SeriesServiceTests.cs | 2 +- API/DTOs/ChapterDto.cs | 9 +- .../ManualMigrations/MigrateChapterFields.cs | 2 +- .../ManualMigrations/MigrateChapterRange.cs | 55 ++++++++++++ API/Data/Repositories/SeriesRepository.cs | 1 + API/Entities/Chapter.cs | 14 ++- API/Extensions/ChapterListExtensions.cs | 7 +- API/Extensions/ParserInfoListExtensions.cs | 7 +- API/Helpers/Builders/ChapterBuilder.cs | 20 ++--- API/Helpers/Builders/MangaFileBuilder.cs | 3 +- API/Services/MetadataService.cs | 1 + API/Services/ReadingItemService.cs | 89 ++++++++++--------- API/Services/SeriesService.cs | 2 +- API/Services/TaskScheduler.cs | 1 + .../Tasks/Scanner/ParseScannedFiles.cs | 8 +- API/Services/Tasks/Scanner/Parser/Parser.cs | 11 +++ API/Services/Tasks/Scanner/ProcessSeries.cs | 3 +- API/Startup.cs | 1 + UI/Web/src/app/_models/chapter.ts | 1 + .../edit-series-modal.component.html | 4 +- .../edit-series-modal.component.ts | 17 +++- .../card-detail-drawer.component.html | 2 +- .../card-detail-drawer.component.ts | 2 +- .../cards/card-item/card-item.component.ts | 5 +- .../nav-header/nav-header.component.html | 3 +- UI/Web/src/assets/langs/en.json | 4 +- openapi.json | 4 +- 29 files changed, 249 insertions(+), 84 deletions(-) create mode 100644 API/Data/ManualMigrations/MigrateChapterRange.cs diff --git a/API.Tests/Extensions/ChapterListExtensionsTests.cs b/API.Tests/Extensions/ChapterListExtensionsTests.cs index a372812179..d27903ca9f 100644 --- a/API.Tests/Extensions/ChapterListExtensionsTests.cs +++ b/API.Tests/Extensions/ChapterListExtensionsTests.cs @@ -105,6 +105,32 @@ public void GetChapterByRange_On_Duplicate_Files_Test_Should_Not_Error() Assert.Equal(chapterList[0], actualChapter); } + [Fact] + public void GetChapterByRange_On_FilenameChange_ShouldGetChapter() + { + var info = new ParserInfo() + { + Chapters = "1", + Edition = "", + Format = MangaFormat.Archive, + FullFilePath = "/manga/detective comics #001.cbz", + Filename = "detective comics #001.cbz", + IsSpecial = false, + Series = "detective comics", + Title = "detective comics", + Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + }; + + var chapterList = new List() + { + CreateChapter("1", "1", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), false), + }; + + var actualChapter = chapterList.GetChapterByRange(info); + + Assert.Equal(chapterList[0], actualChapter); + } + #region GetFirstChapterWithFiles [Fact] diff --git a/API.Tests/Extensions/ParserInfoListExtensionsTests.cs b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs index 6ea35e4717..610c08f746 100644 --- a/API.Tests/Extensions/ParserInfoListExtensionsTests.cs +++ b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.IO; using System.IO.Abstractions.TestingHelpers; using System.Linq; using API.Entities.Enums; @@ -33,7 +34,7 @@ public void DistinctVolumesTest(string[] volumeNumbers, string[] expectedNumbers [Theory] [InlineData(new[] {@"Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, true)] - [InlineData(new[] {@"Cynthia The Mission - c000-006 (v06-07) [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, true)] + [InlineData(new[] {@"Cynthia The Mission - c000-006 (v06-07) [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, false)] [InlineData(new[] {@"Cynthia The Mission v20 c12-20 [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, false)] public void HasInfoTest(string[] inputInfos, string[] inputChapters, bool expectedHasInfo) { @@ -41,8 +42,8 @@ public void HasInfoTest(string[] inputInfos, string[] inputChapters, bool expect foreach (var filename in inputInfos) { infos.Add(_defaultParser.Parse( - filename, - string.Empty)); + Path.Join("E:/Manga/Cynthia the Mission/", filename), + "E:/Manga/")); } var files = inputChapters.Select(s => new MangaFileBuilder(s, MangaFormat.Archive, 199).Build()).ToList(); @@ -52,4 +53,26 @@ public void HasInfoTest(string[] inputInfos, string[] inputChapters, bool expect Assert.Equal(expectedHasInfo, infos.HasInfo(chapter)); } + + [Fact] + public void HasInfoTest_SuccessWhenSpecial() + { + var infos = new[] + { + _defaultParser.Parse( + "E:/Manga/Cynthia the Mission/Cynthia The Mission The Special SP01 [Desudesu&Brolen].zip", + "E:/Manga/") + }; + + var files = new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission The Special SP01 [Desudesu&Brolen].zip"} + .Select(s => new MangaFileBuilder(s, MangaFormat.Archive, 199).Build()) + .ToList(); + var chapter = new ChapterBuilder("Cynthia The Mission The Special SP01 [Desudesu&Brolen].zip") + .WithRange("Cynthia The Mission The Special SP01 [Desudesu&Brolen]") + .WithFiles(files) + .WithIsSpecial(true) + .Build(); + + Assert.True(infos.HasInfo(chapter)); + } } diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index e1d6995754..17208460ff 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -300,7 +300,7 @@ public async Task SeriesDetail_WhenBookLibrary_ShouldReturnVolumesAndSpecial() Assert.Equal("2 - Ano Orokamono ni mo Kyakkou wo! - Volume 2", detail.Volumes.ElementAt(0).Name); Assert.NotEmpty(detail.Specials); - Assert.Equal("Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub", detail.Specials.ElementAt(0).Range); + Assert.Equal("Ano Orokamono ni mo Kyakkou wo! - Volume 1", detail.Specials.ElementAt(0).Range); // A book library where all books are Volumes, will show no "chapters" on the UI because it doesn't make sense Assert.Empty(detail.Chapters); diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index aa28e983a2..afd7db40dd 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -15,15 +15,22 @@ public class ChapterDto : IHasReadTimeEstimate /// /// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". If special, will be special name. /// + /// This can be something like 19.HU or Alpha as some comics are like this public string Range { get; init; } = default!; /// /// Smallest number of the Range. /// [Obsolete("Use MinNumber and MaxNumber instead")] public string Number { get; init; } = default!; + /// + /// This may be 0 under the circumstance that the Issue is "Alpha" or other non-standard numbers. + /// public float MinNumber { get; init; } public float MaxNumber { get; init; } - public float SortOrder { get; init; } + /// + /// The sorting order of the Chapter. Inherits from MinNumber, but can be overridden. + /// + public float SortOrder { get; set; } /// /// Total number of pages in all MangaFiles /// diff --git a/API/Data/ManualMigrations/MigrateChapterFields.cs b/API/Data/ManualMigrations/MigrateChapterFields.cs index 00531060be..f157850fa2 100644 --- a/API/Data/ManualMigrations/MigrateChapterFields.cs +++ b/API/Data/ManualMigrations/MigrateChapterFields.cs @@ -60,7 +60,7 @@ public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork "Running MigrateChapterFields migration - Updating all MangaFiles"); foreach (var mangaFile in dataContext.MangaFile) { - mangaFile.FileName = Path.GetFileNameWithoutExtension(mangaFile.FilePath); + mangaFile.FileName = Parser.RemoveExtensionIfSupported(mangaFile.FilePath); } var looseLeafChapters = await dataContext.Chapter.Where(c => c.Number == "0").ToListAsync(); diff --git a/API/Data/ManualMigrations/MigrateChapterRange.cs b/API/Data/ManualMigrations/MigrateChapterRange.cs new file mode 100644 index 0000000000..cd078699fb --- /dev/null +++ b/API/Data/ManualMigrations/MigrateChapterRange.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Helpers.Builders; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.0 changed the range to that it doesn't have filename by default +/// +public static class MigrateChapterRange +{ + public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateChapterRange")) + { + return; + } + + logger.LogCritical( + "Running MigrateChapterRange migration - Please be patient, this may take some time. This is not an error"); + + var chapters = await dataContext.Chapter.ToListAsync(); + foreach (var chapter in chapters) + { + if (Parser.MinNumberFromRange(chapter.Range) == 0.0f) + { + chapter.Range = chapter.GetNumberTitle(); + } + } + + + // Save changes after processing all series + if (dataContext.ChangeTracker.HasChanges()) + { + await dataContext.SaveChangesAsync(); + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateChapterRange", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + + await dataContext.SaveChangesAsync(); + logger.LogCritical( + "Running MigrateChapterRange migration - Completed. This is not an error"); + } +} diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index d8d82dfa96..51cf80741a 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -498,6 +498,7 @@ public async Task SearchSeries(int userId, bool isAdmin, I .Include(c => c.Files) .Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%") || EF.Functions.Like(c.ISBN, $"%{searchQuery}%") + || EF.Functions.Like(c.Range, $"%{searchQuery}%") ) .Where(c => c.Files.All(f => fileIds.Contains(f.Id))) .AsSplitQuery() diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index 543cbafafd..3613a486d3 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -149,10 +149,13 @@ public void UpdateFrom(ParserInfo info) MinNumber = Parser.DefaultChapterNumber; MaxNumber = Parser.DefaultChapterNumber; } + // NOTE: This doesn't work well for all because Pdf usually should use into.Title or even filename Title = (IsSpecial && info.Format == MangaFormat.Epub) ? info.Title - : Path.GetFileNameWithoutExtension(Range); + : Parser.RemoveExtensionIfSupported(Range); + var specialTreatment = info.IsSpecialInfo(); + Range = specialTreatment ? info.Filename : info.Chapters; } /// @@ -165,13 +168,16 @@ public string GetNumberTitle() { if (MinNumber.Is(Parser.DefaultChapterNumber) && IsSpecial) { - return Title; + return Parser.RemoveExtensionIfSupported(Title); } - else + + if (MinNumber.Is(0) && !float.TryParse(Range, out _)) { - return $"{MinNumber}"; + return $"{Range}"; } + return $"{MinNumber}"; + } return $"{MinNumber}-{MaxNumber}"; } diff --git a/API/Extensions/ChapterListExtensions.cs b/API/Extensions/ChapterListExtensions.cs index a481f71d5a..0a21cc5ada 100644 --- a/API/Extensions/ChapterListExtensions.cs +++ b/API/Extensions/ChapterListExtensions.cs @@ -29,10 +29,11 @@ public static class ChapterListExtensions /// public static Chapter? GetChapterByRange(this IEnumerable chapters, ParserInfo info) { + var normalizedPath = Parser.NormalizePath(info.FullFilePath); var specialTreatment = info.IsSpecialInfo(); - return specialTreatment - ? chapters.FirstOrDefault(c => c.Range == Path.GetFileNameWithoutExtension(info.Filename) || (c.Files.Select(f => f.FilePath).Contains(info.FullFilePath))) - : chapters.FirstOrDefault(c => c.Range == info.Chapters); + return specialTreatment + ? chapters.FirstOrDefault(c => c.Range == Parser.RemoveExtensionIfSupported(info.Filename) || c.Files.Select(f => Parser.NormalizePath(f.FilePath)).Contains(normalizedPath)) + : chapters.FirstOrDefault(c => c.Range == info.Chapters); } /// diff --git a/API/Extensions/ParserInfoListExtensions.cs b/API/Extensions/ParserInfoListExtensions.cs index 58fe6ba527..94eb1c7695 100644 --- a/API/Extensions/ParserInfoListExtensions.cs +++ b/API/Extensions/ParserInfoListExtensions.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.IO; using System.Linq; using API.Entities; using API.Services.Tasks.Scanner.Parser; @@ -27,7 +28,9 @@ public static IList DistinctVolumes(this IList infos) /// public static bool HasInfo(this IList infos, Chapter chapter) { - return chapter.IsSpecial ? infos.Any(v => v.Filename == chapter.Range) - : infos.Any(v => v.Chapters == chapter.Range); + var chapterFiles = chapter.Files.Select(x => Parser.NormalizePath(x.FilePath)).ToList(); + var infoFiles = infos.Select(x => Parser.NormalizePath(x.FullFilePath)).ToList(); + return infoFiles.Intersect(chapterFiles).Any(); } + } diff --git a/API/Helpers/Builders/ChapterBuilder.cs b/API/Helpers/Builders/ChapterBuilder.cs index e8704d69a8..6b0621e57e 100644 --- a/API/Helpers/Builders/ChapterBuilder.cs +++ b/API/Helpers/Builders/ChapterBuilder.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using API.Entities; using API.Entities.Enums; using API.Services.Tasks.Scanner.Parser; @@ -17,7 +18,7 @@ public ChapterBuilder(string number, string? range=null) { _chapter = new Chapter() { - Range = string.IsNullOrEmpty(range) ? number : range, + Range = string.IsNullOrEmpty(range) ? number : Parser.RemoveExtensionIfSupported(range), Title = string.IsNullOrEmpty(range) ? number : range, Number = Parser.MinNumberFromRange(number).ToString(CultureInfo.InvariantCulture), MinNumber = Parser.MinNumberFromRange(number), @@ -32,17 +33,14 @@ public ChapterBuilder(string number, string? range=null) public static ChapterBuilder FromParserInfo(ParserInfo info) { var specialTreatment = info.IsSpecialInfo(); - var specialTitle = specialTreatment ? info.Filename : info.Chapters; + var specialTitle = specialTreatment ? Parser.RemoveExtensionIfSupported(info.Filename) : info.Chapters; var builder = new ChapterBuilder(Parser.DefaultChapter); - // TODO: Come back here and remove this side effect - return builder.WithNumber(specialTreatment ? Parser.DefaultChapter : Parser.MinNumberFromRange(info.Chapters) + string.Empty) + + return builder.WithNumber(Parser.RemoveExtensionIfSupported(info.Chapters)) .WithRange(specialTreatment ? info.Filename : info.Chapters) .WithTitle((specialTreatment && info.Format == MangaFormat.Epub) ? info.Title : specialTitle) - // NEW - //.WithTitle(string.IsNullOrEmpty(info.Filename) ? specialTitle : info.Filename) - .WithTitle(info.Filename) .WithIsSpecial(specialTreatment); } @@ -53,7 +51,7 @@ public ChapterBuilder WithId(int id) } - public ChapterBuilder WithNumber(string number) + private ChapterBuilder WithNumber(string number) { _chapter.Number = number; _chapter.MinNumber = Parser.MinNumberFromRange(number); @@ -79,11 +77,9 @@ public ChapterBuilder WithStoryArcNumber(string number) return this; } - private ChapterBuilder WithRange(string range) + public ChapterBuilder WithRange(string range) { - _chapter.Range = range; - // TODO: HACK: Overriding range - _chapter.Range = _chapter.GetNumberTitle(); + _chapter.Range = Parser.RemoveExtensionIfSupported(range); return this; } diff --git a/API/Helpers/Builders/MangaFileBuilder.cs b/API/Helpers/Builders/MangaFileBuilder.cs index f7d7524abe..584de43988 100644 --- a/API/Helpers/Builders/MangaFileBuilder.cs +++ b/API/Helpers/Builders/MangaFileBuilder.cs @@ -2,6 +2,7 @@ using System.IO; using API.Entities; using API.Entities.Enums; +using API.Services.Tasks.Scanner.Parser; namespace API.Helpers.Builders; @@ -19,7 +20,7 @@ public MangaFileBuilder(string filePath, MangaFormat format, int pages = 0) Pages = pages, LastModified = File.GetLastWriteTime(filePath), LastModifiedUtc = File.GetLastWriteTimeUtc(filePath), - FileName = Path.GetFileNameWithoutExtension(filePath) + FileName = Parser.RemoveExtensionIfSupported(filePath) }; } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 54fc520fd8..f2d86a868f 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -82,6 +82,7 @@ private Task UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, En chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, encodeFormat, coverImageSize); _unitOfWork.ChapterRepository.Update(chapter); + _updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter)); return Task.FromResult(true); } diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index 129a3ad4a2..a4eff9f32f 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -90,46 +90,6 @@ public ReadingItemService(IArchiveService archiveService, IBookService bookServi } - // This is first time ComicInfo is called - info.ComicInfo = GetComicInfo(path); - if (info.ComicInfo == null) return info; - - if (!string.IsNullOrEmpty(info.ComicInfo.Volume)) - { - info.Volumes = info.ComicInfo.Volume; - } - if (!string.IsNullOrEmpty(info.ComicInfo.Series)) - { - info.Series = info.ComicInfo.Series.Trim(); - } - if (!string.IsNullOrEmpty(info.ComicInfo.Number)) - { - info.Chapters = info.ComicInfo.Number; - } - - // Patch is SeriesSort from ComicInfo - if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort)) - { - info.SeriesSort = info.ComicInfo.TitleSort.Trim(); - } - - if (!string.IsNullOrEmpty(info.ComicInfo.Format) && Parser.HasComicInfoSpecial(info.ComicInfo.Format)) - { - info.IsSpecial = true; - info.Chapters = Parser.DefaultChapter; - info.Volumes = Parser.LooseLeafVolume; - } - - if (!string.IsNullOrEmpty(info.ComicInfo.SeriesSort)) - { - info.SeriesSort = info.ComicInfo.SeriesSort.Trim(); - } - - if (!string.IsNullOrEmpty(info.ComicInfo.LocalizedSeries)) - { - info.LocalizedSeries = info.ComicInfo.LocalizedSeries.Trim(); - } - return info; } @@ -218,6 +178,53 @@ public void Extract(string fileFilePath, string targetDirectory, MangaFormat for /// private ParserInfo? Parse(string path, string rootPath, LibraryType type) { - return Parser.IsEpub(path) ? _bookService.ParseInfo(path) : _defaultParser.Parse(path, rootPath, type); + var info = Parser.IsEpub(path) ? _bookService.ParseInfo(path) : _defaultParser.Parse(path, rootPath, type); + + if (info == null) return null; + + info.ComicInfo = GetComicInfo(path); + if (info.ComicInfo == null) return info; + + if (!string.IsNullOrEmpty(info.ComicInfo.Volume)) + { + info.Volumes = info.ComicInfo.Volume; + } + if (!string.IsNullOrEmpty(info.ComicInfo.Series)) + { + info.Series = info.ComicInfo.Series.Trim(); + } + if (!string.IsNullOrEmpty(info.ComicInfo.Number)) + { + info.Chapters = info.ComicInfo.Number; + if (info.IsSpecial && Parser.DefaultChapter != info.Chapters) + { + info.IsSpecial = false; + } + } + + // Patch is SeriesSort from ComicInfo + if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort)) + { + info.SeriesSort = info.ComicInfo.TitleSort.Trim(); + } + + if (!string.IsNullOrEmpty(info.ComicInfo.Format) && Parser.HasComicInfoSpecial(info.ComicInfo.Format)) + { + info.IsSpecial = true; + info.Chapters = Parser.DefaultChapter; + info.Volumes = Parser.LooseLeafVolume; + } + + if (!string.IsNullOrEmpty(info.ComicInfo.SeriesSort)) + { + info.SeriesSort = info.ComicInfo.SeriesSort.Trim(); + } + + if (!string.IsNullOrEmpty(info.ComicInfo.LocalizedSeries)) + { + info.LocalizedSeries = info.ComicInfo.LocalizedSeries.Trim(); + } + + return info; } } diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 3e70933bf8..fa08859080 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -510,7 +510,7 @@ public async Task GetSeriesDetail(int seriesId, int userId) .SelectMany(v => v.Chapters .Select(c => { - if (v.IsLooseLeaf()) return c; + if (v.IsLooseLeaf() || v.IsSpecial()) return c; c.VolumeTitle = v.Name; return c; }) diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 079c28fce0..2cb02e3122 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -386,6 +386,7 @@ public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) } if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) { + // BUG: This can end up triggering a ton of scan series calls _logger.LogInformation("A Scan is already running, rescheduling ScanSeries in 10 minutes"); BackgroundJob.Schedule(() => ScanSeries(libraryId, seriesId, forceUpdate), TimeSpan.FromMinutes(10)); return; diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index af0fb17912..2d2973b77f 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -336,7 +336,7 @@ await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent($"{files.Count} files in {folder}", library.Name, ProgressEventType.Updated)); if (files.Count == 0) { - _logger.LogInformation("[ScannerService] {Folder} is empty or is no longer in this location", folder); + _logger.LogInformation("[ScannerService] {Folder} is empty, no longer in this location, or has no file types that match Library File Types", folder); return; } @@ -416,6 +416,12 @@ private void UpdateSortOrder(ConcurrentDictionary } else { + // TODO: I think I need to bump by 0.1f as if the prevIssue matches counter + if (!string.IsNullOrEmpty(prevIssue) && prevIssue == counter + "") + { + // Bump by 0.1 + counter += 0.1f; + } chapter.IssueOrder = counter; counter++; prevIssue = chapter.Chapters; diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index f4b4e177ce..dba1748297 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -1130,4 +1130,15 @@ private static string ReplaceUnderscores(string name) return null; } + + public static string RemoveExtensionIfSupported(string? filename) + { + if (string.IsNullOrEmpty(filename)) return filename; + + if (Regex.IsMatch(filename, SupportedExtensions)) + { + return Regex.Replace(filename, SupportedExtensions, string.Empty); + } + return filename; + } } diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 76435198f3..2c95177ddb 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -246,6 +246,7 @@ await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded, catch (Exception ex) { _logger.LogError(ex, "[ScannerService] There was an exception updating series for {SeriesName}", series.Name); + return; } var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); @@ -661,7 +662,7 @@ public void UpdateChapters(Series series, Volume volume, IList parse { if (existingChapter.Files.Count == 0 || !parsedInfos.HasInfo(existingChapter)) { - _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", existingChapter.GetNumberTitle(), volume.Name, parsedInfos[0].Series); + _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", existingChapter.Range, volume.Name, parsedInfos[0].Series); volume.Chapters.Remove(existingChapter); } else diff --git a/API/Startup.cs b/API/Startup.cs index 23b5d0ad89..740e59af5f 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -257,6 +257,7 @@ public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJo await MigrateChapterNumber.Migrate(dataContext, logger); await MigrateMixedSpecials.Migrate(dataContext, unitOfWork, logger); await MigrateChapterFields.Migrate(dataContext, unitOfWork, logger); + await MigrateChapterRange.Migrate(dataContext, unitOfWork, logger); // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); diff --git a/UI/Web/src/app/_models/chapter.ts b/UI/Web/src/app/_models/chapter.ts index d3d1451068..4c1b37a6b9 100644 --- a/UI/Web/src/app/_models/chapter.ts +++ b/UI/Web/src/app/_models/chapter.ts @@ -50,4 +50,5 @@ export interface Chapter { webLinks: string; isbn: string; lastReadingProgress: string; + sortOrder: number; } diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index 839198bc4e..680a9f2b4e 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -408,7 +408,7 @@

Volumes

  • -
    {{t('volume-num')}} {{volume.name}}
    +
    {{formatVolumeName(volume)}}
    @@ -432,7 +432,7 @@
    {{t('volume-num')}} {{volume.name}}
      -
    • +
    • {{file.filePath}}
      diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index de971fd20f..bbf10da68f 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -21,7 +21,7 @@ import { forkJoin, Observable, of } from 'rxjs'; import { map } from 'rxjs/operators'; import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import { TypeaheadSettings } from 'src/app/typeahead/_models/typeahead-settings'; -import { Chapter } from 'src/app/_models/chapter'; +import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter'; import { CollectionTag } from 'src/app/_models/collection-tag'; import { Genre } from 'src/app/_models/metadata/genre'; import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto'; @@ -58,6 +58,7 @@ import {EditListComponent} from "../../../shared/edit-list/edit-list.component"; import {AccountService} from "../../../_services/account.service"; import {LibraryType} from "../../../_models/library/library"; import {ToastrService} from "ngx-toastr"; +import {Volume} from "../../../_models/volume"; enum TabID { General = 0, @@ -296,9 +297,10 @@ export class EditSeriesModalComponent implements OnInit { this.volumeCollapsed[v.name] = true; }); this.seriesVolumes.forEach(vol => { - vol.volumeFiles = vol.chapters?.sort(this.utilityService.sortChapters).map((c: Chapter) => c.files.map((f: any) => { + //.sort(this.utilityService.sortChapters) (no longer needed, all data is sorted on the backend) + vol.volumeFiles = vol.chapters?.map((c: Chapter) => c.files.map((f: any) => { // TODO: Identify how to fix this hack - f.chapter = c.number; + f.chapter = c.range; return f; })).flat(); }); @@ -316,6 +318,15 @@ export class EditSeriesModalComponent implements OnInit { }); } + formatVolumeName(volume: Volume) { + if (volume.minNumber === LooseLeafOrDefaultNumber) { + return translate('edit-series-modal.loose-leaf-volume'); + } else if (volume.minNumber === SpecialVolumeNumber) { + return translate('edit-series-modal.specials-volume'); + } + return translate('edit-series-modal.volume-num') + ' ' + volume.name; + } + setupTypeaheads() { forkJoin([ diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html index 7efe50ed88..5016bf7159 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html @@ -114,7 +114,7 @@

      {{utilityService.formatChapterName(libraryType) + 's'}}

      • - +
        diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts index 52815ee3b1..592603ca0a 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts @@ -186,7 +186,7 @@ export class CardDetailDrawerComponent implements OnInit { if (chapter.minNumber === LooseLeafOrDefaultNumber) { return '1'; } - return chapter.minNumber + ''; + return chapter.range + ''; } performAction(action: ActionItem, chapter: Chapter) { diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index 5b74496984..dcb86d0573 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -198,9 +198,10 @@ export class CardItemComponent implements OnInit { this.format = (this.entity as Series).format; if (this.utilityService.isChapter(this.entity)) { - const chapterTitle = this.utilityService.asChapter(this.entity).titleName; + const chapter = this.utilityService.asChapter(this.entity); + const chapterTitle = chapter.titleName; if (chapterTitle === '' || chapterTitle === null || chapterTitle === undefined) { - const volumeTitle = this.utilityService.asChapter(this.entity).volumeTitle + const volumeTitle = chapter.volumeTitle if (volumeTitle === '' || volumeTitle === null || volumeTitle === undefined) { this.tooltipTitle = (this.title).trim(); } else { diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html index eecd4c8099..4779c58317 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html @@ -129,7 +129,8 @@ - {{item.titleName}} + + {{item.titleName || item.range}}
      diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 87453afb80..6276945e3a 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1728,7 +1728,9 @@ "highest-count-tooltip": "Highest Count found across all ComicInfo in the Series", "max-issue-tooltip": "Max Issue or Volume field from all ComicInfo in the series", "force-refresh": "Force Refresh", - "force-refresh-tooltip": "Force refresh external metadata from Kavita+" + "force-refresh-tooltip": "Force refresh external metadata from Kavita+", + "loose-leaf-volume": "Loose Leaf Chapters", + "specials-volume": "Specials" }, "day-breakdown": { diff --git a/openapi.json b/openapi.json index 6ee84f6576..a6a11b9dae 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.14.3" + "version": "0.7.14.5" }, "servers": [ { @@ -13953,6 +13953,7 @@ }, "minNumber": { "type": "number", + "description": "This may be 0 under the circumstance that the Issue is \"Alpha\" or other non-standard numbers.", "format": "float" }, "maxNumber": { @@ -13961,6 +13962,7 @@ }, "sortOrder": { "type": "number", + "description": "The sorting order of the Chapter. Inherits from MinNumber, but can be overridden.", "format": "float" }, "pages": {