Merge branch 'refs/heads/develop' into feature/user-fonts

# Conflicts:
#	API/Services/TaskScheduler.cs
This commit is contained in:
Fesaa 2024-07-08 21:19:07 +02:00
commit 1f2ea8f59d
No known key found for this signature in database
GPG key ID: 9EA789150BEE0E27
100 changed files with 3553 additions and 1416 deletions

View file

@ -12,9 +12,9 @@
<LangVersion>latestmajor</LangVersion>
</PropertyGroup>
<!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
<!-- <Exec Command="swagger tofile &#45;&#45;output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />-->
<!-- </Target>-->
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">
<Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />
</Target>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>
@ -53,8 +53,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="32.0.3" />
<PackageReference Include="MailKit" Version="4.6.0" />
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.7.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -65,44 +65,44 @@
<PackageReference Include="ExCSS" Version="4.2.5" />
<PackageReference Include="Flurl" Version="3.0.7" />
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hangfire" Version="1.8.12" />
<PackageReference Include="Hangfire.InMemory" Version="0.10.0" />
<PackageReference Include="Hangfire" Version="1.8.14" />
<PackageReference Include="Hangfire.InMemory" Version="0.10.3" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.61" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.12" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="2.4.1" />
<PackageReference Include="NetVips.Native" Version="8.15.2" />
<PackageReference Include="NReco.Logging.File" Version="1.2.0" />
<PackageReference Include="NReco.Logging.File" Version="1.2.1" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.37.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.26.0.92422">
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.28.0.94264">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.6.0" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.6.2" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.22" />
<PackageReference Include="System.Drawing.Common" Version="8.0.6" />
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
</ItemGroup>

View file

@ -30,25 +30,25 @@ public class CblController : BaseApiController
/// The first step in a cbl import. This validates the cbl file that if an import occured, would it be successful.
/// If this returns errors, the cbl will always be rejected by Kavita.
/// </summary>
/// <param name="file">FormBody with parameter name of cbl</param>
/// <param name="cbl">FormBody with parameter name of cbl</param>
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
/// <returns></returns>
[HttpPost("validate")]
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl([FromForm(Name = "cbl")] IFormFile file,
[FromForm(Name = "comicVineMatching")] bool comicVineMatching = false)
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl(IFormFile cbl, bool comicVineMatching = false)
{
var userId = User.GetUserId();
try
{
var cbl = await SaveAndLoadCblFile(file);
var importSummary = await _readingListService.ValidateCblFile(userId, cbl, comicVineMatching);
importSummary.FileName = file.FileName;
var cblReadingList = await SaveAndLoadCblFile(cbl);
var importSummary = await _readingListService.ValidateCblFile(userId, cblReadingList, comicVineMatching);
importSummary.FileName = cbl.FileName;
return Ok(importSummary);
}
catch (ArgumentNullException)
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
FileName = cbl.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{
@ -63,7 +63,7 @@ public class CblController : BaseApiController
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
FileName = cbl.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{
@ -80,25 +80,26 @@ public class CblController : BaseApiController
/// <summary>
/// Performs the actual import (assuming dryRun = false)
/// </summary>
/// <param name="file">FormBody with parameter name of cbl</param>
/// <param name="cbl">FormBody with parameter name of cbl</param>
/// <param name="dryRun">If true, will only emulate the import but not perform. This should be done to preview what will happen</param>
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
/// <returns></returns>
[HttpPost("import")]
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl([FromForm(Name = "cbl")] IFormFile file,
[FromForm(Name = "dryRun")] bool dryRun = false, [FromForm(Name = "comicVineMatching")] bool comicVineMatching = false)
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl(IFormFile cbl, bool dryRun = false, bool comicVineMatching = false)
{
try
{
var userId = User.GetUserId();
var cbl = await SaveAndLoadCblFile(file);
var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cbl, dryRun, comicVineMatching);
importSummary.FileName = file.FileName;
var cblReadingList = await SaveAndLoadCblFile(cbl);
var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cblReadingList, dryRun, comicVineMatching);
importSummary.FileName = cbl.FileName;
return Ok(importSummary);
} catch (ArgumentNullException)
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
FileName = cbl.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{
@ -113,7 +114,7 @@ public class CblController : BaseApiController
{
return Ok(new CblImportSummaryDto()
{
FileName = file.FileName,
FileName = cbl.FileName,
Success = CblImportResult.Fail,
Results = new List<CblBookResult>()
{

View file

@ -21,7 +21,6 @@ using AutoMapper;
using EasyCaching.Core;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration.UserSecrets;
using Microsoft.Extensions.Logging;
using TaskScheduler = API.Services.TaskScheduler;
@ -134,7 +133,7 @@ public class LibraryController : BaseApiController
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
await _libraryWatcher.RestartWatching();
_taskScheduler.ScanLibrary(library.Id);
await _taskScheduler.ScanLibrary(library.Id);
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
MessageFactory.LibraryModifiedEvent(library.Id, "create"), false);
await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate,
@ -292,7 +291,7 @@ public class LibraryController : BaseApiController
public async Task<ActionResult> Scan(int libraryId, bool force = false)
{
if (libraryId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "libraryId"));
_taskScheduler.ScanLibrary(libraryId, force);
await _taskScheduler.ScanLibrary(libraryId, force);
return Ok();
}
@ -500,7 +499,7 @@ public class LibraryController : BaseApiController
if (originalFoldersCount != dto.Folders.Count() || typeUpdate)
{
await _libraryWatcher.RestartWatching();
_taskScheduler.ScanLibrary(library.Id);
await _taskScheduler.ScanLibrary(library.Id);
}
if (folderWatchingUpdate)

View file

@ -591,9 +591,22 @@ public class OpdsController : BaseApiController
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList();
foreach (var item in items)
{
feed.Entries.Add(
CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}",
item.Summary ?? string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl));
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId);
// If there is only one file underneath, add a direct acquisition link, otherwise add a subsection
if (chapterDto != null && chapterDto.Files.Count == 1)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(item.SeriesId, userId);
feed.Entries.Add(await CreateChapterWithFile(userId, item.SeriesId, item.VolumeId, item.ChapterId,
chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl));
}
else
{
feed.Entries.Add(
CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}",
item.Summary ?? string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl));
}
}
return CreateXmlResult(SerializeXml(feed));
}
@ -855,6 +868,7 @@ public class OpdsController : BaseApiController
SetFeedId(feed, $"series-{series.Id}");
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}"));
var chapterDict = new Dictionary<int, short>();
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
foreach (var volume in seriesDetail.Volumes)
{
@ -866,6 +880,7 @@ public class OpdsController : BaseApiController
var chapterDto = _mapper.Map<ChapterDto>(chapter);
foreach (var mangaFile in chapter.Files)
{
chapterDict.Add(chapterId, 0);
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map<MangaFileDto>(mangaFile), series,
chapterDto, apiKey, prefix, baseUrl));
}
@ -879,7 +894,7 @@ public class OpdsController : BaseApiController
chapters = seriesDetail.Chapters;
}
foreach (var chapter in chapters.Where(c => !c.IsSpecial))
foreach (var chapter in chapters.Where(c => !c.IsSpecial && !chapterDict.ContainsKey(c.Id)))
{
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
var chapterDto = _mapper.Map<ChapterDto>(chapter);
@ -914,15 +929,15 @@ public class OpdsController : BaseApiController
var (baseUrl, prefix) = await GetPrefix();
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
var chapters =
(await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId))
.OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters);
// var chapters =
// (await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId))
// .OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast);
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ",
$"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix);
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s");
foreach (var chapter in chapters)
foreach (var chapter in volume.Chapters)
{
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id, ChapterIncludes.Files | ChapterIncludes.People);
foreach (var mangaFile in chapterDto.Files)
@ -1111,7 +1126,8 @@ public class OpdsController : BaseApiController
};
}
private async Task<FeedEntry> CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
private async Task<FeedEntry> CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId,
MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
{
var fileSize =
mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) :
@ -1120,7 +1136,7 @@ public class OpdsController : BaseApiController
var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath);
var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath));
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, await GetUser(apiKey));
var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId);
var title = $"{series.Name}";

View file

@ -491,4 +491,59 @@ public class ReadingListController : BaseApiController
if (string.IsNullOrEmpty(name)) return true;
return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name));
}
/// <summary>
/// Promote/UnPromote multiple reading lists in one go. Will only update the authenticated user's reading lists and will only work if the user has promotion role
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("promote-multiple")]
public async Task<ActionResult> PromoteMultipleReadingLists(PromoteReadingListsDto dto)
{
// This needs to take into account owner as I can select other users cards
var userId = User.GetUserId();
if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole))
{
return BadRequest(await _localizationService.Translate(userId, "permission-denied"));
}
var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListsByIds(dto.ReadingListIds);
foreach (var readingList in readingLists)
{
if (readingList.AppUserId != userId) continue;
readingList.Promoted = dto.Promoted;
_unitOfWork.ReadingListRepository.Update(readingList);
}
if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Delete multiple reading lists in one go
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("delete-multiple")]
public async Task<ActionResult> DeleteMultipleReadingLists(DeleteReadingListsDto dto)
{
// This needs to take into account owner as I can select other users cards
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ReadingLists);
if (user == null) return Unauthorized();
user.ReadingLists = user.ReadingLists.Where(uc => !dto.ReadingListIds.Contains(uc.Id)).ToList();
_unitOfWork.UserRepository.Update(user);
if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();
return Ok();
}
}

View file

@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
@ -7,11 +10,15 @@ using API.DTOs.Statistics;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.Services.Plus;
using API.Services.Tasks.Scanner.Parser;
using CsvHelper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using MimeTypes;
namespace API.Controllers;
@ -24,15 +31,18 @@ public class StatsController : BaseApiController
private readonly UserManager<AppUser> _userManager;
private readonly ILocalizationService _localizationService;
private readonly ILicenseService _licenseService;
private readonly IDirectoryService _directoryService;
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork,
UserManager<AppUser> userManager, ILocalizationService localizationService, ILicenseService licenseService)
UserManager<AppUser> userManager, ILocalizationService localizationService,
ILicenseService licenseService, IDirectoryService directoryService)
{
_statService = statService;
_unitOfWork = unitOfWork;
_userManager = userManager;
_localizationService = localizationService;
_licenseService = licenseService;
_directoryService = directoryService;
}
[HttpGet("user/{userId}/read")]
@ -111,6 +121,34 @@ public class StatsController : BaseApiController
return Ok(await _statService.GetFileBreakdown());
}
/// <summary>
/// Generates a csv of all file paths for a given extension
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpGet("server/file-extension")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult> DownloadFilesByExtension(string fileExtension)
{
if (!Regex.IsMatch(fileExtension, Parser.SupportedExtensions))
{
return BadRequest("Invalid file format");
}
var tempFile = Path.Join(_directoryService.TempDirectory,
$"file_breakdown_{fileExtension.Replace(".", string.Empty)}.csv");
if (!_directoryService.FileSystem.File.Exists(tempFile))
{
var results = await _statService.GetFilesByExtension(fileExtension);
await using var writer = new StreamWriter(tempFile);
await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
await csv.WriteRecordsAsync(results);
}
return PhysicalFile(tempFile, MimeTypeMap.GetMimeType(Path.GetExtension(tempFile)),
System.Web.HttpUtility.UrlEncode(Path.GetFileName(tempFile)), true);
}
/// <summary>
/// Returns reading history events for a give or all users, broken up by day, and format

View file

@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs.ReadingLists;
public class DeleteReadingListsDto
{
[Required]
public IList<int> ReadingListIds { get; set; }
}

View file

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs.ReadingLists;
public class PromoteReadingListsDto
{
public IList<int> ReadingListIds { get; init; }
public bool Promoted { get; init; }
}

View file

@ -0,0 +1,15 @@
using CsvHelper.Configuration.Attributes;
namespace API.DTOs.Stats;
/// <summary>
/// Excel export for File Extension Report
/// </summary>
public class FileExtensionExportDto
{
[Name("Path")]
public string FilePath { get; set; }
[Name("Extension")]
public string Extension { get; set; }
}

View file

@ -13,7 +13,7 @@ public class UpdateNotificationDto
/// Semver of the release version
/// <example>0.4.3</example>
/// </summary>
public required string UpdateVersion { get; init; }
public required string UpdateVersion { get; set; }
/// <summary>
/// Release body in HTML
/// </summary>

View file

@ -21,10 +21,11 @@ public static class MigrateEmailTemplates
var files = directoryService.GetFiles(directoryService.CustomizedTemplateDirectory);
if (files.Any())
{
logger.LogCritical("Running MigrateEmailTemplates migration - Completed. This is not an error");
return;
}
logger.LogCritical("Running MigrateEmailTemplates migration - Please be patient, this may take some time. This is not an error");
// Write files to directory
await DownloadAndWriteToFile(EmailChange, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailChange.html"), logger);
await DownloadAndWriteToFile(EmailConfirm, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailConfirm.html"), logger);
@ -33,8 +34,7 @@ public static class MigrateEmailTemplates
await DownloadAndWriteToFile(EmailTest, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailTest.html"), logger);
logger.LogCritical("Running MigrateEmailTemplates migration - Please be patient, this may take some time. This is not an error");
logger.LogCritical("Running MigrateEmailTemplates migration - Completed. This is not an error");
}
private static async Task DownloadAndWriteToFile(string url, string filePath, ILogger<Program> logger)

View file

@ -49,6 +49,7 @@ public interface IReadingListRepository
Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<int> RemoveReadingListsWithoutSeries();
Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items);
Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> ids, ReadingListIncludes includes = ReadingListIncludes.Items);
}
public class ReadingListRepository : IReadingListRepository
@ -156,6 +157,15 @@ public class ReadingListRepository : IReadingListRepository
.FirstOrDefaultAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized) && x.AppUserId == userId);
}
public async Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> ids, ReadingListIncludes includes = ReadingListIncludes.Items)
{
return await _context.ReadingList
.Where(c => ids.Contains(c.Id))
.Includes(includes)
.AsSplitQuery()
.ToListAsync();
}
public void Remove(ReadingListItem item)
{
_context.ReadingListItem.Remove(item);

View file

@ -23,6 +23,10 @@ public enum VolumeIncludes
Chapters = 2,
People = 4,
Tags = 8,
/// <summary>
/// This will include Chapters by default
/// </summary>
Files = 16
}
public interface IVolumeRepository
@ -34,7 +38,7 @@ public interface IVolumeRepository
Task<string?> GetVolumeCoverImageAsync(int volumeId);
Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds);
Task<IList<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId, VolumeIncludes includes = VolumeIncludes.Chapters);
Task<Volume?> GetVolumeAsync(int volumeId);
Task<Volume?> GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files);
Task<VolumeDto?> GetVolumeDtoAsync(int volumeId, int userId);
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
Task<IEnumerable<Volume>> GetVolumes(int seriesId);
@ -173,11 +177,10 @@ public class VolumeRepository : IVolumeRepository
/// </summary>
/// <param name="volumeId"></param>
/// <returns></returns>
public async Task<Volume?> GetVolumeAsync(int volumeId)
public async Task<Volume?> GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files)
{
return await _context.Volume
.Include(vol => vol.Chapters)
.ThenInclude(c => c.Files)
.Includes(includes)
.AsSplitQuery()
.SingleOrDefaultAsync(vol => vol.Id == volumeId);
}

View file

@ -65,22 +65,28 @@ public static class IncludesExtensions
public static IQueryable<Volume> Includes(this IQueryable<Volume> queryable,
VolumeIncludes includes)
{
if (includes.HasFlag(VolumeIncludes.Chapters))
if (includes.HasFlag(VolumeIncludes.Files))
{
queryable = queryable.Include(vol => vol.Chapters);
queryable = queryable
.Include(vol => vol.Chapters.OrderBy(c => c.SortOrder))
.ThenInclude(c => c.Files);
} else if (includes.HasFlag(VolumeIncludes.Chapters))
{
queryable = queryable
.Include(vol => vol.Chapters.OrderBy(c => c.SortOrder));
}
if (includes.HasFlag(VolumeIncludes.People))
{
queryable = queryable
.Include(vol => vol.Chapters)
.Include(vol => vol.Chapters.OrderBy(c => c.SortOrder))
.ThenInclude(c => c.People);
}
if (includes.HasFlag(VolumeIncludes.Tags))
{
queryable = queryable
.Include(vol => vol.Chapters)
.Include(vol => vol.Chapters.OrderBy(c => c.SortOrder))
.ThenInclude(c => c.Tags);
}
@ -104,7 +110,7 @@ public static class IncludesExtensions
{
query = query
.Include(s => s.Volumes)
.ThenInclude(v => v.Chapters);
.ThenInclude(v => v.Chapters.OrderBy(c => c.SortOrder));
}
if (includeFlags.HasFlag(SeriesIncludes.Related))

View file

@ -22,6 +22,7 @@ using API.DTOs.Search;
using API.DTOs.SeriesDetail;
using API.DTOs.Settings;
using API.DTOs.SideNav;
using API.DTOs.Stats;
using API.DTOs.Theme;
using API.Entities;
using API.Entities.Enums;
@ -117,6 +118,10 @@ public class AutoMapperProfiles : Profile
opt =>
opt.MapFrom(src =>
src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Imprints,
opt =>
opt.MapFrom(src =>
src.People.Where(p => p.Role == PersonRole.Imprint).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Letterers,
opt =>
opt.MapFrom(src =>
@ -329,5 +334,8 @@ public class AutoMapperProfiles : Profile
opt.MapFrom(src => ReviewService.GetCharacters(src.Body)));
CreateMap<ExternalRecommendation, ExternalSeriesDto>();
CreateMap<MangaFile, FileExtensionExportDto>();
}
}

42
API/I18N/da.json Normal file
View file

@ -0,0 +1,42 @@
{
"locked-out": "Du er låst ude, grundet for mange forsøg. Vent venligt 10 minutter.",
"disabled-account": "Din konto er deaktiveret. Kontakt server administratoren.",
"register-user": "Der opstod en fejl under brugerregistreringen",
"validate-email": "Der opstod en fejl under validering af emailen: {0}",
"confirm-email": "Du skal bekræfte din email først",
"confirm-token-gen": "Der opstod en fejl under oprettelsen af en bekræftigelsestoken",
"invalid-password": "Ugyldigt kodeord",
"username-taken": "Brugernavenet er allerede taget",
"generate-token": "Der opstod en fejl under generering af bekræftigelsesemailtoken. Se logbeskederne",
"generic-user-update": "Der opstod en fejl ved opdatering af brugeren",
"user-already-registered": "Brugeren er allerede registreret som {0}",
"manual-setup-fail": "Manuel opsætning kunne ikke færdiggøres. Venligst annuller, og genopret invitationen",
"not-accessible-password": "Din server er ikke tilgængelig. Et link til nulstilling af kodeord kan findes i loggen",
"user-migration-needed": "Denne bruger skal migreres. Få dem til at logge ud og ind igen for at starte migreringsflowet",
"invalid-username": "Ugyldigt brugernavn",
"generic-invite-email": "Der opstod en fejl under et forsøg på at sende invitationmail'en igen",
"check-updates": "Tjek for opdateringer",
"update-yearly-stats": "Opdater årlige statistikker",
"no-user": "Brugeren eksistere ikke",
"user-already-confirmed": "Brugeren er allerede bekræftet",
"admin-already-exists": "Administrator eksistere allerede",
"chapter-doesnt-exist": "Kapitel findes ikke",
"not-accessible": "Din server er ikke ekstern tilgængelig",
"email-sent": "Email afsendt",
"book-num": "Bog {0}",
"issue-num": "Nummer {0}{1}",
"chapter-num": "Kapitel {0}",
"backup": "Backup",
"age-restriction-update": "Der opstod en fejl ved opdateringen af aldersbegrænsningen",
"user-already-invited": "Brugeren er allerede inviteret med denne mail, og har ikke accepteret invitationen endnu.",
"invalid-email-confirmation": "Ugyldig mailbekræftigelse",
"password-updated": "Kodeord opdateret",
"denied": "Ikke tilladt",
"permission-denied": "Du har ikke tilladelse til at udføre denne operation",
"nothing-to-do": "Intet at lave",
"password-required": "Du skal skrive dit kodeord for at ændre din konto, medmindre du er en administrator",
"invalid-payload": "Ugyldig payload",
"invalid-token": "Ugyldig token",
"unable-to-reset-key": "Noget gik galt, det var ikke muligt at nulstille nøglen",
"share-multiple-emails": "Du kan ikke dele emails henover flere kontoer"
}

View file

@ -38,7 +38,7 @@
"generic-error": "Es ist ein Fehler ist aufgetreten, bitte versuchen Sie es erneut",
"device-doesnt-exist": "Das Gerät existiert nicht",
"generic-device-create": "Beim Erstellen des Geräts ist ein Fehler aufgetreten",
"send-to-kavita-email": "Das Senden an Gerät kann nicht ohne konfigurierten E-Mail-Dienst durchgeführt werden.",
"send-to-kavita-email": "Das Senden an das Gerät kann nicht mit dem E-Mail-Dienst von Kavita durchgeführt werden. Bitte konfigurieren Sie Ihren eigenen.",
"send-to-device-status": "Übertrage Dateien auf Ihr Gerät",
"series-doesnt-exist": "Die Serie existiert nicht",
"volume-doesnt-exist": "Der Band existiert nicht",
@ -111,7 +111,7 @@
"user-no-access-library-from-series": "Der Benutzer hat keinen Zugang zu der Bibliothek, der zu dieser Serie gehört",
"series-restricted-age-restriction": "Benutzer darf diese Serie aufgrund von Altersbeschränkungen nicht sehen",
"book-num": "Buch {0}",
"issue-num": "Fehler {0}{1}",
"issue-num": "Ausgabe {0}{1}",
"chapter-num": "Kapitel {0}",
"reading-list-position": "Position konnte nicht aktualisiert werden",
"libraries-restricted": "Benutzer hat keinen Zugriff auf jegliche Bibliothek",
@ -178,7 +178,7 @@
"unable-to-reset-k+": "Aufgrund eines Fehlers konnte die Kavita+ Lizenz nicht zurückgesetzt werden. Kontaktieren Sie den Kavita+ Support",
"email-not-enabled": "Der Mailversand ist auf diesem Server nicht aktiviert. Sie können diese Aktion nicht durchführen.",
"invalid-email": "Die für den Benutzer hinterlegte E-Mail ist ungültig. Links finden Sie in den Logs.",
"send-to-unallowed": "Sie können nicht an ein Gerät senden, das nicht Ihnen gehört.",
"send-to-unallowed": "Sie können nicht an ein fremdes Gerät senden.",
"send-to-size-limit": "Die Datei(en), die Sie senden möchten, ist/sind zu groß für Ihren E-Mail-Service",
"check-updates": "Updates überprüfen",
"email-settings-invalid": "E-Mail-Einstellungen fehlen Informationen. Stellen Sie sicher, dass alle E-Mail-Einstellungen gespeichert sind.",

View file

@ -1,6 +1,6 @@
{
"confirm-email": "Esmalt pead oma e-posti kinnitama",
"locked-out": "Oled liiga paljude sisselogimiskatsete tõttu süsteemist keelatud. Palun oota 10 minutit.",
"locked-out": "Sinu konto on liiga paljude ebaõnnestunud sisselogimiskatsete tõttu süsteemis piiratud. Palun oota 10 minutit.",
"disabled-account": "Su konto on keelatud. Võta ühendust serveri administraatoriga.",
"register-user": "Kasutaja registreerimisel läks midagi valesti",
"validate-email": "Teie e-posti kinnitamisel ilmnes probleem: {0}",
@ -15,5 +15,147 @@
"generate-token": "Kinnitusmeili koodi loomisel ilmnes probleem. Vaata logisid",
"confirm-token-gen": "Kinnituskoodi loomisel ilmnes probleem",
"nothing-to-do": "Pole midagi teha",
"age-restriction-update": "Vanusepiirangu värskendamisel ilmnes viga"
"age-restriction-update": "Vanusepiirangu värskendamisel ilmnes viga",
"manual-setup-fail": "Mitteautomaatne seadistus ei suuda lõpetada. Palun katkestage ja looge kutse uuesti",
"user-already-registered": "Kasutaja on juba registreeritud kui {0}",
"user-already-confirmed": "Kasutaja on juba kinnitatud",
"generic-user-update": "Kasutajaandmete uuendamisel tekkis viga",
"user-already-invited": "Selle e-posti aadressiga kasutaja on juba kutsutud ja kutse vajab jaatavalt vastamist.",
"generic-invite-user": "Tekkis probleem kasutaja kutsumisel. Palun loe vealogisid.",
"no-user": "Sellenimelist kasutajat ei ole",
"username-taken": "Kasutajanimi on juba kasutuses",
"send-to-unallowed": "Sa ei saa saata seadmele, mis ei ole sinu",
"generic-send-to": "Tekkis viga faili(de) seadmele saatmisel",
"bookmarks-empty": "Järjehoidjad ei saa olla tühjad",
"no-cover-image": "Pole kaanepilti",
"bookmark-doesnt-exist": "Järjehoidjat ei eksisteeri",
"must-be-defined": "{0} peab olema sätestatud",
"generic-favicon": "Domeeni favicon laadimisel tekkis probleem",
"generic-library": "Tekkis möödapääsmatu probleem. Palun proovi uuesti.",
"series-updated": "Edukalt uuendatud",
"no-library-access": "Kasutaja ei oma juurdepääsu sellele kogule",
"user-doesnt-exist": "Kasutajat ei eksisteeri",
"delete-library-while-scan": "Ei saa kustutada tervikkogu, kui skaneerimine on töös. Oota skaneerimise lõppemist või taaskäivita Kavita ning püüa siis uuesti kustutada",
"valid-number": "Peab olema pädev leheküljenumber",
"generic-reading-list-update": "Lugemisloendi uuendamisel tekkis probleem",
"reading-list-position": "Ei õnnestunud uuendada järge",
"series-restricted": "Kasutajal puudub ligipääs sellele sarjale",
"update-yearly-stats": "Uuenda aastate kaupa statistika",
"remove-from-want-to-read": "Lugemisloendi puhastus",
"process-scrobbling-events": "Töötle scrobble juhtumeid",
"user-no-access-library-from-series": "Kasutaja ei oma juurdepääsu täiskogule, milles see seeria on",
"progress-must-exist": "Kasutajal peab olema järg",
"generic-create-temp-archive": "Tekkis probleem ajutise arhiivi loomisel",
"smart-filter-doesnt-exist": "Nutikas filter ei eksisteeri",
"browse-external-sources": "Lehitse väliseid allikaid",
"recently-updated": "Hiljuti uuendatud",
"browse-recently-updated": "Lehitse hiljuti uuendatuid",
"collections": "Kõik kogumid",
"browse-libraries": "Lehitse täiskogude kaupa",
"password-updated": "Parool uuendatud",
"invalid-username": "Vigane kasutajanimi",
"critical-email-migration": "E-posti migreerimisel tekkis viga. Võta toega ühendust",
"email-not-enabled": "E-post ei ole sellel serveril seadistatud. Seda muudatust ei saa teha.",
"chapter-doesnt-exist": "Peatükki ei ole olemas",
"file-missing": "Faili ei leitud raamatust",
"collection-updated": "Kogu uuendatud edukalt",
"collection-deleted": "Kogu kustutatud",
"generic-error": "Midagi ebaõnnestus, palun proovi uuesti",
"collection-doesnt-exist": "Kogu ei eksisteeri",
"device-doesnt-exist": "Seade ei eksisteeri",
"greater-0": "{0} peab olema suurem, kui 0",
"send-to-kavita-email": "Seadmele saatmine ei saa töötada ilma e-posti seadistamata",
"series-doesnt-exist": "Sari ei eksisteeri",
"file-doesnt-exist": "Faili ei eksisteeri",
"library-name-exists": "Kogu nimi juba eksisteerib, palun vali süsteemisiseselt unikaalne nimi.",
"generic-reading-list-create": "Lugemisloendi loomisel tekkis probleem",
"reading-list-doesnt-exist": "Lugemisloendit ei eksisteeri",
"browse-reading-lists": "Lehitse lugemisloendite kaupa",
"external-sources": "Välised allikad",
"smart-filters": "Nutikad filtrid",
"search": "Otsing",
"query-required": "Päringuparameeter on vaja kaasa anda",
"external-source-doesnt-exist": "Väline allikas ei eksisteeri",
"external-source-required": "APIvõti ja serverinimi on vajalikud",
"external-source-already-exists": "Väline allikas on juba olemas",
"device-duplicate": "Sellenimeline seade juba eksisteerib",
"collection-tag-duplicate": "Sellenimeline kogu juba eksisteerib",
"chapter-num": "Peatükk {0}",
"license-check": "Litsentsikontroll",
"check-updates": "Kontrolli uuendusi",
"process-processed-scrobbling-events": "Töötle juba töödeldud scrobble juhtumid",
"backup": "Varund",
"collection-already-exists": "Kogu juba eksisteerib",
"error-import-stack": "Tekkis probleem MAL kuhja importimisel",
"send-to-device-status": "Edastame failid sinu seadmele",
"volume-doesnt-exist": "Raamat ei eksisteeri",
"invalid-filename": "Vigane failinimi",
"generic-library-update": "Tekkis möödapääsmatu probleem tervikkogu uuendamisel.",
"pdf-doesnt-exist": "PDF ei eksisteeri - samas peaks",
"cache-file-find": "Ei leidnud puhverdatud pilti - taaslae ja proovi uuesti.",
"name-required": "Nimi ei või tühjaks jääda",
"reading-list-permission": "Teil ei ole õigusi sellele lugemisloendile või seda loendit ei eksisteeri",
"reading-list-item-delete": "Ei õnnestunud kustutada element(e|i)",
"reading-list-deleted": "Lugemisloend on kustutatud",
"generic-reading-list-delete": "Lugemisloendi kustutamisel tekkis probleem",
"browse-recently-added": "Lehitse hiljuti lisatuid",
"cleanup": "Puhastus",
"issue-num": "Väljaanne {0}{1}",
"book-num": "Raamat {0}",
"series-restricted-age-restriction": "Kasutajal ei ole vanusepiirangust tulenevalt seeriale juurdepääsu",
"send-to-permission": "Kindle ei toeta mitte-EPUB või mitte-PDF formaati",
"device-not-created": "See seade veel ei eksisteeri. Palun loo seade",
"collection-tag-title-required": "Kogu pealkiri ei saa jääda tühjaks",
"epub-html-missing": "Ei suutnud leida sobivat HTML selle lehekülje jaoks",
"epub-malformed": "Failis on süntaksivead! Ei saa lugeda.",
"theme-doesnt-exist": "Teemafail puudub või on vigane",
"external-source-already-in-use": "Juba eksisteerib voog selle välise allikaga",
"sidenav-stream-doesnt-exist": "SideNav voog ei eksisteeri",
"dashboard-stream-doesnt-exist": "Koondpaneeli voog ei eksisteeri",
"smart-filter-already-in-use": "Juba eksisteerib selle nutika filtriga voog",
"favicon-doesnt-exist": "Faviconi ei eksisteeri",
"search-description": "Otsi sarju, kogusid või lugemisloendeid",
"reading-list-restricted": "Lugemisloend ei eksisteeri või teil puudub juurdepääs",
"browse-smart-filters": "Lehitse nutikate filtritega",
"browse-more-in-genre": "Brausi rohkem {0}",
"more-in-genre": "Rohkem žanris {0}",
"browse-collections": "Lehitse kogude kaupa",
"reading-lists": "Lugemisloendid",
"invalid-email-confirmation": "Vigane e-posti kinnitus",
"generic-user-email-update": "Pole võimalik uuendada kasutaja e-posti aadressi. Kontrolli logisid.",
"not-accessible": "Sinu server ei ole väljast ligipääsetav",
"invalid-email": "E-posti aadress selle kasutaja juures ei vasta RFC-le. Vaata palun logisid.",
"not-accessible-password": "Sinu server ei ole ligipääsetav. Sinu parooli taasseadmise link on logides",
"forgot-password-generic": "E-post saadetakse aadressile, mis on meie andmebaasis",
"generic-password-update": "Uue parooli kinnitamisel esines ootamatu viga",
"email-sent": "E-post saadetud",
"user-migration-needed": "Selle kasutaja andmeid on vaja migreerida. Palu tal välja logida, et saaks migratsiooni töövoo käivitada",
"generic-invite-email": "Esines probleem e-postiga kutse taas-saatmisel",
"admin-already-exists": "Administraator on juba määratud",
"account-email-invalid": "Selle administraatorkonto e-posti aadress ei vasta RFC-le. Test e-posti ei saa saata.",
"email-settings-invalid": "E-posti seaded on puudulikud. Veendu, et e-posti seaded saaksid salvestatud.",
"generic-device-create": "Tekkis viga seadme loomisel",
"generic-device-update": "Tekkis viga seadme uuendamisel",
"generic-device-delete": "Tekkis viga seadme kustutamisel",
"send-to-size-limit": "Fail(id) mida üritad saata on e-posti serveri jaoks liiga suured",
"library-doesnt-exist": "Tervikkogu ei eksisteeri",
"invalid-access": "Vigane juurdepääs",
"no-image-for-page": "Pole sellist pilti leheküljel {0}. Proovi taaslaadimist, et võimaldada taaspuhverdamine.",
"bookmark-save": "Ei õnnestunud salvestada järjehoidjat",
"perform-scan": "Palun viige läbi selle seeria või täiskogu skaneerimine, ning proovige uuesti",
"generic-read-progress": "Tekkis probleem järje salvestamisel",
"generic-clear-bookmarks": "Ei suutnud puhastada järjehoidjaid",
"bookmark-permission": "Teil ei ole õigust järjehoidja seadmiseks/kustutamiseks",
"duplicate-bookmark": "Järjehoidja topeltsissekanne on juba olemas",
"reading-list-updated": "Uuendatud",
"bad-copy-files-for-download": "Ei õnnestu kopeerida faile ajutisse kataloogi arhiivina allalaadimiseks.",
"volume-num": "Köide {0}",
"reading-list-title-required": "Lugemisloendi pealkiri ei saa jääda tühjaks",
"reading-list-name-exists": "Sellenimeline lugemisloend on juba olemas",
"check-scrobbling-tokens": "Kontrolli scrobble turvažetoone",
"report-stats": "Raporteeri statistika",
"invalid-path": "Vigane tee",
"not-authenticated": "Kasutaja on autentimata",
"kavita+-data-refresh": "Kavita+andmete värskendus",
"scan-libraries": "Skaneeri täiskogud"
}

View file

@ -193,5 +193,7 @@
"update-yearly-stats": "연간 통계 업데이트",
"process-processed-scrobbling-events": "처리된 스크로블링 이벤트 처리",
"account-email-invalid": "관리자 계정에 등록된 이메일이 유효한 이메일이 아닙니다. 테스트 이메일을 보낼 수 없습니다.",
"email-settings-invalid": "이메일 설정 누락 된 정보. 모든 이메일 설정을 저장합니다."
"email-settings-invalid": "이메일 설정 누락 된 정보. 모든 이메일 설정을 저장합니다.",
"collection-already-exists": "컬렉션은 이미 존재합니다",
"error-import-stack": "MAL 스택을 가져오는 데 문제가 있었습니다"
}

View file

@ -42,5 +42,9 @@
"no-user": "Ngươi dùng không tồn tại",
"user-already-confirmed": "Người dùng này đã xác minh",
"generic-user-update": "Có sự cố đã xảy ra khi cập nhật thông tin người dùng",
"user-already-invited": "Người dùng đã được mời qua email này nhưng chưa chấp nhận lời mời."
"user-already-invited": "Người dùng đã được mời qua email này nhưng chưa chấp nhận lời mời.",
"generate-token": "Đã xảy ra sự cố khi tạo mã xác nhận email. Xem bản ghi",
"locked-out": "Bạn đã bị khóa do quá nhiều lần thử đăng nhập. Vui lòng chờ 10 phút.",
"unable-to-reset-key": "Có sự cố xảy ra, không thể đặt lại khóa",
"share-multiple-emails": "Một Email không thể được dùng chung cho nhiều tải khoản"
}

View file

@ -17,7 +17,7 @@
"file-doesnt-exist": "檔案不存在",
"admin-already-exists": "管理員已存在",
"age-restriction-update": "更新年齡限制時發生錯誤",
"send-to-kavita-email": "無法將 Kavita 的電子郵件服務用於傳送到裝置。請設定您自己的電子郵件服務。",
"send-to-kavita-email": "未設置電子郵件時無法使用傳送到裝置功能",
"not-accessible": "您的伺服器無法從外部存取",
"collections": "所有收藏",
"email-sent": "電子郵件已傳送",
@ -161,5 +161,39 @@
"collection-deleted": "收藏已刪除",
"permission-denied": "您不被允許進行此操作",
"device-doesnt-exist": "裝置不存在",
"generic-series-delete": "刪除系列作品時發生問題"
"generic-series-delete": "刪除系列作品時發生問題",
"recently-updated": "最近更新",
"external-source-already-in-use": "已存在具有此外部來源的串流",
"report-stats": "統計報告",
"backup": "備份",
"more-in-genre": "更多關於類型 {0}",
"account-email-invalid": "管理員帳號檔案中的電子郵件無效。無法發送測試電子郵件。",
"email-not-enabled": "此伺服器未啟用電子郵件功能。您無法執行此操作。",
"error-import-stack": "匯入 MAL 時出現問題",
"send-to-unallowed": "您無法傳送到不是您自己的裝置",
"email-settings-invalid": "電子郵件設定缺少資訊。請確保所有電子郵件設定已保存。",
"collection-already-exists": "組合已存在",
"send-to-size-limit": "您嘗試傳送的文件對於您的電子郵件系統來說過大",
"external-sources": "外部來源",
"dashboard-stream-doesnt-exist": "儀表板串流不存在",
"unable-to-reset-k+": "發生錯誤,無法重置 Kavita+ 授權。請聯繫 Kavita+ 支援",
"check-scrobbling-tokens": "檢查 Scrobbling Tokens",
"cleanup": "清理",
"browse-more-in-genre": "在 {0} 中繼續瀏覽",
"browse-recently-updated": "瀏覽最近更新",
"external-source-required": "需要 API 金鑰和 Host",
"external-source-doesnt-exist": "外部來源不存在",
"check-updates": "檢查更新",
"license-check": "授權檢查",
"process-scrobbling-events": "處理 Scrobbling 事件",
"process-processed-scrobbling-events": "處理已處理的 Scrobbling 事件",
"remove-from-want-to-read": "清理閱讀清單",
"scan-libraries": "掃描資料庫",
"kavita+-data-refresh": "Kavita+ 資料更新",
"update-yearly-stats": "更新年度統計",
"invalid-email": "使用者檔案中的電子郵件無效。請查看日誌以獲得任何連結。",
"browse-external-sources": "瀏覽外部來源",
"sidenav-stream-doesnt-exist": "側邊導覽串流不存在",
"smart-filter-already-in-use": "已存在具有此智慧篩選器的串流",
"external-source-already-exists": "外部來源已存在"
}

View file

@ -97,7 +97,7 @@ public class Program
Task.Run(async () =>
{
// Apply all migrations on startup
logger.LogInformation("Running Migrations");
logger.LogInformation("Running Manual Migrations");
try
{
@ -113,7 +113,7 @@ public class Program
}
await unitOfWork.CommitAsync();
logger.LogInformation("Running Migrations - complete");
logger.LogInformation("Running Manual Migrations - complete");
}).GetAwaiter()
.GetResult();
}

View file

@ -125,14 +125,90 @@ public class ImageService : IImageService
}
}
/// <summary>
/// Tries to determine if there is a better mode for resizing
/// </summary>
/// <param name="image"></param>
/// <param name="targetWidth"></param>
/// <param name="targetHeight"></param>
/// <returns></returns>
public static Enums.Size GetSizeForDimensions(Image image, int targetWidth, int targetHeight)
{
try
{
if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height))
{
return Enums.Size.Force;
}
}
catch (Exception)
{
/* Swallow */
}
return Enums.Size.Both;
}
public static Enums.Interesting? GetCropForDimensions(Image image, int targetWidth, int targetHeight)
{
try
{
if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height))
{
return null;
}
} catch (Exception)
{
/* Swallow */
return null;
}
return Enums.Interesting.Attention;
}
public static bool WillScaleWell(Image sourceImage, int targetWidth, int targetHeight, double tolerance = 0.1)
{
// Calculate the aspect ratios
var sourceAspectRatio = (double) sourceImage.Width / sourceImage.Height;
var targetAspectRatio = (double) targetWidth / targetHeight;
// Compare aspect ratios
if (Math.Abs(sourceAspectRatio - targetAspectRatio) > tolerance)
{
return false; // Aspect ratios differ significantly
}
// Calculate scaling factors
var widthScaleFactor = (double) targetWidth / sourceImage.Width;
var heightScaleFactor = (double) targetHeight / sourceImage.Height;
// Check resolution quality (example thresholds)
if (widthScaleFactor > 2.0 || heightScaleFactor > 2.0)
{
return false; // Scaling factor too large
}
return true; // Image will scale well
}
private static bool IsLikelyWideImage(int width, int height)
{
var aspectRatio = (double) width / height;
return aspectRatio > 1.25;
}
public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size)
{
if (string.IsNullOrEmpty(path)) return string.Empty;
try
{
var dims = size.GetDimensions();
using var thumbnail = Image.Thumbnail(path, dims.Width, height: dims.Height, size: Enums.Size.Force);
var (width, height) = size.GetDimensions();
using var sourceImage = Image.NewFromFile(path, false, Enums.Access.SequentialUnbuffered);
using var thumbnail = Image.Thumbnail(path, width, height: height,
size: GetSizeForDimensions(sourceImage, width, height),
crop: GetCropForDimensions(sourceImage, width, height));
var filename = fileName + encodeFormat.GetExtension();
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
return filename;
@ -156,22 +232,55 @@ public class ImageService : IImageService
/// <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, CoverImageSize size = CoverImageSize.Default)
{
var dims = size.GetDimensions();
using var thumbnail = Image.ThumbnailStream(stream, dims.Width, height: dims.Height, size: Enums.Size.Force);
var (targetWidth, targetHeight) = size.GetDimensions();
if (stream.CanSeek) stream.Position = 0;
using var sourceImage = Image.NewFromStream(stream);
if (stream.CanSeek) stream.Position = 0;
var scalingSize = GetSizeForDimensions(sourceImage, targetWidth, targetHeight);
var scalingCrop = GetCropForDimensions(sourceImage, targetWidth, targetHeight);
using var thumbnail = sourceImage.ThumbnailImage(targetWidth, targetHeight,
size: scalingSize,
crop: scalingCrop);
var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory);
try
{
_directoryService.FileSystem.File.Delete(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
} catch (Exception) {/* Swallow exception */}
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
return filename;
try
{
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
return filename;
}
catch (VipsException)
{
// NetVips Issue: https://github.com/kleisauke/net-vips/issues/234
// Saving pdf covers from a stream can fail, so revert to old code
if (stream.CanSeek) stream.Position = 0;
using var thumbnail2 = Image.ThumbnailStream(stream, targetWidth, height: targetHeight,
size: scalingSize,
crop: scalingCrop);
thumbnail2.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
return filename;
}
}
public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
{
var dims = size.GetDimensions();
using var thumbnail = Image.Thumbnail(sourceFile, dims.Width, height: dims.Height, size: Enums.Size.Force);
var (width, height) = size.GetDimensions();
using var sourceImage = Image.NewFromFile(sourceFile, false, Enums.Access.SequentialUnbuffered);
using var thumbnail = Image.Thumbnail(sourceFile, width, height: height,
size: GetSizeForDimensions(sourceImage, width, height),
crop: GetCropForDimensions(sourceImage, width, height));
var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory);
try
@ -426,7 +535,7 @@ public class ImageService : IImageService
public static void CreateMergedImage(IList<string> coverImages, CoverImageSize size, string dest)
{
var dims = size.GetDimensions();
var (width, height) = size.GetDimensions();
int rows, cols;
if (coverImages.Count == 1)
@ -446,7 +555,7 @@ public class ImageService : IImageService
}
var image = Image.Black(dims.Width, dims.Height);
var image = Image.Black(width, height);
var thumbnailWidth = image.Width / cols;
var thumbnailHeight = image.Height / rows;

View file

@ -501,6 +501,7 @@ public class SeriesService : ISeriesService
StorylineChapters = storylineChapters,
TotalCount = chapters.Count,
UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages),
// TODO: See if we can get the ContinueFrom here
};
}

View file

@ -5,10 +5,12 @@ using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.DTOs.Statistics;
using API.DTOs.Stats;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Helpers;
using API.Services.Plus;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
@ -35,6 +37,7 @@ public interface IStatisticService
Task UpdateServerStatistics();
Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds);
Task<KavitaPlusMetadataBreakdownDto> GetKavitaPlusMetadataBreakdown();
Task<IEnumerable<FileExtensionExportDto>> GetFilesByExtension(string fileExtension);
}
/// <summary>
@ -559,6 +562,16 @@ public class StatisticService : IStatisticService
}
public async Task<IEnumerable<FileExtensionExportDto>> GetFilesByExtension(string fileExtension)
{
var query = _context.MangaFile
.Where(f => f.Extension == fileExtension)
.ProjectTo<FileExtensionExportDto>(_mapper.ConfigurationProvider)
.OrderBy(f => f.FilePath);
return await query.ToListAsync();
}
public async Task<IEnumerable<TopReadDto>> GetTopUsers(int days)
{
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();

View file

@ -4,11 +4,13 @@ using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.Entities.Enums;
using API.Helpers.Converters;
using API.Services.Plus;
using API.Services.Tasks;
using API.Services.Tasks.Metadata;
using API.SignalR;
using Hangfire;
using Microsoft.Extensions.Logging;
@ -22,12 +24,12 @@ public interface ITaskScheduler
Task ScheduleKavitaPlusTasks();
void ScanFolder(string folderPath, string originalPath, TimeSpan delay);
void ScanFolder(string folderPath);
void ScanLibrary(int libraryId, bool force = false);
void ScanLibraries(bool force = false);
Task ScanLibrary(int libraryId, bool force = false);
Task ScanLibraries(bool force = false);
void CleanupChapters(int[] chapterIds);
void RefreshMetadata(int libraryId, bool forceUpdate = true);
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false);
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false);
void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false);
void CancelStatsTasks();
@ -59,6 +61,7 @@ public class TaskScheduler : ITaskScheduler
private readonly IExternalMetadataService _externalMetadataService;
private readonly ISmartCollectionSyncService _smartCollectionSyncService;
private readonly IFontService _fontService;
private readonly IEventHub _eventHub;
public static BackgroundJobServer Client => new ();
public const string ScanQueue = "scan";
@ -79,7 +82,7 @@ public class TaskScheduler : ITaskScheduler
public const string KavitaPlusDataRefreshId = "kavita+-data-refresh";
public const string KavitaPlusStackSyncId = "kavita+-stack-sync";
private static readonly ImmutableArray<string> ScanTasks =
public static readonly ImmutableArray<string> ScanTasks =
["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"];
private static readonly Random Rnd = new Random();
@ -95,7 +98,7 @@ public class TaskScheduler : ITaskScheduler
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService,
IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService,
IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService,
IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, IEventHub eventHub,
IFontService fontService)
{
_cacheService = cacheService;
@ -116,6 +119,7 @@ public class TaskScheduler : ITaskScheduler
_externalMetadataService = externalMetadataService;
_smartCollectionSyncService = smartCollectionSyncService;
_fontService = fontService;
_eventHub = eventHub;
}
public async Task ScheduleTasks()
@ -127,7 +131,7 @@ public class TaskScheduler : ITaskScheduler
{
var scanLibrarySetting = setting;
_logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting);
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => _scannerService.ScanLibraries(false),
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false),
() => CronConverter.ConvertToCronNotation(scanLibrarySetting), RecurringJobOptions);
}
else
@ -324,18 +328,21 @@ public class TaskScheduler : ITaskScheduler
/// Attempts to call ScanLibraries on ScannerService, but if another scan task is in progress, will reschedule the invocation for 3 hours in future.
/// </summary>
/// <param name="force"></param>
public void ScanLibraries(bool force = false)
public async Task ScanLibraries(bool force = false)
{
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))
{
_logger.LogInformation("A Scan is already running, rescheduling ScanLibraries in 3 hours");
// Send InfoEvent to UI as this is invoked my API
BackgroundJob.Schedule(() => ScanLibraries(force), TimeSpan.FromHours(3));
await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan libraries task delayed",
$"A scan was ongoing during processing of the scan libraries task. Task has been rescheduled for 3 hours: {DateTime.Now.AddHours(3)}"));
return;
}
BackgroundJob.Enqueue(() => _scannerService.ScanLibraries(force));
}
public void ScanLibrary(int libraryId, bool force = false)
public async Task ScanLibrary(int libraryId, bool force = false)
{
if (HasScanTaskRunningForLibrary(libraryId))
{
@ -344,15 +351,18 @@ public class TaskScheduler : ITaskScheduler
}
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))
{
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId);
_logger.LogInformation("A Scan is already running, rescheduling ScanLibrary in 3 hours");
await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan library task delayed",
$"A scan was ongoing during processing of the {library!.Name} scan task. Task has been rescheduled for 3 hours: {DateTime.Now.AddHours(3)}"));
BackgroundJob.Schedule(() => ScanLibrary(libraryId, force), TimeSpan.FromHours(3));
return;
}
_logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId);
BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force, true));
var jobId = BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force, true));
// When we do a scan, force cache to re-unpack in case page numbers change
BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory());
BackgroundJob.ContinueJobWith(jobId, () => _cleanupService.CleanupCacheDirectory());
}
public void TurnOnScrobbling(int userId = 0)
@ -393,7 +403,7 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(libraryId, seriesId, forceUpdate));
}
public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)
public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)
{
if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, forceUpdate], ScanQueue))
{
@ -403,7 +413,10 @@ public class TaskScheduler : ITaskScheduler
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))
{
// BUG: This can end up triggering a ton of scan series calls (but i haven't seen in practice)
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.None);
_logger.LogInformation("A Scan is already running, rescheduling ScanSeries in 10 minutes");
await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan series task delayed: {series!.Name}",
$"A scan was ongoing during processing of the scan series task. Task has been rescheduled for 10 minutes: {DateTime.Now.AddMinutes(10)}"));
BackgroundJob.Schedule(() => ScanSeries(libraryId, seriesId, forceUpdate), TimeSpan.FromMinutes(10));
return;
}
@ -474,6 +487,7 @@ public class TaskScheduler : ITaskScheduler
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, false], ScanQueue, checkRunningJobs);
}
/// <summary>
/// Checks if this same invocation is already enqueued or scheduled
/// </summary>
@ -482,6 +496,7 @@ public class TaskScheduler : ITaskScheduler
/// <param name="args">object[] of arguments in the order they are passed to enqueued job</param>
/// <param name="queue">Queue to check against. Defaults to "default"</param>
/// <param name="checkRunningJobs">Check against running jobs. Defaults to false.</param>
/// <param name="checkArgs">Check against arguments. Defaults to true.</param>
/// <returns></returns>
public static bool HasAlreadyEnqueuedTask(string className, string methodName, object[] args, string queue = DefaultQueue, bool checkRunningJobs = false)
{

View file

@ -150,13 +150,28 @@ public class LibraryWatcher : ILibraryWatcher
{
_logger.LogTrace("[LibraryWatcher] Changed: {FullPath}, {Name}, {ChangeType}", e.FullPath, e.Name, e.ChangeType);
if (e.ChangeType != WatcherChangeTypes.Changed) return;
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name))));
var isDirectoryChange = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name));
if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, isDirectoryChange],
checkRunningJobs: true))
{
return;
}
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, isDirectoryChange));
}
private void OnCreated(object sender, FileSystemEventArgs e)
{
_logger.LogTrace("[LibraryWatcher] Created: {FullPath}, {Name}", e.FullPath, e.Name);
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, !_directoryService.FileSystem.File.Exists(e.Name)));
var isDirectoryChange = !_directoryService.FileSystem.File.Exists(e.Name);
if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, isDirectoryChange],
checkRunningJobs: true))
{
return;
}
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, isDirectoryChange));
}
/// <summary>
@ -168,6 +183,11 @@ public class LibraryWatcher : ILibraryWatcher
var isDirectory = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name));
if (!isDirectory) return;
_logger.LogTrace("[LibraryWatcher] Deleted: {FullPath}, {Name}", e.FullPath, e.Name);
if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, true],
checkRunningJobs: true))
{
return;
}
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, true));
}
@ -298,7 +318,7 @@ public class LibraryWatcher : ILibraryWatcher
/// This is called via Hangfire to decrement the counter. Must work around a lock
/// </summary>
// ReSharper disable once MemberCanBePrivate.Global
public void UpdateLastBufferOverflow()
public static void UpdateLastBufferOverflow()
{
lock (Lock)
{

View file

@ -351,14 +351,17 @@ public class ParseScannedFiles
{
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Starting", library.Name, ProgressEventType.Started));
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 1.A: Process {FolderCount} folders", library.Name, folders.Count());
var processedScannedSeries = new List<ScannedSeriesResult>();
//var processedScannedSeries = new ConcurrentBag<ScannedSeriesResult>();
foreach (var folderPath in folders)
{
try
{
_logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.B: Scan files in {Folder}", library.Name, folderPath);
var scanResults = await ProcessFiles(folderPath, isLibraryScan, seriesPaths, library, forceCheck);
_logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.C: Process files in {Folder}", library.Name, folderPath);
foreach (var scanResult in scanResults)
{
await ParseAndTrackSeries(library, seriesPaths, scanResult, processedScannedSeries);

View file

@ -235,7 +235,7 @@ public static class Parser
// [SugoiSugoi]_NEEDLESS_Vol.2_-_Disk_The_Informant_5_[ENG].rar, Yuusha Ga Shinda! - Vol.tbd Chapter 27.001 V2 Infection ①.cbz,
// Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.30 Omake
new Regex(
@"^(?<Series>.+?)(\s*Chapter\s*\d+)?(\s|_|\-\s)+Vol(ume)?\.?(\d+|tbd|\s\d).+?",
@"^(?<Series>.+?)(?:\s*|_|\-\s*)+(?:Ch(?:apter|\.|)\s*\d+(?:\.\d+)?(?:\s*|_|\-\s*)+)?Vol(?:ume|\.|)\s*(?:\d+|tbd)(?:\s|_|\-\s*).+",
MatchOptions, RegexTimeout),
// Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip, VanDread-v01-c01.zip
new Regex(
@ -764,7 +764,10 @@ public static class Parser
var group = matches
.Select(match => match.Groups["Series"])
.FirstOrDefault(group => group.Success && group != Match.Empty);
if (group != null) return CleanTitle(group.Value);
if (group != null)
{
return CleanTitle(group.Value);
}
}
return string.Empty;

View file

@ -76,6 +76,7 @@ public enum ScanCancelReason
public class ScannerService : IScannerService
{
public const string Name = "ScannerService";
private const int Timeout = 60 * 60 * 60; // 2.5 days
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ScannerService> _logger;
private readonly IMetadataService _metadataService;
@ -156,11 +157,11 @@ public class ScannerService : IScannerService
}
// TODO: Figure out why we have the library type restriction here
if (series != null && series.Library.Type is not (LibraryType.Book or LibraryType.LightNovel))
if (series != null)// && series.Library.Type is not (LibraryType.Book or LibraryType.LightNovel)
{
if (TaskScheduler.HasScanTaskRunningForSeries(series.Id))
{
_logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder);
_logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder);
return;
}
_logger.LogInformation("[ScannerService] Scan folder invoked for {Folder}, Series matched to folder and ScanSeries enqueued for 1 minute", folder);
@ -168,6 +169,7 @@ public class ScannerService : IScannerService
return;
}
// This is basically rework of what's already done in Library Watcher but is needed if invoked via API
var parentDirectory = _directoryService.GetParentDirectoryName(folder);
if (string.IsNullOrEmpty(parentDirectory)) return;
@ -183,7 +185,7 @@ public class ScannerService : IScannerService
{
if (TaskScheduler.HasScanTaskRunningForLibrary(library.Id))
{
_logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder);
_logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder);
return;
}
BackgroundJob.Schedule(() => ScanLibrary(library.Id, false, true), TimeSpan.FromMinutes(1));
@ -196,12 +198,21 @@ public class ScannerService : IScannerService
/// <param name="seriesId"></param>
/// <param name="bypassFolderOptimizationChecks">Not Used. Scan series will always force</param>
[Queue(TaskScheduler.ScanQueue)]
[DisableConcurrentExecution(Timeout)]
[AutomaticRetry(Attempts = 200, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true)
{
if (TaskScheduler.HasAlreadyEnqueuedTask(Name, "ScanSeries", [seriesId, bypassFolderOptimizationChecks], TaskScheduler.ScanQueue))
{
_logger.LogInformation("[ScannerService] Scan series invoked but a task is already running/enqueued. Dropping request");
return;
}
var sw = Stopwatch.StartNew();
var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId);
if (series == null) return; // This can occur when UI deletes a series but doesn't update and user re-requests update
var existingChapterIdsToClean = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId});
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId, LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns);
@ -434,7 +445,7 @@ public class ScannerService : IScannerService
// Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are
if (folders.Any(f => !_directoryService.IsDriveMounted(f)))
{
_logger.LogCritical("Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName);
_logger.LogCritical("[ScannerService] Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName);
await _eventHub.SendMessageAsync(MessageFactory.Error,
MessageFactory.ErrorEvent("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted",
@ -448,7 +459,7 @@ public class ScannerService : IScannerService
if (folders.Any(f => _directoryService.IsDirectoryEmpty(f)))
{
// That way logging and UI informing is all in one place with full context
_logger.LogError("Some of the root folders for the library are empty. " +
_logger.LogError("[ScannerService] Some of the root folders for the library are empty. " +
"Either your mount has been disconnected or you are trying to delete all series in the library. " +
"Scan has be aborted. " +
"Check that your mount is connected or change the library's root folder and rescan");
@ -465,17 +476,25 @@ public class ScannerService : IScannerService
}
[Queue(TaskScheduler.ScanQueue)]
[DisableConcurrentExecution(60 * 60 * 60)]
[DisableConcurrentExecution(Timeout)]
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibraries(bool forceUpdate = false)
{
_logger.LogInformation("Starting Scan of All Libraries, Forced: {Forced}", forceUpdate);
_logger.LogInformation("[ScannerService] Starting Scan of All Libraries, Forced: {Forced}", forceUpdate);
foreach (var lib in await _unitOfWork.LibraryRepository.GetLibrariesAsync())
{
// BUG: This will trigger the first N libraries to scan over and over if there is always an interruption later in the chain
if (TaskScheduler.HasScanTaskRunningForLibrary(lib.Id))
{
// We don't need to send SignalR event as this is a background job that user doesn't need insight into
_logger.LogInformation("[ScannerService] Scan library invoked via nightly scan job but a task is already running for {LibraryName}. Rescheduling for 4 hours", lib.Name);
await Task.Delay(TimeSpan.FromHours(4));
}
await ScanLibrary(lib.Id, forceUpdate, true);
}
_processSeries.Reset();
_logger.LogInformation("Scan of All Libraries Finished");
_logger.LogInformation("[ScannerService] Scan of All Libraries Finished");
}
@ -488,13 +507,14 @@ public class ScannerService : IScannerService
/// <param name="forceUpdate">Defaults to false</param>
/// <param name="isSingleScan">Defaults to true. Is this a standalone invocation or is it in a loop?</param>
[Queue(TaskScheduler.ScanQueue)]
[DisableConcurrentExecution(60 * 60 * 60)]
[DisableConcurrentExecution(Timeout)]
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibrary(int libraryId, bool forceUpdate = false, bool isSingleScan = true)
{
var sw = Stopwatch.StartNew();
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId,
LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns);
var libraryFolderPaths = library!.Folders.Select(fp => fp.Path).ToList();
if (!await CheckMounts(library.Name, libraryFolderPaths)) return;
@ -506,23 +526,27 @@ public class ScannerService : IScannerService
var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths));
if (!shouldUseLibraryScan)
{
_logger.LogError("Library {LibraryName} consists of one or more Series folders, using series scan", library.Name);
_logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders, using series scan", library.Name);
}
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 1: Scan Files", library.Name);
var (scanElapsedTime, processedSeries) = await ScanFiles(library, libraryFolderPaths,
shouldUseLibraryScan, forceUpdate);
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 2: Track Found Series", library.Name);
var parsedSeries = new Dictionary<ParsedSeries, IList<ParserInfo>>();
TrackFoundSeriesAndFiles(parsedSeries, processedSeries);
// We need to remove any keys where there is no actual parser info
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 3: Process Parsed Series", library.Name);
var totalFiles = await ProcessParsedSeries(forceUpdate, parsedSeries, library, scanElapsedTime);
UpdateLastScanned(library);
_unitOfWork.LibraryRepository.Update(library);
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 4: Save Library", library.Name);
if (await _unitOfWork.CommitAsync())
{
if (isSingleScan)
@ -543,6 +567,7 @@ public class ScannerService : IScannerService
totalFiles, parsedSeries.Count, sw.ElapsedMilliseconds, library.Name);
}
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 5: Remove Deleted Series", library.Name);
await RemoveSeriesNotFound(parsedSeries, library);
}
else

View file

@ -91,12 +91,39 @@ public class VersionUpdaterService : IVersionUpdaterService
// Find the latest dto
var latestRelease = updateDtos[0]!;
var updateVersion = new Version(latestRelease.UpdateVersion);
var isNightly = BuildInfo.Version > new Version(latestRelease.UpdateVersion);
// isNightly can be true when we compare something like v0.8.1 vs v0.8.1.0
if (IsVersionEqualToBuildVersion(updateVersion))
{
//latestRelease.UpdateVersion = BuildInfo.Version.ToString();
isNightly = false;
}
latestRelease.IsOnNightlyInRelease = isNightly;
return updateDtos;
}
private static bool IsVersionEqualToBuildVersion(Version updateVersion)
{
return updateVersion.Revision < 0 && BuildInfo.Version.Revision == 0 &&
CompareWithoutRevision(BuildInfo.Version, updateVersion);
}
private static bool CompareWithoutRevision(Version v1, Version v2)
{
if (v1.Major != v2.Major)
return v1.Major == v2.Major;
if (v1.Minor != v2.Minor)
return v1.Minor == v2.Minor;
if (v1.Build != v2.Build)
return v1.Build == v2.Build;
return true;
}
public async Task<int> GetNumberOfReleasesBehind()
{
var updates = await GetAllReleases();
@ -109,6 +136,7 @@ public class VersionUpdaterService : IVersionUpdaterService
var updateVersion = new Version(update.Tag_Name.Replace("v", string.Empty));
var currentVersion = BuildInfo.Version.ToString(4);
return new UpdateNotificationDto()
{
CurrentVersion = currentVersion,
@ -118,7 +146,7 @@ public class VersionUpdaterService : IVersionUpdaterService
UpdateUrl = update.Html_Url,
IsDocker = OsInfo.IsDocker,
PublishDate = update.Published_At,
IsReleaseEqual = BuildInfo.Version == updateVersion,
IsReleaseEqual = IsVersionEqualToBuildVersion(updateVersion),
IsReleaseNewer = BuildInfo.Version < updateVersion,
};
}

View file

@ -345,6 +345,7 @@ public static class MessageFactory
EventType = ProgressEventType.Single,
Body = new
{
Name = Error,
Title = title,
SubTitle = subtitle,
}
@ -362,6 +363,7 @@ public static class MessageFactory
EventType = ProgressEventType.Single,
Body = new
{
Name = Info,
Title = title,
SubTitle = subtitle,
}

View file

@ -137,14 +137,14 @@ public class Startup
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Version = BuildInfo.Version.ToString(),
Version = "3.1.0",
Title = "Kavita",
Description = "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required.",
Description = $"Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v{BuildInfo.Version.ToString()}",
License = new OpenApiLicense
{
Name = "GPL-3.0",
Url = new Uri("https://github.com/Kareadita/Kavita/blob/develop/LICENSE")
}
},
});
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
@ -176,7 +176,7 @@ public class Startup
Url = "{protocol}://{hostpath}",
Variables = new Dictionary<string, OpenApiServerVariable>
{
{ "protocol", new OpenApiServerVariable { Default = "http", Enum = new List<string> { "http", "https" } } },
{ "protocol", new OpenApiServerVariable { Default = "http", Enum = ["http", "https"]} },
{ "hostpath", new OpenApiServerVariable { Default = "localhost:5000" } }
}
});
@ -207,7 +207,7 @@ public class Startup
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseInMemoryStorage());
//.UseSQLiteStorage("config/Hangfire.db")); // UseSQLiteStorage - SQLite has some issues around resuming jobs when aborted (and locking can cause high utilization)
//.UseSQLiteStorage("config/Hangfire.db")); // UseSQLiteStorage - SQLite has some issues around resuming jobs when aborted (and locking can cause high utilization) (NOTE: There is code to clear jobs on startup a redditor gave me)
// Add the processing server as IHostedService
services.AddHangfireServer(options =>
@ -427,8 +427,8 @@ public class Startup
catch (Exception)
{
/* Swallow Exception */
Console.WriteLine($"Kavita - v{BuildInfo.Version}");
}
Console.WriteLine($"Kavita - v{BuildInfo.Version}");
});
logger.LogInformation("Starting with base url as {BaseUrl}", basePath);

View file

@ -2,7 +2,7 @@
"TokenKey": "super secret unguessable key that is longer because we require it",
"Port": 5000,
"IpAddresses": "0.0.0.0,::",
"BaseUrl": "/tes/",
"BaseUrl": "/",
"Cache": 75,
"AllowIFraming": false
}