v0.7.8 - New Filtering System (#2260)

Co-authored-by: JeanPaulDOT <jp.houssier@gmail.com>
Co-authored-by: Francois Wilhelmy <ice_mouton@hotmail.com>
Co-authored-by: Gazy Mahomar <gmahomarf@gmail.com>
Co-authored-by: Stijn <stijn.biemans@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
Co-authored-by: Havokdan <havokdan@yahoo.com.br>
Co-authored-by: Andre <andruecha32@gmail.com>
Co-authored-by: Mateusz <mateuszvx8.96@gmail.com>
Co-authored-by: Antonio Sanchez Castellón <angelfx19@gmail.com>
Co-authored-by: Duarte Silva <smallflake@protonmail.com>
Co-authored-by: LeeWan1210 <dldhks456@live.com>
Co-authored-by: aleixcox <18121624@qq.com>
Co-authored-by: Tomas Battistini <tomas.battistini@gmail.com>
Co-authored-by: mareczek82 <marek.posiadala@gmail.com>
Co-authored-by: Hans Kalisvaart <hans.kalisvaart@gmail.com>
Co-authored-by: majora2007 <kavitareader@gmail.com>
Co-authored-by: afermar <adrian.fm@protonmail.com>
Co-authored-by: oxygen44k <iiccpp@outlook.com>
Co-authored-by: Weblate (bot) <hosted@weblate.org>
Co-authored-by: Hadrien b <hadrien.1997@gmail.com>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: Andre Smith <Hobogrammer@users.noreply.github.com>
Co-authored-by: Safu Wan <safu@yahoo.com>
Co-authored-by: sibeck <sibeck.clown@gmail.com>
Co-authored-by: Florestano Pepe <florestano.pepe@gmail.com>
Co-authored-by: 书签 <shuqian.emu@gmail.com>
Co-authored-by: Stéphane Dupont <aleistor@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: AlienHack <the4got10@windowslive.com>
Co-authored-by: 周書丞 <tmrsm_chan@hotmail.com>
Co-authored-by: Andre Smith <andrepsmithjr@gmail.com>
Co-authored-by: xe1st <dnzkckali@gmail.com>
Co-authored-by: Jiří Heger <jiri.heger@gmail.com>
Co-authored-by: DR <weblate-kavita.snowflake668@slmail.me>
Co-authored-by: Mathieu Ares <matguitarist@gmail.com>
Co-authored-by: Stavros Kois <47820033+stavros-k@users.noreply.github.com>
Co-authored-by: Gazy Mahomar <gmahomarf@users.noreply.github.com>
Co-authored-by: Elias Jakob <elias.jakob100@gmail.com>
Co-authored-by: Christian Zanon <chri8431@libero.it>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: Hoshino0881118 <hoshino0881118@gmail.com>
This commit is contained in:
Joe Milazzo 2023-09-03 12:31:50 -07:00 committed by GitHub
parent bdaadbecfc
commit 979508047c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
221 changed files with 23403 additions and 6863 deletions

View file

@ -30,7 +30,7 @@ jobs:
run: dotnet restore
- name: Set up JDK 17
uses: actions/setup-java@v2
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '17'
@ -107,9 +107,10 @@ jobs:
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
steps:
- name: Find Current Pull Request
uses: jwalton/gh-find-current-pr@v1.3.2
uses: jwalton/gh-find-current-pr@v1
id: findPr
with:
state: all
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Parse PR body
@ -130,7 +131,7 @@ jobs:
body=${body//$'`'/'%60'}
body=${body//$'>'/'%3E'}
echo $body
echo "::set-output name=BODY::$body"
echo "BODY=$body" >> $GITHUB_OUTPUT
- name: Check Out Repo
uses: actions/checkout@v3
@ -138,7 +139,7 @@ jobs:
ref: develop
- name: NodeJS to Compile WebUI
uses: actions/setup-node@v2.1.5
uses: actions/setup-node@v3
with:
node-version: '16'
- run: |
@ -155,7 +156,7 @@ jobs:
cd ../ || exit
- name: Get csproj Version
uses: naminodarie/get-net-sdk-project-versions-action@v1
uses: kzrnm/get-net-sdk-project-versions-action@v1
id: get-version
with:
proj-path: Kavita.Common/Kavita.Common.csproj
@ -163,7 +164,7 @@ jobs:
- name: Parse Version
run: |
version='${{steps.get-version.outputs.assembly-version}}'
echo "::set-output name=VERSION::$version"
echo "VERSION=$version" >> $GITHUB_OUTPUT
id: parse-version
- name: Echo csproj version
@ -193,15 +194,15 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
@ -216,7 +217,7 @@ jobs:
with:
severity: info
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
details: '${{ steps.parse-body.outputs.BODY }}'
details: '${{ steps.findPr.outputs.body }}'
text: <@&939225459156217917> <@&939225350775406643> A new nightly build has been released for docker.
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
@ -227,13 +228,14 @@ jobs:
permissions:
packages: write
contents: read
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) }}
steps:
- name: Find Current Pull Request
uses: jwalton/gh-find-current-pr@v1.0.2
uses: jwalton/gh-find-current-pr@v1
id: findPr
with:
state: all
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Parse PR body
@ -254,7 +256,8 @@ jobs:
body=${body//$'`'/'%60'}
body=${body//$'>'/'%3E'}
echo $body
echo "::set-output name=BODY::$body"
echo "BODY=$body" >> $GITHUB_OUTPUT
- name: Check Out Repo
uses: actions/checkout@v3
@ -262,7 +265,7 @@ jobs:
ref: main
- name: NodeJS to Compile WebUI
uses: actions/setup-node@v2.1.5
uses: actions/setup-node@v3
with:
node-version: '16'
- run: |
@ -280,7 +283,7 @@ jobs:
cd ../ || exit
- name: Get csproj Version
uses: naminodarie/get-net-sdk-project-versions-action@v1
uses: kzrnm/get-net-sdk-project-versions-action@v1
id: get-version
with:
proj-path: Kavita.Common/Kavita.Common.csproj
@ -293,7 +296,7 @@ jobs:
version='${{steps.get-version.outputs.assembly-version}}'
newVersion=${version%.*}
echo $newVersion
echo "::set-output name=VERSION::$newVersion"
echo "VERSION=$newVersion" >> $GITHUB_OUTPUT
id: parse-version
- name: Compile dotnet app
@ -319,15 +322,15 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
@ -342,7 +345,7 @@ jobs:
with:
severity: info
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
details: '${{ steps.parse-body.outputs.BODY }}'
details: '${{ steps.findPr.outputs.body }}'
text: <@&939225192553644133> A new stable build has been released.
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
@ -356,9 +359,10 @@ jobs:
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/canary' }}
steps:
- name: Find Current Pull Request
uses: jwalton/gh-find-current-pr@v1.0.2
uses: jwalton/gh-find-current-pr@v1
id: findPr
with:
state: all
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Check Out Repo
@ -367,7 +371,7 @@ jobs:
ref: canary
- name: NodeJS to Compile WebUI
uses: actions/setup-node@v2.1.5
uses: actions/setup-node@v3
with:
node-version: '16'
- run: |
@ -384,7 +388,7 @@ jobs:
cd ../ || exit
- name: Get csproj Version
uses: naminodarie/get-net-sdk-project-versions-action@v1
uses: kzrnm/get-net-sdk-project-versions-action@v1
id: get-version
with:
proj-path: Kavita.Common/Kavita.Common.csproj
@ -392,7 +396,7 @@ jobs:
- name: Parse Version
run: |
version='${{steps.get-version.outputs.assembly-version}}'
echo "::set-output name=VERSION::$version"
echo "VERSION=$version" >> $GITHUB_OUTPUT
id: parse-version
- name: Echo csproj version
@ -422,15 +426,15 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64

View file

@ -0,0 +1,28 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.DTOs.Filtering.v2;
using API.Extensions.QueryExtensions.Filtering;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace API.Tests.Extensions;
public class SeriesFilterTests : AbstractDbTest
{
protected override Task ResetDb()
{
return Task.CompletedTask;
}
#region HasLanguage
[Fact]
public async Task HasLanguage_Works()
{
var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Contains, new List<string>() { }).ToListAsync();
}
#endregion
}

View file

@ -42,7 +42,7 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService
return 1;
}
public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, EncodeFormat encodeFormat)
public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
{
return string.Empty;
}

View file

@ -43,7 +43,7 @@ internal class MockReadingItemService : IReadingItemService
return 1;
}
public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, EncodeFormat encodeFormat)
public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
{
return string.Empty;
}

View file

@ -802,6 +802,26 @@ public class SeriesServiceTests : AbstractDbTest
return series;
}
[Fact]
public void GetFirstChapterForMetadata_BookWithOnlyVolumeNumbers_Test()
{
var file = new MangaFileBuilder("Test.cbz", MangaFormat.Archive, 1).Build();
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("0").WithPages(1).WithFile(file).Build())
.Build())
.WithVolume(new VolumeBuilder("1.5")
.WithChapter(new ChapterBuilder("0").WithPages(2).WithFile(file).Build())
.Build())
.Build();
series.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build();
var firstChapter = SeriesService.GetFirstChapterForMetadata(series);
Assert.Equal(1, firstChapter.Pages);
}
[Fact]
public void GetFirstChapterForMetadata_Book_Test()
{

View file

@ -15,6 +15,10 @@ public static class EasyCacheProfiles
/// Cache the libraries on the server
/// </summary>
public const string Library = "library";
/// <summary>
/// Metadata filter
/// </summary>
public const string Filter = "filter";
public const string KavitaPlusReviews = "kavita+reviews";
public const string KavitaPlusRecommendations = "kavita+recommendations";
public const string KavitaPlusRatings = "kavita+ratings";

View file

@ -32,7 +32,7 @@ public class CollectionController : BaseApiController
}
/// <summary>
/// Return a list of all collection tags on the server
/// Return a list of all collection tags on the server for the logged in user.
/// </summary>
/// <returns></returns>
[HttpGet]
@ -130,7 +130,6 @@ public class CollectionController : BaseApiController
{
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updateSeriesForTagDto.Tag.Id, CollectionTagIncludes.SeriesMetadata);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
tag.SeriesMetadatas ??= new List<SeriesMetadata>();
if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove))
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated"));
@ -142,4 +141,29 @@ public class CollectionController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
/// <summary>
/// Removes the collection tag from all Series it was attached to
/// </summary>
/// <param name="tagId"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete]
public async Task<ActionResult> DeleteTag(int tagId)
{
try
{
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata);
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
if (await _collectionService.DeleteTag(tag))
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-deleted"));
}
catch (Exception)
{
await _unitOfWork.RollbackAsync();
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
}

View file

@ -87,8 +87,7 @@ public class DeviceController : BaseApiController
[HttpGet]
public async Task<ActionResult<IEnumerable<DeviceDto>>> GetDevices()
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.DeviceRepository.GetDevicesForUserAsync(userId));
return Ok(await _unitOfWork.DeviceRepository.GetDevicesForUserAsync(User.GetUserId()));
}
[HttpPost("send-to")]
@ -100,7 +99,7 @@ public class DeviceController : BaseApiController
if (await _emailService.IsDefaultEmailService())
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"),
"started"), userId);
@ -134,7 +133,7 @@ public class DeviceController : BaseApiController
if (await _emailService.IsDefaultEmailService())
return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email"));
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress,
MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"),
"started"), userId);

View file

@ -0,0 +1,59 @@
using System;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Filtering.v2;
using EasyCaching.Core;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
/// <summary>
/// This is responsible for Filter caching
/// </summary>
public class FilterController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IEasyCachingProviderFactory _cacheFactory;
public FilterController(IUnitOfWork unitOfWork, IEasyCachingProviderFactory cacheFactory)
{
_unitOfWork = unitOfWork;
_cacheFactory = cacheFactory;
}
[HttpGet]
public async Task<ActionResult<FilterV2Dto?>> GetFilter(string name)
{
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter);
if (string.IsNullOrEmpty(name)) return Ok(null);
var filter = await provider.GetAsync<FilterV2Dto>(name);
if (filter.HasValue)
{
filter.Value.Name = name;
return Ok(filter.Value);
}
return Ok(null);
}
/// <summary>
/// Caches the filter in the backend and returns a temp string for retrieving.
/// </summary>
/// <remarks>The cache line lives for only 1 hour</remarks>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost("create-temp")]
public async Task<ActionResult<string>> CreateTempFilter(FilterV2Dto filterDto)
{
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter);
var name = filterDto.Name;
if (string.IsNullOrEmpty(filterDto.Name))
{
name = Guid.NewGuid().ToString();
}
await provider.SetAsync(name, filterDto, TimeSpan.FromHours(1));
return name;
}
}

View file

@ -158,11 +158,6 @@ public class ImageController : BaseApiController
private async Task<string> GenerateReadingListCoverImage(int readingListId)
{
var covers = await _unitOfWork.ReadingListRepository.GetRandomCoverImagesAsync(readingListId);
if (covers.Count < 4)
{
return string.Empty;
}
var destFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory,
ImageService.GetReadingListFormat(readingListId));
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
@ -171,6 +166,7 @@ public class ImageController : BaseApiController
if (_directoryService.FileSystem.File.Exists(destFile)) return destFile;
ImageService.CreateMergedImage(
covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(),
settings.CoverImageSize,
destFile);
return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile;
}
@ -178,11 +174,6 @@ public class ImageController : BaseApiController
private async Task<string> GenerateCollectionCoverImage(int collectionId)
{
var covers = await _unitOfWork.CollectionTagRepository.GetRandomCoverImagesAsync(collectionId);
if (covers.Count < 4)
{
return string.Empty;
}
var destFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory,
ImageService.GetCollectionTagFormat(collectionId));
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
@ -190,6 +181,7 @@ public class ImageController : BaseApiController
if (_directoryService.FileSystem.File.Exists(destFile)) return destFile;
ImageService.CreateMergedImage(
covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(),
settings.CoverImageSize,
destFile);
return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile;
}

View file

@ -161,8 +161,7 @@ public class LibraryController : BaseApiController
[HttpGet("jump-bar")]
public async Task<ActionResult<IEnumerable<JumpKeyDto>>> GetJumpBar(int libraryId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
if (!await _unitOfWork.UserRepository.HasAccessToLibrary(libraryId, userId))
if (!await _unitOfWork.UserRepository.HasAccessToLibrary(libraryId, User.GetUserId()))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-library-access"));
return Ok(_unitOfWork.LibraryRepository.GetJumpBarAsync(libraryId));

View file

@ -37,17 +37,28 @@ public class MetadataController : BaseApiController
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(string? libraryIds)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0)
{
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, userId));
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId()));
}
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync(userId));
return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync(User.GetUserId()));
}
/// <summary>
/// Fetches people from the instance by role
/// </summary>
/// <param name="role">role</param>
/// <returns></returns>
[HttpGet("people-by-role")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"role"})]
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(PersonRole? role)
{
return role.HasValue ?
Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosByRoleAsync(User.GetUserId(), role!.Value)) :
Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
}
/// <summary>
/// Fetches people from the instance
@ -58,13 +69,12 @@ public class MetadataController : BaseApiController
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(string? libraryIds)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0)
{
return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, userId));
return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId()));
}
return Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(userId));
return Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId()));
}
/// <summary>
@ -76,13 +86,12 @@ public class MetadataController : BaseApiController
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
public async Task<ActionResult<IList<TagDto>>> GetAllTags(string? libraryIds)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids != null && ids.Count > 0)
{
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, userId));
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId()));
}
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosAsync(userId));
return Ok(await _unitOfWork.TagRepository.GetAllTagDtosAsync(User.GetUserId()));
}
/// <summary>
@ -138,19 +147,14 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
/// <returns></returns>
[HttpGet("languages")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new []{"libraryIds"})]
public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds)
{
var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList();
if (ids is {Count: > 0})
{
return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
}
return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync());
return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
}
[HttpGet("all-languages")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
public IEnumerable<LanguageDto> GetAllValidLanguages()
@ -163,6 +167,7 @@ public class MetadataController : BaseApiController
}).Where(l => !string.IsNullOrEmpty(l.IsoCode));
}
/// <summary>
/// Returns summary for the chapter
/// </summary>

View file

@ -10,6 +10,7 @@ using API.Data.Repositories;
using API.DTOs;
using API.DTOs.CollectionTags;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.OPDS;
using API.DTOs.Search;
using API.Entities;
@ -65,6 +66,8 @@ public class OpdsController : BaseApiController
SortOptions = null,
PublicationStatus = new List<PublicationStatus>()
};
private readonly FilterV2Dto _filterV2Dto = new FilterV2Dto();
private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default;
private const int PageSize = 20;
@ -139,6 +142,19 @@ public class OpdsController : BaseApiController
}
});
feed.Entries.Add(new FeedEntry()
{
Id = "wantToRead",
Title = await _localizationService.Translate(userId, "want-to-read"),
Content = new FeedEntryContent()
{
Text = await _localizationService.Translate(userId, "browse-want-to-read")
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/want-to-read"),
}
});
feed.Entries.Add(new FeedEntry()
{
Id = "allLibraries",
Title = await _localizationService.Translate(userId, "libraries"),
@ -201,6 +217,8 @@ public class OpdsController : BaseApiController
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries/{library.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}")
}
});
}
@ -208,6 +226,27 @@ public class OpdsController : BaseApiController
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/want-to-read")]
[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 (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));
var feed = CreateFeed(await _localizationService.Translate(userId, "want-to-read"), $"{apiKey}/want-to-read", apiKey, prefix);
SetFeedId(feed, $"want-to-read");
AddPagination(feed, wantToReadSeries, $"{prefix}{apiKey}/want-to-read");
feed.Entries.AddRange(wantToReadSeries.Select(seriesDto =>
CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)));
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/collections")]
[Produces("application/xml")]
public async Task<IActionResult> GetCollections(string apiKey)
@ -226,21 +265,19 @@ public class OpdsController : BaseApiController
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix);
SetFeedId(feed, "collections");
foreach (var tag in tags)
feed.Entries.AddRange(tags.Select(tag => new FeedEntry()
{
feed.Entries.Add(new FeedEntry()
Id = tag.Id.ToString(),
Title = tag.Title,
Summary = tag.Summary,
Links = new List<FeedLink>()
{
Id = tag.Id.ToString(),
Title = tag.Title,
Summary = tag.Summary,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionId={tag.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionId={tag.Id}&apiKey={apiKey}")
}
});
}
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
}
}));
return CreateXmlResult(SerializeXml(feed));
}
@ -315,6 +352,8 @@ public class OpdsController : BaseApiController
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}")
}
});
}
@ -378,17 +417,27 @@ public class OpdsController : BaseApiController
return BadRequest(await _localizationService.Translate(userId, "no-library-access"));
}
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, GetUserParams(pageNumber), _filterDto);
var filter = new FilterV2Dto
{
Statements = new List<FilterStatementDto>() {
new ()
{
Comparison = FilterComparison.Equal,
Field = FilterField.Libraries,
Value = libraryId + string.Empty
}
}
};
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber), filter);
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id));
var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey, prefix);
SetFeedId(feed, $"library-{library.Name}");
AddPagination(feed, series, $"{prefix}{apiKey}/libraries/{libraryId}");
foreach (var seriesDto in series)
{
feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
}
feed.Entries.AddRange(series.Select(seriesDto =>
CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)));
return CreateXmlResult(SerializeXml(feed));
}
@ -401,7 +450,7 @@ public class OpdsController : BaseApiController
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix();
var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, userId, GetUserParams(pageNumber), _filterDto);
var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, GetUserParams(pageNumber), _filterV2Dto);
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id));
var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{prefix}{apiKey}/recently-added", apiKey, prefix);
@ -730,8 +779,10 @@ public class OpdsController : BaseApiController
return new FeedEntry()
{
Id = seriesDto.Id.ToString(),
Title = $"{seriesDto.Name} ({seriesDto.Format})",
Summary = seriesDto.Summary,
Title = $"{seriesDto.Name}",
Summary = $"Format: {seriesDto.Format}" + (string.IsNullOrWhiteSpace(metadata.Summary)
? string.Empty
: $" Summary: {metadata.Summary}"),
Authors = metadata.Writers.Select(p => new FeedAuthor()
{
Name = p.Name,
@ -756,7 +807,8 @@ public class OpdsController : BaseApiController
return new FeedEntry()
{
Id = searchResultDto.SeriesId.ToString(),
Title = $"{searchResultDto.Name} ({searchResultDto.Format})",
Title = $"{searchResultDto.Name}",
Summary = $"Format: {searchResultDto.Format}",
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/series/{searchResultDto.SeriesId}"),

View file

@ -8,6 +8,7 @@ using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
@ -596,7 +597,7 @@ public class ReaderController : BaseApiController
/// <param name="filterDto">Only supports SeriesNameQuery</param>
/// <returns></returns>
[HttpPost("all-bookmarks")]
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetAllBookmarks(FilterDto filterDto)
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetAllBookmarks(FilterV2Dto filterDto)
{
return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(User.GetUserId(), filterDto));
}

View file

@ -39,8 +39,7 @@ public class ReadingListController : BaseApiController
[HttpGet]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetList(int readingListId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, userId));
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, User.GetUserId()));
}
/// <summary>
@ -54,8 +53,7 @@ public class ReadingListController : BaseApiController
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForUser([FromQuery] UserParams userParams,
bool includePromoted = true, bool sortByLastModified = false)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, includePromoted,
var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(User.GetUserId(), includePromoted,
userParams, sortByLastModified);
Response.AddPaginationHeader(items.CurrentPage, items.PageSize, items.TotalCount, items.TotalPages);
@ -70,10 +68,8 @@ public class ReadingListController : BaseApiController
[HttpGet("lists-for-series")]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForSeries(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForSeriesAndUserAsync(userId, seriesId, true);
return Ok(items);
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtosForSeriesAndUserAsync(User.GetUserId(),
seriesId, true));
}
/// <summary>

View file

@ -62,16 +62,18 @@ public class ReviewController : BaseApiController
}
var cacheKey = CacheKey + seriesId;
IEnumerable<UserReviewDto> externalReviews;
IList<UserReviewDto> externalReviews;
var result = await _cacheProvider.GetAsync<IEnumerable<UserReviewDto>>(cacheKey);
if (result.HasValue)
{
externalReviews = result.Value;
externalReviews = result.Value.ToList();
}
else
{
externalReviews = (await _reviewService.GetReviewsForSeries(userId, seriesId)).ToList();
var reviews = (await _reviewService.GetReviewsForSeries(userId, seriesId)).ToList();
externalReviews = SelectSpectrumOfReviews(reviews);
await _cacheProvider.SetAsync(cacheKey, externalReviews, TimeSpan.FromHours(10));
_logger.LogDebug("Caching external reviews for {Key}", cacheKey);
}
@ -80,7 +82,44 @@ public class ReviewController : BaseApiController
// Fetch external reviews and splice them in
userRatings.AddRange(externalReviews);
return Ok(userRatings.Take(10));
return Ok(userRatings);
}
private static IList<UserReviewDto> SelectSpectrumOfReviews(IList<UserReviewDto> reviews)
{
IList<UserReviewDto> externalReviews;
var totalReviews = reviews.Count;
if (totalReviews > 10)
{
//var stepSize = Math.Max(totalReviews / 10, 1); // Calculate step size, ensuring it's at least 1
var stepSize = Math.Max((totalReviews - 4) / 8, 1);
var selectedReviews = new List<UserReviewDto>()
{
reviews[0],
reviews[1],
};
for (var i = 2; i < totalReviews - 2; i += stepSize)
{
selectedReviews.Add(reviews[i]);
if (selectedReviews.Count >= 8)
break;
}
selectedReviews.Add(reviews[totalReviews - 2]);
selectedReviews.Add(reviews[totalReviews - 1]);
externalReviews = selectedReviews;
}
else
{
externalReviews = reviews;
}
return externalReviews;
}
/// <summary>

View file

@ -143,8 +143,7 @@ public class ScrobblingController : BaseApiController
[HttpGet("library-allows-scrobbling")]
public async Task<ActionResult<bool>> LibraryAllowsScrobbling(int seriesId)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library);
return Ok(series != null && series.Library.AllowScrobbling);
return Ok(await _unitOfWork.LibraryRepository.GetAllowsScrobblingBySeriesId(seriesId));
}
/// <summary>

View file

@ -33,8 +33,7 @@ public class SearchController : BaseApiController
[HttpGet("series-for-mangafile")]
public async Task<ActionResult<SeriesDto>> GetSeriesForMangaFile(int mangaFileId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId));
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, User.GetUserId()));
}
/// <summary>
@ -46,8 +45,7 @@ public class SearchController : BaseApiController
[HttpGet("series-for-chapter")]
public async Task<ActionResult<SeriesDto>> GetSeriesForChapter(int chapterId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId));
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, User.GetUserId()));
}
[HttpGet("search")]

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
@ -6,6 +7,7 @@ using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata;
using API.DTOs.SeriesDetail;
using API.Entities;
@ -53,10 +55,19 @@ public class SeriesController : BaseApiController
_recommendationCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
}
/// <summary>
/// Gets series with the applied Filter
/// </summary>
/// <remarks>This is considered v1 and no longer used by Kavita, but will be supported for sometime. See series/v2</remarks>
/// <param name="libraryId"></param>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost]
[Obsolete("use v2")]
public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto);
@ -70,6 +81,30 @@ public class SeriesController : BaseApiController
return Ok(series);
}
/// <summary>
/// Gets series with the applied Filter
/// </summary>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost("v2")]
public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibraryV2([FromQuery] UserParams userParams, [FromBody] FilterV2Dto filterDto)
{
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto);
//TODO: We might want something like libraryId as source so that I don't have to muck with the groups
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest("Could not get series for library");
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary>
/// Fetches a Series for a given Id
/// </summary>
@ -79,8 +114,7 @@ public class SeriesController : BaseApiController
[HttpGet("{seriesId:int}")]
public async Task<ActionResult<SeriesDto>> GetSeries(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, User.GetUserId());
if (series == null) return NoContent();
return Ok(series);
}
@ -115,15 +149,13 @@ public class SeriesController : BaseApiController
[HttpGet("volumes")]
public async Task<ActionResult<IEnumerable<VolumeDto>>> GetVolumes(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId));
return Ok(await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, User.GetUserId()));
}
[HttpGet("volume")]
public async Task<ActionResult<VolumeDto?>> GetVolume(int volumeId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var vol = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId);
var vol = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, User.GetUserId());
if (vol == null) return NoContent();
return Ok(vol);
}
@ -207,7 +239,7 @@ public class SeriesController : BaseApiController
}
/// <summary>
/// Gets all recently added series
/// Gets all recently added series. Obsolete, use recently-added-v2
/// </summary>
/// <param name="filterDto"></param>
/// <param name="userParams"></param>
@ -215,9 +247,10 @@ public class SeriesController : BaseApiController
/// <returns></returns>
[ResponseCache(CacheProfileName = "Instant")]
[HttpPost("recently-added")]
[Obsolete("use recently-added-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, userId, userParams, filterDto);
@ -231,6 +264,30 @@ public class SeriesController : BaseApiController
return Ok(series);
}
/// <summary>
/// Gets all recently added series
/// </summary>
/// <param name="filterDto"></param>
/// <param name="userParams"></param>
/// <returns></returns>
[ResponseCache(CacheProfileName = "Instant")]
[HttpPost("recently-added-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRecentlyAddedV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams)
{
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series"));
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary>
/// Returns series that were recently updated, like adding or removing a chapter
/// </summary>
@ -239,8 +296,7 @@ public class SeriesController : BaseApiController
[HttpPost("recently-updated-series")]
public async Task<ActionResult<IEnumerable<RecentlyAddedItemDto>>> GetRecentlyAddedChapters()
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId, 20));
return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(User.GetUserId(), 20));
}
/// <summary>
@ -250,10 +306,35 @@ public class SeriesController : BaseApiController
/// <param name="userParams"></param>
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpPost("all-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series"));
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary>
/// Returns all series for the library. Obsolete, use all-v2
/// </summary>
/// <param name="filterDto"></param>
/// <param name="userParams"></param>
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpPost("all")]
[Obsolete("User all-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto);
@ -270,16 +351,15 @@ public class SeriesController : BaseApiController
/// <summary>
/// Fetches series that are on deck aka have progress on them.
/// </summary>
/// <param name="filterDto"></param>
/// <param name="userParams"></param>
/// <param name="libraryId">Default of 0 meaning all libraries</param>
/// <returns></returns>
[ResponseCache(CacheProfileName = "Instant")]
[HttpPost("on-deck")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetOnDeck(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetOnDeck([FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, filterDto);
var userId = User.GetUserId();
var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, null);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, pagedList);
@ -288,6 +368,7 @@ public class SeriesController : BaseApiController
return Ok(pagedList);
}
/// <summary>
/// Removes a series from displaying on deck until the next read event on that series
/// </summary>
@ -359,25 +440,24 @@ public class SeriesController : BaseApiController
[HttpPost("metadata")]
public async Task<ActionResult> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto)
{
if (await _seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto))
if (!await _seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "update-metadata-fail"));
if (await _licenseService.HasActiveLicense())
{
if (await _licenseService.HasActiveLicense())
_logger.LogDebug("Clearing cache as series weblinks may have changed");
await _reviewCacheProvider.RemoveAsync(ReviewController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId);
await _ratingCacheProvider.RemoveAsync(RatingController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId);
var allUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(s => s.Id);
foreach (var userId in allUsers)
{
_logger.LogDebug("Clearing cache as series weblinks may have changed");
await _reviewCacheProvider.RemoveAsync(ReviewController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId);
await _ratingCacheProvider.RemoveAsync(RatingController.CacheKey + updateSeriesMetadataDto.SeriesMetadata.SeriesId);
var allUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(s => s.Id);
foreach (var userId in allUsers)
{
await _recommendationCacheProvider.RemoveAsync(RecommendedController.CacheKey + $"{updateSeriesMetadataDto.SeriesMetadata.SeriesId}-{userId}");
}
await _recommendationCacheProvider.RemoveAsync(RecommendedController.CacheKey + $"{updateSeriesMetadataDto.SeriesMetadata.SeriesId}-{userId}");
}
return Ok(await _localizationService.Translate(User.GetUserId(), "series-updated"));
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "update-metadata-fail"));
return Ok(await _localizationService.Translate(User.GetUserId(), "series-updated"));
}
/// <summary>
@ -389,7 +469,7 @@ public class SeriesController : BaseApiController
[HttpGet("series-by-collection")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetSeriesByCollectionTag(int collectionId, [FromQuery] UserParams userParams)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, userParams);
@ -412,8 +492,7 @@ public class SeriesController : BaseApiController
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeriesById(SeriesByIdsDto dto)
{
if (dto.SeriesIds == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-payload"));
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, userId));
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, User.GetUserId()));
}
/// <summary>
@ -443,10 +522,9 @@ public class SeriesController : BaseApiController
[HttpGet("series-detail")]
public async Task<ActionResult<SeriesDetailDto>> GetSeriesDetailBreakdown(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
try
{
return await _seriesService.GetSeriesDetail(seriesId, userId);
return await _seriesService.GetSeriesDetail(seriesId, User.GetUserId());
}
catch (KavitaException ex)
{
@ -465,9 +543,7 @@ public class SeriesController : BaseApiController
[HttpGet("related")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRelatedSeries(int seriesId, RelationKind relation)
{
// Send back a custom DTO with each type or maybe sorted in some way
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForRelationKind(userId, seriesId, relation));
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForRelationKind(User.GetUserId(), seriesId, relation));
}
/// <summary>
@ -478,8 +554,7 @@ public class SeriesController : BaseApiController
[HttpGet("all-related")]
public async Task<ActionResult<RelatedSeriesDto>> GetAllRelatedSeries(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _seriesService.GetRelatedSeries(userId, seriesId));
return Ok(await _seriesService.GetRelatedSeries(User.GetUserId(), seriesId));
}

View file

@ -197,6 +197,12 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.CoverImageSize && updateSettingsDto.CoverImageSize + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.CoverImageSize + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value)
{
setting.Value = updateSettingsDto.TaskScan;

View file

@ -125,14 +125,21 @@ public class StatsController : BaseApiController
}
[HttpGet("day-breakdown")]
[Authorize("RequireAdminRole")]
[ResponseCache(CacheProfileName = "Statistics")]
public ActionResult<IEnumerable<StatCount<DayOfWeek>>> GetDayBreakdown()
public async Task<ActionResult<IEnumerable<StatCount<DayOfWeek>>>> GetDayBreakdown(int userId = 0)
{
return Ok(_statService.GetDayBreakdown());
if (userId == 0)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
if (!isAdmin) return BadRequest();
}
return Ok(_statService.GetDayBreakdown(userId));
}
[HttpGet("user/reading-history")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<ReadHistoryEvent>>> GetReadingHistory(int userId)

View file

@ -68,10 +68,9 @@ public class UsersController : BaseApiController
[HttpGet("has-reading-progress")]
public async Task<ActionResult<bool>> HasReadingProgress(int libraryId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId);
if (library == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-doesnt-exist"));
return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, userId));
return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, User.GetUserId()));
}
[HttpGet("has-library-access")]

View file

@ -1,9 +1,11 @@
using System.Linq;
using System;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.WantToRead;
using API.Extensions;
using API.Helpers;
@ -33,12 +35,13 @@ public class WantToReadController : BaseApiController
}
/// <summary>
/// Return all Series that are in the current logged in user's Want to Read list, filtered
/// Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2)
/// </summary>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost]
[Obsolete("use v2 instead")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetWantToRead([FromQuery] UserParams userParams, FilterDto filterDto)
{
userParams ??= new UserParams();
@ -50,6 +53,24 @@ public class WantToReadController : BaseApiController
return Ok(pagedList);
}
/// <summary>
/// Return all Series that are in the current logged in user's Want to Read list, filtered
/// </summary>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost("v2")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetWantToReadV2([FromQuery] UserParams userParams, FilterV2Dto filterDto)
{
userParams ??= new UserParams();
var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserV2Async(User.GetUserId(), userParams, filterDto);
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(User.GetUserId(), pagedList);
return Ok(pagedList);
}
[HttpGet]
public async Task<ActionResult<bool>> IsSeriesInWantToRead([FromQuery] int seriesId)
{

View file

@ -0,0 +1,7 @@
namespace API.DTOs.Filtering.v2;
public enum FilterCombination
{
Or = 0,
And = 1
}

View file

@ -0,0 +1,56 @@
using System.ComponentModel;
namespace API.DTOs.Filtering.v2;
public enum FilterComparison
{
[Description("Equal")]
Equal = 0,
GreaterThan = 1,
GreaterThanEqual = 2,
LessThan = 3,
LessThanEqual = 4,
/// <summary>
/// value is within any of the series. This is inheritently an OR, even if combinator is an AND
/// </summary>
/// <remarks>Only works with IList</remarks>
Contains = 5,
/// <summary>
/// value is within All of the series. This is an AND, even if combinator ORs the different statements
/// </summary>
/// <remarks>Only works with IList</remarks>
MustContains = 6,
/// <summary>
/// Performs a LIKE %value%
/// </summary>
Matches = 7,
NotContains = 8,
/// <summary>
/// Not Equal to
/// </summary>
NotEqual = 9,
/// <summary>
/// String starts with
/// </summary>
BeginsWith = 10,
/// <summary>
/// String ends with
/// </summary>
EndsWith = 11,
/// <summary>
/// Is Date before X
/// </summary>
IsBefore = 12,
/// <summary>
/// Is Date after X
/// </summary>
IsAfter = 13,
/// <summary>
/// Is Date between now and X seconds ago
/// </summary>
IsInLast = 14,
/// <summary>
/// Is Date not between now and X seconds ago
/// </summary>
IsNotInLast = 15,
}

View file

@ -0,0 +1,40 @@
namespace API.DTOs.Filtering.v2;
/// <summary>
/// Represents the field which will dictate the value type and the Extension used for filtering
/// </summary>
public enum FilterField
{
Summary = 0,
SeriesName = 1,
PublicationStatus = 2,
Languages = 3,
AgeRating = 4,
UserRating = 5,
Tags = 6,
CollectionTags = 7,
Translators = 8,
Characters = 9,
Publisher = 10,
Editor = 11,
CoverArtist = 12,
Letterer = 13,
Colorist = 14,
Inker = 15,
Penciller = 16,
Writers = 17,
Genres = 18,
Libraries = 19,
ReadProgress = 20,
Formats = 21,
ReleaseYear = 22,
ReadTime = 23,
/// <summary>
/// Series Folder
/// </summary>
Path = 24,
/// <summary>
/// File path
/// </summary>
FilePath = 25
}

View file

@ -0,0 +1,8 @@
namespace API.DTOs.Filtering.v2;
public class FilterStatementDto
{
public FilterComparison Comparison { get; set; }
public FilterField Field { get; set; }
public string Value { get; set; }
}

View file

@ -0,0 +1,30 @@
using System.Collections.Generic;
namespace API.DTOs.Filtering.v2;
/// <summary>
/// Metadata filtering for v2 API only
/// </summary>
public class FilterV2Dto
{
/// <summary>
/// The name of the filter
/// </summary>
public string? Name { get; set; }
public ICollection<FilterStatementDto> Statements { get; set; } = new List<FilterStatementDto>();
public FilterCombination Combination { get; set; } = FilterCombination.And;
public SortOptions SortOptions { get; set; }
/// <summary>
/// Limit the number of rows returned. Defaults to not applying a limit (aka 0)
/// </summary>
public int LimitTo { get; set; } = 0;
}

View file

@ -13,4 +13,8 @@ public class BookmarkDto
public int SeriesId { get; set; }
[Required]
public int ChapterId { get; set; }
/// <summary>
/// This is only used when getting all bookmarks.
/// </summary>
public SeriesDto? Series { get; set; }
}

View file

@ -12,7 +12,6 @@ public class SeriesDto : IHasReadTimeEstimate
public string? OriginalName { get; init; }
public string? LocalizedName { get; init; }
public string? SortName { get; init; }
public string? Summary { get; init; }
public int Pages { get; init; }
public bool CoverImageLocked { get; set; }
/// <summary>

View file

@ -84,4 +84,8 @@ public class ServerSettingDto
/// How many Days since today in the past for chapter updates, should content be considered for On Deck, before it gets removed automatically
/// </summary>
public int OnDeckUpdateDays { get; set; }
/// <summary>
/// How large the cover images should be
/// </summary>
public CoverImageSize CoverImageSize { get; set; }
}

View file

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.9");
modelBuilder.HasAnnotation("ProductVersion", "7.0.10");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -180,7 +180,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark");
b.ToTable("AppUserBookmark", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
@ -201,7 +201,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserOnDeckRemoval");
b.ToTable("AppUserOnDeckRemoval", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
@ -315,7 +315,7 @@ namespace API.Data.Migrations
b.HasIndex("ThemeId");
b.ToTable("AppUserPreferences");
b.ToTable("AppUserPreferences", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
@ -365,7 +365,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserProgresses");
b.ToTable("AppUserProgresses", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
@ -398,7 +398,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserRating");
b.ToTable("AppUserRating", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
@ -466,7 +466,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserTableOfContent");
b.ToTable("AppUserTableOfContent", (string)null);
});
modelBuilder.Entity("API.Entities.Chapter", b =>
@ -576,7 +576,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId");
b.ToTable("Chapter");
b.ToTable("Chapter", (string)null);
});
modelBuilder.Entity("API.Entities.CollectionTag", b =>
@ -611,7 +611,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "Promoted")
.IsUnique();
b.ToTable("CollectionTag");
b.ToTable("CollectionTag", (string)null);
});
modelBuilder.Entity("API.Entities.Device", b =>
@ -657,7 +657,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("Device");
b.ToTable("Device", (string)null);
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
@ -679,7 +679,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId");
b.ToTable("FolderPath");
b.ToTable("FolderPath", (string)null);
});
modelBuilder.Entity("API.Entities.Genre", b =>
@ -699,7 +699,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle")
.IsUnique();
b.ToTable("Genre");
b.ToTable("Genre", (string)null);
});
modelBuilder.Entity("API.Entities.Library", b =>
@ -757,7 +757,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("Library");
b.ToTable("Library", (string)null);
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
@ -806,7 +806,7 @@ namespace API.Data.Migrations
b.HasIndex("ChapterId");
b.ToTable("MangaFile");
b.ToTable("MangaFile", (string)null);
});
modelBuilder.Entity("API.Entities.MediaError", b =>
@ -841,7 +841,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("MediaError");
b.ToTable("MediaError", (string)null);
});
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
@ -942,7 +942,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "SeriesId")
.IsUnique();
b.ToTable("SeriesMetadata");
b.ToTable("SeriesMetadata", (string)null);
});
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
@ -966,7 +966,7 @@ namespace API.Data.Migrations
b.HasIndex("TargetSeriesId");
b.ToTable("SeriesRelation");
b.ToTable("SeriesRelation", (string)null);
});
modelBuilder.Entity("API.Entities.Person", b =>
@ -986,7 +986,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("Person");
b.ToTable("Person", (string)null);
});
modelBuilder.Entity("API.Entities.ReadingList", b =>
@ -1049,7 +1049,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("ReadingList");
b.ToTable("ReadingList", (string)null);
});
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
@ -1083,7 +1083,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId");
b.ToTable("ReadingListItem");
b.ToTable("ReadingListItem", (string)null);
});
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b =>
@ -1128,7 +1128,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("ScrobbleError");
b.ToTable("ScrobbleError", (string)null);
});
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b =>
@ -1188,8 +1188,8 @@ namespace API.Data.Migrations
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int?>("VolumeNumber")
.HasColumnType("INTEGER");
b.Property<float?>("VolumeNumber")
.HasColumnType("REAL");
b.HasKey("Id");
@ -1199,7 +1199,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("ScrobbleEvent");
b.ToTable("ScrobbleEvent", (string)null);
});
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b =>
@ -1232,7 +1232,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("ScrobbleHold");
b.ToTable("ScrobbleHold", (string)null);
});
modelBuilder.Entity("API.Entities.Series", b =>
@ -1328,7 +1328,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId");
b.ToTable("Series");
b.ToTable("Series", (string)null);
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
@ -1345,7 +1345,7 @@ namespace API.Data.Migrations
b.HasKey("Key");
b.ToTable("ServerSetting");
b.ToTable("ServerSetting", (string)null);
});
modelBuilder.Entity("API.Entities.ServerStatistics", b =>
@ -1383,7 +1383,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("ServerStatistics");
b.ToTable("ServerStatistics", (string)null);
});
modelBuilder.Entity("API.Entities.SiteTheme", b =>
@ -1421,7 +1421,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("SiteTheme");
b.ToTable("SiteTheme", (string)null);
});
modelBuilder.Entity("API.Entities.Tag", b =>
@ -1441,7 +1441,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle")
.IsUnique();
b.ToTable("Tag");
b.ToTable("Tag", (string)null);
});
modelBuilder.Entity("API.Entities.Volume", b =>
@ -1477,8 +1477,8 @@ namespace API.Data.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<float>("Number")
.HasColumnType("REAL");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
@ -1493,7 +1493,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("Volume");
b.ToTable("Volume", (string)null);
});
modelBuilder.Entity("AppUserLibrary", b =>
@ -1508,7 +1508,7 @@ namespace API.Data.Migrations
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary");
b.ToTable("AppUserLibrary", (string)null);
});
modelBuilder.Entity("ChapterGenre", b =>
@ -1523,7 +1523,7 @@ namespace API.Data.Migrations
b.HasIndex("GenresId");
b.ToTable("ChapterGenre");
b.ToTable("ChapterGenre", (string)null);
});
modelBuilder.Entity("ChapterPerson", b =>
@ -1538,7 +1538,7 @@ namespace API.Data.Migrations
b.HasIndex("PeopleId");
b.ToTable("ChapterPerson");
b.ToTable("ChapterPerson", (string)null);
});
modelBuilder.Entity("ChapterTag", b =>
@ -1553,7 +1553,7 @@ namespace API.Data.Migrations
b.HasIndex("TagsId");
b.ToTable("ChapterTag");
b.ToTable("ChapterTag", (string)null);
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
@ -1568,7 +1568,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata");
b.ToTable("CollectionTagSeriesMetadata", (string)null);
});
modelBuilder.Entity("GenreSeriesMetadata", b =>
@ -1583,7 +1583,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId");
b.ToTable("GenreSeriesMetadata");
b.ToTable("GenreSeriesMetadata", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
@ -1682,7 +1682,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId");
b.ToTable("PersonSeriesMetadata");
b.ToTable("PersonSeriesMetadata", (string)null);
});
modelBuilder.Entity("SeriesMetadataTag", b =>
@ -1697,7 +1697,7 @@ namespace API.Data.Migrations
b.HasIndex("TagsId");
b.ToTable("SeriesMetadataTag");
b.ToTable("SeriesMetadataTag", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>

View file

@ -127,7 +127,6 @@ public class CollectionTagRepository : ICollectionTagRepository
.Select(sm => sm.Series.CoverImage)
.Where(t => !string.IsNullOrEmpty(t))
.ToListAsync();
if (data.Count < 4) return new List<string>();
return data
.OrderBy(_ => random.Next())
.Take(4)

View file

@ -11,6 +11,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Kavita.Common.Extensions;
@ -45,14 +46,14 @@ public interface ILibraryRepository
Task<int> GetTotalFiles();
IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId);
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds);
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds);
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync();
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int>? libraryIds);
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
Task<bool> DoAnySeriesFoldersMatch(IEnumerable<string> folders);
Task<string?> GetLibraryCoverImageAsync(int libraryId);
Task<IList<string>> GetAllCoverImagesAsync();
Task<IDictionary<int, LibraryType>> GetLibraryTypesForIdsAsync(IEnumerable<int> libraryIds);
Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<bool> GetAllowsScrobblingBySeriesId(int seriesId);
}
public class LibraryRepository : ILibraryRepository
@ -260,10 +261,10 @@ public class LibraryRepository : ILibraryRepository
.ToListAsync();
}
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds)
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int>? libraryIds)
{
var ret = await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.WhereIf(libraryIds is {Count: > 0} , s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Metadata.Language)
.AsSplitQuery()
.AsNoTracking()
@ -272,33 +273,33 @@ public class LibraryRepository : ILibraryRepository
return ret
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => new LanguageDto()
{
Title = CultureInfo.GetCultureInfo(s).DisplayName,
IsoCode = s
})
.DistinctBy(Parser.Normalize)
.Select(GetCulture)
.Where(s => s != null)
.OrderBy(s => s.Title)
.ToList();
}
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync()
private static LanguageDto GetCulture(string s)
{
var ret = await _context.Series
.Select(s => s.Metadata.Language)
.AsSplitQuery()
.AsNoTracking()
.Distinct()
.ToListAsync();
return ret
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => new LanguageDto()
try
{
return new LanguageDto()
{
Title = CultureInfo.GetCultureInfo(s).DisplayName,
IsoCode = s
})
.OrderBy(s => s.Title)
.ToList();
};
}
catch (Exception)
{
// ignored
}
return new LanguageDto()
{
Title = s,
IsoCode = s
};;
}
public IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds)
@ -374,4 +375,11 @@ public class LibraryRepository : ILibraryRepository
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync();
}
public async Task<bool> GetAllowsScrobblingBySeriesId(int seriesId)
{
return await _context.Series.Where(s => s.Id == seriesId)
.Select(s => s.Library.AllowScrobbling)
.SingleOrDefaultAsync();
}
}

View file

@ -3,6 +3,7 @@ using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Extensions.QueryExtensions;
using AutoMapper;
@ -17,9 +18,11 @@ public interface IPersonRepository
void Remove(Person person);
Task<IList<Person>> GetAllPeople();
Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId);
Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role);
Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false);
Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(List<int> libraryIds, int userId);
Task<int> GetCountAsync();
}
public class PersonRepository : IPersonRepository
@ -94,4 +97,15 @@ public class PersonRepository : IPersonRepository
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role)
{
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
return await _context.Person
.Where(p => p.Role == role)
.OrderBy(p => p.Name)
.RestrictAgainstAgeRestriction(ageRating)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
}

View file

@ -101,7 +101,6 @@ public class ReadingListRepository : IReadingListRepository
.SelectMany(r => r.Items.Select(ri => ri.Chapter.CoverImage))
.Where(t => !string.IsNullOrEmpty(t))
.ToListAsync();
if (data.Count < 4) return new List<string>();
return data
.OrderBy(_ => random.Next())
.Take(4)

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
@ -10,6 +9,7 @@ using API.Data.Scanner;
using API.DTOs;
using API.DTOs.CollectionTags;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata;
using API.DTOs.ReadingLists;
using API.DTOs.Search;
@ -20,7 +20,9 @@ using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Extensions.QueryExtensions.Filtering;
using API.Helpers;
using API.Helpers.Converters;
using API.Services;
using API.Services.Tasks;
using API.Services.Tasks.Scanner;
@ -95,8 +97,9 @@ public interface ISeriesRepository
/// <returns></returns>
Task AddSeriesModifiers(int userId, IList<SeriesDto> series);
Task<string?> GetSeriesCoverImageAsync(int seriesId);
Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter);
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter);
Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId);
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
@ -118,6 +121,7 @@ public interface ISeriesRepository
Task<SeriesDto?> GetSeriesForMangaFile(int mangaFileId, int userId);
Task<SeriesDto?> GetSeriesForChapter(int chapterId, int userId);
Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetWantToReadForUserV2Async(int userId, UserParams userParams, FilterV2Dto filter);
Task<IList<Series>> GetWantToReadForUserAsync(int userId);
Task<bool> IsSeriesInWantToRead(int userId, int seriesId);
Task<Series?> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
@ -140,6 +144,7 @@ public interface ISeriesRepository
Task<int> GetAverageUserRating(int seriesId, int userId);
Task RemoveFromOnDeck(int seriesId, int userId);
Task ClearOnDeckRemoval(int seriesId, int userId);
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto);
}
public class SeriesRepository : ISeriesRepository
@ -300,6 +305,7 @@ public class SeriesRepository : ISeriesRepository
/// <param name="userParams"></param>
/// <param name="filter"></param>
/// <returns></returns>
[Obsolete("Use GetSeriesDtoForLibraryIdAsync")]
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter)
{
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.None);
@ -605,6 +611,18 @@ public class SeriesRepository : ISeriesRepository
return await query.ToListAsync();
}
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto)
{
var query = await CreateFilteredSearchQueryableV2(userId, filterDto, QueryContext.None);
var retSeries = query
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series)
{
@ -644,7 +662,6 @@ public class SeriesRepository : ISeriesRepository
}
/// <summary>
/// Returns a list of Series that were added, ordered by Created desc
/// </summary>
@ -653,6 +670,7 @@ public class SeriesRepository : ISeriesRepository
/// <param name="userParams">Contains pagination information</param>
/// <param name="filter">Optional filter on query</param>
/// <returns></returns>
[Obsolete("Use GetRecentlyAddedV2")]
public async Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter)
{
var query = await CreateFilteredSearchQueryable(userId, libraryId, filter, QueryContext.Dashboard);
@ -666,6 +684,19 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> GetRecentlyAddedV2(int userId, UserParams userParams, FilterV2Dto filter)
{
var query = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.Dashboard);
var retSeries = query
.OrderByDescending(s => s.Created)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
private IList<MangaFormat> ExtractFilters(int libraryId, int userId, FilterDto filter, ref List<int> userLibraries,
out List<int> allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter,
out bool hasRatingFilter, out bool hasProgressFilter, out IList<int> seriesIds, out bool hasAgeRating, out bool hasTagsFilter,
@ -759,7 +790,7 @@ public class SeriesRepository : ISeriesRepository
/// <param name="userParams">Pagination information</param>
/// <param name="filter">Optional (default null) filter on query</param>
/// <returns></returns>
public async Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter)
public async Task<PagedList<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto? filter)
{
var settings = await _context.ServerSetting
.Select(x => x)
@ -780,11 +811,6 @@ public class SeriesRepository : ISeriesRepository
.Select(d => d.SeriesId)
.AsEnumerable();
// var onDeckRemovals = _context.AppUser.Where(u => u.Id == userId)
// .SelectMany(u => u.OnDeckRemovals.Select(d => d.Id))
// .AsEnumerable();
var query = _context.Series
.Where(s => usersSeriesIds.Contains(s.Id))
.Where(s => !onDeckRemovals.Contains(s.Id))
@ -828,29 +854,47 @@ public class SeriesRepository : ISeriesRepository
var query = _context.Series
.AsNoTracking()
.WhereIf(hasGenresFilter, s => s.Metadata.Genres.Any(g => filter.Genres.Contains(g.Id)))
.WhereIf(hasPeopleFilter, s => s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id)))
.WhereIf(hasCollectionTagFilter,
s => s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id)))
.WhereIf(hasRatingFilter, s => s.Ratings.Any(r => r.Rating >= filter.Rating && r.AppUserId == userId))
.WhereIf(hasProgressFilter, s => seriesIds.Contains(s.Id))
.WhereIf(hasAgeRating, s => filter.AgeRating.Contains(s.Metadata.AgeRating))
.WhereIf(hasTagsFilter, s => s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id)))
.WhereIf(hasLanguageFilter, s => filter.Languages.Contains(s.Metadata.Language))
.WhereIf(hasReleaseYearMinFilter, s => s.Metadata.ReleaseYear >= filter.ReleaseYearRange!.Min)
.WhereIf(hasReleaseYearMaxFilter, s => s.Metadata.ReleaseYear <= filter.ReleaseYearRange!.Max)
.WhereIf(hasPublicationFilter, s => filter.PublicationStatus.Contains(s.Metadata.PublicationStatus))
.WhereIf(hasSeriesNameFilter, s => EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(s.OriginalName!, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(s.LocalizedName!, $"%{filter.SeriesNameQuery}%"))
// This new style can handle any filterComparision coming from the user
.HasLanguage(hasLanguageFilter, FilterComparison.Contains, filter.Languages)
.HasReleaseYear(hasReleaseYearMaxFilter, FilterComparison.LessThanEqual, filter.ReleaseYearRange?.Max)
.HasReleaseYear(hasReleaseYearMinFilter, FilterComparison.GreaterThanEqual, filter.ReleaseYearRange?.Min)
.HasName(hasSeriesNameFilter, FilterComparison.Matches, filter.SeriesNameQuery)
.HasRating(hasRatingFilter, FilterComparison.GreaterThanEqual, filter.Rating, userId)
.HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating)
.HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus)
.HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags)
.HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags)
.HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres)
.HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!)
.HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0)
// TODO: This needs different treatment
.HasPeople(hasPeopleFilter, FilterComparison.Contains, allPeopleIds)
.WhereIf(onlyParentSeries,
s => s.RelationOf.Count == 0 || s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel))
.Where(s => userLibraries.Contains(s.LibraryId))
.Where(s => formats.Contains(s.Format));
.Where(s => userLibraries.Contains(s.LibraryId));
if (filter.ReadStatus.InProgress)
{
query = query.HasReadingProgress(hasProgressFilter, FilterComparison.GreaterThan,
0, userId)
.HasReadingProgress(hasProgressFilter, FilterComparison.LessThan,
100, userId);
} else if (filter.ReadStatus.Read)
{
query = query.HasReadingProgress(hasProgressFilter, FilterComparison.Equal,
100, userId);
}
else if (filter.ReadStatus.NotRead)
{
query = query.HasReadingProgress(hasProgressFilter, FilterComparison.Equal,
0, userId);
}
if (userRating.AgeRating != AgeRating.NotApplicable)
{
// this if statement is included in the extension
query = query.RestrictAgainstAgeRestriction(userRating);
}
@ -889,9 +933,141 @@ public class SeriesRepository : ISeriesRepository
};
}
return query.AsSplitQuery();
}
private async Task<IQueryable<Series>> CreateFilteredSearchQueryableV2(int userId, FilterV2Dto filter, QueryContext queryContext, IQueryable<Series>? query = null)
{
var userLibraries = await GetUserLibrariesForFilteredQuery(0, userId, queryContext);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
var onlyParentSeries = await _context.AppUserPreferences.Where(u => u.AppUserId == userId)
.Select(u => u.CollapseSeriesRelationships)
.SingleOrDefaultAsync();
query ??= _context.Series
.AsNoTracking();
// First setup any FilterField.Libraries in the statements, as these don't have any traditional query statements applied here
query = ApplyLibraryFilter(filter, query);
query = BuildFilterQuery(userId, filter, query);
query = query
.WhereIf(userLibraries.Count > 0, s => userLibraries.Contains(s.LibraryId))
.WhereIf(onlyParentSeries, s =>
s.RelationOf.Count == 0 ||
s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel))
.RestrictAgainstAgeRestriction(userRating);
return ApplyLimit(query
.Sort(filter.SortOptions)
.AsSplitQuery(), filter.LimitTo);
}
private static IQueryable<Series> ApplyLibraryFilter(FilterV2Dto filter, IQueryable<Series> query)
{
var filterIncludeLibs = new List<int>();
var filterExcludeLibs = new List<int>();
if (filter.Statements != null)
{
foreach (var stmt in filter.Statements.Where(stmt => stmt.Field == FilterField.Libraries))
{
var libIds = stmt.Value.Split(',').Select(int.Parse);
if (stmt.Comparison is FilterComparison.Equal or FilterComparison.Contains)
{
filterIncludeLibs.AddRange(libIds);
}
else
{
filterExcludeLibs.AddRange(libIds);
}
}
// Remove as filterLibs now has everything
filter.Statements = filter.Statements.Where(stmt => stmt.Field != FilterField.Libraries).ToList();
}
// We now have a list of libraries the user wants it restricted to and libraries the user doesn't want in the list
// We need to check what the filer combo is to see how to next approach
if (filter.Combination == FilterCombination.And)
{
// If the filter combo is AND, then we need 2 different queries
query = query
.WhereIf(filterIncludeLibs.Count > 0, s => filterIncludeLibs.Contains(s.LibraryId))
.WhereIf(filterExcludeLibs.Count > 0, s => !filterExcludeLibs.Contains(s.LibraryId));
}
else
{
// This is an OR statement. In that case we can just remove the filterExcludes
query = query.WhereIf(filterIncludeLibs.Count > 0, s => filterIncludeLibs.Contains(s.LibraryId));
}
return query;
}
private static IQueryable<Series> BuildFilterQuery(int userId, FilterV2Dto filterDto, IQueryable<Series> query)
{
if (filterDto.Statements == null || !filterDto.Statements.Any()) return query;
var queries = filterDto.Statements
.Select(statement => BuildFilterGroup(userId, statement, query))
.ToList();
return filterDto.Combination == FilterCombination.And
? queries.Aggregate((q1, q2) => q1.Intersect(q2))
: queries.Aggregate((q1, q2) => q1.Union(q2));
}
private static IQueryable<Series> ApplyLimit(IQueryable<Series> query, int limit)
{
return limit <= 0 ? query : query.Take(limit);
}
private static IQueryable<Series> BuildFilterGroup(int userId, FilterStatementDto statement, IQueryable<Series> query)
{
var (value, _) = FilterFieldValueConverter.ConvertValue(statement.Field, statement.Value);
return statement.Field switch
{
FilterField.Summary => query.HasSummary(true, statement.Comparison, (string) value),
FilterField.SeriesName => query.HasName(true, statement.Comparison, (string) value),
FilterField.Path => query.HasPath(true, statement.Comparison, (string) value),
FilterField.FilePath => query.HasFilePath(true, statement.Comparison, (string) value),
FilterField.PublicationStatus => query.HasPublicationStatus(true, statement.Comparison,
(IList<PublicationStatus>) value),
FilterField.Languages => query.HasLanguage(true, statement.Comparison, (IList<string>) value),
FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList<AgeRating>) value),
FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId),
FilterField.Tags => query.HasTags(true, statement.Comparison, (IList<int>) value),
FilterField.CollectionTags => query.HasCollectionTags(true, statement.Comparison, (IList<int>) value),
FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Editor => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.CoverArtist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Letterer => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) value),
FilterField.Libraries =>
// This is handled in the code before this as it's handled in a more general, combined manner
query,
FilterField.ReadProgress => query.HasReadingProgress(true, statement.Comparison, (int) value, userId),
FilterField.Formats => query.HasFormat(true, statement.Comparison, (IList<MangaFormat>) value),
FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value),
FilterField.ReadTime => query.HasAverageReadTime(true, statement.Comparison, (int) value),
_ => throw new ArgumentOutOfRangeException()
};
}
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, IQueryable<Series> sQuery)
{
var userLibraries = await GetUserLibrariesForFilteredQuery(libraryId, userId, QueryContext.Search);
@ -919,41 +1095,10 @@ public class SeriesRepository : ISeriesRepository
|| EF.Functions.Like(s.LocalizedName!, $"%{filter.SeriesNameQuery}%"))
.Where(s => userLibraries.Contains(s.LibraryId)
&& formats.Contains(s.Format))
.Sort(filter.SortOptions)
.AsNoTracking();
// If no sort options, default to using SortName
filter.SortOptions ??= new SortOptions()
{
IsAscending = true,
SortField = SortField.SortName
};
if (filter.SortOptions.IsAscending)
{
query = filter.SortOptions.SortField switch
{
SortField.SortName => query.OrderBy(s => s.SortName!.ToLower()),
SortField.CreatedDate => query.OrderBy(s => s.Created),
SortField.LastModifiedDate => query.OrderBy(s => s.LastModified),
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
_ => query
};
}
else
{
query = filter.SortOptions.SortField switch
{
SortField.SortName => query.OrderByDescending(s => s.SortName!.ToLower()),
SortField.CreatedDate => query.OrderByDescending(s => s.Created),
SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified),
SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead),
_ => query
};
}
return query;
return query.AsSplitQuery();
}
public async Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId)
@ -1615,6 +1760,7 @@ public class SeriesRepository : ISeriesRepository
.AsEnumerable();
}
[Obsolete("Use GetWantToReadForUserV2Async")]
public async Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter)
{
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
@ -1630,6 +1776,21 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(filteredQuery.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> GetWantToReadForUserV2Async(int userId, UserParams userParams, FilterV2Dto filter)
{
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
var query = _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead)
.Where(s => libraryIds.Contains(s.LibraryId))
.AsSplitQuery()
.AsNoTracking();
var filteredQuery = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.None, query);
return await PagedList<SeriesDto>.CreateAsync(filteredQuery.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize);
}
public async Task<IList<Series>> GetWantToReadForUserAsync(int userId)
{
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();

View file

@ -7,12 +7,14 @@ using API.Constants;
using API.DTOs;
using API.DTOs.Account;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Reader;
using API.DTOs.Scrobbling;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Extensions.QueryExtensions.Filtering;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Identity;
@ -53,7 +55,7 @@ public interface IUserRepository
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterDto filter);
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterV2Dto filter);
Task<IEnumerable<AppUserBookmark>> GetAllBookmarksAsync();
Task<AppUserBookmark?> GetBookmarkForPage(int page, int chapterId, int userId);
Task<AppUserBookmark?> GetBookmarkAsync(int bookmarkId);
@ -374,38 +376,92 @@ public class UserRepository : IUserRepository
/// <param name="userId"></param>
/// <param name="filter">Only supports SeriesNameQuery</param>
/// <returns></returns>
public async Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterDto filter)
public async Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId, FilterV2Dto filter)
{
var query = _context.AppUserBookmark
.Where(x => x.AppUserId == userId)
.OrderBy(x => x.Created)
.AsNoTracking();
if (string.IsNullOrEmpty(filter.SeriesNameQuery))
return await query
var filterSeriesQuery = query.Join(_context.Series, b => b.SeriesId, s => s.Id,
(bookmark, series) => new BookmarkSeriesPair()
{
bookmark = bookmark,
series = series
});
var filterStatement = filter.Statements.FirstOrDefault(f => f.Field == FilterField.SeriesName);
if (filterStatement == null || string.IsNullOrWhiteSpace(filterStatement.Value))
{
return await ApplyLimit(filterSeriesQuery
.Sort(filter.SortOptions)
.AsSplitQuery(), filter.LimitTo)
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
var seriesNameQueryNormalized = filter.SeriesNameQuery.ToNormalized();
var filterSeriesQuery = query.Join(_context.Series, b => b.SeriesId, s => s.Id, (bookmark, series) => new
{
bookmark,
series
})
.Where(o => (EF.Functions.Like(o.series.Name, $"%{filter.SeriesNameQuery}%"))
|| (o.series.OriginalName != null && EF.Functions.Like(o.series.OriginalName, $"%{filter.SeriesNameQuery}%"))
|| (o.series.LocalizedName != null && EF.Functions.Like(o.series.LocalizedName, $"%{filter.SeriesNameQuery}%"))
|| (EF.Functions.Like(o.series.NormalizedName, $"%{seriesNameQueryNormalized}%"))
);
var queryString = filterStatement.Value.ToNormalized();
switch (filterStatement.Comparison)
{
case FilterComparison.Equal:
filterSeriesQuery = filterSeriesQuery.Where(s => s.series.Name.Equals(queryString)
|| s.series.OriginalName.Equals(queryString)
|| s.series.LocalizedName.Equals(queryString)
|| s.series.SortName.Equals(queryString));
break;
case FilterComparison.BeginsWith:
filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.series.Name, $"{queryString}%")
||EF.Functions.Like(s.series.OriginalName, $"{queryString}%")
|| EF.Functions.Like(s.series.LocalizedName, $"{queryString}%")
|| EF.Functions.Like(s.series.SortName, $"{queryString}%"));
break;
case FilterComparison.EndsWith:
filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.series.Name, $"%{queryString}")
||EF.Functions.Like(s.series.OriginalName, $"%{queryString}")
|| EF.Functions.Like(s.series.LocalizedName, $"%{queryString}")
|| EF.Functions.Like(s.series.SortName, $"%{queryString}"));
break;
case FilterComparison.Matches:
filterSeriesQuery = filterSeriesQuery.Where(s => EF.Functions.Like(s.series.Name, $"%{queryString}%")
||EF.Functions.Like(s.series.OriginalName, $"%{queryString}%")
|| EF.Functions.Like(s.series.LocalizedName, $"%{queryString}%")
|| EF.Functions.Like(s.series.SortName, $"%{queryString}%"));
break;
case FilterComparison.NotEqual:
filterSeriesQuery = filterSeriesQuery.Where(s => s.series.Name != queryString
|| s.series.OriginalName != queryString
|| s.series.LocalizedName != queryString
|| s.series.SortName != queryString);
break;
case FilterComparison.MustContains:
case FilterComparison.NotContains:
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Contains:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
default:
break;
}
query = filterSeriesQuery.Select(o => o.bookmark);
return await query
return await ApplyLimit(filterSeriesQuery
.Sort(filter.SortOptions)
.AsSplitQuery(), filter.LimitTo)
.Select(o => o.bookmark)
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
private static IQueryable<BookmarkSeriesPair> ApplyLimit(IQueryable<BookmarkSeriesPair> query, int limit)
{
return limit <= 0 ? query : query.Take(limit);
}
/// <summary>
/// Fetches the UserId by API Key. This does not include any extra information
/// </summary>

View file

@ -108,8 +108,9 @@ public static class Seed
new() {Key = ServerSettingKey.HostName, Value = string.Empty},
new() {Key = ServerSettingKey.EncodeMediaAs, Value = EncodeFormat.PNG.ToString()},
new() {Key = ServerSettingKey.LicenseKey, Value = string.Empty},
new() {Key = ServerSettingKey.OnDeckProgressDays, Value = $"{30}"},
new() {Key = ServerSettingKey.OnDeckUpdateDays, Value = $"{7}"},
new() {Key = ServerSettingKey.OnDeckProgressDays, Value = "30"},
new() {Key = ServerSettingKey.OnDeckUpdateDays, Value = "7"},
new() {Key = ServerSettingKey.CoverImageSize, Value = CoverImageSize.Default.ToString()},
new() {
Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty
}, // Not used from DB, but DB is sync with appSettings.json

View file

@ -0,0 +1,36 @@
namespace API.Entities.Enums;
public enum CoverImageSize
{
/// <summary>
/// Default Size: 320x455 (wxh)
/// </summary>
Default = 1,
/// <summary>
/// 640x909
/// </summary>
Medium = 2,
/// <summary>
/// 900x1277
/// </summary>
Large = 3,
/// <summary>
/// 1265x1795
/// </summary>
XLarge = 4
}
public static class CoverImageSizeExtensions
{
public static (int Width, int Height) GetDimensions(this CoverImageSize size)
{
return size switch
{
CoverImageSize.Default => (320, 455),
CoverImageSize.Medium => (640, 909),
CoverImageSize.Large => (900, 1277),
CoverImageSize.XLarge => (1265, 1795),
_ => (320, 455)
};
}
}

View file

@ -143,5 +143,10 @@ public enum ServerSettingKey
/// </summary>
[Description("OnDeckUpdateDays")]
OnDeckUpdateDays = 26,
/// <summary>
/// The size of the cover image thumbnail. Defaults to <see cref="CoverImageSize"/>.Default
/// </summary>
[Description("CoverImageSize")]
CoverImageSize = 27
}

View file

@ -83,6 +83,7 @@ public static class ApplicationServiceExtensions
options.UseInMemory(EasyCacheProfiles.License);
options.UseInMemory(EasyCacheProfiles.Library);
options.UseInMemory(EasyCacheProfiles.RevokedJwt);
options.UseInMemory(EasyCacheProfiles.Filter);
// KavitaPlus stuff
options.UseInMemory(EasyCacheProfiles.KavitaPlusReviews);

View file

@ -0,0 +1,59 @@
using System.Linq;
using API.DTOs.Filtering;
using API.Entities;
namespace API.Extensions.QueryExtensions.Filtering;
public class BookmarkSeriesPair
{
public AppUserBookmark bookmark { get; set; }
public Series series { get; set; }
}
public static class BookmarkSort
{
/// <summary>
/// Applies the correct sort based on <see cref="SortOptions"/>
/// </summary>
/// <param name="query"></param>
/// <param name="sortOptions"></param>
/// <returns></returns>
public static IQueryable<BookmarkSeriesPair> Sort(this IQueryable<BookmarkSeriesPair> query, SortOptions? sortOptions)
{
// If no sort options, default to using SortName
sortOptions ??= new SortOptions()
{
IsAscending = true,
SortField = SortField.SortName
};
if (sortOptions.IsAscending)
{
query = sortOptions.SortField switch
{
SortField.SortName => query.OrderBy(s => s.series.SortName.ToLower()),
SortField.CreatedDate => query.OrderBy(s => s.series.Created),
SortField.LastModifiedDate => query.OrderBy(s => s.series.LastModified),
SortField.LastChapterAdded => query.OrderBy(s => s.series.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.series.AvgHoursToRead),
SortField.ReleaseYear => query.OrderBy(s => s.series.Metadata.ReleaseYear),
_ => query
};
}
else
{
query = sortOptions.SortField switch
{
SortField.SortName => query.OrderByDescending(s => s.series.SortName.ToLower()),
SortField.CreatedDate => query.OrderByDescending(s => s.series.Created),
SortField.LastModifiedDate => query.OrderByDescending(s => s.series.LastModified),
SortField.LastChapterAdded => query.OrderByDescending(s => s.series.LastChapterAdded),
SortField.TimeToRead => query.OrderByDescending(s => s.series.AvgHoursToRead),
SortField.ReleaseYear => query.OrderByDescending(s => s.series.Metadata.ReleaseYear),
_ => query
};
}
return query;
}
}

View file

@ -0,0 +1,674 @@
using System;
using System.Collections.Generic;
using System.Linq;
using API.DTOs.Filtering.v2;
using API.Entities;
using API.Entities.Enums;
using API.Services.Tasks.Scanner.Parser;
using Kavita.Common;
using Microsoft.EntityFrameworkCore;
namespace API.Extensions.QueryExtensions.Filtering;
#nullable enable
public static class SeriesFilter
{
public static IQueryable<Series> HasLanguage(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<string> languages)
{
if (languages.Count == 0 || !condition) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.Language.Equals(languages.First()));
case FilterComparison.Contains:
return queryable.Where(s => languages.Contains(s.Metadata.Language));
case FilterComparison.MustContains:
return queryable.Where(s => languages.All(s2 => s2.Equals(s.Metadata.Language)));
case FilterComparison.NotContains:
return queryable.Where(s => !languages.Contains(s.Metadata.Language));
case FilterComparison.NotEqual:
return queryable.Where(s => !s.Metadata.Language.Equals(languages.First()));
case FilterComparison.Matches:
return queryable.Where(s => EF.Functions.Like(s.Metadata.Language, $"{languages.First()}%"));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasReleaseYear(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, int? releaseYear)
{
if (!condition || releaseYear == null) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.ReleaseYear == releaseYear);
case FilterComparison.GreaterThan:
case FilterComparison.IsAfter:
return queryable.Where(s => s.Metadata.ReleaseYear > releaseYear);
case FilterComparison.GreaterThanEqual:
return queryable.Where(s => s.Metadata.ReleaseYear >= releaseYear);
case FilterComparison.LessThan:
case FilterComparison.IsBefore:
return queryable.Where(s => s.Metadata.ReleaseYear < releaseYear);
case FilterComparison.LessThanEqual:
return queryable.Where(s => s.Metadata.ReleaseYear <= releaseYear);
case FilterComparison.IsInLast:
return queryable.Where(s => s.Metadata.ReleaseYear >= DateTime.Now.Year - (int) releaseYear);
case FilterComparison.IsNotInLast:
return queryable.Where(s => s.Metadata.ReleaseYear < DateTime.Now.Year - (int) releaseYear);
case FilterComparison.Matches:
case FilterComparison.Contains:
case FilterComparison.NotContains:
case FilterComparison.NotEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.ReleaseYear");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasRating(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, int rating, int userId)
{
if (rating < 0 || !condition || userId <= 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Ratings.Any(r => r.Rating == rating && r.AppUserId == userId));
case FilterComparison.GreaterThan:
return queryable.Where(s => s.Ratings.Any(r => r.Rating > rating && r.AppUserId == userId));
case FilterComparison.GreaterThanEqual:
return queryable.Where(s => s.Ratings.Any(r => r.Rating >= rating && r.AppUserId == userId));
case FilterComparison.LessThan:
return queryable.Where(s => s.Ratings.Any(r => r.Rating < rating && r.AppUserId == userId));
case FilterComparison.LessThanEqual:
return queryable.Where(s => s.Ratings.Any(r => r.Rating <= rating && r.AppUserId == userId));
case FilterComparison.Contains:
case FilterComparison.Matches:
case FilterComparison.NotContains:
case FilterComparison.NotEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.Rating");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasAgeRating(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<AgeRating> ratings)
{
if (!condition || ratings.Count == 0) return queryable;
var firstRating = ratings.First();
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.AgeRating == firstRating);
case FilterComparison.GreaterThan:
return queryable.Where(s => s.Metadata.AgeRating > firstRating);
case FilterComparison.GreaterThanEqual:
return queryable.Where(s => s.Metadata.AgeRating >= firstRating);
case FilterComparison.LessThan:
return queryable.Where(s => s.Metadata.AgeRating < firstRating);
case FilterComparison.LessThanEqual:
return queryable.Where(s => s.Metadata.AgeRating <= firstRating);
case FilterComparison.Contains:
return queryable.Where(s => ratings.Contains(s.Metadata.AgeRating));
case FilterComparison.NotContains:
return queryable.Where(s => !ratings.Contains(s.Metadata.AgeRating));
case FilterComparison.NotEqual:
return queryable.Where(s => s.Metadata.AgeRating != firstRating);
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.AgeRating");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasAverageReadTime(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, int avgReadTime)
{
if (!condition || avgReadTime < 0) return queryable;
switch (comparison)
{
case FilterComparison.NotEqual:
return queryable.Where(s => s.AvgHoursToRead != avgReadTime);
case FilterComparison.Equal:
return queryable.Where(s => s.AvgHoursToRead == avgReadTime);
case FilterComparison.GreaterThan:
return queryable.Where(s => s.AvgHoursToRead > avgReadTime);
case FilterComparison.GreaterThanEqual:
return queryable.Where(s => s.AvgHoursToRead >= avgReadTime);
case FilterComparison.LessThan:
return queryable.Where(s => s.AvgHoursToRead < avgReadTime);
case FilterComparison.LessThanEqual:
return queryable.Where(s => s.AvgHoursToRead <= avgReadTime);
case FilterComparison.Contains:
case FilterComparison.Matches:
case FilterComparison.NotContains:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.AverageReadTime");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasPublicationStatus(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<PublicationStatus> pubStatues)
{
if (!condition || pubStatues.Count == 0) return queryable;
var firstStatus = pubStatues.First();
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.PublicationStatus == firstStatus);
case FilterComparison.Contains:
return queryable.Where(s => pubStatues.Contains(s.Metadata.PublicationStatus));
case FilterComparison.NotContains:
return queryable.Where(s => !pubStatues.Contains(s.Metadata.PublicationStatus));
case FilterComparison.NotEqual:
return queryable.Where(s => s.Metadata.PublicationStatus != firstStatus);
case FilterComparison.MustContains:
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.Matches:
throw new KavitaException($"{comparison} not applicable for Series.PublicationStatus");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
/// <summary>
///
/// </summary>
/// <remarks>This is more taxing on memory as the percentage calculation must be done in Memory</remarks>
/// <exception cref="KavitaException"></exception>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public static IQueryable<Series> HasReadingProgress(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, int readProgress, int userId)
{
if (!condition) return queryable;
var subQuery = queryable
.Include(s => s.Progress)
.Where(s => s.Progress != null)
.Select(s => new
{
Series = s,
Percentage = Math.Truncate(((double) s.Progress
.Where(p => p != null && p.AppUserId == userId)
.Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0) * 100))
})
.AsEnumerable();
switch (comparison)
{
case FilterComparison.Equal:
subQuery = subQuery.Where(s => s.Percentage == readProgress);
break;
case FilterComparison.GreaterThan:
subQuery = subQuery.Where(s => s.Percentage > readProgress);
break;
case FilterComparison.GreaterThanEqual:
subQuery = subQuery.Where(s => s.Percentage >= readProgress);
break;
case FilterComparison.LessThan:
subQuery = subQuery.Where(s => s.Percentage < readProgress);
break;
case FilterComparison.LessThanEqual:
subQuery = subQuery.Where(s => s.Percentage <= readProgress);
break;
case FilterComparison.NotEqual:
subQuery = subQuery.Where(s => s.Percentage != readProgress);
break;
case FilterComparison.Matches:
case FilterComparison.Contains:
case FilterComparison.NotContains:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.ReadProgress");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
var ids = subQuery.Select(s => s.Series.Id).ToList();
return queryable.Where(s => ids.Contains(s.Id));
}
public static IQueryable<Series> HasTags(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> tags)
{
if (!condition || tags.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.Tags.Any(t => tags.Contains(t.Id)));
case FilterComparison.NotEqual:
case FilterComparison.NotContains:
return queryable.Where(s => s.Metadata.Tags.Any(t => !tags.Contains(t.Id)));
case FilterComparison.MustContains:
// Deconstruct and do a Union of a bunch of where statements since this doesn't translate
var queries = new List<IQueryable<Series>>()
{
queryable
};
queries.AddRange(tags.Select(gId => queryable.Where(s => s.Metadata.Tags.Any(p => p.Id == gId))));
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Tags");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasPeople(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> people)
{
if (!condition || people.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.Id)));
case FilterComparison.NotEqual:
case FilterComparison.NotContains:
return queryable.Where(s => s.Metadata.People.Any(t => !people.Contains(t.Id)));
case FilterComparison.MustContains:
// Deconstruct and do a Union of a bunch of where statements since this doesn't translate
var queries = new List<IQueryable<Series>>()
{
queryable
};
queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.Id == gId))));
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.Matches:
throw new KavitaException($"{comparison} not applicable for Series.People");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasGenre(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> genres)
{
if (!condition || genres.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.Genres.Any(p => genres.Contains(p.Id)));
case FilterComparison.NotEqual:
case FilterComparison.NotContains:
return queryable.Where(s => s.Metadata.Genres.All(p => !genres.Contains(p.Id)));
case FilterComparison.MustContains:
// Deconstruct and do a Union of a bunch of where statements since this doesn't translate
var queries = new List<IQueryable<Series>>()
{
queryable
};
queries.AddRange(genres.Select(gId => queryable.Where(s => s.Metadata.Genres.Any(p => p.Id == gId))));
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Genres");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasFormat(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<MangaFormat> formats)
{
if (!condition || formats.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => formats.Contains(s.Format));
case FilterComparison.NotContains:
case FilterComparison.NotEqual:
return queryable.Where(s => !formats.Contains(s.Format));
case FilterComparison.MustContains:
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.Format");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasCollectionTags(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, IList<int> collectionTags)
{
if (!condition || collectionTags.Count == 0) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
case FilterComparison.Contains:
return queryable.Where(s => s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id)));
case FilterComparison.NotContains:
case FilterComparison.NotEqual:
return queryable.Where(s => !s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id)));
case FilterComparison.MustContains:
// Deconstruct and do a Union of a bunch of where statements since this doesn't translate
var queries = new List<IQueryable<Series>>()
{
queryable
};
queries.AddRange(collectionTags.Select(gId => queryable.Where(s => s.Metadata.CollectionTags.Any(p => p.Id == gId))));
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Matches:
case FilterComparison.BeginsWith:
case FilterComparison.EndsWith:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
throw new KavitaException($"{comparison} not applicable for Series.CollectionTags");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
}
}
public static IQueryable<Series> HasName(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, string queryString)
{
if (string.IsNullOrEmpty(queryString) || !condition) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Name.Equals(queryString)
|| s.OriginalName.Equals(queryString)
|| s.LocalizedName.Equals(queryString)
|| s.SortName.Equals(queryString));
case FilterComparison.BeginsWith:
return queryable.Where(s => EF.Functions.Like(s.Name, $"{queryString}%")
||EF.Functions.Like(s.OriginalName, $"{queryString}%")
|| EF.Functions.Like(s.LocalizedName, $"{queryString}%")
|| EF.Functions.Like(s.SortName, $"{queryString}%"));
case FilterComparison.EndsWith:
return queryable.Where(s => EF.Functions.Like(s.Name, $"%{queryString}")
||EF.Functions.Like(s.OriginalName, $"%{queryString}")
|| EF.Functions.Like(s.LocalizedName, $"%{queryString}")
|| EF.Functions.Like(s.SortName, $"%{queryString}"));
case FilterComparison.Matches:
return queryable.Where(s => EF.Functions.Like(s.Name, $"%{queryString}%")
||EF.Functions.Like(s.OriginalName, $"%{queryString}%")
|| EF.Functions.Like(s.LocalizedName, $"%{queryString}%")
|| EF.Functions.Like(s.SortName, $"%{queryString}%"));
case FilterComparison.NotEqual:
return queryable.Where(s => s.Name != queryString
|| s.OriginalName != queryString
|| s.LocalizedName != queryString
|| s.SortName != queryString);
case FilterComparison.NotContains:
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Contains:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.Name");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
}
}
public static IQueryable<Series> HasSummary(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, string queryString)
{
if (!condition) return queryable;
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.Metadata.Summary.Equals(queryString));
case FilterComparison.BeginsWith:
return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"{queryString}%"));
case FilterComparison.EndsWith:
return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"%{queryString}"));
case FilterComparison.Matches:
return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"%{queryString}%"));
case FilterComparison.NotEqual:
return queryable.Where(s => s.Metadata.Summary != queryString);
case FilterComparison.NotContains:
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Contains:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.Metadata.Summary");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
}
}
public static IQueryable<Series> HasPath(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, string queryString)
{
if (!condition) return queryable;
var normalizedPath = Parser.NormalizePath(queryString);
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s => s.FolderPath != null && s.FolderPath.Equals(normalizedPath));
case FilterComparison.BeginsWith:
return queryable.Where(s => s.FolderPath != null && EF.Functions.Like(s.FolderPath, $"{normalizedPath}%"));
case FilterComparison.EndsWith:
return queryable.Where(s => s.FolderPath != null && EF.Functions.Like(s.FolderPath, $"%{normalizedPath}"));
case FilterComparison.Matches:
return queryable.Where(s => s.FolderPath != null && EF.Functions.Like(s.FolderPath, $"%{normalizedPath}%"));
case FilterComparison.NotEqual:
return queryable.Where(s => s.FolderPath != null && s.FolderPath != normalizedPath);
case FilterComparison.NotContains:
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Contains:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.FolderPath");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
}
}
public static IQueryable<Series> HasFilePath(this IQueryable<Series> queryable, bool condition,
FilterComparison comparison, string queryString)
{
if (!condition) return queryable;
var normalizedPath = Parser.NormalizePath(queryString);
switch (comparison)
{
case FilterComparison.Equal:
return queryable.Where(s =>
s.Volumes.Any(v =>
v.Chapters.Any(c =>
c.Files.Any(f =>
f.FilePath != null && f.FilePath.Equals(normalizedPath)
)
)
)
);
case FilterComparison.BeginsWith:
return queryable.Where(s =>
s.Volumes.Any(v =>
v.Chapters.Any(c =>
c.Files.Any(f =>
f.FilePath != null && EF.Functions.Like(f.FilePath, $"{normalizedPath}%")
)
)
)
);
case FilterComparison.EndsWith:
return queryable.Where(s =>
s.Volumes.Any(v =>
v.Chapters.Any(c =>
c.Files.Any(f =>
f.FilePath != null && EF.Functions.Like(f.FilePath, $"%{normalizedPath}")
)
)
)
);
case FilterComparison.Matches:
return queryable.Where(s =>
s.Volumes.Any(v =>
v.Chapters.Any(c =>
c.Files.Any(f =>
f.FilePath != null && EF.Functions.Like(f.FilePath, $"%{normalizedPath}%")
)
)
)
);
case FilterComparison.NotEqual:
return queryable.Where(s =>
s.Volumes.Any(v =>
v.Chapters.Any(c =>
c.Files.Any(f =>
f.FilePath == null || !f.FilePath.Equals(normalizedPath)
)
)
)
);
case FilterComparison.NotContains:
case FilterComparison.GreaterThan:
case FilterComparison.GreaterThanEqual:
case FilterComparison.LessThan:
case FilterComparison.LessThanEqual:
case FilterComparison.Contains:
case FilterComparison.IsBefore:
case FilterComparison.IsAfter:
case FilterComparison.IsInLast:
case FilterComparison.IsNotInLast:
case FilterComparison.MustContains:
throw new KavitaException($"{comparison} not applicable for Series.FolderPath");
default:
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
}
}
}

View file

@ -0,0 +1,53 @@
using System.Linq;
using API.DTOs.Filtering;
using API.Entities;
namespace API.Extensions.QueryExtensions.Filtering;
public static class SeriesSort
{
/// <summary>
/// Applies the correct sort based on <see cref="SortOptions"/>
/// </summary>
/// <param name="query"></param>
/// <param name="sortOptions"></param>
/// <returns></returns>
public static IQueryable<Series> Sort(this IQueryable<Series> query, SortOptions? sortOptions)
{
// If no sort options, default to using SortName
sortOptions ??= new SortOptions()
{
IsAscending = true,
SortField = SortField.SortName
};
if (sortOptions.IsAscending)
{
query = sortOptions.SortField switch
{
SortField.SortName => query.OrderBy(s => s.SortName.ToLower()),
SortField.CreatedDate => query.OrderBy(s => s.Created),
SortField.LastModifiedDate => query.OrderBy(s => s.LastModified),
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
SortField.ReleaseYear => query.OrderBy(s => s.Metadata.ReleaseYear),
_ => query
};
}
else
{
query = sortOptions.SortField switch
{
SortField.SortName => query.OrderByDescending(s => s.SortName.ToLower()),
SortField.CreatedDate => query.OrderByDescending(s => s.Created),
SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified),
SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead),
SortField.ReleaseYear => query.OrderByDescending(s => s.Metadata.ReleaseYear),
_ => query
};
}
return query;
}
}

View file

@ -110,6 +110,55 @@ public static class QueryableExtensions
return condition ? queryable.Where(predicate) : queryable;
}
public static IQueryable<T> WhereLike<T>(this IQueryable<T> queryable, bool condition, Expression<Func<T, string>> propertySelector, string searchQuery)
where T : class
{
if (!condition || string.IsNullOrEmpty(searchQuery)) return queryable;
var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) });
var dbFunctions = typeof(EF).GetMethod(nameof(EF.Functions))?.Invoke(null, null);
var searchExpression = Expression.Constant($"%{searchQuery}%");
var likeExpression = Expression.Call(method, Expression.Constant(dbFunctions), propertySelector.Body, searchExpression);
var lambda = Expression.Lambda<Func<T, bool>>(likeExpression, propertySelector.Parameters[0]);
return queryable.Where(lambda);
}
/// <summary>
/// Performs a WhereLike that ORs multiple fields
/// </summary>
/// <param name="queryable"></param>
/// <param name="propertySelectors"></param>
/// <param name="searchQuery"></param>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
/// <exception cref="ArgumentNullException"></exception>
public static IQueryable<T> WhereLike<T>(this IQueryable<T> queryable, bool condition, List<Expression<Func<T, string>>> propertySelectors, string searchQuery)
where T : class
{
if (!condition || string.IsNullOrEmpty(searchQuery)) return queryable;
var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) });
var dbFunctions = typeof(EF).GetMethod(nameof(EF.Functions))?.Invoke(null, null);
var searchExpression = Expression.Constant($"%{searchQuery}%");
Expression orExpression = null;
foreach (var propertySelector in propertySelectors)
{
var likeExpression = Expression.Call(method, Expression.Constant(dbFunctions), propertySelector.Body, searchExpression);
var lambda = Expression.Lambda<Func<T, bool>>(likeExpression, propertySelector.Parameters[0]);
orExpression = orExpression == null ? lambda.Body : Expression.OrElse(orExpression, lambda.Body);
}
if (orExpression == null)
{
throw new ArgumentNullException(nameof(orExpression));
}
var combinedLambda = Expression.Lambda<Func<T, bool>>(orExpression, propertySelectors[0].Parameters[0]);
return queryable.Where(combinedLambda);
}
public static IQueryable<ScrobbleEvent> SortBy(this IQueryable<ScrobbleEvent> query, ScrobbleEventSortField sort, bool isDesc = false)
{
if (isDesc)

View file

@ -18,6 +18,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Entities.Scrobble;
using API.Extensions.QueryExtensions.Filtering;
using API.Helpers.Converters;
using AutoMapper;
using CollectionTag = API.Entities.CollectionTag;
@ -31,6 +32,13 @@ public class AutoMapperProfiles : Profile
{
public AutoMapperProfiles()
{
CreateMap<BookmarkSeriesPair, BookmarkDto>()
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.bookmark.Id))
.ForMember(dest => dest.Page, opt => opt.MapFrom(src => src.bookmark.Page))
.ForMember(dest => dest.VolumeId, opt => opt.MapFrom(src => src.bookmark.VolumeId))
.ForMember(dest => dest.SeriesId, opt => opt.MapFrom(src => src.bookmark.SeriesId))
.ForMember(dest => dest.ChapterId, opt => opt.MapFrom(src => src.bookmark.ChapterId))
.ForMember(dest => dest.Series, opt => opt.MapFrom(src => src.series));
CreateMap<LibraryDto, Library>();
CreateMap<Volume, VolumeDto>();
CreateMap<MangaFile, MangaFileDto>();

View file

@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using API.DTOs.Filtering.v2;
using API.Entities.Enums;
namespace API.Helpers.Converters;
public static class FilterFieldValueConverter
{
public static (object Value, Type Type) ConvertValue(FilterField field, string value)
{
return field switch
{
FilterField.SeriesName => (value, typeof(string)),
FilterField.Path => (value, typeof(string)),
FilterField.FilePath => (value, typeof(string)),
FilterField.ReleaseYear => (int.Parse(value), typeof(int)),
FilterField.Languages => (value.Split(',').ToList(), typeof(IList<string>)),
FilterField.PublicationStatus => (value.Split(',')
.Select(x => (PublicationStatus) Enum.Parse(typeof(PublicationStatus), x))
.ToList(), typeof(IList<PublicationStatus>)),
FilterField.Summary => (value, typeof(string)),
FilterField.AgeRating => (value.Split(',')
.Select(x => (AgeRating) Enum.Parse(typeof(AgeRating), x))
.ToList(), typeof(IList<AgeRating>)),
FilterField.UserRating => (int.Parse(value), typeof(int)),
FilterField.Tags => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.CollectionTags => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Translators => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Characters => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Publisher => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Editor => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.CoverArtist => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Letterer => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Colorist => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Inker => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Penciller => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Writers => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Genres => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.Libraries => (value.Split(',')
.Select(int.Parse)
.ToList(), typeof(IList<int>)),
FilterField.ReadProgress => (int.Parse(value), typeof(int)),
FilterField.Formats => (value.Split(',')
.Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x))
.ToList(), typeof(IList<MangaFormat>)),
FilterField.ReadTime => (int.Parse(value), typeof(int)),
_ => throw new ArgumentException("Invalid field type")
};
}
}

View file

@ -79,6 +79,9 @@ public class ServerSettingConverter : ITypeConverter<IEnumerable<ServerSetting>,
case ServerSettingKey.OnDeckUpdateDays:
destination.OnDeckUpdateDays = int.Parse(row.Value);
break;
case ServerSettingKey.CoverImageSize:
destination.CoverImageSize = Enum.Parse<CoverImageSize>(row.Value);
break;
}
}

163
API/I18N/cs.json Normal file
View file

@ -0,0 +1,163 @@
{
"password-updated": "Heslo aktualizováno",
"reading-list-updated": "Aktualizováno",
"confirm-email": "Musíte potvrdit svůj e-mail",
"locked-out": "Byli jste zablokováni z příliš mnoha pokusů o autorizaci. Počkejte prosím 10 minut.",
"disabled-account": "Váš účet je deaktivován. Kontaktujte správce serveru.",
"register-user": "Při registraci uživatele se něco pokazilo",
"validate-email": "Při ověřování vašeho e-mailu došlo k problému: {0}",
"confirm-token-gen": "Při generování potvrzovacího tokenu došlo k problému",
"denied": "Nepovoleno",
"permission-denied": "K této operaci nemáte oprávnění",
"password-required": "Chcete-li změnit svůj účet a nejste správce, musíte zadat své stávající platné heslo",
"unable-to-reset-key": "Něco se pokazilo, nelze resetovat klíč",
"invalid-payload": "Neplatný náklad",
"nothing-to-do": "Není co dělat",
"share-multiple-emails": "Nemůžete sdílet e-maily mezi více účty",
"generate-token": "Při generování potvrzovacího e-mailového tokenu došlo k problému. Viz protokoly",
"age-restriction-update": "Při aktualizaci věkového omezení došlo k chybě",
"no-user": "Uživatel neexistuje",
"username-taken": "Uživatelské jméno je již používáno",
"bad-credentials": "Vaše přihlašovací údaje nejsou správné",
"invalid-password": "Neplatné heslo",
"invalid-token": "Neplatný token",
"user-already-confirmed": "Uživatel je již potvrzen",
"manual-setup-fail": "Ruční nastavení nelze dokončit. Zrušte a znovu vytvořte pozvánku",
"user-already-registered": "Uživatel je již registrován jako {0}",
"user-already-invited": "Uživatel je již pozván pod tímto e-mailem a dosud pozvánku nepřijal.",
"generic-invite-user": "Při pozvání uživatele došlo k problému. Zkontrolujte protokoly.",
"invalid-email-confirmation": "Neplatné potvrzení e-mailem",
"generic-user-email-update": "Nelze aktualizovat e-mail pro uživatele. Zkontrolujte protokoly.",
"generic-password-update": "Při potvrzování nového hesla došlo k neočekávané chybě",
"name-required": "Název nemůže být prázdný",
"duplicate-bookmark": "Duplicitní položka záložky již existuje",
"reading-list-permission": "K tomuto seznamu čtení nemáte oprávnění nebo seznam neexistuje",
"reading-list-item-delete": "Položku(y) se nepodařilo smazat",
"reading-list-deleted": "Seznam četby byl smazán",
"generic-reading-list-delete": "Při mazání seznamu četby došlo k problému",
"forgot-password-generic": "Na e-mail bude zaslán e-mail, pokud existuje v naší databázi",
"email-sent": "Email odeslán",
"user-migration-needed": "Tento uživatel potřebuje migrovat. Požádejte je, aby se odhlásili a přihlásili, aby spustili migrační tok",
"generic-user-update": "Při aktualizaci uživatele došlo k výjimce",
"valid-number": "Musí být platné číslo stránky",
"reading-list-position": "Pozici se nepodařilo aktualizovat",
"not-accessible-password": "Váš server není přístupný. Odkaz na resetování hesla je v protokolech",
"not-accessible": "Váš server není přístupný externě",
"generic-invite-email": "Při opětovném odesílání e-mailu s pozvánkou došlo k problému",
"chapter-doesnt-exist": "Kapitola neexistuje",
"collection-doesnt-exist": "Sbírka neexistuje",
"device-doesnt-exist": "Zařízení neexistuje",
"generic-device-update": "Při aktualizaci zařízení došlo k chybě",
"generic-device-delete": "Při mazání zařízení došlo k chybě",
"greater-0": "{0} musí být větší než 0",
"send-to-kavita-email": "Odeslat do zařízení nelze použít s e-mailovou službou Kavita. Nakonfigurujte si prosím vlastní.",
"send-to-device-status": "Přenos souborů do vašeho zařízení",
"generic-send-to": "Při odesílání souborů do zařízení došlo k chybě",
"admin-already-exists": "Správce již existuje",
"file-missing": "Soubor nebyl v knize nalezen",
"generic-device-create": "Při vytváření zařízení došlo k chybě",
"collection-updated": "Sbírka byla úspěšně aktualizována",
"series-doesnt-exist": "Série neexistuje",
"must-be-defined": "{0} musí být definováno",
"file-doesnt-exist": "Soubor neexistuje",
"library-name-exists": "Název knihovny již existuje. Zvolte prosím jedinečný název serveru.",
"no-library-access": "Uživatel nemá přístup k této knihovně",
"user-doesnt-exist": "Uživatel neexistuje",
"library-doesnt-exist": "Knihovna neexistuje",
"invalid-path": "Neplatná cesta",
"delete-library-while-scan": "Knihovnu nelze odstranit, když probíhá skenování. Počkejte prosím na dokončení skenování nebo restartujte Kavitu a poté zkuste smazat",
"generic-library-update": "Při aktualizaci knihovny došlo ke kritickému problému.",
"invalid-access": "Neplatný přístup",
"no-image-for-page": "Pro stránku {0} takový obrázek neexistuje. Zkuste obnovit, abyste umožnili opětovné načtení do mezipaměti.",
"perform-scan": "Proveďte prosím skenování této série nebo knihovny a zkuste to znovu",
"generic-read-progress": "Při ukládání postupu došlo k problému",
"bookmark-permission": "Nemáte oprávnění k vytvoření/zrušení záložky",
"cache-file-find": "Nelze najít obrázek uložený v mezipaměti. Znovu načtěte a zkuste to znovu.",
"generic-reading-list-update": "Při aktualizaci seznamu čtení došlo k problému",
"generic-reading-list-create": "Při vytváření seznamu četby došlo k problému",
"reading-list-doesnt-exist": "Seznam četby neexistuje",
"series-restricted": "Uživatel nemá přístup k této sérii",
"generic-scrobble-hold": "Při přidávání blokování došlo k chybě",
"no-series-collection": "Série pro kolekci se nepodařilo získat",
"generic-series-update": "Při aktualizaci série došlo k chybě",
"update-metadata-fail": "Metadata nelze aktualizovat",
"age-restriction-not-applicable": "Bez omezení",
"generic-relationship": "Při aktualizaci vztahů došlo k problému",
"invalid-username": "Neplatné uživatelské jméno",
"critical-email-migration": "Během migrace e-mailu došlo k problému. Kontaktujte podporu",
"generic-error": "Něco se pokazilo. Prosím zkuste to znovu",
"volume-doesnt-exist": "Svazek neexistuje",
"no-cover-image": "Žádný titulní obrázek",
"bookmark-doesnt-exist": "Záložka neexistuje",
"generic-favicon": "Při načítání faviconu pro doménu došlo k problému",
"generic-library": "Došlo ke kritickému problému. Prosím zkuste to znovu.",
"pdf-doesnt-exist": "PDF neexistuje, když by mělo",
"generic-clear-bookmarks": "Záložky se nepodařilo vymazat",
"bookmark-save": "Záložku se nepodařilo uložit",
"libraries-restricted": "Uživatel nemá přístup k žádným knihovnám",
"no-series": "Série pro knihovnu nelze získat",
"generic-series-delete": "Při mazání série došlo k problému",
"series-updated": "Úspěšně aktualizováno",
"bookmarks-empty": "Záložky nemohou být prázdné",
"invalid-filename": "Neplatný název souboru",
"job-already-running": "Práce již běží",
"encode-as-warning": "Nelze převést na PNG. Pro obaly použijte Refresh Covers. Záložky a oblíbené ikony nelze zpětně kódovat.",
"ip-address-invalid": "IP adresa '{0}' je neplatná",
"bookmark-dir-permissions": "Adresář záložek nemá správná oprávnění k použití pro Kavitu",
"total-backups": "Celkový počet záloh musí být mezi 1 a 30",
"reset-chapter-lock": "Nelze resetovat zámek obalu pro Kapitolu",
"generic-user-delete": "Uživatele se nepodařilo smazat",
"generic-user-pref": "Při ukládání předvoleb došlo k problému",
"opds-disabled": "OPDS není na tomto serveru povoleno",
"on-deck": "Na palubě",
"browse-on-deck": "Procházet na palubě",
"recently-added": "Nedávno přidané",
"want-to-read": "Chci číst",
"browse-recently-added": "Procházet naposledy přidané",
"reading-lists": "Seznamy četby",
"browse-reading-lists": "Procházet podle seznamů četby",
"libraries": "Všechny knihovny",
"browse-libraries": "Procházet podle knihoven",
"collections": "Všechny sbírky",
"browse-collections": "Procházet podle sbírek",
"reading-list-restricted": "Seznam četby neexistuje nebo k němu nemáte přístup",
"query-required": "Musíte předat parametr dotazu",
"search": "Vyhledávání",
"search-description": "Vyhledávejte série, sbírky nebo seznamy četby",
"favicon-doesnt-exist": "Favicon neexistuje",
"not-authenticated": "Uživatel není ověřen",
"unable-to-register-k+": "Licenci nelze zaregistrovat kvůli chybě. Obraťte se na podporu Kavita+",
"anilist-cred-expired": "Přihlašovací údaje AniList vypršely nebo nejsou nastaveny",
"scrobble-bad-payload": "Špatné užitečné zatížení od poskytovatele Scrobble",
"theme-doesnt-exist": "Soubor motivu chybí nebo je neplatný",
"generic-create-temp-archive": "Při vytváření dočasného archivu došlo k problému",
"epub-malformed": "Soubor je poškozen! Nelze přečíst.",
"epub-html-missing": "Nelze najít vhodný html pro tuto stránku",
"collection-tag-title-required": "Název sbírky nemůže být prázdný",
"collection-tag-duplicate": "Sbírka s tímto názvem již existuje",
"device-duplicate": "Zařízení s tímto názvem již existuje",
"device-not-created": "Toto zařízení zatím neexistuje. Nejprve prosím vytvořte",
"progress-must-exist": "U uživatele musí existovat pokrok",
"reading-list-name-exists": "Seznam čtení s tímto názvem již existuje",
"user-no-access-library-from-series": "Uživatel nemá přístup do knihovny, do které tato série patří",
"volume-num": "Svazek {0}",
"book-num": "Kniha {0}",
"issue-num": "Vydání {0}{1}",
"chapter-num": "Kapitola {0}",
"total-logs": "Celkový počet protokolů musí být mezi 1 a 30",
"stats-permission-denied": "Nemáte oprávnění prohlížet statistiky jiného uživatele",
"url-not-valid": "Adresa URL nevrací platný obrázek nebo vyžaduje autorizaci",
"url-required": "Chcete-li použít, musíte předat adresu URL",
"generic-cover-series-save": "Titulní obrázek nelze uložit do Série",
"generic-cover-collection-save": "Titulní obrázek nelze uložit do sbírky",
"generic-cover-reading-list-save": "Nelze uložit titulní obrázek do seznamu četby",
"generic-cover-chapter-save": "Nelze uložit titulní obrázek do kapitoly",
"generic-cover-library-save": "Nelze uložit titulní obrázek do knihovny",
"access-denied": "Nemáte přístup",
"browse-want-to-read": "Procházet Chcete si přečíst",
"bad-copy-files-for-download": "Nelze zkopírovat soubory do dočasného stažení archivu adresáře.",
"send-to-permission": "Nelze odeslat non-EPUB nebo PDF do zařízení, která nejsou podporována na Kindle",
"reading-list-title-required": "Název seznamu čtení nemůže být prázdný",
"series-restricted-age-restriction": "Uživatel nemá povoleno sledovat tuto sérii z důvodu věkového omezení",
"collection-deleted": "Sbírka smazána"
}

View file

@ -43,6 +43,7 @@
"file-missing": "File was not found in book",
"collection-updated": "Collection updated successfully",
"collection-deleted": "Collection deleted",
"generic-error": "Something went wrong, please try again",
"collection-doesnt-exist": "Collection does not exist",
@ -140,6 +141,8 @@
"on-deck": "On Deck",
"browse-on-deck": "Browse On Deck",
"recently-added": "Recently Added",
"want-to-read": "Want to Read",
"browse-want-to-read": "Browse Want to Read",
"browse-recently-added": "Browse Recently Added",
"reading-lists": "Reading Lists",
"browse-reading-lists": "Browse by Reading Lists",

View file

@ -4,5 +4,160 @@
"disabled-account": "La cuenta está deshabilitada. Contacta con un administrador.",
"validate-email": "Ha habido un error al validar el correo: {0}",
"locked-out": "Se ha bloqueado el acceso debido a demasiados intentos. Por favor espera 10 minutos.",
"register-user": "Ha ocurrido un error registrando el usuario"
"register-user": "Ha ocurrido un error registrando el usuario",
"denied": "No permitido",
"permission-denied": "No estás autorizado a realizar esta operación",
"password-required": "Debes introducir tu contraseña para cambiar tu cuenta, excepto si eres administrador",
"invalid-password": "Contraseña incorrecta",
"invalid-token": "Token incorrecto",
"unable-to-reset-key": "Algo fue mal, no fue posible reiniciar la clave",
"confirm-token-gen": "Ha habido un problema generando el token de confirmación",
"invalid-payload": "Paquete no válido",
"nothing-to-do": "Nada que hacer",
"share-multiple-emails": "No puedes compartir correos electrónicos entre varias cuentas",
"generate-token": "Ha habido un problema generando un token de confirmación. Comprueba los registros",
"send-to-device-status": "Transfiriendo archivos a tu dispositivo",
"generic-send-to": "Ha ocurrido un error al enviar los archivos a tu dispositivo",
"series-doesnt-exist": "La serie no existe",
"volume-doesnt-exist": "El volumen no existe",
"bookmarks-empty": "Los marcadores no pueden estar vacíos",
"must-be-defined": "{0} debe estar definido",
"invalid-filename": "Nombre de archivo no válido",
"library-name-exists": "El nombre de la biblioteca ya existe. Por favor, elige un nombre único.",
"user-doesnt-exist": "El usuario no existe",
"library-doesnt-exist": "La biblioteca no existe",
"age-restriction-update": "Ha ocurrido un error al actualizar la restricción de edad",
"user-already-confirmed": "El usuario ya está confirmado",
"generic-user-update": "Ha ocurrido una excepción al actualizar el usuario",
"manual-setup-fail": "No se puede completar la configuración manual. Por favor, cancela y vuelve a generar la invitación",
"user-already-registered": "Usuario ya registrado como {0}",
"user-already-invited": "El usuario ya ha recibido una invitación en esta dirección de correo y está pendiente de aceptarla.",
"generic-invite-user": "Ha ocurrido un problema invitando al usuario. Por favor, comprueba el registro.",
"invalid-email-confirmation": "Confirmación de correo electrónico errónea",
"password-updated": "Contraseña Actualizada",
"forgot-password-generic": "Se enviará el correo si la dirección existe en nuestra base de datos",
"generic-device-create": "Ha ocurrido un error al crear el dispositivo",
"greater-0": "{0} debe ser mayor que 0",
"send-to-kavita-email": "Enviar al dispositivo no se puede utilizar con el servicio de correo electrónico de Kavita. Por favor, configura el tuyo propio.",
"no-cover-image": "No hay imagen de portada",
"bookmark-doesnt-exist": "El marcador no existe",
"generic-favicon": "Ha ocurrido un error al obtener el icono para el dominio",
"file-doesnt-exist": "El archivo no existe",
"generic-library": "Ha ocurrido un error fatal. Por favor, inténtalo de nuevo.",
"no-library-access": "El usuario no tiene acceso a esta biblioteca",
"no-user": "El usuario no existe",
"username-taken": "El nombre de usuario ya existe",
"generic-user-email-update": "No ha sido posible actualizar el correo electrónico del usuario. Comprueba los registros.",
"generic-password-update": "Ha ocurrido un error inesperado al confirmar la nueva contraseña",
"not-accessible-password": "Tu servidor no es accesible. El enlace para restablecer tu contraseña se encuentra en el registro",
"not-accessible": "Tu servidor no es accesible desde fuera",
"email-sent": "Correo electrónico enviado",
"user-migration-needed": "El usuario tiene que migrar. Debe cerrar e iniciar sesión para dar inicio al proceso de migración",
"generic-invite-email": "Ha ocurrido un problema al reenviar el correo de invitación",
"admin-already-exists": "El administrador ya existe",
"invalid-username": "Nombre de usuario no válido",
"critical-email-migration": "Ha ocurrido un problema durante la migración de correo electrónico. Contacta con soporte",
"chapter-doesnt-exist": "El Capítulo no existe",
"collection-updated": "Colección actualizada con éxito",
"file-missing": "No se ha encontrado el archivo en el libro",
"generic-error": "Algo fue mal, por favor inténtalo de nuevo",
"collection-doesnt-exist": "La colección no existe",
"device-doesnt-exist": "El dispositivo no existe",
"generic-device-update": "Ha ocurrido un error al actualizar el dispositivo",
"generic-device-delete": "Ha ocurrido un error al eliminar el dispositivo",
"invalid-path": "Ruta no válida",
"generic-library-update": "Hubo un problema crítico al actualizar la biblioteca.",
"pdf-doesnt-exist": "El PDF no existe cuando debería existir",
"invalid-access": "Acceso no válido",
"no-image-for-page": "No existe tal imagen para la página {0}. Intente refrescar para permitir el re-cache.",
"cache-file-find": "No se ha podido encontrar la imagen en caché. Vuelva a cargar la página e inténtelo de nuevo.",
"name-required": "El nombre no puede estar vacío",
"valid-number": "El número de página debe ser válido",
"duplicate-bookmark": "Ya existe un marcador duplicado",
"reading-list-permission": "Usted no tiene permisos en esta lista de lectura o la lista no existe",
"reading-list-position": "No se ha podido actualizar la posición",
"reading-list-updated": "Actualizado",
"generic-reading-list-delete": "Hubo un problema al borrar la lista de lectura",
"generic-reading-list-update": "Hubo un problema al actualizar la lista de lectura",
"reading-list-doesnt-exist": "La lista de lectura no existe",
"libraries-restricted": "El usuario no tiene acceso a ninguna biblioteca",
"no-series": "No se han podido obtener series para la biblioteca",
"generic-series-delete": "Hubo un problema al borrar las series",
"generic-series-update": "Se ha producido un error al actualizar las series",
"series-updated": "Actualizado correctamente",
"update-metadata-fail": "No se han podido actualizar los metadatos",
"generic-relationship": "Hubo un problema al actualizar las relaciones",
"job-already-running": "Trabajo ya en ejecución",
"ip-address-invalid": "La dirección IP '{0}' no es válida",
"bookmark-dir-permissions": "El directorio de marcadores no tiene los permisos correctos para que Kavita pueda utilizarlo",
"total-backups": "El número total de copias de seguridad debe estar entre 1 y 30",
"stats-permission-denied": "No está autorizado a ver las estadísticas de otro usuario",
"url-not-valid": "La url no devuelve una imagen válida o requiere autorización",
"url-required": "Debe pasar una url para usar",
"generic-cover-series-save": "No se puede guardar la imagen de portada en la serie",
"generic-cover-collection-save": "No se puede guardar la imagen de portada en la colección",
"generic-cover-reading-list-save": "No se puede guardar la imagen de portada en la lista de lectura",
"generic-cover-chapter-save": "No se puede guardar la imagen de portada en el capítulo",
"generic-cover-library-save": "No se puede guardar la imagen de portada en la biblioteca",
"generic-user-pref": "Hubo un problema al guardar las preferencias",
"browse-on-deck": "Navegar por el puente",
"recently-added": "Añadido recientemente",
"reading-lists": "Listas de lectura",
"browse-reading-lists": "Navegar por listas de lectura",
"libraries": "Todas las bibliotecas",
"browse-libraries": "Navegar por bibliotecas",
"collections": "Todas las colecciones",
"query-required": "Debe pasar un parámetro de consulta",
"search": "Buscar",
"favicon-doesnt-exist": "El favicon no existe",
"not-authenticated": "El usuario no está autenticado",
"anilist-cred-expired": "Las credenciales de AniList han caducado o no están configuradas",
"scrobble-bad-payload": "Mala carga útil del proveedor de Scrobble",
"theme-doesnt-exist": "Archivo de tema no válido o no existe",
"generic-create-temp-archive": "Hubo un problema al crear un archivo temporal",
"epub-malformed": "¡El archivo está malformado! No se puede leer.",
"book-num": "Libro {0}",
"issue-num": "Incidencia {0}{1}",
"search-description": "Buscar series, colecciones o listas de lectura",
"unable-to-register-k+": "No se ha podido registrar la licencia debido a un error. Póngase en contacto con el servicio de asistencia de Kavita",
"bad-copy-files-for-download": "No se pueden copiar archivos al directorio temporal de descarga de archivos.",
"send-to-permission": "No se puede enviar archivos que no sean EPUB o PDF a dispositivos no compatibles con Kindle",
"progress-must-exist": "El progreso debe existir en el usuario",
"epub-html-missing": "No se ha podido encontrar el HTML apropiado para esa página",
"collection-tag-duplicate": "Ya existe una colección con este nombre",
"device-duplicate": "Ya existe un dispositivo con este nombre",
"collection-tag-title-required": "El título de la colección no puede estar vacío",
"reading-list-title-required": "El título de la lista de lectura no puede estar vacío",
"device-not-created": "Este dispositivo aún no existe. Por favor, créelo primero",
"reading-list-name-exists": "Ya existe una lista de lectura con este nombre",
"user-no-access-library-from-series": "El usuario no tiene acceso a la biblioteca a la que pertenece esta serie",
"series-restricted-age-restriction": "El usuario no puede ver esta serie debido a restricciones de edad",
"volume-num": "Volumen {0}",
"chapter-num": "Capítulo {0}",
"delete-library-while-scan": "No puede eliminar una biblioteca mientras se está realizando una escaneo. Por favor, espere a que finalice el escaneo o reinicie Kavita y luego intente borrar",
"perform-scan": "Por favor, realice un escaneo en esta serie o biblioteca e inténtelo de nuevo",
"generic-read-progress": "Hubo un problema al guardar el progreso",
"generic-clear-bookmarks": "No se pueden limpiar los marcadores",
"bookmark-permission": "Usted no tiene permiso para marcar/desmarcar",
"bookmark-save": "No se ha podido guardar el marcador",
"reading-list-item-delete": "No se ha podido eliminar el/los elemento(s)",
"reading-list-deleted": "Se ha eliminado la lista de lectura",
"generic-reading-list-create": "Hubo un problema al crear la lista de lectura",
"series-restricted": "El usuario no tiene acceso a esta serie",
"generic-scrobble-hold": "Se ha producido un error al añadir la retención",
"age-restriction-not-applicable": "Sin restricciones",
"no-series-collection": "No se han podido obtener series para la colección",
"encode-as-warning": "No se puede convertir a PNG. Para las carátulas, utilice refrescar carátulas. Los marcadores y favicons no se pueden volver a codificar.",
"total-logs": "El número total de registros debe estar comprendido entre 1 y 30",
"on-deck": "En el puente",
"access-denied": "Usted no tiene acceso",
"reset-chapter-lock": "No se puede restablecer el bloqueo de la portada del capítulo",
"generic-user-delete": "No se ha podido eliminar el usuario",
"opds-disabled": "OPDS no está habilitado en este servidor",
"browse-recently-added": "Navegar por los añadidos recientemente",
"browse-collections": "Navegar por colecciones",
"reading-list-restricted": "La lista de lectura no existe o no tiene acceso",
"browse-want-to-read": "Navegar en deseo leer",
"want-to-read": "Deseo leer",
"collection-deleted": "Colección eliminada"
}

View file

@ -23,5 +23,140 @@
"user-already-registered": "L'usager à déjà été enregistré en tant que {0}",
"user-already-invited": "L'usager à déjà été invité avec ce courriel et n'a pas encore accepté l'invitation.",
"generic-invite-user": "Une erreur est survenue lors de l'invitation de l'usager. Voir le journal.",
"invalid-email-confirmation": "La confirmation de courriel est invalide"
"invalid-email-confirmation": "La confirmation de courriel est invalide",
"invalid-payload": "Payload invalide",
"manual-setup-fail": "La configuration manuelle est impossible. Veuillez annuler et recréer l'invitation",
"generic-user-email-update": "Impossible de mettre à jour le courriel de l'utilisateur. Veuillez vérifier les logs.",
"generic-password-update": "Une erreur s'est produite lors de la vérification du nouveau mot de passe",
"password-updated": "Mot de passe mis a jour",
"forgot-password-generic": "Un courriel sera envoyé à cette adresse si elle existe dans notre base de données",
"not-accessible-password": "Votre serveur n'est pas accessible. Un lien pour réinitialiser votre mot de passe est dans les logs",
"not-accessible": "Votre serveur n'est pas accessible publiquement",
"email-sent": "Email envoyé",
"user-migration-needed": "Cet utilisateur doit être déplacé. Faite le se déconnecter et reconnecter afin d'entamer la procédure",
"generic-invite-email": "Erreur lors du renvoi de l'invitation par courriel",
"admin-already-exists": "Administrateur déjà existant",
"invalid-username": "Nom d'utilisateur invalide",
"critical-email-migration": "Un problème est survenu lors de la migration du courriel. Veuillez contacter le support",
"chapter-doesnt-exist": "Chapitre non existant",
"file-missing": "Fichier introuvable dans le livre",
"generic-device-delete": "Erreur lors de la suppression de l'appareil",
"send-to-kavita-email": "Envoyer à l'appareil ne peut pas être utilisé par le service e-mail de Kavita. Veuillez configurer le votre.",
"generic-favicon": "Erreur lors de la récupération de la favicon pour le domaine",
"generic-library": "Erreur critique. Essayez à nouveau.",
"delete-library-while-scan": "Vous ne pouvez pas supprimer une bibliothèque lorsqu'une analyse est en cours. Veuillez attendre la fin de l'analyse ou redémarrez Kavita, puis essayez de la supprimer",
"collection-updated": "Collection mise à jour avec succès",
"generic-error": "Erreur, essayez à nouveau",
"collection-doesnt-exist": "Collection non existante",
"device-doesnt-exist": "Cet appareil n'existe pas",
"generic-device-create": "Erreur lors de la création de l'appareil",
"generic-device-update": "Erreur lors de la mise à jour de l'appareil",
"greater-0": "{0} doit être plus grand que 0",
"send-to-device-status": "Transfert de fichier vers votre appareil",
"generic-send-to": "Erreur lors de l'envoi du/des fichiers vers l'appareil",
"series-doesnt-exist": "Série non existante",
"volume-doesnt-exist": "Volume non existant",
"bookmarks-empty": "Marque-pages ne peux pas être vide",
"no-cover-image": "Pas de couverture",
"bookmark-doesnt-exist": "Marque-page non existant",
"must-be-defined": "{0}doit être défini",
"invalid-filename": "Nom du fichier incorrect",
"file-doesnt-exist": "Fichier non existant",
"library-name-exists": "Le nom de la bibliothèque existe déjà. Veuillez choisir un nom unique pour le serveur.",
"no-library-access": "L'utilisateur n'as pas accès à la bibliothèque",
"user-doesnt-exist": "Utilisateur non existant",
"library-doesnt-exist": "Bibliothèque non existante",
"invalid-path": "Chemin invalide",
"generic-library-update": "Erreur critique lors de la mise à jour de la bibliothèque.",
"reading-list-position": "Impossible de mettre a jour la position",
"reading-list-updated": "Mis à Jour",
"reading-list-item-delete": "Impossible de supprimer le/les objets",
"reading-list-deleted": "Liste de lecture à été supprimé",
"generic-reading-list-delete": "Erreur lors de la suppression de la liste de lecture",
"libraries-restricted": "L'utilisateur n'a accès à aucune bibliothèque",
"generic-series-update": "Erreur lors de la mise à jour de la série",
"generic-cover-collection-save": "Impossible d'enregistrer l'image de couverture dans la collection",
"generic-cover-library-save": "Impossible d'enregistrer l'image de couverture dans la bibliothèque",
"browse-on-deck": "Parcourir Ce que vous avez commencé",
"browse-libraries": "Parcourir par Bibliothèques",
"query-required": "Vous devez fournir un paramètre de requête",
"encode-as-warning": "Impossible de convertir en PNG. Pour les couvertures, utilisez l'option Actualiser les couvertures. Les Marque-pages et favicons ne peuvent pas être encodée a nouveau.",
"stats-permission-denied": "Vous n'êtes pas autorisé à consulter les statistiques d'un autre utilisateur",
"generic-reading-list-update": "Erreur lors de la mise à jour de la liste de lecture",
"pdf-doesnt-exist": "PDF non existant alors qu'il devrait l'être",
"invalid-access": "Accès Invalide",
"no-image-for-page": "Aucune image pour la page {0}. Essayez d'actualiser pour autoriser la remise en cache.",
"perform-scan": "Veuillez effectuer un scan sur cette série ou bibliothèque et réessayez",
"generic-read-progress": "Erreur lors de la sauvegarde de la progression",
"generic-clear-bookmarks": "Impossible d'effacer les marque-pages",
"bookmark-permission": "Vous n'avez pas l'autorisation d'ajouter ou de retirer des marque-pages",
"bookmark-save": "Impossible de sauvegarder le marque-page",
"cache-file-find": "Impossible de trouver l'image en cache. Rechargez et recommencez.",
"name-required": "Nom ne peut pas être vide",
"valid-number": "Doit être un numéro de page valide",
"duplicate-bookmark": "Un double du marque-page est déjà existant",
"reading-list-permission": "Vous n'avez pas de droits sur cette liste de lecture ou la liste n'existe pas",
"generic-reading-list-create": "Erreur lors de la création de la liste de lecture",
"reading-list-doesnt-exist": "Liste de lecture non existante",
"series-restricted": "L'utilisateur n'as pas accès a cette Série",
"generic-scrobble-hold": "Une erreur est apparu lors de l'ajout du hold",
"no-series": "Impossible d'obtenir des séries pour la bibliothèque",
"no-series-collection": "Impossible d'obtenir une série pour la collection",
"generic-series-delete": "Erreur lors de la suppression de la série",
"series-updated": "Mise à jour réussie",
"update-metadata-fail": "Impossible de mettre à jour les métadonnées",
"age-restriction-not-applicable": "Aucune restriction",
"generic-relationship": "Erreur lors de la mise à jour des relations",
"job-already-running": "Travail déjà en cours",
"ip-address-invalid": "L'adresse IP '{0}' n'est pas valide",
"bookmark-dir-permissions": "Le répertoire de marque-page n'a pas les autorisations nécessaires pour que Kavita puisse l'utiliser",
"total-backups": "Le nombre total de sauvegardes doit être compris entre 1 et 30",
"total-logs": "Le nombre total de logs doit être compris entre 1 et 30",
"url-not-valid": "L'URL ne renvoie pas d'image valide ou nécessite une autorisation",
"url-required": "Vous devez fournir une URL pour utiliser",
"generic-cover-series-save": "Impossible d'enregistrer l'image de couverture dans la série",
"generic-cover-reading-list-save": "Impossible d'enregistrer l'image de couverture dans la liste de lecture",
"generic-cover-chapter-save": "Impossible d'enregistrer l'image de couverture dans le chapitre",
"access-denied": "Vous n'avez pas accès",
"reset-chapter-lock": "Impossible de réinitialiser le verrouillage de la couverture pour le chapitre",
"generic-user-delete": "Impossible de supprimer l'utilisateur",
"generic-user-pref": "Erreur lors de la sauvegarde des préférences",
"opds-disabled": "OPDS n'est pas activé sur ce serveur",
"on-deck": "Continuez votre lecture",
"recently-added": "Récemment Ajouté",
"browse-recently-added": "Parcourir Récemment Ajouté",
"reading-lists": "Liste de Lecture",
"browse-reading-lists": "Parcourir vos Liste de lecture",
"libraries": "Toutes les Bibliothèques",
"collections": "Toutes les Collections",
"browse-collections": "Parcourir par Collections",
"reading-list-restricted": "La liste de lecture n'existe pas ou vous n'y avez pas accès",
"search": "Rechercher",
"search-description": "Recherche de séries, collections ou listes de lecture",
"favicon-doesnt-exist": "Favicon non existante",
"not-authenticated": "L'utilisateur n'est pas authentifié",
"unable-to-register-k+": "Impossible d'enregistrer la licence en raison d'une erreur. Contactez le support de Kavita+",
"anilist-cred-expired": "Les informations d'identification AniList ont expiré ou n'ont pas été définies",
"scrobble-bad-payload": "Payload invalide de la part de Scrobble",
"theme-doesnt-exist": "Fichier de thème manquant ou invalide",
"bad-copy-files-for-download": "Impossible de copier les fichiers dans le répertoire temporaire de l'archive de téléchargement.",
"generic-create-temp-archive": "Erreur lors de la création de l'archive temporaire",
"series-restricted-age-restriction": "L'utilisateur n'est pas autorisé à visionner cette série en raison de restrictions d'âge",
"book-num": "Tome {0}",
"epub-malformed": "Fichier malformé ! Impossible de le lire.",
"epub-html-missing": "Impossible de trouver le code html approprié pour cette page",
"collection-tag-title-required": "Le titre de la collection ne peut pas être vide",
"reading-list-title-required": "Le titre de la liste de lecture ne peut être vide",
"collection-tag-duplicate": "Une collection portant ce nom existe déjà",
"device-duplicate": "Un appareil portant ce nom existe déjà",
"device-not-created": "Cet appareil n'existe pas encore. Veuillez d'abord le créer",
"send-to-permission": "Impossible d'envoyer des fichiers non-EPUB ou PDF à des appareils car ils ne sont pas pris en charge par Kindle",
"progress-must-exist": "La progression doit exister sur l'utilisateur",
"reading-list-name-exists": "Une liste de lecture de ce nom existe déjà",
"user-no-access-library-from-series": "L'utilisateur n'a pas accès à la bibliothèque à laquelle appartient cette série",
"volume-num": "Volume {0}",
"issue-num": "Numéro {0}{1}",
"chapter-num": "Chapitre {0}",
"want-to-read": "À Lire",
"browse-want-to-read": "Parcourir À Lire"
}

23
API/I18N/he.json Normal file
View file

@ -0,0 +1,23 @@
{
"confirm-email": "חובה לאמת תחילה כתובת דואר אלקטרוני",
"denied": "לא מאושר",
"bad-credentials": "שם משתמש או סיסמא לא נכונים",
"locked-out": "חשבונך ננעל לאחר מספר מקסימלי של נסיונות כניסה לא מוצלחים. אנא המתן/ני 10 דקות.",
"disabled-account": "חשבונך לא פעיל. אנא פנה למנהל המערכת.",
"validate-email": "אירעה תקלה בעת ניסיון וידוא כתובת הדואר האלקטרוני שלך: {0}",
"confirm-token-gen": "אירעה תקלה בעת ניסיון יצירת טוקן אישור",
"invalid-payload": "מטען לא חוקי",
"nothing-to-do": "אין מה לעשות",
"register-user": "אירעה שגיאה בעת רישום המשתמש",
"permission-denied": "אינך מורשה לבצע פעולה זו",
"password-required": "עליך להזין את הסיסמה הקיימת שלך כדי לשנות את חשבונך, אלא אם את/ה מנהל/ת מערכת",
"invalid-password": "סיסמא שגויה",
"invalid-token": "טוקן שגוי",
"unable-to-reset-key": "משהו השתבש, לא ניתן לאפס את המפתח",
"share-multiple-emails": "לא ניתן להשתמש באותה כתובת דואר אלקטרוני במספר חשבונות",
"generate-token": "אירעה תקלה בעת יצירת טוקן דוא״ל אימות. ראה/י לוגים",
"no-user": "משתמש לא קיים",
"username-taken": "שם משתמש תפוס",
"user-already-confirmed": "המשתמש כבר אושר",
"age-restriction-update": "אירעה תקלה בעת עדכון הגבלת גיל"
}

View file

@ -156,5 +156,8 @@
"browse-libraries": "Sfoglia Librerie",
"collections": "Tutte le Collezioni",
"browse-collections": "Sfoglia per Collezioni",
"reading-list-restricted": "L'elenco di lettura non esiste o non hai accesso"
"reading-list-restricted": "L'elenco di lettura non esiste o non hai accesso",
"browse-want-to-read": "Sfoglia Vuoi leggere",
"want-to-read": "Vuoi leggere",
"collection-deleted": "Collezione cancellata"
}

163
API/I18N/ko.json Normal file
View file

@ -0,0 +1,163 @@
{
"confirm-email": "먼저 이메일을 확인해야 합니다",
"bad-credentials": "자격 증명이 올바르지 않습니다",
"locked-out": "너무 많은 인증 시도로 인해 잠겼습니다. 10분 동안 기다려 주십시오.",
"invalid-password": "유효하지 않은 비밀번호",
"user-already-registered": "사용자는 이미 {0}로 등록되어 있습니다",
"password-updated": "비밀번호 업데이트됨",
"not-accessible-password": "서버에 액세스할 수 없습니다. 비밀번호 재설정 링크는 로그에 있습니다",
"not-accessible": "외부에서 서버에 액세스할 수 없습니다",
"chapter-doesnt-exist": "챕터가 존재하지 않습니다",
"file-missing": "책에서 파일을 찾을 수 없습니다",
"generic-error": "문제가 발생했습니다, 다시 시도하십시오",
"generic-device-delete": "장치를 삭제하는 중에 오류가 발생했습니다",
"greater-0": "{0}는 0보다 커야 합니다",
"send-to-device-status": "장치로 파일 전송",
"generic-send-to": "파일을 장치로 보내는 중 오류가 발생했습니다",
"volume-doesnt-exist": "볼륨이 존재하지 않습니다",
"generic-favicon": "도메인의 파비콘을 가져오는 중에 문제가 발생했습니다",
"no-library-access": "사용자는 이 라이브러리에 액세스할 수 없습니다",
"user-doesnt-exist": "사용자가 존재하지 않습니다",
"library-doesnt-exist": "라이브러리가 존재하지 않습니다",
"duplicate-bookmark": "중복된 북마크 항목이 이미 존재합니다",
"reading-list-position": "위치를 업데이트할 수 없습니다",
"reading-list-deleted": "읽기 목록이 삭제되었습니다",
"generic-reading-list-delete": "읽기 목록을 삭제하는 중에 문제가 발생했습니다",
"reading-list-doesnt-exist": "읽기 목록이 존재하지 않습니다",
"no-series": "라이브러리에 대한 시리즈를 가져올 수 없습니다",
"age-restriction-not-applicable": "제한 없음",
"generic-relationship": "관계를 업데이트하는 중에 문제가 발생했습니다",
"job-already-running": "이미 실행 중인 작업",
"url-required": "사용할 URL을 전달해야 합니다",
"reading-list-title-required": "읽기 목록 제목은 비워둘 수 없습니다",
"progress-must-exist": "사용자에게 진행 상황이 있어야 합니다",
"volume-num": "볼륨 {0}",
"chapter-num": "챕터 {0}",
"disabled-account": "계정이 비활성화되었습니다. 서버 관리자에게 문의하세요.",
"validate-email": "이메일을 확인하는 중에 문제가 발생했습니다: {0}",
"register-user": "사용자를 등록하는 중에 문제가 발생했습니다",
"confirm-token-gen": "확인 토큰을 생성하는 중에 문제가 발생했습니다",
"permission-denied": "이 작업을 수행할 수 없습니다",
"denied": "허용되지 않음",
"password-required": "관리자가 아닌 경우 계정을 변경하려면 기존 비밀번호를 입력해야 합니다",
"invalid-payload": "유효하지 않은 페이로드",
"nothing-to-do": "할 것이 없음",
"share-multiple-emails": "여러 계정에서 이메일을 공유할 수 없습니다",
"invalid-token": "유효하지 않은 토큰",
"unable-to-reset-key": "문제가 발생하여 키를 재설정할 수 없습니다",
"generate-token": "확인 이메일 토큰을 생성하는 중에 문제가 발생했습니다. 로그 보기",
"no-user": "사용자가 존재하지 않습니다",
"age-restriction-update": "연령 제한을 업데이트 하는 중에 오류가 발생했습니다",
"username-taken": "이미 사용중인 이름입니다",
"user-already-confirmed": "사용자가 이미 확인되었습니다",
"generic-user-update": "사용자를 업데이트 중에 예외가 발생했습니다",
"manual-setup-fail": "수동 설정을 완료할 수 없습니다. 초대를 취소하고 다시 만드십시오",
"user-already-invited": "사용자는 이미 이 이메일로 초대되었으며 아직 초대를 수락하지 않았습니다.",
"generic-invite-user": "사용자를 초대하는 중에 문제가 발생했습니다. 로그를 확인하십시오.",
"invalid-email-confirmation": "잘못된 이메일 확인",
"generic-user-email-update": "사용자의 이메일을 업데이트할 수 없습니다. 로그를 확인하십시오.",
"generic-password-update": "새 비밀번호를 확인하는 중에 예상치 못한 오류가 발생했습니다",
"email-sent": "이메일을 보냈습니다",
"admin-already-exists": "관리자가 이미 존재합니다",
"user-migration-needed": "이 사용자는 이전해야 합니다. 로그아웃하고 로그인하여 마이그레이션 흐름을 트리거하도록 합니다",
"forgot-password-generic": "이메일이 데이터베이스에 존재하는 경우 이메일이 이메일로 전송됩니다",
"generic-invite-email": "초대 이메일을 다시 보내는 중에 문제가 발생했습니다",
"invalid-username": "유효하지 않은 아이디",
"critical-email-migration": "이메일 이전 중에 문제가 발생했습니다. 연락처 지원",
"collection-updated": "컬렉션이 성공적으로 업데이트되었습니다",
"collection-doesnt-exist": "컬렉션이 존재하지 않습니다",
"generic-device-create": "장치를 생성하는 중에 오류가 발생했습니다",
"device-doesnt-exist": "장치가 존재하지 않습니다",
"generic-device-update": "장치를 업데이트 하는 중에 오류가 발생했습니다",
"send-to-kavita-email": "장치로 보내기는 Kavita의 이메일 서비스와 함께 사용할 수 없습니다. 직접 구성하십시오.",
"no-cover-image": "표지 이미지 없음",
"series-doesnt-exist": "시리즈가 존재하지 않습니다",
"bookmarks-empty": "북마크는 비워둘 수 없습니다",
"bookmark-doesnt-exist": "북마크가 존재하지 않습니다",
"must-be-defined": "{0}을(를) 정의해야 합니다",
"generic-library": "심각한 문제가 있었습니다. 다시 시도해 주세요.",
"invalid-filename": "유효하지 않은 파일 이름",
"file-doesnt-exist": "파일이 없습니다",
"library-name-exists": "라이브러리 이름이 이미 존재합니다. 서버에 고유한 이름을 선택하십시오.",
"invalid-path": "유효하지 않은 경로",
"delete-library-while-scan": "스캔이 진행 중인 동안에는 라이브러리를 삭제할 수 없습니다. 스캔이 완료될 때까지 기다리거나 Kavita를 다시 시작한 다음 삭제를 시도하십시오",
"pdf-doesnt-exist": "PDF가 있어야 할 때 존재하지 않음",
"no-image-for-page": "페이지 {0}에 해당 이미지가 없습니다. 재캐시를 허용하려면 새로고침해 보세요.",
"generic-clear-bookmarks": "북마크를 지울 수 없습니다",
"invalid-access": "유효하지 않은 액세스",
"generic-library-update": "라이브러리를 업데이트하는 중 심각한 문제가 발생했습니다.",
"bookmark-permission": "북마크/북마크해제 권한이 없습니다",
"perform-scan": "이 시리즈 또는 라이브러리에서 스캔을 수행하고 다시 시도하십시오",
"bookmark-save": "북마크를 저장할 수 없습니다",
"cache-file-find": "캐시된 이미지를 찾을 수 없습니다. 새로고침하고 다시 시도하세요.",
"name-required": "이름은 비워둘 수 없습니다",
"reading-list-permission": "이 읽기 목록에 대한 권한이 없거나 목록이 존재하지 않습니다",
"generic-read-progress": "진행 상황을 저장하는 중에 문제가 발생했습니다",
"valid-number": "유효한 페이지 번호여야 합니다",
"reading-list-updated": "업데이트됨",
"reading-list-item-delete": "항목을 삭제할 수 없습니다",
"series-restricted": "사용자는 이 시리즈에 액세스할 수 없습니다",
"generic-reading-list-update": "읽기 목록을 업데이트하는 중에 문제가 발생했습니다",
"generic-reading-list-create": "읽기 목록을 생성하는 중에 문제가 발생했습니다",
"generic-scrobble-hold": "보류를 추가하는 중에 오류가 발생했습니다",
"libraries-restricted": "사용자는 라이브러리에 액세스할 수 없습니다",
"no-series-collection": "컬렉션에 대한 시리즈를 가져올 수 없습니다",
"generic-series-delete": "시리즈를 삭제하는 중에 문제가 발생했습니다",
"series-updated": "성공적으로 업데이트됨",
"update-metadata-fail": "메타데이터를 업데이트할 수 없습니다",
"encode-as-warning": "PNG로 변환할 수 없습니다. 표지의 경우 표지 새로 고침을 사용하십시오. 북마크와 파비콘은 다시 인코딩할 수 없습니다.",
"ip-address-invalid": "IP 주소 '{0}'이(가) 잘못되었습니다",
"bookmark-dir-permissions": "북마크 디렉토리에 Kavita가 사용할 수 있는 올바른 권한이 없습니다",
"generic-series-update": "시리즈를 업데이트하는 중에 오류가 발생했습니다",
"total-backups": "총 백업은 1에서 30 사이여야 합니다",
"stats-permission-denied": "다른 사용자의 통계를 볼 권한이 없습니다",
"total-logs": "총 로그는 1에서 30 사이여야 합니다",
"url-not-valid": "URL이 유효한 이미지를 반환하지 않거나 승인이 필요합니다",
"generic-cover-series-save": "표지 이미지를 시리즈에 저장할 수 없습니다",
"generic-cover-collection-save": "컬렉션에 표지 이미지를 저장할 수 없습니다",
"generic-user-pref": "환경설정을 저장하는 중에 문제가 발생했습니다",
"generic-cover-reading-list-save": "읽기 목록에 표지 이미지를 저장할 수 없습니다",
"generic-cover-chapter-save": "표지 이미지를 챕터에 저장할 수 없습니다",
"generic-cover-library-save": "표지 이미지를 라이브러리에 저장할 수 없습니다",
"opds-disabled": "이 서버에서 OPDS를 사용할 수 없습니다",
"access-denied": "액세스 권한이 없습니다",
"reset-chapter-lock": "챕터에 대한 표지 잠금을 재설정할 수 없습니다",
"on-deck": "계속 읽기",
"browse-on-deck": "계속 읽기에서 찾아보기",
"reading-lists": "읽기 목록",
"libraries": "모든 라이브러리",
"generic-user-delete": "사용자를 삭제할 수 없습니다",
"recently-added": "최근에 추가됨",
"collections": "모든 컬렉션",
"browse-collections": "컬렉션에서 찾아보기",
"reading-list-restricted": "읽기 목록이 없거나 액세스 권한이 없습니다",
"query-required": "쿼리 매개변수를 전달해야 합니다",
"search-description": "시리즈, 컬렉션 또는 읽기 목록 검색",
"favicon-doesnt-exist": "파비콘이 존재하지 않습니다",
"not-authenticated": "사용자가 인증되지 않았습니다",
"anilist-cred-expired": "AniList 자격 증명이 만료되었거나 설정되지 않았습니다",
"scrobble-bad-payload": "스크로블 공급자의 잘못된 페이로드",
"bad-copy-files-for-download": "임시 디렉토리 아카이브 다운로드에 파일을 복사할 수 없습니다.",
"search": "검색",
"theme-doesnt-exist": "테마 파일이 없거나 유효하지 않음",
"generic-create-temp-archive": "임시 보관 파일을 만드는 중에 문제가 발생했습니다",
"epub-html-missing": "해당 페이지에 적합한 HTML을 찾을 수 없습니다",
"epub-malformed": "파일 형식이 잘못되었습니다! 읽을 수 없습니다.",
"collection-tag-title-required": "컬렉션 제목은 비워둘 수 없습니다",
"collection-tag-duplicate": "이 이름을 가진 컬렉션이 이미 존재합니다",
"device-not-created": "이 장치는 아직 존재하지 않습니다. 먼저 생성해주세요",
"device-duplicate": "이 이름을 가진 장치가 이미 존재합니다",
"send-to-permission": "Kindle에서 지원되지 않는 비 EPUB 또는 PDF를 장치로 보낼 수 없음",
"user-no-access-library-from-series": "사용자는 이 시리즈가 속한 라이브러리에 액세스할 수 없습니다",
"reading-list-name-exists": "이 이름의 읽기 목록이 이미 있습니다",
"series-restricted-age-restriction": "사용자는 연령 제한으로 인해 이 시리즈를 볼 수 없습니다",
"book-num": "책 {0}",
"issue-num": "이슈 {0}{1}",
"browse-recently-added": "최근 추가된 항목에서 찾아보기",
"browse-reading-lists": "읽기 목록에서 찾아보기",
"browse-libraries": "라이브러리에서 찾아보기",
"unable-to-register-k+": "오류로 인해 라이선스를 등록할 수 없습니다. Kavita+ 지원 문의",
"want-to-read": "읽고 싶어요",
"browse-want-to-read": "읽고 싶어요에서 찾아보기",
"collection-deleted": "컬렉션이 삭제되었습니다"
}

View file

@ -154,5 +154,7 @@
"book-num": "Boek {0}",
"issue-num": "Uitgave {0}{1}",
"chapter-num": "Hoofdstuk {0}",
"generic-scrobble-hold": "Er is een fout opgetreden bij het toevoegen van de bewaarplicht"
"generic-scrobble-hold": "Er is een fout opgetreden bij het toevoegen van de bewaarplicht",
"on-deck": "Aan het lezen",
"browse-on-deck": "Aan Het Lezen doorbladeren"
}

60
API/I18N/pl.json Normal file
View file

@ -0,0 +1,60 @@
{
"bad-credentials": "Twoje dane uwierzytelniające są nieprawidłowe",
"disabled-account": "Twoje konto jest wyłączone. Skontaktuj się z administratorem serwera.",
"register-user": "Coś poszło nie tak podczas rejestracji użytkownika",
"validate-email": "Wystąpił problem z weryfikacją adresu e-mail: {0}",
"confirm-token-gen": "Wystąpił problem z wygenerowaniem tokena potwierdzenia",
"denied": "Niedozwolone",
"password-required": "Aby zmienić konto, musisz wprowadzić istniejące hasło, chyba że jesteś administratorem",
"invalid-password": "Nieprawidłowe hasło",
"unable-to-reset-key": "Coś poszło nie tak, nie można zresetować klucza",
"invalid-payload": "Nieprawidłowy payload",
"nothing-to-do": "Nie ma, nic do zrobienia",
"share-multiple-emails": "Nie można udostępniać wiadomości e-mail na wielu kontach",
"age-restriction-update": "Wystąpił błąd aktualizacji ograniczenia wiekowego",
"no-user": "Użytkownik nie istnieje",
"username-taken": "Nazwa użytkownika jest już zajęta",
"confirm-email": "Najpierw musisz potwierdzić swój adres e-mail",
"locked-out": "Zostałeś zablokowany z powodu zbyt wielu prób autoryzacji. Odczekaj 10 minut.",
"permission-denied": "Ta operacja jest niedozwolona",
"invalid-token": "Nieprawidłowy token",
"generate-token": "Wystąpił problem z generowaniem tokenu wiadomości e-mail z potwierdzeniem. Zobacz logi",
"generic-user-update": "Wystąpił wyjątek podczas aktualizacji użytkownika",
"user-already-registered": "Użytkownik jest już zarejestrowany jako {0}",
"user-already-confirmed": "Użytkownik jest już potwierdzony",
"manual-setup-fail": "Nie można ukończyć konfiguracji ręcznej. Anuluj i ponownie utwórz zaproszenie",
"user-already-invited": "Użytkownik jest już zaproszony pod tym adresem e-mail i nie zaakceptował jeszcze zaproszenia.",
"generic-invite-user": "Wystąpił problem z zaproszeniem użytkownika. Sprawdź logi.",
"generic-password-update": "Wystąpił nieoczekiwany błąd podczas potwierdzania nowego hasła",
"password-updated": "Hasło zaktualizowane",
"forgot-password-generic": "Wiadomość zostanie wysłana na adres e-mail, jeśli istnieje on w naszej bazie danych",
"not-accessible-password": "Serwer jest niedostępny. Link do zresetowania hasła znajduje się w logach",
"email-sent": "E-mail wysłany",
"user-migration-needed": "Ten użytkownik musi zostać zmigrowany. Niech się wyloguje i zaloguje, aby uruchomić migrację",
"generic-invite-email": "Wystąpił problem z ponownym wysłaniem wiadomości e-mail z zaproszeniem",
"critical-email-migration": "Wystąpił błąd podczas migracji poczty e-mail. Skontaktuj się z pomocą techniczną",
"file-missing": "Plik nie został znaleziony w książce",
"generic-error": "Coś poszło nie tak, spróbuj ponownie",
"device-doesnt-exist": "Urządzenie nie istnieje",
"generic-device-delete": "Wystąpił błąd podczas usuwania urządzenia",
"greater-0": "{0} musi być większe od 0",
"send-to-device-status": "Przesyłanie plików do urządzenia",
"generic-send-to": "Wystąpił błąd podczas wysyłania pliku(ów) do urządzenia",
"volume-doesnt-exist": "Tom nie istnieje",
"no-cover-image": "Brak okładki",
"invalid-email-confirmation": "Nieprawidłowe potwierdzenie e-mail",
"generic-user-email-update": "Nie można zaktualizować adresu e-mail użytkownika. Sprawdź logi.",
"not-accessible": "Serwer nie jest dostępny z zewnątrz",
"invalid-username": "Nieprawidłowa nazwa użytkownika",
"admin-already-exists": "Administrator już istnieje",
"chapter-doesnt-exist": "Rozdział nie istnieje",
"bookmarks-empty": "Zakładki nie mogą być puste",
"collection-updated": "Kolekcja została pomyślnie zaktualizowana",
"collection-doesnt-exist": "Kolekcja nie istnieje",
"generic-device-create": "Wystąpił błąd podczas tworzenia urządzenia",
"generic-device-update": "Wystąpił błąd podczas aktualizacji urządzenia",
"send-to-kavita-email": "Funkcja Wyślij do urządzenia nie może być używana z usługą e-mail Kavita. Należy skonfigurować własną.",
"bookmark-doesnt-exist": "Zakładka nie istnieje",
"series-doesnt-exist": "Seria nie istnieje",
"must-be-defined": "{0} musi być zdefiniowane"
}

View file

@ -151,5 +151,12 @@
"browse-libraries": "Explorar por Bibliotecas",
"browse-collections": "Explorar por Coleções",
"invalid-payload": "Payload inválido",
"scrobble-bad-payload": "Payload inválido de Fornecedor de Scrobble"
"scrobble-bad-payload": "Payload inválido de Fornecedor de Scrobble",
"on-deck": "Continuar a Ler",
"browse-on-deck": "Explorar Continuar a Ler",
"issue-num": "Número {0}{1}",
"generic-scrobble-hold": "Ocorreu um erro ao adicionar o hold",
"bad-copy-files-for-download": "Não foi possível copiar os ficheiros para a diretoria temp para descarregar os arquivos.",
"want-to-read": "Leituras Futuras",
"browse-want-to-read": "Explorar Leituras Futuras"
}

162
API/I18N/pt_BR.json Normal file
View file

@ -0,0 +1,162 @@
{
"generic-error": "Alguma coisa deu errado. Por favor, tente outra vez",
"collection-doesnt-exist": "A coleção não existe",
"send-to-kavita-email": "Enviar para o dispositivo não pode ser usado com o serviço de e-mail da Kavita. Por favor, configure o seu próprio.",
"volume-doesnt-exist": "O volume não existe",
"no-cover-image": "Sem imagem de capa",
"invalid-filename": "Nome de arquivo inválido",
"file-doesnt-exist": "Arquivo não existe",
"no-library-access": "O usuário não tem acesso a esta biblioteca",
"library-doesnt-exist": "A biblioteca não existe",
"delete-library-while-scan": "Você não pode excluir uma biblioteca enquanto uma verificação estiver em andamento. Aguarde a conclusão da verificação ou reinicie o Kavita e tente excluir",
"generic-library-update": "Ocorreu um problema crítico ao atualizar a biblioteca.",
"confirm-email": "Você deve confirmar seu e-mail primeiro",
"bad-credentials": "Suas credenciais não estão corretas",
"locked-out": "Você foi bloqueado por muitas tentativas de autorização. Aguarde 10 minutos.",
"validate-email": "Ocorreu um problema ao validar seu e-mail: {0}",
"denied": "Não permitido",
"invalid-password": "Senha Inválida",
"invalid-token": "Token inválido",
"unable-to-reset-key": "Algo deu errado, não foi possível redefinir a chave",
"nothing-to-do": "Nada para fazer",
"share-multiple-emails": "Você não pode compartilhar e-mails em várias contas",
"age-restriction-update": "Ocorreu um erro ao atualizar a restrição de idade",
"no-user": "Usuário não existe",
"username-taken": "Nome de usuário já em uso",
"user-already-confirmed": "O usuário já está confirmado",
"manual-setup-fail": "A configuração manual não pode ser concluída. Cancele e recrie o convite",
"user-already-registered": "O usuário já está registrado como {0}",
"generic-invite-user": "Ocorreu um problema ao convidar o usuário. Verifique os registros.",
"invalid-email-confirmation": "Confirmação de e-mail inválido",
"password-updated": "Senha atualizada",
"forgot-password-generic": "Um e-mail será enviado para o e-mail caso exista em nosso banco de dados",
"not-accessible-password": "Seu servidor não está acessível. O link para redefinir sua senha está nos registros",
"email-sent": "E-mail enviado",
"generic-invite-email": "Ocorreu um problema ao reenviar o e-mail de convite",
"admin-already-exists": "O administrador já existe",
"invalid-username": "Nome de usuário Inválido",
"critical-email-migration": "Ocorreu um problema durante a migração de e-mail. Entre em contato com o suporte",
"chapter-doesnt-exist": "Capítulo não existe",
"collection-updated": "Coleção atualizada com sucesso",
"device-doesnt-exist": "O dispositivo não existe",
"generic-device-create": "Ocorreu um erro ao criar o dispositivo",
"generic-device-update": "Ocorreu um erro ao atualizar o dispositivo",
"generic-device-delete": "Ocorreu um erro ao excluir o dispositivo",
"greater-0": "{0} deve ser maior que 0",
"send-to-device-status": "Transferindo arquivos para o seu dispositivo",
"generic-send-to": "Ocorreu um erro ao enviar o(s) arquivo(s) para o dispositivo",
"series-doesnt-exist": "A série não existe",
"bookmarks-empty": "Os marcadores não podem estar vazios",
"bookmark-doesnt-exist": "Favorito não existe",
"must-be-defined": "{0} deve ser definido",
"generic-favicon": "Ocorreu um problema ao buscar favicon para o domínio",
"library-name-exists": "O nome da biblioteca já existe. Escolha um nome exclusivo para o servidor.",
"generic-library": "Houve um problema crítico. Por favor, tente novamente.",
"user-doesnt-exist": "Usuário não existe",
"invalid-path": "Caminho inválido",
"pdf-doesnt-exist": "PDF não existe quando deveria",
"invalid-access": "Acesso inválido",
"no-image-for-page": "Essa imagem não existe para a página {0}. Tente atualizar para permitir o re-cache.",
"bookmark-permission": "Você não tem permissão para marcar/desmarcar",
"bookmark-save": "Não foi possível salvar o favorito",
"cache-file-find": "Não foi possível encontrar a imagem em cache. Recarregue e tente novamente.",
"name-required": "O nome não pode estar vazio",
"valid-number": "Deve ser um número de página válido",
"duplicate-bookmark": "Já existe uma entrada de marcador duplicada",
"reading-list-position": "Não foi possível atualizar a posição",
"reading-list-updated": "Atualizado",
"reading-list-deleted": "A lista de leitura foi excluída",
"generic-reading-list-update": "Ocorreu um problema ao atualizar a lista de leitura",
"generic-reading-list-create": "Ocorreu um problema ao criar a lista de leitura",
"reading-list-doesnt-exist": "A lista de leitura não existe",
"series-restricted": "O usuário não tem acesso a esta série",
"libraries-restricted": "O usuário não tem acesso a nenhuma biblioteca",
"no-series": "Não foi possível obter a série para a Biblioteca",
"generic-series-delete": "Ocorreu um problema ao excluir a série",
"series-updated": "Atualizado com sucesso",
"update-metadata-fail": "Não foi possível atualizar os metadados",
"age-restriction-not-applicable": "Sem Restrição",
"job-already-running": "Tarefa já em execução",
"encode-as-warning": "Você não pode converter para PNG. Para capas, use Atualizar capas. Marcadores e favicons não podem ser codificados de volta.",
"ip-address-invalid": "O endereço IP '{0}' é inválido",
"total-logs": "O total de registros deve estar entre 1 e 30",
"stats-permission-denied": "Você não está autorizado a visualizar as estatísticas de outro usuário",
"url-not-valid": "URL não retorna uma imagem válida ou requer autorização",
"generic-cover-collection-save": "Não foi possível salvar a imagem da capa na Coleção",
"generic-cover-reading-list-save": "Não é possível salvar a imagem da capa na lista de leitura",
"generic-cover-chapter-save": "Não foi possível salvar a imagem da capa no Capítulo",
"access-denied": "Você não tem acesso",
"reset-chapter-lock": "Não é possível redefinir o bloqueio da tampa para o Capítulo",
"generic-user-delete": "Não foi possível excluir o usuário",
"on-deck": "Na Estante",
"browse-on-deck": "Navegar Na Estante",
"recently-added": "Adicionado Recentemente",
"browse-recently-added": "Navegar no Adicionado Recentemente",
"reading-lists": "Listas de leitura",
"search-description": "Pesquisar por séries, coleções ou listas de leitura",
"favicon-doesnt-exist": "O favicon não existe",
"device-duplicate": "Já existe um dispositivo com este nome",
"disabled-account": "Sua conta está desativada. Entre em contato com o administrador do servidor.",
"register-user": "Algo deu errado ao registrar o usuário",
"confirm-token-gen": "Ocorreu um problema ao gerar um token de confirmação",
"permission-denied": "Você não tem permissão para esta operação",
"password-required": "Você deve inserir sua senha existente para alterar sua conta, a menos que seja um administrador",
"generate-token": "Ocorreu um problema ao gerar um token de e-mail de confirmação. Ver registros",
"generic-user-update": "Houve uma exceção ao atualizar o usuário",
"user-already-invited": "O usuário já foi convidado neste e-mail e ainda não aceitou o convite.",
"generic-user-email-update": "Não foi possível atualizar o e-mail do usuário. Verifique os registros.",
"generic-password-update": "Ocorreu um erro inesperado ao confirmar a nova senha",
"not-accessible": "Seu servidor não está acessível externamente",
"user-migration-needed": "Este usuário precisa migrar. Faça com que eles saiam e façam login para acionar um fluxo de migração",
"file-missing": "O arquivo não foi encontrado no livro",
"generic-reading-list-delete": "Ocorreu um problema ao excluir a lista de leitura",
"generic-scrobble-hold": "Ocorreu um erro ao adicionar a retenção",
"no-series-collection": "Não foi possível obter a série para a Coleção",
"generic-series-update": "Ocorreu um erro ao atualizar a série",
"generic-relationship": "Ocorreu um problema ao atualizar as relações",
"bookmark-dir-permissions": "O Diretório de marcadores não tem as permissões corretas para Kavita usar",
"total-backups": "O total de backups deve estar entre 1 e 30",
"url-required": "Você deve passar uma url para usar",
"generic-cover-series-save": "Não é possível salvar a imagem da capa na Séries",
"generic-cover-library-save": "Não foi possível salvar a imagem da capa na Biblioteca",
"generic-user-pref": "Ocorreu um problema ao salvar as preferências",
"opds-disabled": "OPDS não está ativado neste servidor",
"browse-reading-lists": "Navegar por Listas de Leitura",
"libraries": "Todas as Bibliotecas",
"browse-libraries": "Navegar nas Bibliotecas",
"collections": "Todas as Coleções",
"browse-collections": "Navegar nas Coleções",
"reading-list-restricted": "A lista de leitura não existe ou você não tem acesso",
"query-required": "Você deve passar um parâmetro de consulta",
"search": "Pesquisar",
"not-authenticated": "O usuário não está autenticado",
"unable-to-register-k+": "Não foi possível registrar a licença devido a um erro. Entre em contato com o Suporte Kavita+",
"anilist-cred-expired": "As credenciais do AniList expiraram ou não foram definidas",
"scrobble-bad-payload": "Carga útil inválida do provedor Scrobble",
"theme-doesnt-exist": "Arquivo de tema ausente ou inválido",
"bad-copy-files-for-download": "Não é possível copiar os arquivos para o download do arquivo do diretório temporário.",
"generic-create-temp-archive": "Ocorreu um problema ao criar o arquivo temporário",
"epub-malformed": "O arquivo está malformado! Não posso ler.",
"epub-html-missing": "Não foi possível encontrar o html apropriado para essa página",
"collection-tag-title-required": "O título da coleção não pode estar vazio",
"reading-list-title-required": "O título da lista de leitura não pode estar vazio",
"collection-tag-duplicate": "Já existe uma coleção com este nome",
"device-not-created": "Este dispositivo ainda não existe. Por favor, crie primeiro",
"send-to-permission": "Não é possível enviar arquivos não EPUB ou PDF para dispositivos porque não são compatíveis com o Kindle",
"progress-must-exist": "O progresso deve existir no usuário",
"reading-list-name-exists": "Já existe uma lista de leitura com este nome",
"user-no-access-library-from-series": "O usuário não tem acesso à biblioteca a que esta série pertence",
"series-restricted-age-restriction": "O usuário não tem permissão para ver esta série devido a restrições de idade",
"volume-num": "Volume {0}",
"book-num": "Livro {0}",
"perform-scan": "Realize uma varredura nesta série ou biblioteca e tente novamente",
"generic-read-progress": "Ocorreu um problema ao salvar o progresso",
"generic-clear-bookmarks": "Não foi possível limpar os marcadores",
"reading-list-permission": "Você não tem permissões nesta lista de leitura ou a lista não existe",
"reading-list-item-delete": "Não foi possível excluir os itens",
"invalid-payload": "Carga inválida",
"issue-num": "Número {0}{1}",
"chapter-num": "Capítulo {0}",
"want-to-read": "Quero Ler",
"browse-want-to-read": "Navegar no Quero Ler"
}

View file

@ -156,5 +156,7 @@
"query-required": "คุณต้องส่งพารามิเตอร์การค้นหา",
"theme-doesnt-exist": "ไฟล์ธีมหายไปหรือไม่ถูกต้อง",
"epub-html-missing": "ไม่พบ html ที่เหมาะสมสำหรับหน้านั้น",
"collection-tag-title-required": "ชื่อคอลเลกชันต้องไม่ว่างเปล่า"
"collection-tag-title-required": "ชื่อคอลเลกชันต้องไม่ว่างเปล่า",
"want-to-read": "ต้องการอ่าน",
"browse-want-to-read": "ดูรายการต้องการอ่าน"
}

View file

@ -1,21 +1,163 @@
{
"bad-credentials": "你的用户信息不匹配",
"bad-credentials": "您的登录信息不正确",
"validate-email": "验证你的邮件时出了点问题: {0}",
"confirm-token-gen": "生成认证令牌时出现问题",
"denied": "未被允许",
"password-required": "除非您是管理员,否则您必须输入现有密码才能更改帐户信息",
"invalid-token": "无效令牌",
"unable-to-reset-key": "出错了,无法重置",
"confirm-email": "你必须先确认你的邮箱",
"disabled-account": "你的账号已被关闭。联系服务器的管理员。",
"confirm-email": "您必须先确认电子邮件",
"disabled-account": "您的账号已被禁用,请联系管理员。",
"register-user": "注册用户时出现一些错误",
"locked-out": "您因多次错误登陆已被阻止。请稍等 10 分钟。",
"locked-out": "您因多次错误登陆已被阻止,请稍等 10 分钟",
"permission-denied": "您无权执行此操作",
"invalid-password": "无效密码",
"generate-token": "生成确认电子邮件令牌时出现问题参见日志",
"generate-token": "生成确认电子邮件令牌时出现问题参见日志",
"generic-user-update": "更新用户时出现了异常",
"share-multiple-emails": "不能在多个账户间共享电子邮件",
"age-restriction-update": "年龄限制更新出错",
"share-multiple-emails": "无法在多个账户间共用电子邮件地址",
"age-restriction-update": "更新年龄限制时出错",
"no-user": "用户不存在",
"username-taken": "用户名已被使用"
"username-taken": "用户名已被使用",
"generic-invite-user": "邀请用户时出现问题,请检查日志。",
"invalid-email-confirmation": "邮件确认信息无效",
"password-updated": "密码已更新",
"forgot-password-generic": "如果电子邮件地址存在于数据库中,则会向该地址发送电子邮件",
"admin-already-exists": "管理员已存在",
"file-missing": "在书籍中没有找到相关文件",
"collection-updated": "收藏更新成功",
"device-doesnt-exist": "设备不存在",
"volume-doesnt-exist": "卷不存在",
"generic-password-update": "确认新密码时出现意外错误",
"not-accessible": "此服务器无法从外部访问",
"email-sent": "电子邮件已发送",
"generic-invite-email": "重新发送邀请电子邮件时出现问题",
"invalid-username": "用户名无效",
"generic-error": "发生了一些小问题,请重新尝试",
"collection-doesnt-exist": "收藏不存在",
"generic-device-create": "创建设备时出错",
"generic-device-update": "更新设备时出错",
"generic-device-delete": "删除设备时出错",
"greater-0": "{0}必须大于 0",
"send-to-device-status": "正在向您的设备传输文件",
"generic-send-to": "将文件发送到设备时出错",
"series-doesnt-exist": "系列不存在",
"bookmarks-empty": "书签不能为空",
"no-cover-image": "无封面",
"bookmark-doesnt-exist": "书签不存在",
"must-be-defined": "必须定义{0}",
"invalid-filename": "无效的文件名",
"file-doesnt-exist": "文件不存在",
"library-name-exists": "资料库名称已存在,请重新指定一个唯一的名称。",
"generic-library": "发生了一个严重错误,请重试。",
"delete-library-while-scan": "在扫描过程中您无法删除资料库。请等待扫描完成或重启启动Kavita然后尝试删除",
"generic-library-update": "更新资料库时产生一个严重问题。",
"no-image-for-page": "第 {0} 页没有此图像,请尝试刷新重新缓存。",
"perform-scan": "请对此系列或者资料库执行扫描,并且重新尝试",
"generic-cover-chapter-save": "无法为该章节保存封面",
"generic-cover-library-save": "无法为该资料库保存封面",
"access-denied": "无权访问",
"generic-clear-bookmarks": "无法清除书签",
"opds-disabled": "此服务器未启用OPDS",
"bookmark-permission": "您无权加入书签或删除书签",
"bookmark-save": "无法保存书签",
"reading-list-permission": "您无权访问阅读清单,或者阅读清单不存在",
"reading-list-updated": "已更新",
"reading-list-item-delete": "无法删除条目",
"reading-list-deleted": "已删除阅读清单",
"no-library-access": "用户无权访问此资料库",
"user-doesnt-exist": "用户不存在",
"library-doesnt-exist": "资料库不存在",
"invalid-path": "无效路径",
"invalid-access": "无效访问",
"generic-read-progress": "保存进度时出现问题",
"cache-file-find": "找不到缓存的图像,请重新加载并重试。",
"name-required": "名称不能为空",
"valid-number": "必须是有效的页码",
"duplicate-bookmark": "相同的书签已存在",
"reading-list-position": "无法更新定位",
"generic-reading-list-delete": "删除阅读清单时出现问题",
"generic-reading-list-update": "更新阅读清单时出现问题",
"generic-reading-list-create": "建立阅读清单时出现问题",
"reading-list-doesnt-exist": "阅读清单不存在",
"series-restricted": "用户无权访问此系列",
"libraries-restricted": "用户无权访问任何资料库",
"generic-series-delete": "删除系列时出现问题",
"generic-series-update": "更新系列时出错",
"series-updated": "更新成功",
"update-metadata-fail": "无法更新元数据",
"age-restriction-not-applicable": "无限制",
"job-already-running": "任务运行中",
"total-backups": "备份总数必须介于1到30之间",
"ip-address-invalid": "IP地址“{0}”无效",
"total-logs": "日志总数必须介于1到30之间",
"stats-permission-denied": "您无权查看其他用户的统计信息",
"url-not-valid": "URL无法返回有效图像或者需要授权",
"generic-cover-series-save": "无法为该系列保存封面",
"generic-cover-collection-save": "无法为该收藏保存封面",
"generic-cover-reading-list-save": "无法为该阅读列表保存封面",
"generic-user-delete": "无法删除用户",
"generic-user-pref": "保存首选项时出现问题",
"browse-reading-lists": "按阅读清单浏览",
"libraries": "所有资料库",
"browse-libraries": "按资料库浏览",
"collections": "所有收藏",
"browse-collections": "按收藏浏览",
"reading-list-restricted": "阅读清单不存在或您没有访问权限",
"search-description": "搜索系列、收藏或阅读清单",
"favicon-doesnt-exist": "图标不存在",
"not-authenticated": "用户未通过身份验证",
"anilist-cred-expired": "AniList凭据已过期或未设置",
"theme-doesnt-exist": "主题文件丢失或者无效",
"generic-user-email-update": "无法更新用户的电子邮件。请检查日志。",
"epub-malformed": "文件格式不正确!无法读取。",
"user-already-invited": "已经向此用户的电子邮箱发送了邀请邮件,但该用户尚未接受邀请。",
"want-to-read": "想读",
"browse-want-to-read": "浏览想读",
"epub-html-missing": "找不到该页面相应的html文件",
"collection-tag-title-required": "收藏标题不能为空",
"reading-list-title-required": "阅读清单标题不能为空",
"collection-tag-duplicate": "收藏的名称已存在",
"chapter-num": "第{0}话",
"not-accessible-password": "您的服务器无法访问,重置密码的链接位于日志中",
"invalid-payload": "无效的数据",
"nothing-to-do": "没有需要处理的任务",
"user-already-confirmed": "用户已确认",
"manual-setup-fail": "无法进行手动设置,请取消邀请并重新创建",
"user-already-registered": "用户已经注册为{0}",
"critical-email-migration": "更改电子邮件地址时出现问题,请联系支持人员",
"chapter-doesnt-exist": "章节不存在",
"send-to-kavita-email": "无法使用Kavita服务向设备发送电子邮件请自行配置。",
"generic-favicon": "获取图标时出现问题",
"pdf-doesnt-exist": "PDF文件应该存在但未找到",
"no-series": "无法获取资料库中的系列",
"no-series-collection": "无法获取收藏中的系列",
"bookmark-dir-permissions": "书签目录权限不正确",
"on-deck": "最近阅读",
"browse-on-deck": "浏览最近阅读",
"recently-added": "最近加入",
"browse-recently-added": "浏览最近加入",
"reading-lists": "阅读清单",
"search": "搜索",
"unable-to-register-k+": "因为一些错误导致无法注册许可证。请联系 Kavita+ 支持人员",
"device-duplicate": "设备名称已存在",
"device-not-created": "设备不存在,请先创建",
"send-to-permission": "无法向设备发送Kindel不支持的非EPUB格式或者PDF格式文件",
"reading-list-name-exists": "此名称的阅读清单已存在",
"volume-num": "第{0}卷",
"issue-num": "问题编号{0}{1}",
"book-num": "第{0}本",
"user-migration-needed": "该用户需要进行迁移。通知他们注销并重新登录,以触发迁移流程",
"generic-relationship": "更新关系时发生了问题",
"encode-as-warning": "无法转换为PNG格式。对于封面请使用刷新封面功能。书签和网站图标无法再进行编码。",
"url-required": "必须提供一个URL才能使用",
"series-restricted-age-restriction": "由于年龄限制用户无权查看此系列",
"user-no-access-library-from-series": "用户无法访问此系列所属的资料库",
"generic-create-temp-archive": "创建临时档案时出现问题",
"query-required": "您必须传递一个查询参数",
"scrobble-bad-payload": "Scrobble服务提供商的数据无效",
"bad-copy-files-for-download": "无法复制文件至临时下载目录",
"progress-must-exist": "用户进程必须存在",
"generic-scrobble-hold": "启用锁定时发生错误",
"reset-chapter-lock": "无法重置章节的封面锁",
"collection-deleted": "收藏已删除"
}

View file

@ -22,7 +22,7 @@ public interface IArchiveService
{
void ExtractArchive(string archivePath, string extractPath);
int GetNumberOfPagesFromArchive(string archivePath);
string GetCoverImage(string archivePath, string fileName, string outputDirectory, EncodeFormat format);
string GetCoverImage(string archivePath, string fileName, string outputDirectory, EncodeFormat format, CoverImageSize size = CoverImageSize.Default);
bool IsValidArchive(string archivePath);
ComicInfo? GetComicInfo(string archivePath);
ArchiveLibrary CanOpen(string archivePath);
@ -205,7 +205,7 @@ public class ArchiveService : IArchiveService
/// <param name="outputDirectory">Where to output the file, defaults to covers directory</param>
/// <param name="format">When saving the file, use encoding</param>
/// <returns></returns>
public string GetCoverImage(string archivePath, string fileName, string outputDirectory, EncodeFormat format)
public string GetCoverImage(string archivePath, string fileName, string outputDirectory, EncodeFormat format, CoverImageSize size = CoverImageSize.Default)
{
if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty;
try
@ -221,7 +221,7 @@ public class ArchiveService : IArchiveService
var entry = archive.Entries.Single(e => e.FullName == entryName);
using var stream = entry.Open();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, format);
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, format, size);
}
case ArchiveLibrary.SharpCompress:
{
@ -232,7 +232,7 @@ public class ArchiveService : IArchiveService
var entry = archive.Entries.Single(e => e.Key == entryName);
using var stream = entry.OpenEntryStream();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, format);
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, format, size);
}
case ArchiveLibrary.NotSupported:
_logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath);
@ -246,7 +246,7 @@ public class ArchiveService : IArchiveService
{
_logger.LogWarning(ex, "[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath);
_mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService,
"This archive cannot be read or not supported", ex);
"This archive cannot be read or not supported", ex); // TODO: Localize this
}
return string.Empty;

View file

@ -33,7 +33,7 @@ namespace API.Services;
public interface IBookService
{
int GetNumberOfPages(string filePath);
string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat);
string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default);
ComicInfo? GetComicInfo(string filePath);
ParserInfo? ParseInfo(string filePath);
/// <summary>
@ -1196,13 +1196,13 @@ public class BookService : IBookService
/// <param name="outputDirectory">Where to output the file, defaults to covers directory</param>
/// <param name="encodeFormat">When saving the file, use encoding</param>
/// <returns></returns>
public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat)
public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
{
if (!IsValidFile(fileFilePath)) return string.Empty;
if (Parser.IsPdf(fileFilePath))
{
return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, encodeFormat);
return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, encodeFormat, size);
}
using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions);
@ -1217,20 +1217,20 @@ public class BookService : IBookService
if (coverImageContent == null) return string.Empty;
using var stream = coverImageContent.GetContentStream();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat, size);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath);
_mediaErrorService.ReportMediaIssue(fileFilePath, MediaErrorProducer.BookService,
"There was a critical error and prevented thumbnail generation", ex);
"There was a critical error and prevented thumbnail generation", ex); // TODO: Localize this
}
return string.Empty;
}
private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat)
private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size)
{
try
{
@ -1240,7 +1240,7 @@ public class BookService : IBookService
using var stream = StreamManager.GetStream("BookService.GetPdfPage");
GetPdfPage(docReader, 0, stream);
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat, size);
}
catch (Exception ex)

View file

@ -17,6 +17,7 @@ namespace API.Services;
public interface ICollectionTagService
{
Task<bool> TagExistsByName(string name);
Task<bool> DeleteTag(CollectionTag tag);
Task<bool> UpdateTag(CollectionTagDto dto);
Task<bool> AddTagToSeries(CollectionTag? tag, IEnumerable<int> seriesIds);
Task<bool> RemoveTagFromSeries(CollectionTag? tag, IEnumerable<int> seriesIds);
@ -49,6 +50,12 @@ public class CollectionTagService : ICollectionTagService
return await _unitOfWork.CollectionTagRepository.TagExists(name);
}
public async Task<bool> DeleteTag(CollectionTag tag)
{
_unitOfWork.CollectionTagRepository.Remove(tag);
return await _unitOfWork.CommitAsync();
}
public async Task<bool> UpdateTag(CollectionTagDto dto)
{
var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(dto.Id);
@ -130,6 +137,7 @@ public class CollectionTagService : ICollectionTagService
public async Task<bool> RemoveTagFromSeries(CollectionTag? tag, IEnumerable<int> seriesIds)
{
if (tag == null) return false;
tag.SeriesMetadatas ??= new List<SeriesMetadata>();
foreach (var seriesIdToRemove in seriesIds)
{
tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove));

View file

@ -21,7 +21,7 @@ namespace API.Services;
public interface IImageService
{
void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1);
string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat);
string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size);
/// <summary>
/// Creates a Thumbnail version of a base64 image
@ -40,7 +40,7 @@ public interface IImageService
/// <param name="outputDirectory"></param>
/// <param name="encodeFormat"></param>
/// <returns></returns>
string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat);
string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default);
/// <summary>
/// Writes out a thumbnail by file path input
/// </summary>
@ -49,7 +49,7 @@ public interface IImageService
/// <param name="outputDirectory"></param>
/// <param name="encodeFormat"></param>
/// <returns></returns>
string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat);
string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default);
/// <summary>
/// Converts the passed image to encoding and outputs it in the same directory
/// </summary>
@ -87,6 +87,7 @@ public class ImageService : IImageService
/// </summary>
public const int LibraryThumbnailWidth = 32;
private static readonly string[] ValidIconRelations = {
"icon",
"apple-touch-icon",
@ -124,13 +125,14 @@ public class ImageService : IImageService
}
}
public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat)
public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size)
{
if (string.IsNullOrEmpty(path)) return string.Empty;
try
{
using var thumbnail = Image.Thumbnail(path, ThumbnailWidth, height: ThumbnailHeight, size: Enums.Size.Force);
var dims = size.GetDimensions();
using var thumbnail = Image.Thumbnail(path, dims.Width, height: dims.Height, size: Enums.Size.Force);
var filename = fileName + encodeFormat.GetExtension();
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
return filename;
@ -152,9 +154,10 @@ public class ImageService : IImageService
/// <param name="outputDirectory">Where to output the file, defaults to covers directory</param>
/// <param name="encodeFormat">Export the file as the passed encoding</param>
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat)
public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
{
using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth, height: ThumbnailHeight, size: Enums.Size.Force);
var dims = size.GetDimensions();
using var thumbnail = Image.ThumbnailStream(stream, dims.Width, height: dims.Height, size: Enums.Size.Force);
var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory);
try
@ -165,9 +168,10 @@ public class ImageService : IImageService
return filename;
}
public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat)
public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
{
using var thumbnail = Image.Thumbnail(sourceFile, ThumbnailWidth, height: ThumbnailHeight, size: Enums.Size.Force);
var dims = size.GetDimensions();
using var thumbnail = Image.Thumbnail(sourceFile, dims.Width, height: dims.Height, size: Enums.Size.Force);
var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory);
try
@ -416,31 +420,62 @@ public class ImageService : IImageService
public static string GetWebLinkFormat(string url, EncodeFormat encodeFormat)
{
return $"{new Uri(url).Host}{encodeFormat.GetExtension()}";
return $"{new Uri(url).Host.Replace("www.", string.Empty)}{encodeFormat.GetExtension()}";
}
public static string CreateMergedImage(IList<string> coverImages, string dest)
public static void CreateMergedImage(IList<string> coverImages, CoverImageSize size, string dest)
{
var image = Image.Black(ThumbnailWidth, ThumbnailHeight); // 320x455
var dims = size.GetDimensions();
int rows, cols;
var thumbnailWidth = image.Width / 2;
var thumbnailHeight = image.Height / 2;
if (coverImages.Count == 1)
{
rows = 1;
cols = 1;
}
else if (coverImages.Count == 2)
{
rows = 1;
cols = 2;
}
else if (coverImages.Count == 3)
{
rows = 2;
cols = 2;
}
else
{
// Default to 2x2 layout for more than 3 images
rows = 2;
cols = 2;
}
var image = Image.Black(dims.Width, dims.Height);
var thumbnailWidth = image.Width / cols;
var thumbnailHeight = image.Height / rows;
for (var i = 0; i < coverImages.Count; i++)
{
var tile = Image.NewFromFile(coverImages[i], access: Enums.Access.Sequential);
// Resize the tile to fit the thumbnail size
tile = tile.ThumbnailImage(thumbnailWidth, height: thumbnailHeight);
var x = (i % 2) * thumbnailWidth;
var y = (i / 2) * thumbnailHeight;
var row = i / cols;
var col = i % cols;
var x = col * thumbnailWidth;
var y = row * thumbnailHeight;
if (coverImages.Count == 3 && i == 2)
{
x = (image.Width - thumbnailWidth) / 2;
y = thumbnailHeight;
}
image = image.Insert(tile, x, y);
}
image.WriteToFile(dest);
return dest;
}
}

View file

@ -37,6 +37,7 @@ public class MediaErrorService : IMediaErrorService
public void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, Exception ex)
{
// TODO: Localize all these messages
// To avoid overhead on commits, do async. We don't need to wait.
BackgroundJob.Enqueue(() => ReportMediaIssueAsync(filename, producer, errorMessage, ex.Message));
}

View file

@ -33,7 +33,7 @@ public interface IMetadataService
/// <param name="forceUpdate">Overrides any cache logic and forces execution</param>
Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true);
Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, bool forceUpdate = false);
Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false);
Task RemoveAbandonedMetadataKeys();
}
@ -65,7 +65,7 @@ public class MetadataService : IMetadataService
/// <param name="chapter"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
/// <param name="encodeFormat">Convert image to Encoding Format when extracting the cover</param>
private Task<bool> UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, EncodeFormat encodeFormat)
private Task<bool> UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize)
{
var firstFile = chapter.Files.MinBy(x => x.Chapter);
if (firstFile == null) return Task.FromResult(false);
@ -79,7 +79,7 @@ public class MetadataService : IMetadataService
_logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath);
chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath,
ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, encodeFormat);
ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, encodeFormat, coverImageSize);
_unitOfWork.ChapterRepository.Update(chapter);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter));
return Task.FromResult(true);
@ -143,7 +143,7 @@ public class MetadataService : IMetadataService
/// <param name="series"></param>
/// <param name="forceUpdate"></param>
/// <param name="encodeFormat"></param>
private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat)
private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize)
{
_logger.LogDebug("[MetadataService] Processing cover image generation for series: {SeriesName}", series.OriginalName);
try
@ -156,7 +156,7 @@ public class MetadataService : IMetadataService
var index = 0;
foreach (var chapter in volume.Chapters)
{
var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat);
var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat, coverImageSize);
// If cover was update, either the file has changed or first scan and we should force a metadata update
UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated);
if (index == 0 && chapterUpdated)
@ -208,7 +208,9 @@ public class MetadataService : IMetadataService
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.CoverUpdateProgressEvent(library.Id, 0F, ProgressEventType.Started, $"Starting {library.Name}"));
var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var encodeFormat = settings.EncodeMediaAs;
var coverImageSize = settings.CoverImageSize;
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++)
{
@ -238,7 +240,7 @@ public class MetadataService : IMetadataService
try
{
await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat);
await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize);
}
catch (Exception ex)
{
@ -288,8 +290,10 @@ public class MetadataService : IMetadataService
return;
}
var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
await GenerateCoversForSeries(series, encodeFormat, forceUpdate);
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var encodeFormat = settings.EncodeMediaAs;
var coverImageSize = settings.CoverImageSize;
await GenerateCoversForSeries(series, encodeFormat, coverImageSize, forceUpdate);
}
/// <summary>
@ -298,13 +302,13 @@ public class MetadataService : IMetadataService
/// <param name="series">A full Series, with metadata, chapters, etc</param>
/// <param name="encodeFormat">When saving the file, what encoding should be used</param>
/// <param name="forceUpdate"></param>
public async Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, bool forceUpdate = false)
public async Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false)
{
var sw = Stopwatch.StartNew();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.CoverUpdateProgressEvent(series.LibraryId, 0F, ProgressEventType.Started, series.Name));
await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat);
await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize);
if (_unitOfWork.HasChanges())

View file

@ -128,15 +128,18 @@ public class LicenseService : ILicenseService
/// <remarks>Expected to be called at startup and on reoccurring basis</remarks>
public async Task ValidateLicenseStatus()
{
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
try
{
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
if (string.IsNullOrEmpty(license.Value)) return;
if (string.IsNullOrEmpty(license.Value)) {
await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
return;
}
_logger.LogInformation("Validating Kavita+ License");
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
await provider.FlushAsync();
await provider.FlushAsync();
var isValid = await IsLicenseValid(license.Value);
await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout);
@ -145,6 +148,7 @@ public class LicenseService : ILicenseService
catch (Exception ex)
{
_logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins");
await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30));
}
}

View file

@ -104,7 +104,7 @@ public class ScrobblingService : IScrobblingService
/// <summary>
///
/// An automated job that will run against all user's tokens and validate if they are still active
/// </summary>
/// <remarks>This service can validate without license check as the task which calls will be guarded</remarks>
/// <returns></returns>
@ -115,6 +115,7 @@ public class ScrobblingService : IScrobblingService
foreach (var user in users)
{
if (string.IsNullOrEmpty(user.AniListAccessToken) || !_tokenService.HasTokenExpired(user.AniListAccessToken)) continue;
_logger.LogInformation("User {UserName}'s AniList token has expired! They need to regenerate it for scrobbling to work", user.UserName);
await _eventHub.SendMessageToAsync(MessageFactory.ScrobblingKeyExpired,
MessageFactory.ScrobblingKeyExpiredEvent(ScrobbleProvider.AniList), user.Id);
}
@ -184,17 +185,13 @@ public class ScrobblingService : IScrobblingService
public async Task ScrobbleReviewUpdate(int userId, int seriesId, string reviewTitle, string reviewBody)
{
if (!await _licenseService.HasActiveLicense()) return;
var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList);
if (await HasTokenExpired(token, ScrobbleProvider.AniList))
{
throw new KavitaException(await _localizationService.Translate(userId, "unable-to-register-k+"));
}
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
if (library is not {AllowScrobbling: true}) return;
if (library.Type == LibraryType.Comic) return;
_logger.LogInformation("Processing Scrobbling review event for {UserId} on {SeriesName}", userId, series.Name);
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
ScrobbleEventType.Review);
@ -229,17 +226,12 @@ public class ScrobblingService : IScrobblingService
public async Task ScrobbleRatingUpdate(int userId, int seriesId, float rating)
{
if (!await _licenseService.HasActiveLicense()) return;
var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList);
if (await HasTokenExpired(token, ScrobbleProvider.AniList))
{
throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired"));
}
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
if (library is not {AllowScrobbling: true}) return;
if (library.Type == LibraryType.Comic) return;
_logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name);
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
ScrobbleEventType.ScoreUpdated);
@ -273,22 +265,12 @@ public class ScrobblingService : IScrobblingService
public async Task ScrobbleReadingUpdate(int userId, int seriesId)
{
if (!await _licenseService.HasActiveLicense()) return;
var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList);
if (await HasTokenExpired(token, ScrobbleProvider.AniList))
{
throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired"));
}
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId))
{
_logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name, userId);
return;
}
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
if (library is not {AllowScrobbling: true}) return;
if (library.Type == LibraryType.Comic) return;
_logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name);
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
ScrobbleEventType.ChapterRead);
@ -338,17 +320,12 @@ public class ScrobblingService : IScrobblingService
public async Task ScrobbleWantToReadUpdate(int userId, int seriesId, bool onWantToRead)
{
if (!await _licenseService.HasActiveLicense()) return;
var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList);
if (await HasTokenExpired(token, ScrobbleProvider.AniList))
{
throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired"));
}
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
if (library is not {AllowScrobbling: true}) return;
if (library.Type == LibraryType.Comic) return;
_logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name);
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
var existing = await _unitOfWork.ScrobbleRepository.Exists(userId, series.Id,
onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead);
@ -369,6 +346,21 @@ public class ScrobblingService : IScrobblingService
_logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {UserId} ", series.Name, userId);
}
private async Task<bool> CheckIfCanScrobble(int userId, int seriesId, Series series)
{
if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId))
{
_logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name,
userId);
return true;
}
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
if (library is not {AllowScrobbling: true}) return true;
if (library.Type == LibraryType.Comic) return true;
return false;
}
private async Task<int> GetRateLimit(string license, string aniListToken)
{
if (string.IsNullOrWhiteSpace(aniListToken)) return 0;

View file

@ -10,7 +10,7 @@ public interface IReadingItemService
{
ComicInfo? GetComicInfo(string filePath);
int GetNumberOfPages(string filePath, MangaFormat format);
string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat);
string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default);
void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1);
ParserInfo? ParseFile(string path, string rootPath, LibraryType type);
}
@ -162,7 +162,7 @@ public class ReadingItemService : IReadingItemService
}
}
public string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat)
public string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
{
if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(fileName))
{
@ -172,10 +172,10 @@ public class ReadingItemService : IReadingItemService
return format switch
{
MangaFormat.Epub => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
MangaFormat.Archive => _archiveService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
MangaFormat.Image => _imageService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
MangaFormat.Pdf => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
MangaFormat.Epub => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat, size),
MangaFormat.Archive => _archiveService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat, size),
MangaFormat.Image => _imageService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat, size),
MangaFormat.Pdf => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat, size),
_ => string.Empty
};
}

View file

@ -65,16 +65,19 @@ public class SeriesService : ISeriesService
/// <returns></returns>
public static Chapter? GetFirstChapterForMetadata(Series series)
{
var sortedVolumes = series.Volumes.OrderBy(v => v.Number, ChapterSortComparer.Default);
var sortedVolumes = series.Volumes
.Where(v => float.TryParse(v.Name, out var parsedValue) && parsedValue != 0.0f)
.OrderBy(v => float.TryParse(v.Name, out var parsedValue) ? parsedValue : float.MaxValue);
var minVolumeNumber = sortedVolumes
.Where(v => v.Number != 0)
.MinBy(v => v.Number);
.MinBy(v => float.Parse(v.Name));
var minChapter = series.Volumes
.SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparer.Default))
var allChapters = series.Volumes
.SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparer.Default)).ToList();
var minChapter = allChapters
.FirstOrDefault();
if (minVolumeNumber != null && minChapter != null && float.Parse(minChapter.Number) > minVolumeNumber.Number)
if (minVolumeNumber != null && minChapter != null && float.TryParse(minChapter.Number, out var chapNum) && chapNum >= minVolumeNumber.Number)
{
return minVolumeNumber.Chapters.MinBy(c => float.Parse(c.Number), ChapterSortComparer.Default);
}
@ -223,7 +226,15 @@ public class SeriesService : ISeriesService
await _unitOfWork.CommitAsync();
// Trigger code to cleanup tags, collections, people, etc
await _taskScheduler.CleanupDbEntries();
try
{
await _taskScheduler.CleanupDbEntries();
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue cleaning up DB entries. This may happen if Komf is spamming updates. Nightly cleanup will work");
}
if (updateSeriesMetadataDto.CollectionTags == null) return true;
foreach (var tag in updateSeriesMetadataDto.CollectionTags)

View file

@ -27,7 +27,7 @@ public interface IStatisticService
Task<IEnumerable<TopReadDto>> GetTopUsers(int days);
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0, int days = 0);
IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown();
IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown(int userId = 0);
IEnumerable<StatCount<int>> GetPagesReadCountByYear(int userId = 0);
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
Task UpdateServerStatistics();
@ -411,11 +411,12 @@ public class StatisticService : IStatisticService
return results.OrderBy(r => r.Value);
}
public IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown()
public IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown(int userId)
{
return _context.AppUserProgresses
.AsSplitQuery()
.AsNoTracking()
.WhereIf(userId > 0, p => p.AppUserId == userId)
.GroupBy(p => p.LastModified.DayOfWeek)
.OrderBy(g => g.Key)
.Select(g => new StatCount<DayOfWeek>{ Value = g.Key, Count = g.Count() })

View file

@ -229,14 +229,20 @@ public class LibraryWatcher : ILibraryWatcher
public async Task ProcessChange(string filePath, bool isDirectoryChange = false)
{
var sw = Stopwatch.StartNew();
_logger.LogDebug("[LibraryWatcher] Processing change of {FilePath}", filePath);
_logger.LogTrace("[LibraryWatcher] Processing change of {FilePath}", filePath);
try
{
// If the change occurs in a blacklisted folder path, then abort processing
if (Parser.Parser.HasBlacklistedFolderInPath(filePath))
{
return;
}
// If not a directory change AND file is not an archive or book, ignore
if (!isDirectoryChange &&
!(Parser.Parser.IsArchive(filePath) || Parser.Parser.IsBook(filePath)))
{
_logger.LogDebug("[LibraryWatcher] Change from {FilePath} is not an archive or book, ignoring change", filePath);
_logger.LogTrace("[LibraryWatcher] Change from {FilePath} is not an archive or book, ignoring change", filePath);
return;
}
@ -248,10 +254,10 @@ public class LibraryWatcher : ILibraryWatcher
.ToList();
var fullPath = GetFolder(filePath, libraryFolders);
_logger.LogDebug("Folder path: {FolderPath}", fullPath);
_logger.LogTrace("Folder path: {FolderPath}", fullPath);
if (string.IsNullOrEmpty(fullPath))
{
_logger.LogDebug("[LibraryWatcher] Change from {FilePath} could not find root level folder, ignoring change", filePath);
_logger.LogTrace("[LibraryWatcher] Change from {FilePath} could not find root level folder, ignoring change", filePath);
return;
}

View file

@ -146,7 +146,6 @@ public class ProcessSeries : IProcessSeries
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
// parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort)
// BUG: This check doesn't work for Books, as books usually have metadata on all files. (#2167)
var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo);
UpdateVolumes(series, parsedInfos, forceUpdate);
@ -231,7 +230,8 @@ public class ProcessSeries : IProcessSeries
_logger.LogError(ex, "[ScannerService] There was an exception updating series for {SeriesName}", series.Name);
}
await _metadataService.GenerateCoversForSeries(series, (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs);
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
await _metadataService.GenerateCoversForSeries(series, settings.EncodeMediaAs, settings.CoverImageSize);
EnqueuePostSeriesProcessTasks(series.LibraryId, series.Id);
}

View file

@ -35,7 +35,7 @@ public class StatsService : IStatsService
private readonly IUnitOfWork _unitOfWork;
private readonly DataContext _context;
private readonly IStatisticService _statisticService;
private const string ApiUrl = "https://stats.kavitareader.com";
private const string ApiUrl = "https://stats.kavitareader.com"; // ""
public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork, DataContext context, IStatisticService statisticService)
{

View file

@ -54,7 +54,6 @@ public class TokenService : ITokenService
};
var roles = await _userManager.GetRolesAsync(user);
claims.AddRange(roles.Select(role => new Claim(Role, role)));
var credentials = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature);

View file

@ -57,18 +57,6 @@ public class PresenceTracker : IPresenceTracker
});
}
}
// Update the last active for the user
try
{
user.UpdateLastActive();
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
}
catch (Exception)
{
// Swallow the exception
}
}
public Task UserDisconnected(int userId, string connectionId)

View file

@ -317,7 +317,7 @@ public class Startup
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials() // For SignalR token query param
.WithOrigins("http://localhost:4200", $"http://{GetLocalIpAddress()}:4200", $"http://{GetLocalIpAddress()}:5000", "https://kavita.majora2007.duckdns.org")
.WithOrigins("http://localhost:4200", $"http://{GetLocalIpAddress()}:4200", $"http://{GetLocalIpAddress()}:5000")
.WithExposedHeaders("Content-Disposition", "Pagination"));
}
else
@ -327,7 +327,6 @@ public class Startup
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials() // For SignalR token query param
.WithOrigins("https://kavita.majora2007.duckdns.org")
.WithExposedHeaders("Content-Disposition", "Pagination"));
}

View file

@ -11,6 +11,7 @@ COPY _output/*.tar.gz /files/
COPY UI/Web/dist /files/wwwroot
COPY copy_runtime.sh /copy_runtime.sh
RUN /copy_runtime.sh
RUN chmod +x /Kavita/Kavita
#Production image
FROM ubuntu:focal
@ -25,6 +26,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 5000

View file

@ -4,7 +4,7 @@
<TargetFramework>net7.0</TargetFramework>
<Company>kavitareader.com</Company>
<Product>Kavita</Product>
<AssemblyVersion>0.7.7.0</AssemblyVersion>
<AssemblyVersion>0.7.8.0</AssemblyVersion>
<NeutralLanguage>en</NeutralLanguage>
<TieredPGO>true</TieredPGO>
</PropertyGroup>
@ -21,4 +21,4 @@
</ItemGroup>
</Project>
</Project>

View file

@ -23,7 +23,7 @@
"@iplab/ngx-file-upload": "^16.0.1",
"@microsoft/signalr": "^7.0.10",
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
"@ngneat/transloco": "^5.0.6",
"@ngneat/transloco": "^5.0.7",
"@ngneat/transloco-locale": "^5.1.1",
"@ngneat/transloco-persist-lang": "^5.0.0",
"@ngneat/transloco-persist-translations": "^5.0.0",
@ -37,6 +37,7 @@
"file-saver": "^2.0.5",
"lazysizes": "^5.3.2",
"ng-circle-progress": "^1.7.1",
"ng-select2-component": "^13.0.6",
"ngx-color-picker": "^14.0.0",
"ngx-extended-pdf-viewer": "^16.2.16",
"ngx-file-drop": "^16.0.0",
@ -3169,9 +3170,9 @@
}
},
"node_modules/@ngneat/transloco": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@ngneat/transloco/-/transloco-5.0.6.tgz",
"integrity": "sha512-pt0jiU0co0nT72bhodT9ervBvSgl1jVUrTbLsHwjtP3WoJZxfOmXN21j5MSA/GJFRkolceI8+yWqtG7jux+WDg==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/@ngneat/transloco/-/transloco-5.0.7.tgz",
"integrity": "sha512-x1c2e+7cOYPPVFPgqGcN3R6d7f18a4sMHzxsCamcxS2w7vWXcEzWKZ8JcI1TdpxrM+RKuj2NRfEEcr1HjAI/4w==",
"dependencies": {
"@ngneat/transloco-utils": "^5.0.0",
"flat": "5.0.2",
@ -10556,6 +10557,20 @@
"rxjs": ">=6.4.0"
}
},
"node_modules/ng-select2-component": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-13.0.6.tgz",
"integrity": "sha512-CiAelglSz2aeYy0BiXRi32zc49Mq27+J1eDzTrXmf2o50MvNo3asS3NRVQcnSldo/zLcJafWCMueVfjVaV1etw==",
"dependencies": {
"ngx-infinite-scroll": ">=16.0.0",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/cdk": ">=16.1.0",
"@angular/common": ">=16.1.0",
"@angular/core": ">=16.1.0"
}
},
"node_modules/ngx-color-picker": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-14.0.0.tgz",
@ -10598,6 +10613,18 @@
"@angular/core": ">=14.0.0"
}
},
"node_modules/ngx-infinite-scroll": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/ngx-infinite-scroll/-/ngx-infinite-scroll-16.0.0.tgz",
"integrity": "sha512-bzyNYd+wVlUUxcopRVr2DAa81eEc8vITtKVvb+c7R1uy8hWPTlxOEXf3L1qA4FMwTEzCQ9b37TXzlJji3qBy+A==",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": ">=16.0.0 <17.0.0",
"@angular/core": ">=16.0.0 <17.0.0"
}
},
"node_modules/ngx-slider-v2": {
"version": "16.0.2",
"resolved": "https://registry.npmjs.org/ngx-slider-v2/-/ngx-slider-v2-16.0.2.tgz",

View file

@ -28,7 +28,7 @@
"@iplab/ngx-file-upload": "^16.0.1",
"@microsoft/signalr": "^7.0.10",
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
"@ngneat/transloco": "^5.0.6",
"@ngneat/transloco": "^5.0.7",
"@ngneat/transloco-locale": "^5.1.1",
"@ngneat/transloco-persist-lang": "^5.0.0",
"@ngneat/transloco-persist-translations": "^5.0.0",
@ -42,6 +42,7 @@
"file-saver": "^2.0.5",
"lazysizes": "^5.3.2",
"ng-circle-progress": "^1.7.1",
"ng-select2-component": "^13.0.6",
"ngx-color-picker": "^14.0.0",
"ngx-extended-pdf-viewer": "^16.2.16",
"ngx-file-drop": "^16.0.0",

View file

@ -1,4 +1,5 @@
export interface Language {
isoCode: string;
title: string;
}
}

View file

@ -1,4 +1,6 @@
import { MangaFormat } from "../manga-format";
import { SeriesFilterV2 } from "./v2/series-filter-v2";
import {FilterField} from "./v2/filter-field";
export interface FilterItem<T> {
title: string;
@ -6,38 +8,6 @@ export interface FilterItem<T> {
selected: boolean;
}
export interface Range<T> {
min: T;
max: T;
}
export interface SeriesFilter {
formats: Array<MangaFormat>;
libraries: Array<number>,
readStatus: ReadStatus;
genres: Array<number>;
writers: Array<number>;
artists: Array<number>;
penciller: Array<number>;
inker: Array<number>;
colorist: Array<number>;
letterer: Array<number>;
coverArtist: Array<number>;
editor: Array<number>;
publisher: Array<number>;
character: Array<number>;
translators: Array<number>;
collectionTags: Array<number>;
rating: number;
ageRating: Array<number>;
sortOptions: SortOptions | null;
tags: Array<number>;
languages: Array<string>;
publicationStatus: Array<number>;
seriesNameQuery: string;
releaseYearRange: Range<number> | null;
}
export interface SortOptions {
sortField: SortField;
isAscending: boolean;
@ -52,11 +22,9 @@ export enum SortField {
ReleaseYear = 6,
}
export interface ReadStatus {
notRead: boolean,
inProgress: boolean,
read: boolean,
}
export const allSortFields = Object.keys(SortField)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10)) as SortField[];
export const mangaFormatFilters = [
{
@ -82,7 +50,7 @@ export const mangaFormatFilters = [
];
export interface FilterEvent {
filter: SeriesFilter;
filterV2: SeriesFilterV2;
isFirst: boolean;
}

View file

@ -0,0 +1,4 @@
export enum FilterCombination {
Or = 0,
And = 1
}

View file

@ -0,0 +1,46 @@
export enum FilterComparison {
Equal = 0,
GreaterThan =1,
GreaterThanEqual = 2,
LessThan = 3,
LessThanEqual = 4,
/// <summary>
///
/// </summary>
/// <remarks>Only works with IList</remarks>
Contains = 5,
MustContains = 6,
/// <summary>
/// Performs a LIKE %value%
/// </summary>
Matches = 7,
NotContains = 8,
/// <summary>
/// Not Equal to
/// </summary>
NotEqual = 9,
/// <summary>
/// String starts with
/// </summary>
BeginsWith = 10,
/// <summary>
/// String ends with
/// </summary>
EndsWith = 11,
/// <summary>
/// Is Date before X
/// </summary>
IsBefore = 12,
/// <summary>
/// Is Date after X
/// </summary>
IsAfter = 13,
/// <summary>
/// Is Date between now and X seconds ago
/// </summary>
IsInLast = 14,
/// <summary>
/// Is Date not between now and X seconds ago
/// </summary>
IsNotInLast = 15,
}

View file

@ -0,0 +1,35 @@
export enum FilterField
{
None = -1,
Summary = 0,
SeriesName = 1,
PublicationStatus = 2,
Languages = 3,
AgeRating = 4,
UserRating = 5,
Tags = 6,
CollectionTags = 7,
Translators = 8,
Characters = 9,
Publisher = 10,
Editor = 11,
CoverArtist = 12,
Letterer = 13,
Colorist = 14,
Inker = 15,
Penciller = 16,
Writers = 17,
Genres = 18,
Libraries = 19,
ReadProgress = 20,
Formats = 21,
ReleaseYear = 22,
ReadTime = 23,
Path = 24,
FilePath = 25
}
export const allFields = Object.keys(FilterField)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10))
.sort((a, b) => a - b) as FilterField[];

View file

@ -0,0 +1,8 @@
import { FilterComparison } from "./filter-comparison";
import { FilterField } from "./filter-field";
export interface FilterStatement {
comparison: FilterComparison;
field: FilterField;
value: string;
}

View file

@ -0,0 +1,11 @@
import { SortOptions } from "../series-filter";
import {FilterStatement} from "./filter-statement";
import {FilterCombination} from "./filter-combination";
export interface SeriesFilterV2 {
name?: string;
statements: Array<FilterStatement>;
combination: FilterCombination;
sortOptions?: SortOptions;
limitTo: number;
}

View file

@ -1,8 +1,10 @@
import {Series} from "../series";
export interface PageBookmark {
id: number;
page: number;
seriesId: number;
volumeId: number;
chapterId: number;
fileName: string;
}
series: Series;
}

View file

@ -55,7 +55,6 @@ export class AccountService {
private messageHub: MessageHubService, private themeService: ThemeService) {
messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate),
map(evt => evt.payload as UserUpdateEvent),
tap(u => console.log('user update: ', u)),
filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username),
switchMap(() => this.refreshAccount()))
.subscribe(() => {});
@ -307,7 +306,6 @@ export class AccountService {
private refreshAccount() {
console.log('Refreshing account');
if (this.currentUser === null || this.currentUser === undefined) return of();
return this.httpClient.get<User>(this.baseUrl + 'account/refresh-account').pipe(map((user: User) => {
if (user) {

View file

@ -92,6 +92,8 @@ export enum Action {
* Removes the Series from On Deck inclusion
*/
RemoveFromOnDeck = 19,
AddRuleGroup = 20,
RemoveRuleGroup = 21
}
export interface ActionItem<T> {
@ -178,6 +180,15 @@ export class ActionFactoryService {
return this.applyCallbackToList(this.bookmarkActions, callback);
}
getMetadataFilterActions(callback: (action: ActionItem<any>, data: any) => void) {
const actions = [
{title: 'add-rule-group-and', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback},
{title: 'add-rule-group-or', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback},
{title: 'remove-rule-group', action: Action.RemoveRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback},
];
return this.applyCallbackToList(actions, callback);
}
dummyCallback(action: ActionItem<any>, data: any) {}
filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) {
@ -236,6 +247,14 @@ export class ActionFactoryService {
requiresAdmin: true,
children: [],
},
{
action: Action.Delete,
title: 'delete',
callback: this.dummyCallback,
requiresAdmin: false,
class: 'danger',
children: [],
},
];
this.seriesActions = [

View file

@ -41,4 +41,8 @@ export class CollectionTagService {
tagNameExists(name: string) {
return this.httpClient.get<boolean>(this.baseUrl + 'collection/name-exists?name=' + name);
}
deleteTag(tagId: number) {
return this.httpClient.delete<string>(this.baseUrl + 'collection?tagId=' + tagId, TextResonse);
}
}

View file

@ -1,16 +1,23 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {map, tap} from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Genre } from '../_models/metadata/genre';
import { AgeRating } from '../_models/metadata/age-rating';
import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
import { Language } from '../_models/metadata/language';
import { PublicationStatusDto } from '../_models/metadata/publication-status-dto';
import { Person } from '../_models/metadata/person';
import { Tag } from '../_models/tag';
import { TextResonse } from '../_types/text-response';
import {of, ReplaySubject, switchMap} from 'rxjs';
import {environment} from 'src/environments/environment';
import {Genre} from '../_models/metadata/genre';
import {AgeRating} from '../_models/metadata/age-rating';
import {AgeRatingDto} from '../_models/metadata/age-rating-dto';
import {Language} from '../_models/metadata/language';
import {PublicationStatusDto} from '../_models/metadata/publication-status-dto';
import {Person, PersonRole} from '../_models/metadata/person';
import {Tag} from '../_models/tag';
import {TextResonse} from '../_types/text-response';
import {FilterComparison} from '../_models/metadata/v2/filter-comparison';
import {FilterField} from '../_models/metadata/v2/filter-field';
import {Router} from "@angular/router";
import {SortField} from "../_models/metadata/series-filter";
import {FilterCombination} from "../_models/metadata/v2/filter-combination";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {FilterStatement} from "../_models/metadata/v2/filter-statement";
@Injectable({
providedIn: 'root'
@ -18,25 +25,9 @@ import { TextResonse } from '../_types/text-response';
export class MetadataService {
baseUrl = environment.apiUrl;
private ageRatingTypes: {[key: number]: string} | undefined = undefined;
private validLanguages: Array<Language> = [];
constructor(private httpClient: HttpClient) { }
getAgeRating(ageRating: AgeRating) {
if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {
return of(this.ageRatingTypes[ageRating]);
}
return this.httpClient.get<string>(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, TextResonse).pipe(map(ratingString => {
if (this.ageRatingTypes === undefined) {
this.ageRatingTypes = {};
}
this.ageRatingTypes[ageRating] = ratingString;
return this.ageRatingTypes[ageRating];
}));
}
constructor(private httpClient: HttpClient, private router: Router) { }
getAllAgeRatings(libraries?: Array<number>) {
let method = 'metadata/age-ratings'
@ -78,6 +69,7 @@ export class MetadataService {
return this.httpClient.get<Array<Language>>(this.baseUrl + method);
}
/**
* All the potential language tags there can be
*/
@ -97,7 +89,37 @@ export class MetadataService {
return this.httpClient.get<Array<Person>>(this.baseUrl + method);
}
getChapterSummary(chapterId: number) {
return this.httpClient.get<string>(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, TextResonse);
getAllPeopleByRole(role: PersonRole) {
return this.httpClient.get<Array<Person>>(this.baseUrl + 'metadata/people-by-role?role=' + role);
}
// getChapterSummary(chapterId: number) {
// return this.httpClient.get<string>(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, TextResonse);
// }
createDefaultFilterDto(): SeriesFilterV2 {
return {
statements: [] as FilterStatement[],
combination: FilterCombination.And,
limitTo: 0,
sortOptions: {
isAscending: true,
sortField: SortField.SortName
}
};
}
createDefaultFilterStatement(field: FilterField = FilterField.SeriesName, comparison = FilterComparison.Equal, value = '') {
return {
comparison: comparison,
field: field,
value: value
};
}
updateFilter(arr: Array<FilterStatement>, index: number, filterStmt: FilterStatement) {
arr[index].comparison = filterStmt.comparison;
arr[index].field = filterStmt.field;
arr[index].value = filterStmt.value ? filterStmt.value + '' : '';
}
}

View file

@ -10,7 +10,6 @@ import { MangaFormat } from '../_models/manga-format';
import { BookmarkInfo } from '../_models/manga-reader/bookmark-info';
import { PageBookmark } from '../_models/readers/page-bookmark';
import { ProgressBookmark } from '../_models/readers/progress-bookmark';
import { SeriesFilter } from '../_models/metadata/series-filter';
import { UtilityService } from '../shared/_services/utility.service';
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
import { FileDimension } from '../manga-reader/_models/file-dimension';
@ -19,6 +18,7 @@ import { TextResonse } from '../_types/text-response';
import { AccountService } from './account.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {PersonalToC} from "../_models/readers/personal-toc";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
export const CHAPTER_ID_DOESNT_EXIST = -1;
export const CHAPTER_ID_NOT_FETCHED = -2;
@ -70,12 +70,8 @@ export class ReaderService {
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page});
}
getAllBookmarks(filter: SeriesFilter | undefined) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, undefined, undefined);
const data = this.filterUtilityService.createSeriesFilter(filter);
return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', data);
getAllBookmarks(filter: SeriesFilterV2 | undefined) {
return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter);
}
getBookmarks(chapterId: number) {

View file

@ -12,12 +12,12 @@ import { PaginatedResult } from '../_models/pagination';
import { Series } from '../_models/series';
import { RelatedSeries } from '../_models/series-detail/related-series';
import { SeriesDetail } from '../_models/series-detail/series-detail';
import { SeriesFilter } from '../_models/metadata/series-filter';
import { SeriesGroup } from '../_models/series-group';
import { SeriesMetadata } from '../_models/metadata/series-metadata';
import { Volume } from '../_models/volume';
import { ImageService } from './image.service';
import { TextResonse } from '../_types/text-response';
import { SeriesFilterV2 } from '../_models/metadata/v2/series-filter-v2';
import {UserReview} from "../_single-module/review-card/user-review";
import {Rating} from "../_models/rating";
import {Recommendation} from "../_models/series-detail/recommendation";
@ -32,26 +32,26 @@ export class SeriesService {
paginatedSeriesForTagsResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>();
constructor(private httpClient: HttpClient, private imageService: ImageService,
private utilityService: UtilityService, private filterUtilityService: FilterUtilitiesService) { }
private utilityService: UtilityService) { }
getAllSeries(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = this.filterUtilityService.createSeriesFilter(filter);
const data = filter || {};
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series/all', data, {observe: 'response', params}).pipe(
map((response: any) => {
return this.utilityService.createPaginatedResult(response, this.paginatedResults);
})
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series/all-v2', data, {observe: 'response', params}).pipe(
map((response: any) => {
return this.utilityService.createPaginatedResult(response, this.paginatedResults);
})
);
}
getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = this.filterUtilityService.createSeriesFilter(filter);
const data = filter || {};
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series/v2', data, {observe: 'response', params}).pipe(
map((response: any) => {
return this.utilityService.createPaginatedResult(response, this.paginatedResults);
})
@ -102,12 +102,12 @@ export class SeriesService {
return this.httpClient.post<void>(this.baseUrl + 'reader/mark-unread', {seriesId});
}
getRecentlyAdded(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
const data = this.filterUtilityService.createSeriesFilter(filter);
getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<Series[]>(this.baseUrl + 'series/recently-added?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
const data = filter || {};
return this.httpClient.post<Series[]>(this.baseUrl + 'series/recently-added-v2', data, {observe: 'response', params}).pipe(
map(response => {
return this.utilityService.createPaginatedResult(response, new PaginatedResult<Series[]>());
})
@ -118,13 +118,12 @@ export class SeriesService {
return this.httpClient.post<SeriesGroup[]>(this.baseUrl + 'series/recently-updated-series', {});
}
getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter): Observable<PaginatedResult<Series[]>> {
const data = this.filterUtilityService.createSeriesFilter(filter);
getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2): Observable<PaginatedResult<Series[]>> {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = filter || {};
return this.httpClient.post<Series[]>(this.baseUrl + 'want-to-read/', data, {observe: 'response', params}).pipe(
return this.httpClient.post<Series[]>(this.baseUrl + 'want-to-read/v2', data, {observe: 'response', params}).pipe(
map(response => {
return this.utilityService.createPaginatedResult(response, new PaginatedResult<Series[]>());
}));
@ -137,11 +136,10 @@ export class SeriesService {
}));
}
getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
const data = this.filterUtilityService.createSeriesFilter(filter);
getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
const data = filter || {};
return this.httpClient.post<Series[]>(this.baseUrl + 'series/on-deck?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
map(response => {

View file

@ -112,7 +112,7 @@ export class StatisticsService {
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days);
}
getDayBreakdown() {
return this.httpClient.get<Array<StatCount<DayOfWeek>>>(this.baseUrl + 'stats/day-breakdown');
getDayBreakdown( userId = 0) {
return this.httpClient.get<Array<StatCount<DayOfWeek>>>(this.baseUrl + 'stats/day-breakdown?userId=' + userId);
}
}

Some files were not shown because too many files have changed in this diff Show more