mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-08-30 23:00:06 -04:00
Koreader Sync Fix and More (#4006)
Co-authored-by: Joe Milazzo <josephmajora@gmail.com>
This commit is contained in:
parent
aa268c2dca
commit
6137d2a30e
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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")]
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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")]
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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});
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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(() => {
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user