Koreader Sync Fix and More (#4006)

Co-authored-by: Joe Milazzo <josephmajora@gmail.com>
This commit is contained in:
Fesaa 2025-08-28 01:44:30 +02:00 committed by GitHub
parent aa268c2dca
commit 6137d2a30e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 556 additions and 310 deletions

View File

@ -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;
}
}

View File

@ -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));
}
/// <summary>
/// Handles edge case testing around duplicate numbers in the filename
/// </summary>
[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")]

View File

@ -41,11 +41,6 @@ public class ExternalMetadataServiceTests: AbstractDbTest
private async Task<(IExternalMetadataService, Dictionary<string, Genre>, Dictionary<string, Tag>, Dictionary<string, Person>)> 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;

View File

@ -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
/// <param name="ebookHash"></param>
/// <returns></returns>
[HttpGet("{apiKey}/syncs/progress/{ebookHash}")]
public async Task<ActionResult<KoreaderBookDto>> GetProgress(string apiKey, string ebookHash)
public async Task<IActionResult> 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)
{

View File

@ -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<OpdsController> 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<OpdsController> _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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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
/// </summary>
/// <returns></returns>
private async Task<int> GetUser(string apiKey)
public async Task<int> GetUser(string apiKey)
{
try
{

View File

@ -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<IActionResult> 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,

View File

@ -299,12 +299,14 @@ public class SeriesController : BaseApiController
/// <summary>
/// Returns series that were recently updated, like adding or removing a chapter
/// </summary>
/// <param name="userParams">Page size and offset</param>
/// <returns></returns>
[ResponseCache(CacheProfileName = "Instant")]
[HttpPost("recently-updated-series")]
public async Task<ActionResult<IEnumerable<RecentlyAddedItemDto>>> GetRecentlyAddedChapters()
public async Task<ActionResult<IEnumerable<RecentlyAddedItemDto>>> 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));
}
/// <summary>

View File

@ -11,23 +11,27 @@ public class KoreaderBookDto
/// <summary>
/// This is the Koreader hash of the book. It is used to identify the book.
/// </summary>
public string Document { get; set; }
public string document { get; set; }
/// <summary>
/// A randomly generated id from the koreader device. Only used to maintain the Koreader interface.
/// </summary>
public string Device_id { get; set; }
public string device_id { get; set; }
/// <summary>
/// The Koreader device name. Only used to maintain the Koreader interface.
/// </summary>
public string Device { get; set; }
public string device { get; set; }
/// <summary>
/// Percent progress of the book. Only used to maintain the Koreader interface.
/// </summary>
public float Percentage { get; set; }
public float percentage { get; set; }
/// <summary>
/// An XPath string read by Koreader to determine the location within the epub.
/// Essentially, it is Koreader's equivalent to ProgressDto.BookScrollId.
/// </summary>
/// <seealso cref="ProgressDto.BookScrollId"/>
public string Progress { get; set; }
public string progress { get; set; }
/// <summary>
/// Last Progress in Unix seconds since epoch
/// </summary>
public long timestamp { get; set; }
}

View File

@ -134,7 +134,7 @@ public interface ISeriesRepository
Task<Series?> GetFullSeriesForSeriesIdAsync(int seriesId);
Task<Chunk> GetChunkInfo(int libraryId = 0);
Task<IList<SeriesMetadata>> GetSeriesMetadataForIdsAsync(IEnumerable<int> seriesIds);
Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30);
Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, UserParams? userParams);
Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind);
Task<PagedList<SeriesDto>> 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<SearchResultDto>(_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<PersonDto>(_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<ChapterDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -1476,12 +1480,12 @@ public class SeriesRepository : ISeriesRepository
/// <remarks>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. </remarks>
/// <param name="userId">Used to ensure user has access to libraries</param>
/// <param name="pageSize">How many entities to return</param>
/// <param name="userParams">Page size and offset</param>
/// <returns></returns>
public async Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30)
public async Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, UserParams? userParams)
{
var seriesMap = new Dictionary<string, GroupedSeriesDto>();
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<int, GroupedSeriesDto>();
var toSkip = (userParams.PageNumber - 1) * userParams.PageSize;
var skipped = new HashSet<int>();
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,

View File

@ -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<IWantToReadSyncService, WantToReadSyncService>();
services.AddScoped<IOidcService, OidcService>();
services.AddScoped<OpdsActionFilterAttribute>();
services.AddSqLite();
services.AddSignalR(opt => opt.EnableDetailedErrors = true);

View File

@ -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<ReadingList> Search(this IQueryable<ReadingList> 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<Library> Search(this IQueryable<Library> 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<Tag> SearchTags(this IQueryable<SeriesMetadata> 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);
}
}

View File

@ -14,33 +14,40 @@ public class KoreaderBookDtoBuilder : IEntityBuilder<KoreaderBookDto>
{
_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;
}
}

View File

@ -40,8 +40,8 @@ public class KoreaderService : IKoreaderService
/// <param name="userId"></param>
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();
}
}

View File

@ -102,7 +102,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> 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);

View File

@ -49,9 +49,12 @@ public class ReadingItemService : IReadingItemService
/// Gets the ComicInfo for the file if it exists. Null otherwise.
/// </summary>
/// <param name="filePath">Fully qualified path of file</param>
/// <param name="enableMetadata">If false, returns null</param>
/// <returns></returns>
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;

View File

@ -117,6 +117,36 @@ public static partial class Parser
private static readonly Regex SpecialTokenRegex = new(@"SP\d+",
MatchOptions, RegexTimeout);
/// <summary>
/// An additional check to avoid situations like "One Piece - Vol 4 ch 2 - vol 6 omakes"
/// </summary>
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|_)*(?<Volume>\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|_)*(?<Chapter>\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(
@"(?<Series>.*)(\b|_)(?!\[)v(?<Volume>" + NumberRange + @")(?!\])",
@"(?<Series>.*)(\b|_)(?!\[)v(?<Volume>" + 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)(?<Volume>\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(
@"(?<Series>.*)(\b|_)((S|T)(?<Volume>\d+))",
@"(?<Series>.*)(\b|_)((S|T)(?<Volume>\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(
@"시즌(?<Volume>\d+\-?\d+)",
@"시즌(?<Volume>\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;
}
/// <summary>
/// Checks for a duplicate volume marker and removes it
/// </summary>
/// <param name="filename"></param>
/// <returns></returns>
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;
}
/// <summary>
/// Removes duplicate chapter markers from filename, keeping only the first occurrence
/// </summary>
/// <param name="filename">Original filename</param>
/// <returns>Processed filename with duplicate chapter markers removed</returns>
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")]

View File

@ -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<Library>(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',

View File

@ -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<Library>, 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<Library>, callback?: LibraryActionCallback) {
if (!library.hasOwnProperty('id') || library.id === undefined) {
return;

View File

@ -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<number>) {
return this.httpClient.post(this.baseUrl + 'library/analyze-multiple', {ids: libraryIds, force: false});
}
copySettingsFromLibrary(sourceLibraryId: number, targetLibraryIds: Array<number>, includeType: boolean) {
return this.httpClient.post(this.baseUrl + 'library/copy-settings-from', {sourceLibraryId, targetLibraryIds, includeType});
}

View File

@ -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<boolean>(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),

View File

@ -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(() => {

View File

@ -2,7 +2,7 @@
<div class="position-relative">
<div class="position-absolute custom-position-2">
<app-card-actionables [inputActions]="bulkActions" btnClass="btn-outline-primary ms-1" [label]="t('bulk-action-label')"
[disabled]="bulkMode">
[disabled]="bulkMode" (actionHandler)="handleBulkAction($event, null!)">
</app-card-actionables>
</div>

View File

@ -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):

View File

@ -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;

View File

@ -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();

View File

@ -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;

View File

@ -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();

View File

@ -1,19 +1,21 @@
<ng-container *transloco="let t; read:'settings'">
@if (accountService.currentUser$ | async; as user) {
<ng-container *transloco="let t; prefix:'settings'">
@if (accountService.currentUserSignal(); as user) {
@if((navService.sideNavCollapsed$ | async) === false) {
<div class="preference side-nav-container" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async),
'hidden': (navService.sideNavVisibility$ | async) === false,
'no-donate': (licenseService.hasValidLicense$ | async) === true}">
@if(!navService.sideNavCollapsedSignal()) {
<div class="preference side-nav-container" [ngClass]="{
'closed' : navService.sideNavCollapsedSignal(),
'hidden': !navService.sideNavVisibilitySignal(),
'no-donate': licenseService.hasValidLicenseSignal()
}">
<div class="side-nav">
@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) {
<h5 class="side-nav-header mb-2" [ngClass]="{'mt-4': idx > 0}">{{t(section.title)}}</h5>
@for(item of section.children; track item.fragment) {
@if (accountService.hasAnyRole(user, item.roles, item.restrictRoles)) {
<app-side-nav-item [id]="'nav-item-' + item.fragment" [noIcon]="true" link="/settings" [fragment]="item.fragment" [title]="item.fragment | settingFragment" [badgeCount]="item.badgeCount$ | async"></app-side-nav-item>
}
@for(item of children; track item.fragment) {
<app-side-nav-item [id]="'nav-item-' + item.fragment" [noIcon]="true" link="/settings" [fragment]="item.fragment" [title]="item.fragment | settingFragment" [badgeCount]="item.badgeCount$ | async"></app-side-nav-item>
}
}
}
@ -22,7 +24,7 @@
</div>
@if (utilityService.activeBreakpoint$ | async; as breakpoint) {
@if (breakpoint < Breakpoint.Desktop) {
<div class="side-nav-overlay" (click)="collapse()" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async)}"></div>
<div class="side-nav-overlay" (click)="collapse()" [ngClass]="{'closed' : navService.sideNavCollapsedSignal()}"></div>
}
}
}

View File

@ -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<Role> = [];
badgeCount$?: Observable<number> | undefined;
kPlusOnly: boolean;
constructor(fragment: SettingsTabId, roles: Array<Role> = [], badgeCount$: Observable<number> | undefined = undefined, restrictRoles: Array<Role> = []) {
constructor(fragment: SettingsTabId, roles: Array<Role> = [], badgeCount$: Observable<number> | undefined = undefined, restrictRoles: Array<Role> = [], 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<Role> = [], badgeCount$: Observable<number> | undefined = undefined, restrictRoles: Array<Role> = []) {
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<PrefSection> = [
{
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<PrefSection> = [];
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() {

View File

@ -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",