Merge branch 'refs/heads/develop' into feature/user-fonts
# Conflicts: # API/Services/TaskScheduler.cs
This commit is contained in:
commit
1f2ea8f59d
100 changed files with 3553 additions and 1416 deletions
|
|
@ -12,9 +12,9 @@
|
|||
<LangVersion>latestmajor</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
|
||||
<!-- <Exec Command="swagger tofile --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>
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
API/DTOs/ReadingLists/DeleteReadingListsDto.cs
Normal file
10
API/DTOs/ReadingLists/DeleteReadingListsDto.cs
Normal 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; }
|
||||
}
|
||||
9
API/DTOs/ReadingLists/PromoteReadingListsDto.cs
Normal file
9
API/DTOs/ReadingLists/PromoteReadingListsDto.cs
Normal 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; }
|
||||
}
|
||||
15
API/DTOs/Stats/FileExtensionExportDto.cs
Normal file
15
API/DTOs/Stats/FileExtensionExportDto.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
42
API/I18N/da.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
146
API/I18N/et.json
146
API/I18N/et.json
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 스택을 가져오는 데 문제가 있었습니다"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "外部來源已存在"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue