Stats Fix & Library Bulk Actions (#3209)

Co-authored-by: Fesaa <77553571+Fesaa@users.noreply.github.com>
Co-authored-by: Weblate (bot) <hosted@weblate.org>
Co-authored-by: Gregory.Open <gregory.open@proton.me>
Co-authored-by: Mateusz <mateuszvx8.96@gmail.com>
Co-authored-by: majora2007 <kavitareader@gmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
This commit is contained in:
Joe Milazzo 2024-09-23 08:07:37 -05:00 committed by GitHub
parent 894b49bb76
commit 857e419e4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 72523 additions and 30914 deletions

View file

@ -12,10 +12,10 @@
<LangVersion>latestmajor</LangVersion>
</PropertyGroup>
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">
<Delete Files="../openapi.json" />
<Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />
</Target>
<!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
<!-- <Delete Files="../openapi.json" />-->
<!-- <Exec Command="swagger tofile &#45;&#45;output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />-->
<!-- </Target>-->
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>
@ -67,10 +67,10 @@
<PackageReference Include="Flurl" Version="3.0.7" />
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hangfire" Version="1.8.14" />
<PackageReference Include="Hangfire.InMemory" Version="0.10.3" />
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.64" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.66" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
@ -94,7 +94,7 @@
<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="SharpCompress" Version="0.38.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.32.0.97167">
<PrivateAssets>all</PrivateAssets>

View file

@ -78,7 +78,6 @@ public class LibraryController : BaseApiController
.WithFolders(dto.Folders.Select(x => new FolderPath {Path = x}).Distinct().ToList())
.WithFolderWatching(dto.FolderWatching)
.WithIncludeInDashboard(dto.IncludeInDashboard)
.WithIncludeInRecommended(dto.IncludeInRecommended)
.WithManageCollections(dto.ManageCollections)
.WithManageReadingLists(dto.ManageReadingLists)
.WIthAllowScrobbling(dto.AllowScrobbling)
@ -302,6 +301,22 @@ public class LibraryController : BaseApiController
return Ok();
}
/// <summary>
/// Enqueues a bunch of library scans
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("scan-multiple")]
public async Task<ActionResult> ScanMultiple(BulkActionDto dto)
{
foreach (var libraryId in dto.Ids)
{
await _taskScheduler.ScanLibrary(libraryId, dto.Force ?? false);
}
return Ok();
}
/// <summary>
/// Scans a given library for file changes. If another scan task is in progress, will reschedule the invocation for 3 hours in future.
/// </summary>
@ -323,6 +338,18 @@ public class LibraryController : BaseApiController
return Ok();
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("refresh-metadata-multiple")]
public ActionResult RefreshMetadataMultiple(BulkActionDto dto, bool forceColorscape = true)
{
foreach (var libraryId in dto.Ids)
{
_taskScheduler.RefreshMetadata(libraryId, dto.Force ?? false, forceColorscape);
}
return Ok();
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("analyze")]
public ActionResult Analyze(int libraryId)
@ -331,6 +358,61 @@ public class LibraryController : BaseApiController
return Ok();
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("analyze-multiple")]
public ActionResult AnalyzeMultiple(BulkActionDto dto)
{
foreach (var libraryId in dto.Ids)
{
_taskScheduler.AnalyzeFilesForLibrary(libraryId, dto.Force ?? false);
}
return Ok();
}
/// <summary>
/// Copy the library settings (adv tab + optional type) to a set of other libraries.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("copy-settings-from")]
public async Task<ActionResult> CopySettingsFromLibraryToLibraries(CopySettingsFromLibraryDto dto)
{
var sourceLibrary = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(dto.SourceLibraryId, LibraryIncludes.ExcludePatterns | LibraryIncludes.FileTypes);
if (sourceLibrary == null) return BadRequest("SourceLibraryId must exist");
var libraries = await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.TargetLibraryIds, LibraryIncludes.ExcludePatterns | LibraryIncludes.FileTypes | LibraryIncludes.Folders);
foreach (var targetLibrary in libraries)
{
UpdateLibrarySettings(new UpdateLibraryDto()
{
Folders = targetLibrary.Folders.Select(s => s.Path),
Name = targetLibrary.Name,
Id = targetLibrary.Id,
Type = sourceLibrary.Type,
AllowScrobbling = sourceLibrary.AllowScrobbling,
ExcludePatterns = sourceLibrary.LibraryExcludePatterns.Select(p => p.Pattern).ToList(),
FolderWatching = sourceLibrary.FolderWatching,
ManageCollections = sourceLibrary.ManageCollections,
FileGroupTypes = sourceLibrary.LibraryFileTypes.Select(t => t.FileTypeGroup).ToList(),
IncludeInDashboard = sourceLibrary.IncludeInDashboard,
IncludeInSearch = sourceLibrary.IncludeInSearch,
ManageReadingLists = sourceLibrary.ManageReadingLists
}, targetLibrary, dto.IncludeType);
}
await _unitOfWork.CommitAsync();
if (sourceLibrary.FolderWatching)
{
BackgroundJob.Enqueue(() => _libraryWatcher.RestartWatching());
}
return Ok();
}
/// <summary>
/// Given a valid path, will invoke either a Scan Series or Scan Library. If the folder does not exist within Kavita, the request will be ignored
/// </summary>
@ -474,33 +556,7 @@ public class LibraryController : BaseApiController
var typeUpdate = library.Type != dto.Type;
var folderWatchingUpdate = library.FolderWatching != dto.FolderWatching;
library.Type = dto.Type;
library.FolderWatching = dto.FolderWatching;
library.IncludeInDashboard = dto.IncludeInDashboard;
library.IncludeInRecommended = dto.IncludeInRecommended;
library.IncludeInSearch = dto.IncludeInSearch;
library.ManageCollections = dto.ManageCollections;
library.ManageReadingLists = dto.ManageReadingLists;
library.AllowScrobbling = dto.AllowScrobbling;
library.LibraryFileTypes = dto.FileGroupTypes
.Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id})
.Distinct()
.ToList();
library.LibraryExcludePatterns = dto.ExcludePatterns
.Distinct()
.Select(t => new LibraryExcludePattern() {Pattern = t, LibraryId = library.Id})
.ToList();
// Override Scrobbling for Comic libraries since there are no providers to scrobble to
if (library.Type == LibraryType.Comic)
{
_logger.LogInformation("Overrode Library {Name} to disable scrobbling since there are no providers for Comics", dto.Name.Replace(Environment.NewLine, string.Empty));
library.AllowScrobbling = false;
}
_unitOfWork.LibraryRepository.Update(library);
UpdateLibrarySettings(dto, library);
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(userId, "generic-library-update"));
@ -526,6 +582,39 @@ public class LibraryController : BaseApiController
}
private void UpdateLibrarySettings(UpdateLibraryDto dto, Library library, bool updateType = true)
{
if (updateType)
{
library.Type = dto.Type;
}
library.FolderWatching = dto.FolderWatching;
library.IncludeInDashboard = dto.IncludeInDashboard;
library.IncludeInSearch = dto.IncludeInSearch;
library.ManageCollections = dto.ManageCollections;
library.ManageReadingLists = dto.ManageReadingLists;
library.AllowScrobbling = dto.AllowScrobbling;
library.LibraryFileTypes = dto.FileGroupTypes
.Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id})
.Distinct()
.ToList();
library.LibraryExcludePatterns = dto.ExcludePatterns
.Distinct()
.Select(t => new LibraryExcludePattern() {Pattern = t, LibraryId = library.Id})
.ToList();
// Override Scrobbling for Comic libraries since there are no providers to scrobble to
if (library.Type is LibraryType.Comic or LibraryType.ComicVine)
{
_logger.LogInformation("Overrode Library {Name} to disable scrobbling since there are no providers for Comics", dto.Name.Replace(Environment.NewLine, string.Empty));
library.AllowScrobbling = false;
}
_unitOfWork.LibraryRepository.Update(library);
}
/// <summary>
/// Returns the type of the underlying library
/// </summary>

12
API/DTOs/BulkActionDto.cs Normal file
View file

@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace API.DTOs;
public class BulkActionDto
{
public List<int> Ids { get; set; }
/**
* If this is a Scan action, will ignore optimizations
*/
public bool? Force { get; set; }
}

View file

@ -111,7 +111,7 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; }
public float AvgHoursToRead { get; set; }
/// <summary>
/// Comma-separated link of urls to external services that have some relation to the Chapter
/// </summary>

View file

@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace API.DTOs;
public class CopySettingsFromLibraryDto
{
public int SourceLibraryId { get; set; }
public List<int> TargetLibraryIds { get; set; }
/// <summary>
/// Include copying over the type
/// </summary>
public bool IncludeType { get; set; }
}

View file

@ -16,5 +16,5 @@ public record HourEstimateRangeDto
/// <summary>
/// Estimated average hours to read the selection
/// </summary>
public int AvgHours { get; init; } = 1;
public float AvgHours { get; init; } = 1f;
}

View file

@ -53,7 +53,7 @@ public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; }
public float AvgHoursToRead { get; set; }
/// <summary>
/// The highest level folder for this Series
/// </summary>

View file

@ -8,11 +8,11 @@ public class TopReadDto
/// <summary>
/// Amount of time read on Comic libraries
/// </summary>
public long ComicsTime { get; set; }
public float ComicsTime { get; set; }
/// <summary>
/// Amount of time read on
/// </summary>
public long BooksTime { get; set; }
public long MangaTime { get; set; }
public float BooksTime { get; set; }
public float MangaTime { get; set; }
}

View file

@ -19,8 +19,6 @@ public class UpdateLibraryDto
[Required]
public bool IncludeInDashboard { get; init; }
[Required]
public bool IncludeInRecommended { get; init; }
[Required]
public bool IncludeInSearch { get; init; }
[Required]
public bool ManageCollections { get; init; }

View file

@ -43,7 +43,7 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; }
public float AvgHoursToRead { get; set; }
public long WordCount { get; set; }
/// <summary>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,66 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class AvgReadingTimeFloat : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<float>(
name: "AvgHoursToRead",
table: "Volume",
type: "REAL",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AlterColumn<float>(
name: "AvgHoursToRead",
table: "Series",
type: "REAL",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AlterColumn<float>(
name: "AvgHoursToRead",
table: "Chapter",
type: "REAL",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "AvgHoursToRead",
table: "Volume",
type: "INTEGER",
nullable: false,
oldClrType: typeof(float),
oldType: "REAL");
migrationBuilder.AlterColumn<int>(
name: "AvgHoursToRead",
table: "Series",
type: "INTEGER",
nullable: false,
oldClrType: typeof(float),
oldType: "REAL");
migrationBuilder.AlterColumn<int>(
name: "AvgHoursToRead",
table: "Chapter",
type: "INTEGER",
nullable: false,
oldClrType: typeof(float),
oldType: "REAL");
}
}
}

View file

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.7");
modelBuilder.HasAnnotation("ProductVersion", "8.0.8");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -731,8 +731,8 @@ namespace API.Data.Migrations
b.Property<string>("AlternateSeries")
.HasColumnType("TEXT");
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
b.Property<float>("AvgHoursToRead")
.HasColumnType("REAL");
b.Property<bool>("CharacterLocked")
.HasColumnType("INTEGER");
@ -1809,8 +1809,8 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
b.Property<float>("AvgHoursToRead")
.HasColumnType("REAL");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
@ -2040,8 +2040,8 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
b.Property<float>("AvgHoursToRead")
.HasColumnType("REAL");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");

View file

@ -117,7 +117,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
/// <inheritdoc cref="IHasReadTimeEstimate"/>
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate"/>
public int AvgHoursToRead { get; set; }
public float AvgHoursToRead { get; set; }
/// <summary>
/// Comma-separated link of urls to external services that have some relation to the Chapter
/// </summary>

View file

@ -21,5 +21,5 @@ public interface IHasReadTimeEstimate
/// Average hours to read the chapter
/// </summary>
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
public int AvgHoursToRead { get; set; }
public float AvgHoursToRead { get; set; }
}

View file

@ -38,7 +38,7 @@ public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
/// </summary>
public DateTime Created { get; set; }
/// <summary>
/// Whenever a modification occurs. Ie) New volumes, removed volumes, title update, etc
/// Whenever a modification occurs. ex: New volumes, removed volumes, title update, etc
/// </summary>
public DateTime LastModified { get; set; }
@ -101,7 +101,7 @@ public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
public int MinHoursToRead { get; set; }
public int MaxHoursToRead { get; set; }
public int AvgHoursToRead { get; set; }
public float AvgHoursToRead { get; set; }
public SeriesMetadata Metadata { get; set; } = null!;
public ExternalSeriesMetadata ExternalSeriesMetadata { get; set; } = null!;

View file

@ -53,7 +53,7 @@ public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
public long WordCount { get; set; }
public int MinHoursToRead { get; set; }
public int MaxHoursToRead { get; set; }
public int AvgHoursToRead { get; set; }
public float AvgHoursToRead { get; set; }
// Relationships

View file

@ -698,21 +698,23 @@ public class ReaderService : IReaderService
{
var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 0);
var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 0);
return new HourEstimateRangeDto
{
MinHours = Math.Min(minHours, maxHours),
MaxHours = Math.Max(minHours, maxHours),
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour))
AvgHours = wordCount / AvgWordsPerHour
};
}
var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 0);
var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 0);
return new HourEstimateRangeDto
{
MinHours = Math.Min(minHoursPages, maxHoursPages),
MaxHours = Math.Max(minHoursPages, maxHoursPages),
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F))
AvgHours = pageCount / AvgPagesPerMinute / 60F
};
}
@ -808,6 +810,7 @@ public class ReaderService : IReaderService
{
switch(libraryType)
{
case LibraryType.Image:
case LibraryType.Manga:
return "Chapter" + (includeSpace ? " " : string.Empty);
case LibraryType.Comic:

View file

@ -595,7 +595,6 @@ public class StatisticService : IStatisticService
.Contains(c.Id))
})
.OrderByDescending(d => d.Chapters.Sum(c => c.AvgHoursToRead))
.Take(5)
.ToList();
@ -615,16 +614,17 @@ public class StatisticService : IStatisticService
chapterLibLookup.Add(cl.ChapterId, cl.LibraryId);
}
var user = new Dictionary<int, Dictionary<LibraryType, long>>();
var user = new Dictionary<int, Dictionary<LibraryType, float>>();
foreach (var userChapter in topUsersAndReadChapters)
{
if (!user.ContainsKey(userChapter.User.Id)) user.Add(userChapter.User.Id, new Dictionary<LibraryType, long>());
if (!user.ContainsKey(userChapter.User.Id)) user.Add(userChapter.User.Id, []);
var libraryTimes = user[userChapter.User.Id];
foreach (var chapter in userChapter.Chapters)
{
var library = libraries.First(l => l.Id == chapterLibLookup[chapter.Id]);
if (!libraryTimes.ContainsKey(library.Type)) libraryTimes.Add(library.Type, 0L);
libraryTimes.TryAdd(library.Type, 0f);
var existingHours = libraryTimes[library.Type];
libraryTimes[library.Type] = existingHours + chapter.AvgHoursToRead;
}

View file

@ -421,6 +421,12 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _scannerService.ScanSeries(seriesId, forceUpdate));
}
/// <summary>
/// Calculates TimeToRead and bytes
/// </summary>
/// <param name="libraryId"></param>
/// <param name="seriesId"></param>
/// <param name="forceUpdate"></param>
public void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false)
{
if (HasAlreadyEnqueuedTask("WordCountAnalyzerService", "ScanSeries", [libraryId, seriesId, forceUpdate]))

View file

@ -217,6 +217,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
chapter.MinHoursToRead = est.MinHours;
chapter.MaxHoursToRead = est.MaxHours;
chapter.AvgHoursToRead = est.AvgHours;
foreach (var file in chapter.Files)
{
UpdateFileAnalysis(file);

View file

@ -448,9 +448,7 @@ public class Startup
}
catch (Exception ex)
{
if ((ex.Message.Contains("Permission denied")
|| ex.Message.Contains("UnauthorizedAccessException"))
&& baseUrl.Equals(Configuration.DefaultBaseUrl) && OsInfo.IsDocker)
if (ex is UnauthorizedAccessException && baseUrl.Equals(Configuration.DefaultBaseUrl) && OsInfo.IsDocker)
{
// Swallow the exception as the install is non-root and Docker
return;