From 6137d2a30eb85a24aae166c62245088601b36e17 Mon Sep 17 00:00:00 2001 From: Fesaa <77553571+Fesaa@users.noreply.github.com> Date: Thu, 28 Aug 2025 01:44:30 +0200 Subject: [PATCH] Koreader Sync Fix and More (#4006) Co-authored-by: Joe Milazzo --- API.Tests/AbstractDbTest.cs | 2 +- API.Tests/Parsing/MangaParsingTests.cs | 72 ++++++ .../Services/ExternalMetadataServiceTests.cs | 5 - API/Controllers/KoreaderController.cs | 17 +- API/Controllers/OPDSController.cs | 181 ++++++++------ API/Controllers/OidcController.cs | 15 +- API/Controllers/SeriesController.cs | 6 +- API/DTOs/Koreader/KoreaderBookDto.cs | 14 +- API/Data/Repositories/SeriesRepository.cs | 38 ++- .../ApplicationServiceExtensions.cs | 2 + .../Filtering/SearchQueryableExtensions.cs | 12 +- .../Builders/KoreaderBookDtoBuilder.cs | 19 +- API/Services/KoreaderService.cs | 7 +- API/Services/OidcService.cs | 2 +- API/Services/ReadingItemService.cs | 15 +- API/Services/Tasks/Scanner/Parser/Parser.cs | 111 ++++++++- .../app/_services/action-factory.service.ts | 12 +- UI/Web/src/app/_services/action.service.ts | 26 --- UI/Web/src/app/_services/library.service.ts | 8 - UI/Web/src/app/_services/nav.service.ts | 4 +- .../import-mappings.component.ts | 12 +- .../manage-library.component.html | 2 +- .../manage-library.component.ts | 9 - .../library-detail.component.ts | 3 - .../infinite-scroller.component.ts | 17 +- .../side-nav/side-nav.component.ts | 3 - .../library-settings-modal.component.ts | 3 - .../preference-nav.component.html | 26 ++- .../preference-nav.component.ts | 221 ++++++++++-------- UI/Web/src/assets/langs/en.json | 2 +- 30 files changed, 556 insertions(+), 310 deletions(-) diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index 171d35746..4c023a1f0 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -92,7 +92,7 @@ public abstract class AbstractDbTest(ITestOutputHelper testOutputHelper): Abstra } catch (Exception ex) { - testOutputHelper.WriteLine($"[SeedDb] Error: {ex.Message}"); + testOutputHelper.WriteLine($"[SeedDb] Error: {ex.Message} \n{ex.StackTrace}"); return false; } } diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs index a16d3ec4f..82941158d 100644 --- a/API.Tests/Parsing/MangaParsingTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -73,6 +73,7 @@ public class MangaParsingTests [InlineData("몰?루 아카이브 7.5권", "7.5")] [InlineData("63권#200", "63")] [InlineData("시즌34삽화2", "34")] + [InlineData("시즌3-4삽화2", "3-4")] [InlineData("Accel World Chapter 001 Volume 002", "2")] [InlineData("Accel World Volume 2", "2")] [InlineData("Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.31 Omake", "30")] @@ -87,11 +88,53 @@ public class MangaParsingTests [InlineData("Adventure Time (2012)/Adventure Time Ch 1 (2012)", Parser.LooseLeafVolume)] [InlineData("Adventure Time TPB (2012)/Adventure Time v01 (2012).cbz", "1")] [InlineData("Monster Ch. 001 [MangaPlus] [Digital] [amit34521]", Parser.LooseLeafVolume)] + [InlineData("Alter Ego (2020) (Digital) (v3dio)", Parser.LooseLeafVolume)] + [InlineData("Alter Ego (2020) (Digital) (t3dio)", Parser.LooseLeafVolume)] public void ParseVolumeTest(string filename, string expected) { Assert.Equal(expected, Parser.ParseVolume(filename, LibraryType.Manga)); } + [Theory] + [InlineData("One Piece - Vol 2 Ch 1.1 - Volume 4 Omakes", "2")] + [InlineData("Attack on Titan - Vol. 5 Ch. 20 - Vol.10 Special", "5")] + [InlineData("Naruto - Volume 1 Chapter 1 - Volume 2 Preview", "1")] + [InlineData("My Hero Academia - Vol 15 - Vol 20 Extras", "15")] + + // Edge cases for duplicate detection + [InlineData("Series - Vol 1 - Not Vol but Voldemort", "1")] // Should not trigger false positive + [InlineData("Volume Wars - Vol 1 vs Vol 2", "1")] // Series name contains "Volume" + [InlineData("Vol 3 - The Volume Chronicles - Vol 5", "3")] // Multiple volume references + + // Thai Volume tests + [InlineData("เล่ม 5 - Chapter 1", "5")] + [InlineData("เล่มที่ 12 Test", "12")] + + // Chinese Volume tests + [InlineData("幽游白书完全版 第03卷 天下", "3")] + [InlineData("阿衰online 第1册", "1")] + [InlineData("卷5 Test", "5")] + [InlineData("册10 Test", "10")] + + // Korean Volume tests + [InlineData("제5권 Test", "5")] + [InlineData("10화 Test", "10")] + [InlineData("시즌3 Test", "3")] + [InlineData("5시즌 Test", Parser.LooseLeafVolume)] + + // Japanese Volume tests + [InlineData("Test 5巻", "5")] + [InlineData("Series 10-15巻", "10-15")] + + // Russian Volume tests + [InlineData("Том 5 Test", "5")] + [InlineData("Тома 10 Test", "10")] + [InlineData("5 Том Test", "5")] + public void ParseDuplicateVolumeTest(string filename, string expected) + { + Assert.Equal(expected, Parser.ParseVolume(filename, LibraryType.Manga)); + } + [Theory] [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "Killing Bites")] [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "My Girlfriend Is Shobitch")] @@ -321,6 +364,35 @@ public class MangaParsingTests Assert.Equal(expected, Parser.ParseChapter(filename, LibraryType.Manga)); } + /// + /// Handles edge case testing around duplicate numbers in the filename + /// + [Theory] + [InlineData("Manga Title - Ch.1 - The 22 beers", "1")] + public void ParseExtraNumberChaptersTest(string filename, string expected) + { + Assert.Equal(expected, Parser.ParseChapter(filename, LibraryType.Manga)); + } + + [Theory] + [InlineData("Manga Title - Ch.1 Part.A - Ch.2 Omake", "1")] + [InlineData("Another Series - Chapter 10 Something - Chapter 15 Extra", "10")] + [InlineData("Test_Ch_3_Content_Ch_7_Bonus", "3")] + [InlineData("One Piece - Ch 5 Part 1 - Chapter 10 Omakes", "5")] + [InlineData("Attack on Titan - Chapter 20 Content - Ch 25 Special", "20")] + [InlineData("Naruto - Ch. 1 Story - Ch. 5 Preview", "1")] + [InlineData("My Hero Academia - Chapter 15 - Chapter 20 Extras", "15")] + [InlineData("Series Name - c2 Content - c5 Bonus", "2")] + [InlineData("Test Series - c1 Part1 - Chapter 3 Extra", "1")] + [InlineData("Another Test - Chapter 7 - c10 Omake", "7")] + [InlineData("Series - Ch 1 - Not Ch but Chaos", "1")] + [InlineData("Chapter Wars - Ch 1 vs Ch 2", "1")] + [InlineData("Ch 3 - The Chapter Chronicles - Ch 5", "3")] + public void ParseDuplicateChapterTest(string filename, string expected) + { + Assert.Equal(expected, Parser.ParseChapter(filename, LibraryType.Manga)); + } + [Theory] [InlineData("Tenjou Tenge Omnibus", "Omnibus")] diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs index 7e45a9a31..0a94b3d36 100644 --- a/API.Tests/Services/ExternalMetadataServiceTests.cs +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -41,11 +41,6 @@ public class ExternalMetadataServiceTests: AbstractDbTest private async Task<(IExternalMetadataService, Dictionary, Dictionary, Dictionary)> Setup(IUnitOfWork unitOfWork, DataContext context, IMapper mapper) { - context.Series.RemoveRange(context.Series); - context.AppUser.RemoveRange(context.AppUser); - context.Genre.RemoveRange(context.Genre); - context.Tag.RemoveRange(context.Tag); - context.Person.RemoveRange(context.Person); var metadataSettings = await unitOfWork.SettingsRepository.GetMetadataSettings(); metadataSettings.Enabled = false; diff --git a/API/Controllers/KoreaderController.cs b/API/Controllers/KoreaderController.cs index 8c4c41585..6122272ff 100644 --- a/API/Controllers/KoreaderController.cs +++ b/API/Controllers/KoreaderController.cs @@ -74,7 +74,7 @@ public class KoreaderController : BaseApiController var userId = await GetUserId(apiKey); await _koreaderService.SaveProgress(request, userId); - return Ok(new KoreaderProgressUpdateDto{ Document = request.Document, Timestamp = DateTime.UtcNow }); + return Ok(new KoreaderProgressUpdateDto{ Document = request.document, Timestamp = DateTime.UtcNow }); } catch (KavitaException ex) { @@ -89,15 +89,24 @@ public class KoreaderController : BaseApiController /// /// [HttpGet("{apiKey}/syncs/progress/{ebookHash}")] - public async Task> GetProgress(string apiKey, string ebookHash) + public async Task GetProgress(string apiKey, string ebookHash) { try { var userId = await GetUserId(apiKey); var response = await _koreaderService.GetProgress(ebookHash, userId); - _logger.LogDebug("Koreader response progress for User ({UserId}): {Progress}", userId, response.Progress.Sanitize()); + _logger.LogDebug("Koreader response progress for User ({UserId}): {Progress}", userId, response.progress.Sanitize()); - return Ok(response); + + // We must pack this manually for Koreader due to a bug in their code: https://github.com/koreader/koreader/issues/13629 + var json = System.Text.Json.JsonSerializer.Serialize(response); + + return new ContentResult() + { + Content = json, + ContentType = "application/json", + StatusCode = 200 + }; } catch (KavitaException ex) { diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 6e96c3063..0d04c181f 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Net; using System.Threading.Tasks; using System.Xml; using System.Xml.Serialization; @@ -28,6 +29,7 @@ using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; using MimeTypes; @@ -35,7 +37,59 @@ namespace API.Controllers; #nullable enable +/** + * Middleware that checks if Opds has been enabled for this server + */ +[AttributeUsage(AttributeTargets.Class)] +public class OpdsActionFilterAttribute(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILogger logger): ActionFilterAttribute +{ + + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + int userId; + try + { + if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) || + apiKeyObj is not string apiKey || context.Controller is not OpdsController controller) + { + context.Result = new BadRequestResult(); + return; + } + + userId = await controller.GetUser(apiKey); + if (userId == null || userId == 0) + { + context.Result = new UnauthorizedResult(); + return; + } + + var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (!settings.EnableOpds) + { + context.Result = new ContentResult + { + Content = await localizationService.Translate(userId, "opds-disabled"), + ContentType = "text/plain", + StatusCode = (int)HttpStatusCode.BadRequest, + }; + return; + } + } + catch (Exception ex) + { + logger.LogError(ex, "failed to handle OPDS request"); + context.Result = new BadRequestResult(); + return; + } + + context.HttpContext.Items.Add(OpdsController.UserId, userId); + await next(); + } + +} + [AllowAnonymous] +[ServiceFilter(typeof(OpdsActionFilterAttribute))] public class OpdsController : BaseApiController { private readonly ILogger _logger; @@ -80,6 +134,7 @@ public class OpdsController : BaseApiController private readonly FilterV2Dto _filterV2Dto = new FilterV2Dto(); private readonly ChapterSortComparerDefaultLast _chapterSortComparerDefaultLast = ChapterSortComparerDefaultLast.Default; private const int PageSize = 20; + public const string UserId = nameof(UserId); public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService, IDirectoryService directoryService, ICacheService cacheService, @@ -102,15 +157,17 @@ public class OpdsController : BaseApiController _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); } + private int GetUserIdFromContext() + { + return (int) HttpContext.Items[UserId]!; + } + [HttpPost("{apiKey}")] [HttpGet("{apiKey}")] [Produces("application/xml")] public async Task Get(string apiKey) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); - + var userId = GetUserIdFromContext(); var (_, prefix) = await GetPrefix(); var feed = CreateFeed("Kavita", string.Empty, apiKey, prefix); @@ -316,12 +373,9 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetSmartFilter(string apiKey, int filterId, [FromQuery] int pageNumber = 0) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); - var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId); if (filter == null) return BadRequest(_localizationService.Translate(userId, "smart-filter-doesnt-exist")); var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters-" + filter.Id), $"{apiKey}/smart-filters/{filter.Id}/", apiKey, prefix); @@ -345,9 +399,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetSmartFilters(string apiKey) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (_, prefix) = await GetPrefix(); var filters = _unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId); @@ -376,9 +428,7 @@ public class OpdsController : BaseApiController public async Task GetExternalSources(string apiKey) { // NOTE: This doesn't seem possible in OPDS v2.1 due to the resulting stream using relative links and most apps resolve against source url. Even using full paths doesn't work - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (_, prefix) = await GetPrefix(); var externalSources = await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId); @@ -408,9 +458,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetLibraries(string apiKey) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{apiKey}/libraries", apiKey, prefix); SetFeedId(feed, "libraries"); @@ -442,9 +490,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetWantToRead(string apiKey, [FromQuery] int pageNumber = 0) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); var wantToReadSeries = await _unitOfWork.SeriesRepository.GetWantToReadForUserV2Async(userId, GetUserParams(pageNumber), _filterV2Dto); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(wantToReadSeries.Select(s => s.Id)); @@ -463,9 +509,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetCollections(string apiKey) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null) return Unauthorized(); @@ -501,9 +545,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 0) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null) return Unauthorized(); @@ -534,9 +576,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetReadingLists(string apiKey, [FromQuery] int pageNumber = 0) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, @@ -583,7 +623,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 0) { - var userId = await GetUser(apiKey); + var userId = GetUserIdFromContext(); if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) { @@ -633,9 +673,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 0) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); var library = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).SingleOrDefault(l => @@ -674,9 +712,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = 1) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, GetUserParams(pageNumber), _filterV2Dto); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id)); @@ -697,9 +733,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetMoreInGenre(string apiKey, [FromQuery] int genreId, [FromQuery] int pageNumber = 1) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); var genre = await _unitOfWork.GenreRepository.GetGenreById(genreId); var seriesDtos = await _unitOfWork.SeriesRepository.GetMoreIn(userId, 0, genreId, GetUserParams(pageNumber)); @@ -721,13 +755,21 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetRecentlyUpdated(string apiKey, [FromQuery] int pageNumber = 1) { - var userId = await GetUser(apiKey); + var userId = GetUserIdFromContext(); if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + { return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); - var (baseUrl, prefix) = await GetPrefix(); - var seriesDtos = (await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId, PageSize)).ToList(); + } + + var userParams = new UserParams + { + PageNumber = pageNumber, + PageSize = PageSize, + }; + var seriesDtos = (await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId, userParams)).ToList(); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(seriesDtos.Select(s => s.SeriesId)); + var (baseUrl, prefix) = await GetPrefix(); var feed = CreateFeed(await _localizationService.Translate(userId, "recently-updated"), $"{apiKey}/recently-updated", apiKey, prefix); SetFeedId(feed, "recently-updated"); @@ -751,10 +793,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); - + var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); var userParams = GetUserParams(pageNumber); @@ -785,9 +824,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task SearchSeries(string apiKey, [FromQuery] string query) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); @@ -859,9 +896,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetSearchDescriptor(string apiKey) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (_, prefix) = await GetPrefix(); var feed = new OpenSearchDescription() { @@ -884,9 +919,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetSeries(string apiKey, int seriesId) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); @@ -958,24 +991,34 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetVolume(string apiKey, int seriesId, int volumeId) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); + if (series == null) + { + return NotFound(); + } + var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters); + if (volume == null) + { + return NotFound(); + } var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix); SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s"); - foreach (var chapter in volume.Chapters) + foreach (var chapterId in volume.Chapters.Select(c => c.Id)) { - var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id, ChapterIncludes.Files | ChapterIncludes.People); + var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, ChapterIncludes.Files | ChapterIncludes.People); + if (chapterDto == null) continue; + foreach (var mangaFile in chapterDto.Files) { - feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapter.Id, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl)); + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl)); } } @@ -986,9 +1029,7 @@ public class OpdsController : BaseApiController [Produces("application/xml")] public async Task GetChapter(string apiKey, int seriesId, int volumeId, int chapterId) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); @@ -1023,10 +1064,8 @@ public class OpdsController : BaseApiController [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}")] public async Task DownloadFile(string apiKey, int seriesId, int volumeId, int chapterId, string filename) { - var userId = await GetUser(apiKey); - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(await GetUser(apiKey)); + var userId = GetUserIdFromContext(); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (!await _accountService.HasDownloadPermission(user)) { return Forbid("User does not have download permissions"); @@ -1249,7 +1288,7 @@ public class OpdsController : BaseApiController public async Task GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber, [FromQuery] bool saveProgress = true) { - var userId = await GetUser(apiKey); + var userId = GetUserIdFromContext(); if (pageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "Page")); var chapter = await _cacheService.Ensure(chapterId, true); if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "cache-file-find")); @@ -1293,7 +1332,7 @@ public class OpdsController : BaseApiController [ResponseCache(Duration = 60 * 60, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetFavicon(string apiKey) { - var userId = await GetUser(apiKey); + var userId = GetUserIdFromContext(); var files = _directoryService.GetFilesWithExtension(Path.Join(Directory.GetCurrentDirectory(), ".."), @"\.ico"); if (files.Length == 0) return BadRequest(await _localizationService.Translate(userId, "favicon-doesnt-exist")); var path = files[0]; @@ -1307,7 +1346,7 @@ public class OpdsController : BaseApiController /// Gets the user from the API key /// /// - private async Task GetUser(string apiKey) + public async Task GetUser(string apiKey) { try { diff --git a/API/Controllers/OidcController.cs b/API/Controllers/OidcController.cs index aefa361ef..22e8530b4 100644 --- a/API/Controllers/OidcController.cs +++ b/API/Controllers/OidcController.cs @@ -1,8 +1,12 @@ -using API.Extensions; +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Extensions; using API.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; @@ -20,7 +24,7 @@ public class OidcController: ControllerBase } [HttpGet("logout")] - public IActionResult Logout() + public async Task Logout() { if (!Request.Cookies.ContainsKey(OidcService.CookieName)) @@ -28,6 +32,13 @@ public class OidcController: ControllerBase return Redirect("/"); } + var res = await Request.HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + if (!res.Succeeded || res.Properties == null || string.IsNullOrEmpty(res.Properties.GetString(OidcService.IdToken))) + { + HttpContext.Response.Cookies.Delete(OidcService.CookieName); + return Redirect("/"); + } + return SignOut( new AuthenticationProperties { RedirectUri = "/login" }, CookieAuthenticationDefaults.AuthenticationScheme, diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 389ff33a7..2573c8ca8 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -299,12 +299,14 @@ public class SeriesController : BaseApiController /// /// Returns series that were recently updated, like adding or removing a chapter /// + /// Page size and offset /// [ResponseCache(CacheProfileName = "Instant")] [HttpPost("recently-updated-series")] - public async Task>> GetRecentlyAddedChapters() + public async Task>> GetRecentlyAddedChapters([FromQuery] UserParams? userParams) { - return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(User.GetUserId(), 20)); + userParams ??= UserParams.Default; + return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(User.GetUserId(), userParams)); } /// diff --git a/API/DTOs/Koreader/KoreaderBookDto.cs b/API/DTOs/Koreader/KoreaderBookDto.cs index b66b7da3a..9bfc4adc3 100644 --- a/API/DTOs/Koreader/KoreaderBookDto.cs +++ b/API/DTOs/Koreader/KoreaderBookDto.cs @@ -11,23 +11,27 @@ public class KoreaderBookDto /// /// This is the Koreader hash of the book. It is used to identify the book. /// - public string Document { get; set; } + public string document { get; set; } /// /// A randomly generated id from the koreader device. Only used to maintain the Koreader interface. /// - public string Device_id { get; set; } + public string device_id { get; set; } /// /// The Koreader device name. Only used to maintain the Koreader interface. /// - public string Device { get; set; } + public string device { get; set; } /// /// Percent progress of the book. Only used to maintain the Koreader interface. /// - public float Percentage { get; set; } + public float percentage { get; set; } /// /// An XPath string read by Koreader to determine the location within the epub. /// Essentially, it is Koreader's equivalent to ProgressDto.BookScrollId. /// /// - public string Progress { get; set; } + public string progress { get; set; } + /// + /// Last Progress in Unix seconds since epoch + /// + public long timestamp { get; set; } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 0c4b8350a..d5d89994c 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -134,7 +134,7 @@ public interface ISeriesRepository Task GetFullSeriesForSeriesIdAsync(int seriesId); Task GetChunkInfo(int libraryId = 0); Task> GetSeriesMetadataForIdsAsync(IEnumerable seriesIds); - Task> GetRecentlyUpdatedSeries(int userId, int pageSize = 30); + Task> GetRecentlyUpdatedSeries(int userId, UserParams? userParams); Task GetRelatedSeries(int userId, int seriesId); Task> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind); Task> GetQuickReads(int userId, int libraryId, UserParams userParams); @@ -417,7 +417,8 @@ public class SeriesRepository : ISeriesRepository .Include(s => s.Library) .AsNoTracking() .AsSplitQuery() - .OrderBy(s => s.SortName!.ToLower()) + .OrderBy(s => s.SortName!.Length) + .ThenBy(s => s.SortName!.ToLower()) .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .AsEnumerable(); @@ -435,7 +436,8 @@ public class SeriesRepository : ISeriesRepository EF.Functions.Like(joined.Series.OriginalName, $"%{searchQuery}%")) || (joined.Series.LocalizedName != null && EF.Functions.Like(joined.Series.LocalizedName, $"%{searchQuery}%")))) - .OrderBy(joined => joined.Series.Name) + .OrderBy(joined => joined.Series.NormalizedName.Length) + .ThenBy(joined => joined.Series.NormalizedName) .Take(maxRecords) .Select(joined => new BookmarkSearchResultDto() { @@ -473,7 +475,8 @@ public class SeriesRepository : ISeriesRepository result.Persons = await _context.Person .Where(p => personIds.Contains(p.Id)) - .OrderBy(p => p.NormalizedName) + .OrderBy(p => p.NormalizedName.Length) + .ThenBy(p => p.NormalizedName) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -523,7 +526,8 @@ public class SeriesRepository : ISeriesRepository ) .Where(c => c.Files.All(f => fileIds.Contains(f.Id))) .AsSplitQuery() - .OrderBy(c => c.TitleName) + .OrderBy(c => c.TitleName.Length) + .ThenBy(c => c.TitleName) .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -1476,12 +1480,12 @@ public class SeriesRepository : ISeriesRepository /// This provides 2 levels of pagination. Fetching the individual chapters only looks at 3000. Then when performing grouping /// in memory, we stop after 30 series. /// Used to ensure user has access to libraries - /// How many entities to return + /// Page size and offset /// - public async Task> GetRecentlyUpdatedSeries(int userId, int pageSize = 30) + public async Task> GetRecentlyUpdatedSeries(int userId, UserParams? userParams) { - var seriesMap = new Dictionary(); - var index = 0; + userParams ??= UserParams.Default; + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); var items = (await GetRecentlyAddedChaptersQuery(userId)); @@ -1490,20 +1494,30 @@ public class SeriesRepository : ISeriesRepository items = items.RestrictAgainstAgeRestriction(userRating); } + var index = 0; + var seriesMap = new Dictionary(); + var toSkip = (userParams.PageNumber - 1) * userParams.PageSize; + var skipped = new HashSet(); + foreach (var item in items) { - if (seriesMap.Keys.Count == pageSize) break; + if (seriesMap.Keys.Count == userParams.PageSize) break; if (item.SeriesName == null) continue; + if (skipped.Count < toSkip) + { + skipped.Add(item.SeriesId); + continue; + } - if (seriesMap.TryGetValue(item.SeriesName + "_" + item.LibraryId, out var value)) + if (seriesMap.TryGetValue(item.SeriesId, out var value)) { value.Count += 1; } else { - seriesMap[item.SeriesName + "_" + item.LibraryId] = new GroupedSeriesDto() + seriesMap[item.SeriesId] = new GroupedSeriesDto() { LibraryId = item.LibraryId, LibraryType = item.LibraryType, diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index c0eddd272..eacb9f51c 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -1,5 +1,6 @@ using System.IO.Abstractions; using API.Constants; +using API.Controllers; using API.Data; using API.Helpers; using API.Services; @@ -86,6 +87,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSqLite(); services.AddSignalR(opt => opt.EnableDetailedErrors = true); diff --git a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs index d7acf9381..79cf66033 100644 --- a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs @@ -20,7 +20,8 @@ public static class SearchQueryableExtensions .Where(s => EF.Functions.Like(s.Title!, $"%{searchQuery}%") || EF.Functions.Like(s.NormalizedTitle!, $"%{searchQuery}%")) .RestrictAgainstAgeRestriction(userRating) - .OrderBy(s => s.NormalizedTitle); + .OrderBy(s => s.NormalizedTitle.Length) + .ThenBy(s => s.NormalizedTitle); } public static IQueryable Search(this IQueryable queryable, @@ -30,7 +31,8 @@ public static class SearchQueryableExtensions .Where(rl => rl.AppUserId == userId || rl.Promoted) .Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%")) .RestrictAgainstAgeRestriction(userRating) - .OrderBy(s => s.NormalizedTitle); + .OrderBy(s => s.NormalizedTitle.Length) + .ThenBy(s => s.NormalizedTitle); } public static IQueryable Search(this IQueryable queryable, @@ -80,7 +82,8 @@ public static class SearchQueryableExtensions .Where(sm => seriesIds.Contains(sm.SeriesId)) .SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) .Distinct() - .OrderBy(t => t.NormalizedTitle); + .OrderBy(t => t.NormalizedTitle.Length) + .ThenBy(t => t.NormalizedTitle); } public static IQueryable SearchTags(this IQueryable queryable, @@ -91,6 +94,7 @@ public static class SearchQueryableExtensions .SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) .AsSplitQuery() .Distinct() - .OrderBy(t => t.NormalizedTitle); + .OrderBy(t => t.NormalizedTitle.Length) + .ThenBy(t => t.NormalizedTitle); } } diff --git a/API/Helpers/Builders/KoreaderBookDtoBuilder.cs b/API/Helpers/Builders/KoreaderBookDtoBuilder.cs index debbe0347..564f0ca33 100644 --- a/API/Helpers/Builders/KoreaderBookDtoBuilder.cs +++ b/API/Helpers/Builders/KoreaderBookDtoBuilder.cs @@ -14,33 +14,40 @@ public class KoreaderBookDtoBuilder : IEntityBuilder { _dto = new KoreaderBookDto() { - Document = documentHash, - Device = "Kavita" + document = documentHash, + device = "Kavita" }; } public KoreaderBookDtoBuilder WithDocument(string documentHash) { - _dto.Document = documentHash; + _dto.document = documentHash; return this; } public KoreaderBookDtoBuilder WithProgress(string progress) { - _dto.Progress = progress; + _dto.progress = progress; return this; } public KoreaderBookDtoBuilder WithPercentage(int? pageNum, int pages) { - _dto.Percentage = (pageNum ?? 0) / (float) pages; + _dto.percentage = (pageNum ?? 0) / (float) pages; return this; } public KoreaderBookDtoBuilder WithDeviceId(string installId, int userId) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes(installId + userId)); - _dto.Device_id = Convert.ToHexString(hash); + _dto.device_id = Convert.ToHexString(hash); + return this; + } + + public KoreaderBookDtoBuilder WithTimestamp(DateTime? lastModifiedUtc) + { + var time = lastModifiedUtc ?? new DateTime(0, DateTimeKind.Utc); + _dto.timestamp = new DateTimeOffset(time).ToUnixTimeSeconds(); return this; } } diff --git a/API/Services/KoreaderService.cs b/API/Services/KoreaderService.cs index a38e8c468..8300199f0 100644 --- a/API/Services/KoreaderService.cs +++ b/API/Services/KoreaderService.cs @@ -40,8 +40,8 @@ public class KoreaderService : IKoreaderService /// public async Task SaveProgress(KoreaderBookDto koreaderBookDto, int userId) { - _logger.LogDebug("Saving Koreader progress for User ({UserId}): {KoreaderProgress}", userId, koreaderBookDto.Progress.Sanitize()); - var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(koreaderBookDto.Document); + _logger.LogDebug("Saving Koreader progress for User ({UserId}): {KoreaderProgress}", userId, koreaderBookDto.progress.Sanitize()); + var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(koreaderBookDto.document); if (file == null) throw new KavitaException(await _localizationService.Translate(userId, "file-missing")); var userProgressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId); @@ -61,7 +61,7 @@ public class KoreaderService : IKoreaderService }; } // Update the bookScrollId if possible - KoreaderHelper.UpdateProgressDto(userProgressDto, koreaderBookDto.Progress); + KoreaderHelper.UpdateProgressDto(userProgressDto, koreaderBookDto.progress); await _readerService.SaveReadingProgress(userProgressDto, userId); } @@ -86,6 +86,7 @@ public class KoreaderService : IKoreaderService return new KoreaderBookDtoBuilder(bookHash).WithProgress(koreaderProgress) .WithPercentage(progressDto?.PageNum, file.Pages) .WithDeviceId(settingsDto.InstallId, userId) + .WithTimestamp(progressDto?.LastModifiedUtc) .Build(); } } diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index 67ca14d8c..e19663be1 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -102,7 +102,7 @@ public class OidcService(ILogger logger, UserManager userM throw new KavitaException("errors.oidc.missing-external-id"); } - var user = await unitOfWork.UserRepository.GetByOidcId(oidcId, AppUserIncludes.UserPreferences); + var user = await unitOfWork.UserRepository.GetByOidcId(oidcId, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams); if (user != null) { await SyncUserSettings(request, settings, principal, user); diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index 6ff8d19de..543fbaedb 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -49,9 +49,12 @@ public class ReadingItemService : IReadingItemService /// Gets the ComicInfo for the file if it exists. Null otherwise. /// /// Fully qualified path of file + /// If false, returns null /// - private ComicInfo? GetComicInfo(string filePath) + private ComicInfo? GetComicInfo(string filePath, bool enableMetadata) { + if (!enableMetadata) return null; + if (Parser.IsEpub(filePath) || Parser.IsPdf(filePath)) { return _bookService.GetComicInfo(filePath); @@ -181,23 +184,23 @@ public class ReadingItemService : IReadingItemService { if (_comicVineParser.IsApplicable(path, type)) { - return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); + return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path, enableMetadata)); } if (_imageParser.IsApplicable(path, type)) { - return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); + return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path, enableMetadata)); } if (_bookParser.IsApplicable(path, type)) { - return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); + return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path, enableMetadata)); } if (_pdfParser.IsApplicable(path, type)) { - return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); + return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path, enableMetadata)); } if (_basicParser.IsApplicable(path, type)) { - return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); + return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path, enableMetadata)); } return null; diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 673fa4d95..1ba2cca37 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -117,6 +117,36 @@ public static partial class Parser private static readonly Regex SpecialTokenRegex = new(@"SP\d+", MatchOptions, RegexTimeout); + /// + /// An additional check to avoid situations like "One Piece - Vol 4 ch 2 - vol 6 omakes" + /// + private static readonly Regex DuplicateVolumeRegex = new Regex( + @"(?i)(vol\.?|volume|v)(\s|_)*\d+.*?(vol\.?|volume|v)(\s|_)*\d+", + MatchOptions, RegexTimeout); + + private static readonly Regex DuplicateChapterRegex = new Regex( + @"(?i)(ch\.?|chapter|c)(\s|_)*\d+.*?(ch\.?|chapter|c)(\s|_)*\d+", + MatchOptions, RegexTimeout); + + // Regex to detect range patterns that should NOT be treated as duplicates (History's Strongest c1-c4) + private static readonly Regex VolumeRangeRegex = new Regex( + @"(vol\.?|v)(\s|_)?\d+(\.\d+)?-(vol\.?|v)(\s|_)?\d+(\.\d+)?", + MatchOptions, RegexTimeout); + + private static readonly Regex ChapterRangeRegex = new Regex( + @"(ch\.?|c)(\s|_)?\d+(\.\d+)?-(ch\.?|c)(\s|_)?\d+(\.\d+)?", + MatchOptions, RegexTimeout); + + // Regex to find volume number after a volume marker + private static readonly Regex VolumeNumberRegex = new Regex( + @"(vol\.?|volume|v)(\s|_)*(?\d+(\.\d+)?(-\d+(\.\d+)?)?)", + MatchOptions, RegexTimeout); + + // Regex to find chapter number after a chapter marker + private static readonly Regex ChapterNumberRegex = new Regex( + @"(ch\.?|chapter|c)(\s|_)*(?\d+(\.\d+)?(-\d+(\.\d+)?)?)", + MatchOptions, RegexTimeout); + private static readonly Regex[] MangaSeriesRegex = [ @@ -408,7 +438,7 @@ public static partial class Parser MatchOptions, RegexTimeout), // Historys Strongest Disciple Kenichi_v11_c90-98.zip or Dance in the Vampire Bund v16-17 new Regex( - @"(?.*)(\b|_)(?!\[)v(?" + NumberRange + @")(?!\])", + @"(?.*)(\b|_)(?!\[)v(?" + NumberRange + @")(?!\])(\b|_)", MatchOptions, RegexTimeout), // Kodomo no Jikan vol. 10, [dmntsf.net] One Piece - Digital Colored Comics Vol. 20.5-21.5 Ch. 177 new Regex( @@ -422,9 +452,9 @@ public static partial class Parser new Regex( @"((volume|tome)\s)(?\d+(\.\d)?)", MatchOptions, RegexTimeout), - // Tower Of God S01 014 (CBT) (digital).cbz, Tower Of God T01 014 (CBT) (digital).cbz, + // Tower Of God S01 014 (CBT) (digital).cbz, Tower Of God T01 014 (CBT) (digital).cbz, new Regex( - @"(?.*)(\b|_)((S|T)(?\d+))", + @"(?.*)(\b|_)((S|T)(?\d+)(\b|_))", MatchOptions, RegexTimeout), // vol_001-1.cbz for MangaPy default naming convention new Regex( @@ -445,7 +475,7 @@ public static partial class Parser MatchOptions, RegexTimeout), // Korean Season: 시즌n -> Season n, new Regex( - @"시즌(?\d+\-?\d+)", + @"시즌(?\d+(\-\d+)?)", MatchOptions, RegexTimeout), // Korean Season: 시즌n -> Season n, n시즌 -> season n new Regex( @@ -745,6 +775,8 @@ public static partial class Parser public static string ParseMangaVolume(string filename) { + filename = RemoveDuplicateVolumeIfExists(filename); + foreach (var regex in MangaVolumeRegex) { var matches = regex.Matches(filename); @@ -845,6 +877,8 @@ public static partial class Parser private static string ParseMangaChapter(string filename) { + filename = RemoveDuplicateChapterIfExists(filename); + foreach (var regex in MangaChapterRegex) { var matches = regex.Matches(filename); @@ -1189,6 +1223,75 @@ public static partial class Parser return filename; } + /// + /// Checks for a duplicate volume marker and removes it + /// + /// + /// + private static string RemoveDuplicateVolumeIfExists(string filename) + { + // First check if this contains a volume range pattern - if so, don't process as duplicate (v1-v2, edge case) + if (VolumeRangeRegex.IsMatch(filename)) + return filename; + + var duplicateMatch = DuplicateVolumeRegex.Match(filename); + if (!duplicateMatch.Success) return filename; + + // Find the start position of the first volume marker + var firstVolumeStart = duplicateMatch.Groups[1].Index; + + // Find the volume number after the first marker + var volumeNumberMatch = VolumeNumberRegex.Match(filename, firstVolumeStart); + if (!volumeNumberMatch.Success) return filename; + + var volumeNumberEnd = volumeNumberMatch.Index + volumeNumberMatch.Length; + + // Find the second volume marker after the first volume number + var secondVolumeMatch = VolumeNumberRegex.Match(filename, volumeNumberEnd); + if (secondVolumeMatch.Success) + { + // Truncate the filename at the second volume marker + return filename.Substring(0, secondVolumeMatch.Index).TrimEnd(' ', '-', '_'); + } + + return filename; + } + + /// + /// Removes duplicate chapter markers from filename, keeping only the first occurrence + /// + /// Original filename + /// Processed filename with duplicate chapter markers removed + public static string RemoveDuplicateChapterIfExists(string filename) + { + // First check if this contains a chapter range pattern - if so, don't process as duplicate (c1-c2, edge case) + if (ChapterRangeRegex.IsMatch(filename)) + return filename; + + var duplicateMatch = DuplicateChapterRegex.Match(filename); + if (!duplicateMatch.Success) return filename; + + // Find the start position of the first chapter marker + var firstChapterStart = duplicateMatch.Groups[1].Index; + + // Find the chapter number after the first marker + var chapterNumberMatch = ChapterNumberRegex.Match(filename, firstChapterStart); + if (!chapterNumberMatch.Success) return filename; + + var chapterNumberEnd = chapterNumberMatch.Index + chapterNumberMatch.Length; + + // Find the second chapter marker after the first chapter number + var secondChapterMatch = ChapterNumberRegex.Match(filename, chapterNumberEnd); + if (secondChapterMatch.Success) + { + // Truncate the filename at the second chapter marker + return filename.Substring(0, secondChapterMatch.Index).TrimEnd(' ', '-', '_'); + } + + return filename; + } + + [GeneratedRegex(SupportedExtensions)] private static partial Regex SupportedExtensionsRegex(); [GeneratedRegex(@"\d-{1}\d")] diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index e5967bf24..8bd667b60 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -303,7 +303,7 @@ export class ActionFactoryService { // Scan is currently not supported due to the backend not being able to handle it yet const actions = this.flattenActions(this.libraryActions).filter(a => { - return [Action.Delete, Action.GenerateColorScape, Action.AnalyzeFiles, Action.RefreshMetadata, Action.CopySettings].includes(a.action); + return [Action.Delete, Action.GenerateColorScape, Action.RefreshMetadata, Action.CopySettings].includes(a.action); }); actions.push({ @@ -410,16 +410,6 @@ export class ActionFactoryService { requiredRoles: [Role.Admin], children: [], }, - { - action: Action.AnalyzeFiles, - title: 'analyze-files', - description: 'analyze-files-tooltip', - callback: this.dummyCallback, - shouldRender: this.dummyShouldRender, - requiresAdmin: true, - requiredRoles: [Role.Admin], - children: [], - }, { action: Action.Delete, title: 'delete', diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 2328bf72e..58f7fde9b 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -136,32 +136,6 @@ export class ActionService { }); } - /** - * Request an analysis of files for a given Library (currently just word count) - * @param library Partial Library, must have id and name populated - * @param callback Optional callback to perform actions after API completes - * @returns - */ - async analyzeFiles(library: Partial, callback?: LibraryActionCallback) { - if (!library.hasOwnProperty('id') || library.id === undefined) { - return; - } - - if (!await this.confirmService.alert(translate('toasts.alert-long-running'))) { - if (callback) { - callback(library); - } - return; - } - - this.libraryService.analyze(library?.id).pipe(take(1)).subscribe((res: any) => { - this.toastr.info(translate('toasts.library-file-analysis-queued', {name: library.name})); - if (callback) { - callback(library); - } - }); - } - async deleteLibrary(library: Partial, callback?: LibraryActionCallback) { if (!library.hasOwnProperty('id') || library.id === undefined) { return; diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index 4c945cf9a..5d3a89f0a 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -101,10 +101,6 @@ export class LibraryService { return this.httpClient.post(this.baseUrl + 'library/scan-multiple', {ids: libraryIds, force: force}); } - analyze(libraryId: number) { - return this.httpClient.post(this.baseUrl + 'library/analyze?libraryId=' + libraryId, {}); - } - refreshMetadata(libraryId: number, forceUpdate = false, forceColorscape = false) { return this.httpClient.post(this.baseUrl + `library/refresh-metadata?libraryId=${libraryId}&force=${forceUpdate}&forceColorscape=${forceColorscape}`, {}); } @@ -113,10 +109,6 @@ export class LibraryService { return this.httpClient.post(this.baseUrl + 'library/refresh-metadata-multiple?forceColorscape=' + forceColorscape, {ids: libraryIds, force: force}); } - analyzeFilesMultipleLibraries(libraryIds: Array) { - return this.httpClient.post(this.baseUrl + 'library/analyze-multiple', {ids: libraryIds, force: false}); - } - copySettingsFromLibrary(sourceLibraryId: number, targetLibraryIds: Array, includeType: boolean) { return this.httpClient.post(this.baseUrl + 'library/copy-settings-from', {sourceLibraryId, targetLibraryIds, includeType}); } diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 37de1e6e8..ce9f2df78 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -8,7 +8,7 @@ import {TextResonse} from "../_types/text-response"; import {AccountService} from "./account.service"; import {map} from "rxjs/operators"; import {NavigationEnd, Router} from "@angular/router"; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component"; import {WikiLink} from "../_models/wiki"; import {AuthGuard} from "../_guards/auth.guard"; @@ -79,12 +79,14 @@ export class NavService { * If the Side Nav is in a collapsed state or not. */ sideNavCollapsed$ = this.sideNavCollapseSource.asObservable(); + sideNavCollapsedSignal = toSignal(this.sideNavCollapsed$, {initialValue: false}); private sideNavVisibilitySource = new ReplaySubject(1); /** * If the side nav is rendered or not into the DOM. */ sideNavVisibility$ = this.sideNavVisibilitySource.asObservable(); + sideNavVisibilitySignal = toSignal(this.sideNavVisibility$, {initialValue: false}) usePreferenceSideNav$ = this.router.events.pipe( filter(event => event instanceof NavigationEnd), diff --git a/UI/Web/src/app/admin/import-mappings/import-mappings.component.ts b/UI/Web/src/app/admin/import-mappings/import-mappings.component.ts index 563b0f041..3e0f88860 100644 --- a/UI/Web/src/app/admin/import-mappings/import-mappings.component.ts +++ b/UI/Web/src/app/admin/import-mappings/import-mappings.component.ts @@ -33,7 +33,7 @@ import { ImportModes, ImportSettings } from "../../_models/import-field-mappings"; -import {firstValueFrom, switchMap} from "rxjs"; +import {catchError, firstValueFrom, of, switchMap} from "rxjs"; import {map, tap} from "rxjs/operators"; import {AgeRatingPipe} from "../../_pipes/age-rating.pipe"; import {NgTemplateOutlet} from "@angular/common"; @@ -213,8 +213,16 @@ export class ImportMappingsComponent implements OnInit { const settings = this.importSettingsForm.value as ImportSettings; return firstValueFrom(this.settingsService.importFieldMappings(data, settings).pipe( - tap((res) => this.importResult.set(res)), + catchError(err => { + console.error(err); + this.toastr.error(translate('import-mappings.invalid-file')); + return of(null) + }), switchMap((res) => { + if (res == null) return of(null); + + this.importResult.set(res); + return this.settingsService.getMetadataSettings().pipe( tap(dto => this.settings.set(dto)), tap(() => { diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.html b/UI/Web/src/app/admin/manage-library/manage-library.component.html index 10c535b01..a40ea6729 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.html +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.html @@ -2,7 +2,7 @@
+ [disabled]="bulkMode" (actionHandler)="handleBulkAction($event, null!)">
diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.ts b/UI/Web/src/app/admin/manage-library/manage-library.component.ts index fa8a79bb8..38cad4237 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.ts +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.ts @@ -221,14 +221,6 @@ export class ManageLibraryComponent implements OnInit { this.resetBulkMode(); }); break - case Action.AnalyzeFiles: - this.bulkMode = true; - this.cdRef.markForCheck(); - this.libraryService.analyzeFilesMultipleLibraries(selected.map(l => l.id)).subscribe(() => { - this.getLibraries(); - this.resetBulkMode(); - }); - break; case Action.GenerateColorScape: this.bulkMode = true; this.cdRef.markForCheck(); @@ -280,7 +272,6 @@ export class ManageLibraryComponent implements OnInit { case(Action.RefreshMetadata): case(Action.GenerateColorScape): case (Action.Delete): - case (Action.AnalyzeFiles): await this.applyBulkAction(); break; case (Action.CopySettings): diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index 62a60fa7b..537fe6f99 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -280,9 +280,6 @@ export class LibraryDetailComponent implements OnInit { this.loadPageSource.next(true); }); break; - case (Action.AnalyzeFiles): - await this.actionService.analyzeFiles(library); - break; case(Action.Edit): this.actionService.editLibrary(library); break; diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts index 3ca9c7007..667c9205a 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts @@ -14,7 +14,7 @@ import { OnDestroy, OnInit, Output, - Renderer2, Signal, + Renderer2, signal, Signal, SimpleChanges, ViewChild } from '@angular/core'; @@ -45,6 +45,11 @@ const DEFAULT_SCROLL_DEBOUNCE = 20; * Safari does not support the scrollEnd event, we can use scroll event with higher debounce time to emulate it */ const EMULATE_SCROLL_END_DEBOUNCE = 100; +/** + * Time which must have passed before auto chapter changes can occur. + * See: https://github.com/Kareadita/Kavita/issues/3970 + */ +const INITIAL_LOAD_GRACE_PERIOD = 1000; /** * Bitwise enums for configuring how much debug information we want @@ -178,6 +183,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, * Tracks the first load, until all the initial prefetched images are loaded. We use this to reduce opacity so images can load without jerk. */ initFinished: boolean = false; + /** + * True until INITIAL_LOAD_GRACE_PERIOD ms have passed since the component was created + */ + isInitialLoad = true; /** * Debug mode. Will show extra information. Use bitwise (|) operators between different modes to enable different output */ @@ -252,6 +261,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, } ngOnInit(): void { + setTimeout(() => { + this.isInitialLoad = false; + }, INITIAL_LOAD_GRACE_PERIOD); + this.initScrollHandler(); this.recalculateImageWidth(); @@ -430,7 +443,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, } checkIfShouldTriggerContinuousReader() { - if (this.isScrolling) return; + if (this.isScrolling || this.isInitialLoad) return; if (this.scrollingDirection === PAGING_DIRECTION.FORWARD) { const totalHeight = this.getTotalHeight(); diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts index be9d841b5..1dc24b6cc 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts @@ -169,9 +169,6 @@ export class SideNavComponent implements OnInit { case(Action.GenerateColorScape): await this.actionService.refreshLibraryMetadata(lib, undefined, false); break; - case (Action.AnalyzeFiles): - await this.actionService.analyzeFiles(lib); - break; case (Action.Delete): await this.actionService.deleteLibrary(lib); break; diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts index 9b70e79f3..98e52f5ba 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts @@ -463,9 +463,6 @@ export class LibrarySettingsModalComponent implements OnInit { case Action.GenerateColorScape: await this.actionService.refreshLibraryMetadata(this.library!, undefined, false); break; - case (Action.AnalyzeFiles): - await this.actionService.analyzeFiles(this.library!); - break; case Action.Delete: await this.actionService.deleteLibrary(this.library!, () => { this.modal.dismiss(); diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.html b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.html index b8c1ec8af..2cd2e965e 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.html +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.html @@ -1,19 +1,21 @@ - - @if (accountService.currentUser$ | async; as user) { + + @if (accountService.currentUserSignal(); as user) { - @if((navService.sideNavCollapsed$ | async) === false) { -
+ @if(!navService.sideNavCollapsedSignal()) { +
@for(section of sections; track section.title + section.children.length; let idx = $index;) { - @if (hasAnyChildren(user, section)) { + @let children = getVisibleChildren(user, section); + + @if (children.length > 0) {
{{t(section.title)}}
- @for(item of section.children; track item.fragment) { - @if (accountService.hasAnyRole(user, item.roles, item.restrictRoles)) { - - } + @for(item of children; track item.fragment) { + } } } @@ -22,7 +24,7 @@
@if (utilityService.activeBreakpoint$ | async; as breakpoint) { @if (breakpoint < Breakpoint.Desktop) { -
+
} } } diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts index 22b4afc0e..0a31f1f8b 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts @@ -1,4 +1,12 @@ -import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject} from '@angular/core'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + effect, + inject +} from '@angular/core'; import {TranslocoDirective} from "@jsverse/transloco"; import {AsyncPipe, DOCUMENT, NgClass} from "@angular/common"; import {NavService} from "../../_services/nav.service"; @@ -55,8 +63,16 @@ export enum SettingsTabId { CBLImport = 'cbl-import' } +export enum SettingSectionId { + AccountSection = 'account-section-title', + ServerSection = 'server-section-title', + ImportSection = 'import-section-title', + InfoSection = 'info-section-title', + KavitaPlusSection = 'kavitaplus-section-title', +} + interface PrefSection { - title: string; + title: SettingSectionId; children: SideNavItem[]; } @@ -68,13 +84,27 @@ class SideNavItem { */ restrictRoles: Array = []; badgeCount$?: Observable | undefined; + kPlusOnly: boolean; - constructor(fragment: SettingsTabId, roles: Array = [], badgeCount$: Observable | undefined = undefined, restrictRoles: Array = []) { + constructor(fragment: SettingsTabId, roles: Array = [], badgeCount$: Observable | undefined = undefined, restrictRoles: Array = [], kPlusOnly: boolean = false) { this.fragment = fragment; this.roles = roles; this.restrictRoles = restrictRoles; this.badgeCount$ = badgeCount$; + this.kPlusOnly = kPlusOnly; } + + /** + * Create a new SideNavItem with kPlusOnly set to true + * @param fragment + * @param roles + * @param badgeCount$ + * @param restrictRoles + */ + static kPlusOnly(fragment: SettingsTabId, roles: Array = [], badgeCount$: Observable | undefined = undefined, restrictRoles: Array = []) { + return new SideNavItem(fragment, roles, badgeCount$, restrictRoles, true); + } + } @Component({ @@ -105,76 +135,11 @@ export class PreferenceNavComponent implements AfterViewInit { private readonly manageService = inject(ManageService); private readonly document = inject(DOCUMENT); - hasActiveLicense = false; /** * This links to settings.component.html which has triggers on what underlying component to render out. */ - sections: Array = [ - { - title: 'account-section-title', - children: [ - new SideNavItem(SettingsTabId.Account, []), - new SideNavItem(SettingsTabId.Preferences), - new SideNavItem(SettingsTabId.ReadingProfiles), - new SideNavItem(SettingsTabId.Customize, [], undefined, [Role.ReadOnly]), - new SideNavItem(SettingsTabId.Clients), - new SideNavItem(SettingsTabId.Theme), - new SideNavItem(SettingsTabId.Devices), - new SideNavItem(SettingsTabId.UserStats), - ] - }, - { - title: 'server-section-title', - children: [ - new SideNavItem(SettingsTabId.General, [Role.Admin]), - new SideNavItem(SettingsTabId.ManageMetadata, [Role.Admin]), - new SideNavItem(SettingsTabId.OpenIDConnect, [Role.Admin]), - new SideNavItem(SettingsTabId.Media, [Role.Admin]), - new SideNavItem(SettingsTabId.Email, [Role.Admin]), - new SideNavItem(SettingsTabId.Users, [Role.Admin]), - new SideNavItem(SettingsTabId.Libraries, [Role.Admin]), - new SideNavItem(SettingsTabId.Tasks, [Role.Admin]), - ] - }, - { - title: 'import-section-title', - children: [ - new SideNavItem(SettingsTabId.CBLImport, [], undefined, [Role.ReadOnly]), - new SideNavItem(SettingsTabId.MappingsImport, [Role.Admin]), - ] - }, - { - title: 'info-section-title', - children: [ - new SideNavItem(SettingsTabId.System, [Role.Admin]), - new SideNavItem(SettingsTabId.Statistics, [Role.Admin]), - new SideNavItem(SettingsTabId.MediaIssues, [Role.Admin], - this.accountService.currentUser$.pipe( - take(1), - switchMap(user => { - if (!user || !this.accountService.hasAdminRole(user)) { - // If no user or user does not have the admin role, return an observable of -1 - return of(-1); - } else { - return this.serverService.getMediaErrors().pipe( - takeUntilDestroyed(this.destroyRef), - map(d => d.length), - shareReplay({ bufferSize: 1, refCount: true }) - ); - } - }) - )), - new SideNavItem(SettingsTabId.EmailHistory, [Role.Admin]), - ] - }, - { - title: 'kavitaplus-section-title', - children: [ - new SideNavItem(SettingsTabId.KavitaPlusLicense, [Role.Admin]) - // All other sections added dynamically - ] - } - ]; + sections: Array = []; + collapseSideNavOnMobileNav$ = this.router.events.pipe( filter(event => event instanceof NavigationEnd), takeUntilDestroyed(this.destroyRef), @@ -225,6 +190,22 @@ export class PreferenceNavComponent implements AfterViewInit { }) ); + private readonly mediaIssuesBadgeCount$ = this.accountService.currentUser$.pipe( + take(1), + switchMap(user => { + if (!user || !this.accountService.hasAdminRole(user)) { + // If no user or user does not have the admin role, return an observable of -1 + return of(-1); + } + + return this.serverService.getMediaErrors().pipe( + takeUntilDestroyed(this.destroyRef), + map(d => d.length), + shareReplay({ bufferSize: 1, refCount: true }) + ); + }) + ); + constructor() { this.collapseSideNavOnMobileNav$.subscribe(); @@ -233,32 +214,70 @@ export class PreferenceNavComponent implements AfterViewInit { this.navService.collapseSideNav(true); } - this.licenseService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => { - this.hasActiveLicense = res; - if (res) { - const kavitaPlusSection = this.sections[4]; - if (kavitaPlusSection.children.length === 1) { - kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.ManageUserTokens, [Role.Admin])); - kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.Metadata, [Role.Admin])); - - // Keep all setting type of screens above this line - kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.MatchedMetadata, [Role.Admin], - this.matchedMetadataBadgeCount$ - )); - - // Scrobbling History needs to be per-user and allow admin to view all - kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.ScrobblingHolds, [])); - kavitaPlusSection.children.push(new SideNavItem(SettingsTabId.Scrobbling, [], this.scrobblingErrorBadgeCount$) - ); - } - - if (this.sections[2].children.length === 1) { - this.sections[2].children.push(new SideNavItem(SettingsTabId.MALStackImport, [])); - } - - this.scrollToActiveItem(); - this.cdRef.markForCheck(); + this.sections = [ + { + title: SettingSectionId.AccountSection, + children: [ + new SideNavItem(SettingsTabId.Account, []), + new SideNavItem(SettingsTabId.Preferences), + new SideNavItem(SettingsTabId.ReadingProfiles), + new SideNavItem(SettingsTabId.Customize, [], undefined, [Role.ReadOnly]), + new SideNavItem(SettingsTabId.Clients), + new SideNavItem(SettingsTabId.Theme), + new SideNavItem(SettingsTabId.Devices), + new SideNavItem(SettingsTabId.UserStats), + ] + }, + { + title: SettingSectionId.ServerSection, + children: [ + new SideNavItem(SettingsTabId.General, [Role.Admin]), + new SideNavItem(SettingsTabId.ManageMetadata, [Role.Admin]), + new SideNavItem(SettingsTabId.OpenIDConnect, [Role.Admin]), + new SideNavItem(SettingsTabId.Media, [Role.Admin]), + new SideNavItem(SettingsTabId.Email, [Role.Admin]), + new SideNavItem(SettingsTabId.Users, [Role.Admin]), + new SideNavItem(SettingsTabId.Libraries, [Role.Admin]), + new SideNavItem(SettingsTabId.Tasks, [Role.Admin]), + ] + }, + { + title: SettingSectionId.ImportSection, + children: [ + new SideNavItem(SettingsTabId.MappingsImport, [Role.Admin]), + new SideNavItem(SettingsTabId.CBLImport, [], undefined, [Role.ReadOnly]), + SideNavItem.kPlusOnly(SettingsTabId.MALStackImport), + ] + }, + { + title: SettingSectionId.InfoSection, + children: [ + new SideNavItem(SettingsTabId.System, [Role.Admin]), + new SideNavItem(SettingsTabId.Statistics, [Role.Admin]), + new SideNavItem(SettingsTabId.MediaIssues, [Role.Admin], this.mediaIssuesBadgeCount$), + new SideNavItem(SettingsTabId.EmailHistory, [Role.Admin]), + ] + }, + { + title: SettingSectionId.KavitaPlusSection, + children: [ + new SideNavItem(SettingsTabId.KavitaPlusLicense, [Role.Admin]), + SideNavItem.kPlusOnly(SettingsTabId.ManageUserTokens, [Role.Admin]), + SideNavItem.kPlusOnly(SettingsTabId.Metadata, [Role.Admin]), + SideNavItem.kPlusOnly(SettingsTabId.MatchedMetadata, [Role.Admin], this.matchedMetadataBadgeCount$), + SideNavItem.kPlusOnly(SettingsTabId.ScrobblingHolds), + SideNavItem.kPlusOnly(SettingsTabId.Scrobbling, [], this.scrobblingErrorBadgeCount$), + ] } + ]; + + this.scrollToActiveItem(); + this.cdRef.markForCheck(); + + // Refresh visibility if license changes + effect(() => { + this.licenseService.hasValidLicenseSignal(); + this.cdRef.markForCheck(); }); } @@ -276,14 +295,12 @@ export class PreferenceNavComponent implements AfterViewInit { } } - hasAnyChildren(user: User, section: PrefSection) { - // Filter out items where the user has a restricted role - const visibleItems = section.children.filter(item => - (item.restrictRoles.length === 0 || !this.accountService.hasAnyRestrictedRole(user, item.restrictRoles)) && - (item.roles.length === 0 || this.accountService.hasAnyRole(user, item.roles)) - ); + getVisibleChildren(user: User, section: PrefSection) { + return section.children.filter(item => this.isItemVisible(user, item)); + } - return visibleItems.length > 0; + isItemVisible(user: User, item: SideNavItem) { + return this.accountService.hasAnyRole(user, item.roles, item.restrictRoles) && (!item.kPlusOnly || this.licenseService.hasValidLicenseSignal()) } collapse() { diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 22ec016c5..2425379bb 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1798,7 +1798,7 @@ "admin-matched-metadata": "Matched Metadata", "admin-manage-tokens": "Manage User Tokens", "admin-metadata": "Manage Metadata", - "admin-mappings-import": "Metadata settings", + "admin-mappings-import": "Metadata Settings", "scrobble-holds": "Scrobble Holds", "account": "Account", "preferences": "Preferences",