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:
parent
bdaadbecfc
commit
979508047c
221 changed files with 23403 additions and 6863 deletions
58
.github/workflows/build-and-test.yml
vendored
58
.github/workflows/build-and-test.yml
vendored
|
@ -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
|
||||
|
|
28
API.Tests/Extensions/SeriesFilterTests.cs
Normal file
28
API.Tests/Extensions/SeriesFilterTests.cs
Normal 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
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
{
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
59
API/Controllers/FilterController.cs
Normal file
59
API/Controllers/FilterController.cs
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}"),
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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")]
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")]
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
|
|
7
API/DTOs/Filtering/v2/FilterCombination.cs
Normal file
7
API/DTOs/Filtering/v2/FilterCombination.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace API.DTOs.Filtering.v2;
|
||||
|
||||
public enum FilterCombination
|
||||
{
|
||||
Or = 0,
|
||||
And = 1
|
||||
}
|
56
API/DTOs/Filtering/v2/FilterComparision.cs
Normal file
56
API/DTOs/Filtering/v2/FilterComparision.cs
Normal 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,
|
||||
}
|
40
API/DTOs/Filtering/v2/FilterField.cs
Normal file
40
API/DTOs/Filtering/v2/FilterField.cs
Normal 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
|
||||
}
|
8
API/DTOs/Filtering/v2/FilterStatementDto.cs
Normal file
8
API/DTOs/Filtering/v2/FilterStatementDto.cs
Normal 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; }
|
||||
}
|
30
API/DTOs/Filtering/v2/FilterV2Dto.cs
Normal file
30
API/DTOs/Filtering/v2/FilterV2Dto.cs
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
@ -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 =>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
36
API/Entities/Enums/CoverImageSize.cs
Normal file
36
API/Entities/Enums/CoverImageSize.cs
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
59
API/Extensions/QueryExtensions/Filtering/BookmarkSort.cs
Normal file
59
API/Extensions/QueryExtensions/Filtering/BookmarkSort.cs
Normal 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;
|
||||
}
|
||||
}
|
674
API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs
Normal file
674
API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
53
API/Extensions/QueryExtensions/Filtering/SeriesSort.cs
Normal file
53
API/Extensions/QueryExtensions/Filtering/SeriesSort.cs
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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>();
|
||||
|
|
78
API/Helpers/Converters/FilterFieldValueConverter.cs
Normal file
78
API/Helpers/Converters/FilterFieldValueConverter.cs
Normal 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")
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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
163
API/I18N/cs.json
Normal 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"
|
||||
}
|
|
@ -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",
|
||||
|
|
157
API/I18N/es.json
157
API/I18N/es.json
|
@ -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"
|
||||
}
|
||||
|
|
137
API/I18N/fr.json
137
API/I18N/fr.json
|
@ -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
23
API/I18N/he.json
Normal 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": "אירעה תקלה בעת עדכון הגבלת גיל"
|
||||
}
|
|
@ -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
163
API/I18N/ko.json
Normal 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": "컬렉션이 삭제되었습니다"
|
||||
}
|
|
@ -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
60
API/I18N/pl.json
Normal 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"
|
||||
}
|
|
@ -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
162
API/I18N/pt_BR.json
Normal 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"
|
||||
}
|
|
@ -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": "ดูรายการต้องการอ่าน"
|
||||
}
|
||||
|
|
|
@ -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": "收藏已删除"
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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() })
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
35
UI/Web/package-lock.json
generated
35
UI/Web/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export interface Language {
|
||||
isoCode: string;
|
||||
title: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
4
UI/Web/src/app/_models/metadata/v2/filter-combination.ts
Normal file
4
UI/Web/src/app/_models/metadata/v2/filter-combination.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export enum FilterCombination {
|
||||
Or = 0,
|
||||
And = 1
|
||||
}
|
46
UI/Web/src/app/_models/metadata/v2/filter-comparison.ts
Normal file
46
UI/Web/src/app/_models/metadata/v2/filter-comparison.ts
Normal 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,
|
||||
}
|
35
UI/Web/src/app/_models/metadata/v2/filter-field.ts
Normal file
35
UI/Web/src/app/_models/metadata/v2/filter-field.ts
Normal 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[];
|
8
UI/Web/src/app/_models/metadata/v2/filter-statement.ts
Normal file
8
UI/Web/src/app/_models/metadata/v2/filter-statement.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { FilterComparison } from "./filter-comparison";
|
||||
import { FilterField } from "./filter-field";
|
||||
|
||||
export interface FilterStatement {
|
||||
comparison: FilterComparison;
|
||||
field: FilterField;
|
||||
value: string;
|
||||
}
|
11
UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts
Normal file
11
UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 + '' : '';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue